@cardinali-studio/adminkit 2.2.21 → 2.2.23
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.
- package/dist/api.d.ts +38 -2
- package/dist/api.js +13 -0
- package/dist/app/app.css +15 -0
- package/dist/app/next.css +1 -0
- package/dist/app/routes/GallerySectionsEditor.d.ts +1 -1
- package/dist/app/routes/GallerySectionsEditor.js +14 -2
- package/dist/app/routes/PageEditor.d.ts +10 -0
- package/dist/app/routes/PageEditor.js +31 -8
- package/dist/app/routes/PortfolioWorksEditor.d.ts +1 -1
- package/dist/app/routes/PortfolioWorksEditor.js +11 -3
- package/dist/form/fields/index.js +2 -1
- package/dist/form/media/Lightbox.js +33 -3
- package/dist/form/media/MediaGrid.js +9 -6
- package/dist/form/media/media-model.d.ts +2 -0
- package/dist/form/media/media-model.js +8 -0
- package/dist/form/media/useMediaLibrary.d.ts +11 -2
- package/dist/form/media/useMediaLibrary.js +89 -24
- package/dist/functions/_shared/d1-media.js +105 -91
- package/dist/functions/_shared/media-alt.js +61 -0
- package/dist/functions/api/assets/delete.js +20 -3
- package/dist/functions/api/assets/media.js +57 -2
- package/dist/functions/api/assets/move.js +5 -1
- package/dist/functions/api/assets/upload.js +3 -1
- package/dist/functions/api/assets.js +5 -2
- package/dist/gallery.d.ts +2 -0
- package/dist/media.d.ts +19 -0
- package/dist/media.js +132 -0
- package/dist/normalize.d.ts +1 -0
- package/dist/normalize.js +2 -1
- package/dist/persistence.d.ts +8 -0
- package/dist/persistence.js +78 -6
- package/dist/theme.css +42 -0
- package/package.json +1 -1
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
export function mediaAltStatus(file, locales) {
|
|
2
|
+
if (file.alt?.decorative)
|
|
3
|
+
return "decorative";
|
|
4
|
+
const texts = file.alt?.texts ?? {};
|
|
5
|
+
return locales.every((locale) => typeof texts[locale] === "string" && texts[locale].trim())
|
|
6
|
+
? "described"
|
|
7
|
+
: "missing";
|
|
8
|
+
}
|
|
1
9
|
/** L'unico dominio su cui "Seleziona tutto" e la relativa checkbox possono ragionare. */
|
|
2
10
|
export function mediaSelectableItems({ mode, prefix, shown, subfolders, }) {
|
|
3
11
|
const visibleFolders = mode === "manage" && prefix !== ""
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { api, type AssetFile, type AssetFolder } from "../../api.js";
|
|
1
|
+
import { api, type AssetAltText, type AssetFile, type AssetFolder } from "../../api.js";
|
|
2
2
|
import type { MediaOperationStatus } from "../../media.js";
|
|
3
3
|
import { type MediaUploadJob } from "./media-upload-queue.js";
|
|
4
4
|
/** D1 registra l'intera cancellazione nel preflight: non esistono né il token Git né i
|
|
@@ -7,9 +7,12 @@ import { type MediaUploadJob } from "./media-upload-queue.js";
|
|
|
7
7
|
export declare function deleteMediaKeys(keys: string[], remove: typeof api.remove, onProgress?: (done: number) => void): Promise<{
|
|
8
8
|
done: number;
|
|
9
9
|
refsToken?: string;
|
|
10
|
+
operationId?: string;
|
|
11
|
+
requiresPublication?: boolean;
|
|
10
12
|
}>;
|
|
11
|
-
export type MediaOperationState = "idle" | "planning" | "awaiting_confirmation" | "moving" | "deleting" | "renaming" | "creating_folder" | "journal";
|
|
13
|
+
export type MediaOperationState = "idle" | "planning" | "awaiting_confirmation" | "moving" | "deleting" | "renaming" | "creating_folder" | "saving_alt" | "journal";
|
|
12
14
|
export type SortKey = "uploaded" | "capturedAt" | "name" | "size";
|
|
15
|
+
export type AltFilter = "all" | "missing" | "described" | "decorative";
|
|
13
16
|
export declare const SORT_LABELS: Record<SortKey, string>;
|
|
14
17
|
export interface MediaLibrary {
|
|
15
18
|
mode: "manage" | "pick";
|
|
@@ -39,6 +42,9 @@ export interface MediaLibrary {
|
|
|
39
42
|
} | null;
|
|
40
43
|
sortKey: SortKey;
|
|
41
44
|
sortDir: "asc" | "desc";
|
|
45
|
+
altFilter: AltFilter;
|
|
46
|
+
altLocales: readonly string[];
|
|
47
|
+
defaultAltLocale: string;
|
|
42
48
|
uploadDraft: {
|
|
43
49
|
files: File[];
|
|
44
50
|
destination: string | null;
|
|
@@ -55,6 +61,7 @@ export interface MediaLibrary {
|
|
|
55
61
|
setAll(on: boolean): void;
|
|
56
62
|
setSort(k: SortKey): void;
|
|
57
63
|
flipDir(): void;
|
|
64
|
+
setAltFilter(filter: AltFilter): void;
|
|
58
65
|
/** Accoda lo STESSO lavoro usato da Media. `destination` è esplicita nel picker; in gestione
|
|
59
66
|
* il ripiego resta la cartella aperta, che è il comportamento storico. */
|
|
60
67
|
upload(files: File[] | FileList, destination?: string): Promise<string | null>;
|
|
@@ -63,6 +70,7 @@ export interface MediaLibrary {
|
|
|
63
70
|
setUploadFiles(files: File[] | FileList): void;
|
|
64
71
|
setUploadDestination(destination: string | null): void;
|
|
65
72
|
setAutoWebp(on: boolean): Promise<void>;
|
|
73
|
+
saveAlt(key: string, alt: AssetAltText): Promise<boolean>;
|
|
66
74
|
view(i: number | null): void;
|
|
67
75
|
step(delta: number): void;
|
|
68
76
|
/** File e cartelle insieme: una cartella si riconosce dalla chiave, che finisce con "/". */
|
|
@@ -76,6 +84,7 @@ export interface MediaLibrary {
|
|
|
76
84
|
/** Cestina la cartella e tutto ciò che contiene. */
|
|
77
85
|
removeFolder(prefix: string): Promise<void>;
|
|
78
86
|
resumeMedia(operation: MediaOperationStatus): Promise<void>;
|
|
87
|
+
publishMedia(operation: MediaOperationStatus): Promise<void>;
|
|
79
88
|
cancelMedia(operation: MediaOperationStatus): Promise<void>;
|
|
80
89
|
/** Il nome da mostrare per un prefisso: l'etichetta se c'è, altrimenti lo slug. */
|
|
81
90
|
labelOf(prefix: string): string;
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
/*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
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\
|
|
427
|
-
+
|
|
428
|
-
+
|
|
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
|
|
645
|
-
if (!
|
|
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,
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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:
|
|
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 {
|
|
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) => [
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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'
|
|
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 =
|
|
269
|
-
const
|
|
270
|
-
|
|
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
|
|
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
|
+
}
|