@cardinali-studio/adminkit 2.2.21 → 2.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,9 @@
11
11
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
12
12
  import { api, ApiError } from "../../api.js";
13
13
  import { storageKey } from "../../config.js";
14
- import { contentPersistenceMode } from "../../app/site.js";
14
+ import { contentPersistenceMode, site } from "../../app/site.js";
15
15
  import { startMediaUpload, useMediaUploadCompletions, useMediaUploads, } from "./media-upload-queue.js";
16
- import { invalidMediaMoveReason, isValidMediaMoveDestination, mediaFolderSlug, mediaSelectableItems, mediaSelectionState, normalizeMediaSelection, summarizeMediaSelection, tryBeginMediaMutation, } from "./media-model.js";
16
+ import { invalidMediaMoveReason, isValidMediaMoveDestination, mediaFolderSlug, mediaAltStatus, mediaSelectableItems, mediaSelectionState, normalizeMediaSelection, summarizeMediaSelection, tryBeginMediaMutation, } from "./media-model.js";
17
17
  import { executeMediaMovePlan, mediaMoveConfirmation, mediaMoveNeedsConfirmation, } from "./media-move.js";
18
18
  /* Quanti file per richiesta nelle operazioni lunghe (cestina, sposta). Ogni file costa al
19
19
  * server tre operazioni su R2 — legge, riscrive altrove, cancella — su oggetti da qualche MB.
@@ -29,7 +29,11 @@ export async function deleteMediaKeys(keys, remove, onProgress = () => { }) {
29
29
  if (preflight.d1 === true) {
30
30
  const done = preflight.trashed ?? keys.length;
31
31
  onProgress(done);
32
- return { done };
32
+ return {
33
+ done,
34
+ operationId: preflight.operationId,
35
+ requiresPublication: preflight.requiresPublication === true,
36
+ };
33
37
  }
34
38
  if (!preflight.refsToken) {
35
39
  throw new Error("Il server non ha confermato la verifica dei riferimenti.");
@@ -92,6 +96,9 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
92
96
  // tutti i giorni non ha motivo di azzerarsi per una riorganizzazione dei sorgenti.
93
97
  const [sortKey, setSortKey] = useState(() => localStorage.getItem(storageKey("sortKey")) || "uploaded");
94
98
  const [sortDir, setSortDir] = useState(() => localStorage.getItem(storageKey("sortDir")) || "desc");
99
+ const [altFilter, setAltFilter] = useState("all");
100
+ const altLocales = site().languages?.available ?? ["default"];
101
+ const defaultAltLocale = site().languages?.default ?? "default";
95
102
  /* I nomi per esteso già visti. Servono al percorso in alto: dentro `food/piatti-2024/`
96
103
  * l'elenco delle cartelle non contiene più né l'una né l'altra — `folders` ha il primo livello
97
104
  * e `subfolders` i FIGLI di dove sei — quindi senza memoria il percorso mostrerebbe gli slug,
@@ -230,13 +237,16 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
230
237
  return () => removeEventListener("beforeunload", warn);
231
238
  }, [progress]);
232
239
  const shown = useMemo(() => {
233
- const list = mode === "pick" ? files.filter((f) => (f.type ?? "").startsWith("image/")) : files.slice();
240
+ let list = mode === "pick" ? files.filter((f) => (f.type ?? "").startsWith("image/")) : files.slice();
241
+ if (mode === "manage" && altFilter !== "all") {
242
+ list = list.filter((file) => (file.type ?? "").startsWith("image/") && mediaAltStatus(file, altLocales) === altFilter);
243
+ }
234
244
  const dir = sortDir === "asc" ? 1 : -1;
235
245
  return list.sort((a, b) => {
236
246
  const x = a[sortKey] ?? "", y = b[sortKey] ?? "";
237
247
  return (x < y ? -1 : x > y ? 1 : 0) * dir;
238
248
  });
239
- }, [files, sortKey, sortDir, mode]);
249
+ }, [files, sortKey, sortDir, mode, altFilter, altLocales]);
240
250
  const selectableItems = useMemo(() => mediaSelectableItems({ mode, prefix, shown, subfolders }), [mode, prefix, shown, subfolders]);
241
251
  const selectableIds = useMemo(() => selectableItems.map((item) => item.id), [selectableItems]);
242
252
  const selectableIdSet = useMemo(() => new Set(selectableIds), [selectableIds]);
@@ -326,6 +336,29 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
326
336
  setSettingsBusy(false);
327
337
  }
328
338
  }, [autoWebp]);
339
+ const saveAlt = useCallback(async (key, alt) => {
340
+ if (!beginMutation("saving_alt"))
341
+ return false;
342
+ setBusy(true);
343
+ setError(null);
344
+ try {
345
+ const result = await api.saveMediaAlt(key, alt);
346
+ setFiles((current) => current.map((file) => file.key === key ? { ...file, alt: result.alt } : file));
347
+ /* Se si sta facendo audit dei mancanti, al salvataggio la foto può uscire dal filtro:
348
+ * chiudere il visore evita che il suo indice finisca per indicare la foto successiva. */
349
+ if (altFilter !== "all")
350
+ setViewing(null);
351
+ return true;
352
+ }
353
+ catch (error) {
354
+ setError(error instanceof ApiError ? error.message : String(error));
355
+ return false;
356
+ }
357
+ finally {
358
+ setBusy(false);
359
+ endMutation();
360
+ }
361
+ }, [altFilter, beginMutation, endMutation]);
329
362
  const labelOf = useCallback((p) => {
330
363
  const f = folders.find((x) => x.prefix === p) ?? subfolders.find((x) => x.prefix === p);
331
364
  return f?.label || seenLabels.current[p] || p.replace(/\/$/, "").split("/").pop() || "";
@@ -411,21 +444,17 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
411
444
  throw new Error("Prima di eliminare queste foto devi sostituirle nel contenuto:\n"
412
445
  + u.blockers.map((b) => `• ${b.label}: ${b.reason}`).join("\n"));
413
446
  }
414
- /* LA RIGA IN PIÙ QUANDO LE FOTO SONO IN USO, e non è pignoleria.
415
- *
416
- * Le foto stanno nel bucket, che è uno solo: non esiste una versione "in bozza" di un
417
- * file. Eliminarne una la toglie SUBITO dal sito pubblicato, mentre la ripulitura dei
418
- * riferimenti nelle pagine resta sulla bozza finché non si pubblica — quindi per quel
419
- * tempo le pagine online chiedono una foto che non c'è più.
420
- *
421
- * La finestra esisteva anche prima (durava la build, un paio di minuti); ora dura fino
422
- * alla pubblicazione, cioè quanto decide chi sta lavorando. Non si aggiunge macchinario
423
- * per chiuderla: si dice, e si dice SOLO quando è vero — un avviso che compare sempre
424
- * smette di essere letto, e allora non avvisa più di niente. */
447
+ /* Quando una foto è online la conferma deve descrivere l'intera operazione: Admin
448
+ * pubblicherà una revisione isolata delle sole pagine coinvolte e toccherà il file
449
+ * soltanto dopo. Sostituire prima resta la strada raccomandata perché una rimozione
450
+ * valida per lo schema può comunque cambiare l'impaginazione. */
425
451
  const msg = u.count
426
- ? `Eliminare ${quante}?\n\nATTENZIONE: ${u.count} di queste foto sono usate in: ${u.pages.join(", ")}.\n`
427
- + `I riferimenti verranno rimossi da quelle sorgenti.\n\n`
428
- + `Le foto spariscono subito dal sito online; le pagine si sistemano quando pubblichi.`
452
+ ? `Eliminare ${quante}?\n\n${u.count === 1 ? "Una delle immagini è utilizzata" : `${u.count} immagini sono utilizzate`} in:\n`
453
+ + u.pages.map((page) => `• ${page}`).join("\n")
454
+ + `\n\nAdmin rimuoverà le immagini da queste pagine e pubblicherà automaticamente soltanto questa modifica. `
455
+ + `Gallerie, griglie o sezioni potrebbero cambiare impaginazione.\n\n`
456
+ + `Ti consigliamo di annullare e sostituire prima le immagini nelle pagine indicate. `
457
+ + `Confermando, procederai comunque con la rimozione e la pubblicazione.`
429
458
  : `Eliminare ${quante}?`;
430
459
  if (!confirm(`${msg}\n\nLe foto vanno nel cestino e si possono recuperare.`))
431
460
  return;
@@ -435,6 +464,18 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
435
464
  const result = await deleteMediaKeys(keys, api.remove, (done) => setProgress({ done, total: keys.length, verb: "Cestino" }));
436
465
  refsToken = result.refsToken;
437
466
  const done = result.done;
467
+ if (result.operationId && result.requiresPublication) {
468
+ setProgress({ done: keys.length, total: keys.length, verb: "Pubblico" });
469
+ try {
470
+ await api.contentPublishMedia(result.operationId);
471
+ }
472
+ catch (error) {
473
+ const apiError = error;
474
+ failure = apiError.code === "deployment_lane_busy" || apiError.code === "release_in_progress"
475
+ ? "C'è già una pubblicazione in corso. L'eliminazione è al sicuro: usa «Riprova pubblicazione» quando termina."
476
+ : `Eliminazione preparata, ma la pubblicazione non è partita: ${apiError.message}`;
477
+ }
478
+ }
438
479
  if (done < keys.length) {
439
480
  failure = `Cestinate ${done} foto su ${keys.length}: le altre sono ancora lì, riprova.`;
440
481
  }
@@ -641,9 +682,27 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
641
682
  }, [prefix, load, moveFolder, beginMutation, endMutation]);
