mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
271 lines
8.6 KiB
JavaScript
271 lines
8.6 KiB
JavaScript
/**
|
||
* 04 — Ré-upload les WebP générés vers Strapi et remplace les entrées média dans
|
||
* les fiches concernées (API authentifiée).
|
||
*
|
||
* Requiert dans l’env : STRAPI_API_TOKEN (JWT Strapi avec droits upload + mise à jour
|
||
* des collection types utilisés ; typiquement un jeton Full access en local).
|
||
*
|
||
* Par défaut : **dry-run** (aucune mutation).
|
||
* Mutation réelle : node strapi_extraction/media-sync/04-upload-replace.js --execute
|
||
*
|
||
* DANGER : sauvegarder au préalable votre base Strapi / médias. Tester sur une
|
||
* copie locale (Strapi localhost + même base si possible).
|
||
*/
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
require("dotenv").config({
|
||
path: path.join(__dirname, "../../cmsbackend/.env"),
|
||
});
|
||
require("dotenv").config({
|
||
path: path.join(__dirname, "../../.env.local"),
|
||
});
|
||
|
||
const {
|
||
FILE_INVENTORY,
|
||
DIR_WEBP,
|
||
API_BASE,
|
||
} = require("./config");
|
||
|
||
const TOKEN = process.env.STRAPI_API_TOKEN;
|
||
const EXECUTE = process.argv.includes("--execute");
|
||
|
||
function mimeForFile(filePath) {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const map = {
|
||
".webp": "image/webp",
|
||
".svg": "image/svg+xml",
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
};
|
||
return map[ext] || "application/octet-stream";
|
||
}
|
||
|
||
/** Compte combien de lignes d’inventaire pointent le même fileId */
|
||
function countRefs(files) {
|
||
const c = {};
|
||
for (const r of files) {
|
||
c[r.fileId] = (c[r.fileId] || 0) + 1;
|
||
}
|
||
return c;
|
||
}
|
||
|
||
/**
|
||
* Nom réservé multipart (ByteString) : les noms avec caractères hors U+00–U+FF
|
||
* (ex. U+2194 flèches dans des noms générés) provoquent l’erreur Node
|
||
* « Cannot convert argument to a ByteString » sur form.append(..., filename).
|
||
*/
|
||
function asciiUploadName(filePath, fileId) {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const extOk = /^\.(webp|svg|png|jpe?g|gif|avif)$/.test(ext) ? ext : ".webp";
|
||
return `upload-${fileId}${extOk}`;
|
||
}
|
||
|
||
async function postUpload(filePath, fileId) {
|
||
const buf = fs.readFileSync(filePath);
|
||
const asciiName = asciiUploadName(filePath, fileId);
|
||
const blob = new Blob([buf], { type: mimeForFile(filePath) });
|
||
const body = new FormData();
|
||
body.append("files", blob, asciiName);
|
||
|
||
const res = await fetch(`${API_BASE}/upload`, {
|
||
method: "POST",
|
||
headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {},
|
||
body,
|
||
});
|
||
if (!res.ok) {
|
||
const t = await res.text();
|
||
throw new Error(`POST /upload ${res.status} ${t.slice(0, 800)}`);
|
||
}
|
||
const json = await res.json();
|
||
const arr = Array.isArray(json) ? json : json?.data;
|
||
if (!Array.isArray(arr) || !arr[0]) {
|
||
throw new Error(`Réponse upload inattendue : ${JSON.stringify(json).slice(0, 400)}`);
|
||
}
|
||
return arr[0];
|
||
}
|
||
|
||
function unwrapEntry(raw) {
|
||
if (!raw) return null;
|
||
if (raw.data) return unwrapEntry(raw.data);
|
||
if (raw.attributes) {
|
||
return {
|
||
...raw.attributes,
|
||
id: raw.id,
|
||
documentId: raw.documentId,
|
||
};
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
async function getEntryPlural(plural, documentId) {
|
||
const qs = new URLSearchParams({ populate: "*" });
|
||
const url = `${API_BASE}/${plural}/${documentId}?${qs}`;
|
||
const res = await fetch(url, {
|
||
headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {},
|
||
});
|
||
if (!res.ok) {
|
||
const t = await res.text();
|
||
throw new Error(`GET ${url} → ${res.status} ${t.slice(0, 400)}`);
|
||
}
|
||
const json = await res.json();
|
||
return unwrapEntry(json);
|
||
}
|
||
|
||
function getMediaIdsFromField(entry, fieldName, multiple) {
|
||
const v = entry[fieldName];
|
||
if (multiple) {
|
||
const arr = Array.isArray(v) ? v : [];
|
||
return arr.map((x) => (typeof x === "object" && x !== null ? x.id : x)).filter(Boolean);
|
||
}
|
||
if (!v) return [];
|
||
if (typeof v === "object" && v.id) return [v.id];
|
||
if (typeof v === "number") return [v];
|
||
return [];
|
||
}
|
||
|
||
async function putEntry(plural, documentId, payloadData) {
|
||
const url = `${API_BASE}/${plural}/${documentId}`;
|
||
const res = await fetch(url, {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
|
||
},
|
||
body: JSON.stringify({ data: payloadData }),
|
||
});
|
||
if (!res.ok) {
|
||
const t = await res.text();
|
||
throw new Error(`PUT ${url} → ${res.status} ${t.slice(0, 800)}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function deleteFile(fileId) {
|
||
const url = `${API_BASE}/upload/files/${fileId}`;
|
||
const res = await fetch(url, {
|
||
method: "DELETE",
|
||
headers: TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {},
|
||
});
|
||
if (!res.ok) {
|
||
const t = await res.text();
|
||
throw new Error(`DELETE ${url} → ${res.status} ${t.slice(0, 400)}`);
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
if (!fs.existsSync(FILE_INVENTORY)) {
|
||
console.error("Manque media-inventory.json");
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!TOKEN) {
|
||
console.error(
|
||
"STRAPI_API_TOKEN manquant. Crée un jeton API dans Strapi (Settings → API Tokens), puis exporte :\n" +
|
||
" Windows PowerShell : $env:STRAPI_API_TOKEN=\"…\"\n" +
|
||
" ou ajoute STRAPI_API_TOKEN dans cmsbackend/.env (non commité)."
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
const inventory = JSON.parse(fs.readFileSync(FILE_INVENTORY, "utf8"));
|
||
const files = inventory.files || [];
|
||
const refCount = countRefs(files);
|
||
|
||
const candidates = files.filter(
|
||
(r) =>
|
||
r.relativeWebpPath &&
|
||
r.relativeWebpPath.endsWith(".webp") &&
|
||
r.entryDocumentId
|
||
);
|
||
|
||
console.log(
|
||
`Lignes inventaire : ${files.length} — candidats WebP remplaçables (avec documentId) : ${candidates.length}`
|
||
);
|
||
console.log(`Mode : ${EXECUTE ? "EXECUTE (mutations réelles)" : "DRY-RUN (aucune mutation)"}\n`);
|
||
|
||
if (!EXECUTE) {
|
||
console.log(
|
||
"Exemple d’actions qui seraient effectuées avec --execute :\n" +
|
||
" 1. POST /api/upload pour chaque fichier .webp sous webp/\n" +
|
||
" 2. PUT /api/:collection/:documentId avec le champ média mis à jour (ids)\n" +
|
||
" 3. DELETE /api/upload/files/:oldFileId uniquement si l’ancien id n’a qu’une référence dans l’inventaire\n"
|
||
);
|
||
for (let i = 0; i < Math.min(5, candidates.length); i++) {
|
||
const r = candidates[i];
|
||
console.log(
|
||
` • fileId=${r.fileId} → ${r.relativeWebpPath} → entrée ${r.collectionPlural} documentId=${r.entryDocumentId} champ=${r.fieldName}[${r.fieldIndex}]`
|
||
);
|
||
}
|
||
console.log("\nAjoute --execute pour réellement téléverser (après avoir testé la sauvegarde).");
|
||
return;
|
||
}
|
||
|
||
/** Par sécurité, traite fichier par fichier ; re-fetch après chaque succès évite désalignement indices */
|
||
for (const row of candidates) {
|
||
const src = path.join(DIR_WEBP, row.relativeWebpPath.replace(/\//g, path.sep));
|
||
if (!fs.existsSync(src)) {
|
||
console.warn(`SKIP fileId=${row.fileId} : fichier absent ${src}`);
|
||
continue;
|
||
}
|
||
|
||
const docId = row.entryDocumentId;
|
||
const plural = row.collectionPlural;
|
||
const field = row.fieldName;
|
||
const idx = row.fieldIndex;
|
||
const oldId = row.fileId;
|
||
|
||
try {
|
||
const uploaded = await postUpload(src, row.fileId);
|
||
const newId = uploaded.id;
|
||
|
||
const entry = await getEntryPlural(plural, docId);
|
||
if (!entry) throw new Error("entrée introuvable");
|
||
|
||
const multiple = row.fieldMultiple;
|
||
const currentIds = getMediaIdsFromField(entry, field, multiple);
|
||
|
||
if (multiple) {
|
||
if (idx < 0 || idx >= currentIds.length) {
|
||
throw new Error(`index ${idx} hors limites (ids actuels : ${currentIds.join(",")})`);
|
||
}
|
||
if (currentIds[idx] !== oldId) {
|
||
throw new Error(
|
||
`id à l’index ${idx} est ${currentIds[idx]}, attendu ${oldId} — arrêt pour éviter corruption`
|
||
);
|
||
}
|
||
const next = currentIds.slice();
|
||
next[idx] = newId;
|
||
await putEntry(plural, docId, { [field]: next });
|
||
} else {
|
||
const cur = currentIds[0];
|
||
if (cur != null && cur !== oldId) {
|
||
throw new Error(`champ simple : attendu ancien id ${oldId}, vu ${cur}`);
|
||
}
|
||
await putEntry(plural, docId, { [field]: newId });
|
||
}
|
||
|
||
console.log(`OK upload+remplace : ${oldId} → ${newId} (${plural}/${docId})`);
|
||
|
||
const canDeleteOld = refCount[oldId] === 1;
|
||
if (canDeleteOld && oldId !== newId) {
|
||
try {
|
||
await deleteFile(oldId);
|
||
console.log(` ancien fichier Strapi ${oldId} supprimé`);
|
||
} catch (de) {
|
||
console.warn(` suppression ancien échouée (non bloquant) : ${de.message}`);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(`ÉCHEC fileId=${row.fileId} : ${e.message}`);
|
||
}
|
||
}
|
||
|
||
console.log("\nTerminé. Recharge les fiches dans l’admin et vide le cache navigateur.");
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error(e);
|
||
process.exit(1);
|
||
});
|