642
683
  /** Cestina una cartella con tutto quello che contiene. È `remove` con una lista di uno. */
643
684
  const removeFolder = useCallback((target) => remove([target]), [remove]);
644
- const resumeMedia = useCallback(async (operation) => {
645
- if (!operation.releaseId)
685
+ const publishMedia = useCallback(async (operation) => {
686
+ if (!beginMutation("journal"))
646
687
  return;
688
+ setBusy(true);
689
+ setError(null);
690
+ let failure = null;
691
+ try {
692
+ await api.contentPublishMedia(operation.operationId);
693
+ }
694
+ catch (e) {
695
+ failure = e instanceof ApiError ? e.message : String(e);
696
+ }
697
+ finally {
698
+ setBusy(false);
699
+ await load(prefix);
700
+ endMutation();
701
+ if (failure)
702
+ setError(`Pubblicazione Media non riuscita: ${failure}`);
703
+ }
704
+ }, [beginMutation, endMutation, load, prefix]);
705
+ const resumeMedia = useCallback(async (operation) => {
647
706
  if (!beginMutation("journal"))
648
707
  return;
649
708
  setBusy(true);
@@ -697,7 +756,8 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
697
756
  mode, multiple, prefix, folders, subfolders, shown, selected, selectableIds,
698
757
  selectedFiles: selectionSummary.files, selectedFolders: selectionSummary.folders,
699
758
  allSelected, someSelected, busy, mutating, operationState, error, mediaOperations, progress,
700
- sortKey, sortDir, uploadDraft, pickUploadJob, viewing, autoWebp, settingsBusy,
759
+ sortKey, sortDir, altFilter, altLocales, defaultAltLocale,
760
+ uploadDraft, pickUploadJob, viewing, autoWebp, settingsBusy,
701
761
  go: (p) => { if (!mutationRef.current)
702
762
  void load(p); },
703
763
  reload: () => { if (!mutationRef.current)
@@ -705,6 +765,11 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
705
765
  toggle, setAll,
706
766
  setSort: setSortKey,
707
767
  flipDir: () => setSortDir((d) => (d === "asc" ? "desc" : "asc")),
768
+ setAltFilter: (filter) => {
769
+ setAltFilter(filter);
770
+ setSelected(new Set());
771
+ setViewing(null);
772
+ },
708
773
  upload,
709
774
  openUpload: (files = [], destination) => {
710
775
  if (mutationRef.current)
@@ -718,12 +783,12 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
718
783
  setUploadDraft(null); },
719
784
  setUploadFiles: (files) => setUploadDraft((draft) => (draft ? { ...draft, files: Array.from(files) } : draft)),
720
785
  setUploadDestination: (destination) => setUploadDraft((draft) => (draft ? { ...draft, destination } : draft)),
721
- setAutoWebp,
786
+ setAutoWebp, saveAlt,
722
787
  view: setViewing,
723
788
  step,
724
789
  move,
725
790
  canMoveTo: (ids, dest) => !mutationRef.current && isValidMediaMoveDestination(ids, dest),
726
- remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
791
+ remove, newFolder, renameFolder, removeFolder, resumeMedia, publishMedia, cancelMedia, labelOf,
727
792
  clearError: () => setError(null),
728
793
  };
729
794
  }
@@ -5,15 +5,11 @@ import {
5
5
  canonicalChecksum, canonicalJson, contentSaveRequestChecksum, D1ContentRepository,
6
6
  } from "../../persistence.js";
7
7
  import {
8
- D1MediaOperationRepository, prepareMediaCopy,
8
+ D1MediaOperationRepository, mediaPublicUrl, prepareMediaCopy, rewriteMediaValue,
9
9
  } from "../../media.js";
10
10
  import { validateContentDocument } from "../site.js";
11
11
  import { isKey, moveToTrash, sanitizeName } from "./assets.js";
12
12
 
13
- const MEDIA_PREFIX = "/media/";
14
-
15
- function mediaUrl(key) { return MEDIA_PREFIX + key.split("/").map(encodeURIComponent).join("/"); }
16
-
17
13
  function editor(data) {
18
14
  const payload = data?.cloudflareAccess?.JWT?.payload;
19
15
  if (!payload?.sub) throw new Error("unauthenticated");
@@ -26,67 +22,7 @@ function operationId(value) {
26
22
  return value;
27
23
  }
28
24
 
29
- function replaceBoundedUrl(value, from, to) {
30
- let cursor = 0;
31
- let next = "";
32
- while (cursor < value.length) {
33
- const found = value.indexOf(from, cursor);
34
- if (found < 0) return next + value.slice(cursor);
35
- const after = value[found + from.length];
36
- /* A media key is a path, not a string prefix. Query/fragment suffixes and
37
- * further URL/path characters belong to a different reference and must
38
- * not be rewritten accidentally. */
39
- if (after && /[A-Za-z0-9._~%!$&'()*+,;=:@/?#-]/.test(after)) {
40
- next += value.slice(cursor, found + from.length);
41
- cursor = found + from.length;
42
- continue;
43
- }
44
- next += value.slice(cursor, found) + to;
45
- cursor = found + from.length;
46
- }
47
- return next;
48
- }
49
-
50
- function replaceText(value, replacements, removals) {
51
- let next = value;
52
- for (const [from, to] of Object.entries(replacements)) next = replaceBoundedUrl(next, from, to);
53
- for (const from of removals) next = replaceBoundedUrl(next, from, "");
54
- return next;
55
- }
56
-
57
- /** JSON Media values are intentionally rewritten only as complete public URLs
58
- * (or a URL embedded in Markdown). Arrays lose an exact removed URL; scalar
59
- * slots become empty strings and are then validated by the site's schema. */
60
- function rewriteValue(value, replacements, removals, inArray = false) {
61
- if (typeof value === "string") {
62
- if (removals.has(value) && inArray) return { changed: true, removed: true };
63
- const next = replaceText(value, replacements, removals);
64
- return { changed: next !== value, value: next };
65
- }
66
- if (Array.isArray(value)) {
67
- let changed = false;
68
- const next = [];
69
- for (const item of value) {
70
- const result = rewriteValue(item, replacements, removals, true);
71
- changed ||= result.changed;
72
- if (!result.removed) next.push(result.value);
73
- }
74
- return { changed, value: next };
75
- }
76
- if (value && typeof value === "object") {
77
- let changed = false;
78
- const next = {};
79
- for (const [key, item] of Object.entries(value)) {
80
- const result = rewriteValue(item, replacements, removals, false);
81
- changed ||= result.changed;
82
- if (!result.removed) next[key] = result.value;
83
- }
84
- return { changed, value: next };
85
- }
86
- return { changed: false, value };
87
- }
88
-
89
- async function effectiveDocuments(db) {
25
+ async function mediaDocuments(db) {
90
26
  return ((await db.prepare(`SELECT document.document_id AS documentId, document.kind, document.locale,
91
27
  document.draft_revision_id AS draftRevisionId, published.revision_id AS publishedRevisionId,
92
28
  draft.payload_json AS draftPayload, draft.is_tombstone AS draftTombstone,
@@ -101,16 +37,33 @@ async function effectiveDocuments(db) {
101
37
  }
102
38
 
103
39
  async function rewriteDocuments({ db, data, replacements = {}, removals = [] }) {
104
- const replacementUrls = Object.fromEntries(Object.entries(replacements).map(([from, to]) => [mediaUrl(from), mediaUrl(to)]));
105
- const removalUrls = new Set(removals.map(mediaUrl));
106
40
  const commands = [];
107
- for (const row of await effectiveDocuments(db)) {
108
- const useDraft = row.draftRevisionId !== null;
109
- const raw = useDraft ? row.draftPayload : row.publishedPayload;
110
- if (!raw || (useDraft ? row.draftTombstone : row.publishedTombstone)) continue;
111
- let payload;
112
- try { payload = JSON.parse(raw); } catch { throw new Error("invalid_document"); }
113
- const rewritten = rewriteValue(payload, replacementUrls, removalUrls);
41
+ const publishedDocuments = [];
42
+ for (const row of await mediaDocuments(db)) {
43
+ /* Validate the exact published patch now, before the operation can become
44
+ * ready. Publication later starts from this same immutable release side,
45
+ * never from an unrelated editorial draft. */
46
+ if (row.publishedPayload && !row.publishedTombstone) {
47
+ let publishedPayload;
48
+ try { publishedPayload = JSON.parse(row.publishedPayload); } catch { throw new Error("invalid_document"); }
49
+ const published = rewriteMediaValue(publishedPayload, { replacements, removals });
50
+ if (published.changed) {
51
+ validateContentDocument({
52
+ documentId: row.documentId, kind: row.kind, locale: row.locale, schemaVersion: row.schemaVersion,
53
+ tombstone: false, payload: published.value,
54
+ });
55
+ publishedDocuments.push({ documentId: row.documentId, kind: row.kind });
56
+ }
57
+ }
58
+
59
+ /* Existing drafts keep every unrelated edit but must not be able to
60
+ * reintroduce a source after the isolated Media release. No draft is
61
+ * manufactured when none existed: the detached published patch owns that
62
+ * side of the operation. */
63
+ if (row.draftRevisionId === null || !row.draftPayload || row.draftTombstone) continue;
64
+ let draftPayload;
65
+ try { draftPayload = JSON.parse(row.draftPayload); } catch { throw new Error("invalid_document"); }
66
+ const rewritten = rewriteMediaValue(draftPayload, { replacements, removals });
114
67
  if (!rewritten.changed) continue;
115
68
  const normalized = validateContentDocument({
116
69
  documentId: row.documentId, kind: row.kind, locale: row.locale, schemaVersion: row.schemaVersion,
@@ -118,14 +71,14 @@ async function rewriteDocuments({ db, data, replacements = {}, removals = [] })
118
71
  });
119
72
  commands.push({
120
73
  revisionId: crypto.randomUUID(), documentId: row.documentId,
121
- baseRevisionId: useDraft ? row.draftRevisionId : row.publishedRevisionId,
74
+ baseRevisionId: row.draftRevisionId,
122
75
  expectedDraftRevisionId: row.draftRevisionId, kind: row.kind, locale: row.locale,
123
76
  schemaVersion: row.schemaVersion, tombstone: false, payload: normalized.payload,
124
77
  });
125
78
  }
126
79
  /* Nessun riferimento è un esito valido: l'operazione Media deve proseguire
127
80
  * senza costruire una transazione editoriale vuota, che `saveMany` rifiuta. */
128
- if (!commands.length) return { documents: [] };
81
+ if (!commands.length) return { documents: [], publishedDocuments };
129
82
  const who = editor(data);
130
83
  const repository = new D1ContentRepository(db);
131
84
  /* Every command was fully parsed and site-validated before the first write.
@@ -136,7 +89,10 @@ async function rewriteDocuments({ db, data, replacements = {}, removals = [] })
136
89
  ...command, payloadChecksum: await canonicalChecksum(command.payload), requestChecksum: await contentSaveRequestChecksum(command),
137
90
  now, authorSubject: who.subject, authorLabel: who.label,
138
91
  }))));
139
- return { documents: commands.map((command) => ({ documentId: command.documentId, kind: command.kind })) };
92
+ return {
93
+ documents: commands.map((command) => ({ documentId: command.documentId, kind: command.kind })),
94
+ publishedDocuments,
95
+ };
140
96
  }
141
97
 
142
98
  function keys(input) {
@@ -205,14 +161,22 @@ export async function d1MoveMedia({ env, data, body }) {
205
161
  const stored = await journal.entries(id);
206
162
  return {
207
163
  operationId: id, repeated: true, moved: stored.length,
208
- urlMap: Object.fromEntries(stored.filter((entry) => entry.destinationKey).map((entry) => [mediaUrl(entry.sourceKey), mediaUrl(entry.destinationKey)])),
209
- documents: [],
164
+ urlMap: Object.fromEntries(stored.filter((entry) => entry.destinationKey).map((entry) => [mediaPublicUrl(entry.sourceKey), mediaPublicUrl(entry.destinationKey)])),
165
+ documents: [], publishedDocuments: [], requiresPublication: true,
210
166
  };
211
167
  }
212
168
  try {
213
169
  for (const entry of entries) await prepareMediaCopy({ bucket: env.MEDIA, journal, operationId: id, entryId: entry.entryId, sourceKey: entry.sourceKey, destinationKey: entry.destinationKey, now: Date.now() });
214
170
  const rewritten = await rewriteDocuments({ db: env.CONTENT_DB, data, replacements: Object.fromEntries(entries.map((entry) => [entry.sourceKey, entry.destinationKey])) });
215
- return { operationId: id, repeated: false, moved: entries.length, urlMap: Object.fromEntries(entries.map((entry) => [mediaUrl(entry.sourceKey), mediaUrl(entry.destinationKey)])), documents: rewritten.documents };
171
+ const immediate = rewritten.publishedDocuments.length
172
+ ? { requiresPublication: true }
173
+ : await finalizeUnpublishedD1MediaOperation({ env, operationId: id });
174
+ return {
175
+ operationId: id, repeated: false, moved: entries.length,
176
+ urlMap: Object.fromEntries(entries.map((entry) => [mediaPublicUrl(entry.sourceKey), mediaPublicUrl(entry.destinationKey)])),
177
+ documents: rewritten.documents, publishedDocuments: rewritten.publishedDocuments,
178
+ ...immediate,
179
+ };
216
180
  } catch (error) {
217
181
  await journal.cancel(id, Date.now()).catch(() => null);
218
182
  throw error;
@@ -225,12 +189,22 @@ export async function d1DeleteMedia({ env, data, body }) {
225
189
  const id = operationId(body.operationId);
226
190
  const entries = sourceKeys.map((sourceKey) => ({ entryId: crypto.randomUUID(), sourceKey }));
227
191
  const { journal, created } = await createOperation({ db: env.CONTENT_DB, data, type: "delete", id, entries, clientChecksum: body.requestChecksum });
228
- if (created === "repeated") return { operationId: id, repeated: true, trashed: (await journal.entries(id)).length, documents: [] };
192
+ if (created === "repeated") return {
193
+ operationId: id, repeated: true, trashed: (await journal.entries(id)).length,
194
+ documents: [], publishedDocuments: [], requiresPublication: true,
195
+ };
229
196
  try {
230
197
  const inspected = await Promise.all(entries.map((entry) => inspectSource(env.MEDIA, entry)));
231
198
  const rewritten = await rewriteDocuments({ db: env.CONTENT_DB, data, removals: sourceKeys });
232
199
  for (const entry of inspected) await journal.readyDeleteEntry({ ...entry, operationId: id, now: Date.now() });
233
- return { operationId: id, repeated: false, trashed: entries.length, documents: rewritten.documents };
200
+ const immediate = rewritten.publishedDocuments.length
201
+ ? { requiresPublication: true }
202
+ : await finalizeUnpublishedD1MediaOperation({ env, operationId: id });
203
+ return {
204
+ operationId: id, repeated: false, trashed: entries.length,
205
+ documents: rewritten.documents, publishedDocuments: rewritten.publishedDocuments,
206
+ ...immediate,
207
+ };
234
208
  } catch (error) {
235
209
  await journal.cancel(id, Date.now()).catch(() => null);
236
210
  throw error;
@@ -246,28 +220,42 @@ export async function d1PendingSourceKeys(db) {
246
220
  export async function d1MediaUsage(db, sourceKeys) {
247
221
  const keys = [...new Set(sourceKeys.filter(isKey))];
248
222
  if (!keys.length) return { usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] };
249
- const rows = (await db.prepare(`SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
223
+ const rows = (await db.prepare(`SELECT document.kind, document.document_id AS documentId,
224
+ revision.payload_json AS payload, 0 AS locationRank
250
225
  FROM content_documents document
251
226
  JOIN content_revisions revision ON revision.document_id = document.document_id AND revision.revision_id = document.draft_revision_id
252
227
  UNION
253
- SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
228
+ SELECT document.kind, document.document_id AS documentId,
229
+ revision.payload_json AS payload, 1 AS locationRank
254
230
  FROM content_site_state state
255
231
  JOIN content_release_entries entry ON entry.release_id = state.current_release_id
256
232
  JOIN content_documents document ON document.document_id = entry.document_id
257
233
  JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
258
234
  UNION
259
- SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
235
+ SELECT document.kind, document.document_id AS documentId,
236
+ revision.payload_json AS payload, 2 AS locationRank
260
237
  FROM content_releases release
261
238
  JOIN content_release_entries entry ON entry.release_id = release.release_id
262
239
  JOIN content_documents document ON document.document_id = entry.document_id
263
240
  JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
264
- WHERE release.status = 'publishing'`).all()).results ?? [];
241
+ WHERE release.status = 'publishing'
242
+ ORDER BY locationRank, documentId`).all()).results ?? [];
265
243
  const usage = {};
266
244
  const pages = new Set();
267
245
  for (const key of keys) {
268
- const url = mediaUrl(key);
269
- const locations = [...new Set(rows.filter((row) => typeof row.payload === "string" && row.payload.includes(url))
270
- .map((row) => `${row.kind} · ${row.documentId}`))];
246
+ const url = mediaPublicUrl(key);
247
+ const documents = new Map();
248
+ for (const row of rows) {
249
+ if (documents.has(row.documentId) || typeof row.payload !== "string" || !row.payload.includes(url)) continue;
250
+ let payload;
251
+ try { payload = JSON.parse(row.payload); } catch { payload = null; }
252
+ const candidate = payload && typeof payload === "object" && !Array.isArray(payload)
253
+ ? [payload.title, payload.name, payload.label, payload.slug]
254
+ .find((value) => typeof value === "string" && value.trim())
255
+ : null;
256
+ documents.set(row.documentId, candidate ? String(candidate).trim().slice(0, 200) : `${row.kind} · ${row.documentId}`);
257
+ }
258
+ const locations = [...documents.values()];
271
259
  if (locations.length) { usage[key] = locations; locations.forEach((location) => pages.add(location)); }
272
260
  }
273
261
  return { usage, pages: [...pages], count: Object.keys(usage).length, selectedCount: keys.length, blockers: [] };
@@ -302,10 +290,36 @@ export async function finalizeD1MediaRelease({ env, releaseId, now = Date.now()
302
290
  return { completed, failed };
303
291
  }
304
292
 
293
+ async function finalizeUnpublishedD1MediaOperation({ env, operationId: id, now = Date.now(), resume = false }) {
294
+ if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
295
+ const journal = new D1MediaOperationRepository(env.CONTENT_DB);
296
+ if (resume) await journal.resumeUnboundFinalization(id, now);
297
+ else if (!await journal.beginUnpublishedFinalization(id, now)) return { requiresPublication: true, completed: 0, failed: 0 };
298
+ const entries = await journal.unboundFinalizationWork(id);
299
+ let completed = 0;
300
+ let failed = 0;
301
+ for (const entry of entries) {
302
+ try {
303
+ if (entry.type === "delete") await moveToTrash(env.MEDIA, entry.sourceKey);
304
+ else await env.MEDIA.delete(entry.sourceKey);
305
+ await journal.recordFinalizedEntry({ operationId: id, entryId: entry.entryId, now, outcome: "completed" });
306
+ completed += 1;
307
+ } catch {
308
+ await journal.recordFinalizedEntry({
309
+ operationId: id, entryId: entry.entryId, now, outcome: "failed",
310
+ safeError: "La pulizia del Media verrà ritentata.",
311
+ });
312
+ failed += 1;
313
+ }
314
+ }
315
+ return { requiresPublication: false, completed, failed };
316
+ }
317
+
305
318
  export async function resumeD1MediaOperation({ env, data, operationId: id, releaseId }) {
306
319
  if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
307
320
  editor(data);
308
- if (typeof id !== "string" || typeof releaseId !== "string") throw new Error("invalid_media_operation");
321
+ if (typeof id !== "string" || !(releaseId === null || releaseId === undefined || typeof releaseId === "string")) throw new Error("invalid_media_operation");
322
+ if (!releaseId) return finalizeUnpublishedD1MediaOperation({ env, operationId: id, now: Date.now(), resume: true });
309
323
  const journal = new D1MediaOperationRepository(env.CONTENT_DB);
310
324
  await journal.resumeFinalization(id, releaseId, Date.now());
311
325
  return finalizeD1MediaRelease({ env, releaseId, now: Date.now() });
@@ -0,0 +1,61 @@
1
+ /* Testi alternativi associati al file Media.
2
+ *
3
+ * Vivono nei custom metadata dell'oggetto R2: in questo modo seguono già le copie usate da
4
+ * spostamento, cestino e ripristino, senza una seconda tabella da tenere sincronizzata con le
5
+ * chiavi del bucket. Il valore è JSON perché R2 accetta solo stringhe nei custom metadata.
6
+ */
7
+
8
+ export const MEDIA_ALT_METADATA_KEY = "adminkitAlt";
9
+ export const MEDIA_UPLOADED_METADATA_KEY = "adminkitUploadedAt";
10
+
11
+ const EMPTY = Object.freeze({ decorative: false, texts: Object.freeze({}) });
12
+
13
+ export function altLocales(languageConfig) {
14
+ return languageConfig?.available?.length ? [...languageConfig.available] : ["default"];
15
+ }
16
+
17
+ /** Legge in modo difensivo anche metadati scritti da una versione futura o incompleta. */
18
+ export function mediaAltFromMetadata(customMetadata, locales = null) {
19
+ const raw = customMetadata?.[MEDIA_ALT_METADATA_KEY];
20
+ if (!raw) return EMPTY;
21
+ let value;
22
+ try { value = JSON.parse(raw); } catch { return EMPTY; }
23
+ if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY;
24
+ const allowed = locales ? new Set(locales) : null;
25
+ const texts = {};
26
+ if (value.texts && typeof value.texts === "object" && !Array.isArray(value.texts)) {
27
+ for (const [locale, text] of Object.entries(value.texts)) {
28
+ if ((!allowed || allowed.has(locale)) && typeof text === "string" && text.trim()) {
29
+ texts[locale] = text.trim();
30
+ }
31
+ }
32
+ }
33
+ return { decorative: value.decorative === true, texts };
34
+ }
35
+
36
+ /** Valida il comando editoriale. Il limite è solo tecnico (dimensione dei metadata R2), non
37
+ * una falsa regola SEO: l'interfaccia non impone un maxlength al testo. */
38
+ export function normalizeMediaAlt(value, locales) {
39
+ if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.decorative !== "boolean") {
40
+ throw new Error("invalid_media_alt");
41
+ }
42
+ if (!value.texts || typeof value.texts !== "object" || Array.isArray(value.texts)) {
43
+ throw new Error("invalid_media_alt");
44
+ }
45
+ const allowed = new Set(locales);
46
+ const texts = {};
47
+ for (const [locale, text] of Object.entries(value.texts)) {
48
+ if (!allowed.has(locale) || typeof text !== "string") throw new Error("invalid_media_alt");
49
+ const normalized = text.trim();
50
+ if (normalized) texts[locale] = normalized;
51
+ }
52
+ const result = { decorative: value.decorative, texts: value.decorative ? {} : texts };
53
+ if (new TextEncoder().encode(JSON.stringify(result)).byteLength > 1600) {
54
+ throw new Error("media_alt_too_large");
55
+ }
56
+ return result;
57
+ }
58
+
59
+ export function mediaAltMetadataValue(value) {
60
+ return value.decorative || Object.keys(value.texts).length ? JSON.stringify(value) : null;
61
+ }
@@ -126,10 +126,27 @@ export async function onRequestPost({ request, env, data }) {
126
126
  const operable = keyList.filter((key) => !pending.has(key));
127
127
  const result = operable.length ? await d1DeleteMedia({ env, data, body: { ...body, keys: operable } }) : null;
128
128
  for (const keepKey of keepKeys) await env.MEDIA.delete(keepKey);
129
- return json({ ok: true, trashed: result?.trashed ?? keyList.length, operationId: result?.operationId, pagesUpdated: result?.documents.map((document) => document.kind) ?? [], deferred: Boolean(result), d1: true, warning: result ? "I file resteranno disponibili fino alla pubblicazione." : undefined });
129
+ return json({
130
+ ok: true,
131
+ trashed: result?.trashed ?? keyList.length,
132
+ operationId: result?.operationId,
133
+ pagesUpdated: result?.publishedDocuments?.map((document) => document.kind) ?? [],
134
+ requiresPublication: result?.requiresPublication === true,
135
+ deferred: result?.requiresPublication === true || Number(result?.failed ?? 0) > 0,
136
+ completed: result ? Number(result.completed ?? 0) : keyList.length,
137
+ failed: Number(result?.failed ?? 0),
138
+ d1: true,
139
+ warning: result?.requiresPublication ? "Le pagine verranno pubblicate senza questi Media prima di spostarli nel cestino." : undefined,
140
+ });
130
141
  } catch (error) {
131
- const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
132
- return json({ ok: false, code, error: code === "media_source_busy" ? "Questo Media è già in un'operazione in corso." : "Non riesco a registrare l'operazione Media D1." }, code === "unauthenticated" ? 401 : 409);
142
+ const code = error?.message === "unauthenticated" ? "unauthenticated"
143
+ : error?.message === "media_source_busy" ? "media_source_busy"
144
+ : error?.message === "invalid_document" ? "media_replacement_required"
145
+ : "media_operation_unavailable";
146
+ const message = code === "media_source_busy" ? "Questo Media è già in un'operazione in corso."
147
+ : code === "media_replacement_required" ? "Una pagina richiede questa immagine: sostituiscila prima di eliminarla."
148
+ : "Non riesco a registrare l'operazione Media D1.";
149
+ return json({ ok: false, code, error: message }, code === "unauthenticated" ? 401 : code === "media_replacement_required" ? 422 : 409);
133
150
  }
134
151
  }
135
152