@cardinali-studio/adminkit 2.2.10 → 2.2.11
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 +15 -0
- package/dist/api.js +26 -2
- package/dist/app/lib/publish.js +6 -1
- package/dist/form/media/MediaGrid.js +10 -1
- package/dist/form/media/useMediaLibrary.d.ts +4 -0
- package/dist/form/media/useMediaLibrary.js +51 -4
- package/dist/functions/_shared/d1-media.js +317 -0
- package/dist/functions/api/assets/delete.js +19 -1
- package/dist/functions/api/assets/folders.js +3 -1
- package/dist/functions/api/assets/media.js +30 -0
- package/dist/functions/api/assets/move.js +22 -6
- package/dist/functions/api/assets/usage.js +5 -1
- package/dist/functions/api/assets.js +3 -1
- package/dist/functions/api/content-ci.js +8 -1
- package/dist/functions/index.js +2 -0
- package/dist/media.d.ts +52 -0
- package/dist/media.js +103 -1
- package/dist/persistence.d.ts +9 -0
- package/dist/persistence.js +108 -5
- package/dist/theme.css +12 -0
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type JsonValue } from "./persistence.js";
|
|
2
|
+
import type { MediaOperationStatus } from "./media.js";
|
|
2
3
|
export declare class ApiError extends Error {
|
|
3
4
|
code: string;
|
|
4
5
|
status: number;
|
|
@@ -143,6 +144,7 @@ export declare const api: {
|
|
|
143
144
|
kind: string;
|
|
144
145
|
updatedAt: number;
|
|
145
146
|
}[];
|
|
147
|
+
mediaOperations?: MediaOperationStatus[];
|
|
146
148
|
activeReleaseId?: string | null;
|
|
147
149
|
}>;
|
|
148
150
|
contentPublish: () => Promise<{
|
|
@@ -219,6 +221,19 @@ export declare const api: {
|
|
|
219
221
|
}[];
|
|
220
222
|
warning?: string;
|
|
221
223
|
}>;
|
|
224
|
+
mediaOperations: () => Promise<{
|
|
225
|
+
ok: true;
|
|
226
|
+
operations: MediaOperationStatus[];
|
|
227
|
+
}>;
|
|
228
|
+
resumeMediaOperation: (operationId: string, releaseId: string) => Promise<{
|
|
229
|
+
ok: true;
|
|
230
|
+
completed: number;
|
|
231
|
+
failed: number;
|
|
232
|
+
}>;
|
|
233
|
+
cancelMediaOperation: (operationId: string) => Promise<{
|
|
234
|
+
ok: true;
|
|
235
|
+
cancelled: true;
|
|
236
|
+
}>;
|
|
222
237
|
usage: (target: {
|
|
223
238
|
keys?: string[];
|
|
224
239
|
prefixes?: string[];
|
package/dist/api.js
CHANGED
|
@@ -78,14 +78,38 @@ export const api = {
|
|
|
78
78
|
/** `label` è il nome per esteso; lo slug della chiave lo calcola il server e lo restituisce. */
|
|
79
79
|
createFolder: (parent, name, opts = {}) => post("/admin/api/assets/folder", { parent, name, ...opts }),
|
|
80
80
|
/** Con `skipRefs` copia senza cancellare gli originali e restituisce la mappa degli URL. */
|
|
81
|
-
move: (keys, dest, opts = {}) =>
|
|
81
|
+
move: async (keys, dest, opts = {}) => {
|
|
82
|
+
const operationId = crypto.randomUUID();
|
|
83
|
+
const destinationPrefix = dest.trim().endsWith("/") ? dest.trim() : `${dest.trim()}/`;
|
|
84
|
+
const sourceKeys = [...new Set(keys)].filter((key) => {
|
|
85
|
+
const slash = key.lastIndexOf("/");
|
|
86
|
+
const parent = slash < 0 ? "" : key.slice(0, slash + 1);
|
|
87
|
+
return parent !== destinationPrefix;
|
|
88
|
+
});
|
|
89
|
+
if (!sourceKeys.length)
|
|
90
|
+
return {
|
|
91
|
+
ok: true, moved: 0, finalized: 0, urlMap: {}, pagesUpdated: [], failed: [],
|
|
92
|
+
};
|
|
93
|
+
const requestChecksum = await canonicalChecksum({ action: "media.operation", operationId, type: "move", sourceKeys, sourcePrefix: null, destinationPrefix });
|
|
94
|
+
return post("/admin/api/assets/move", { keys: sourceKeys, dest: destinationPrefix, operationId, requestChecksum, ...opts });
|
|
95
|
+
},
|
|
82
96
|
/** Riscrive i riferimenti, poi elimina gli originali le cui copie sono state verificate. */
|
|
83
97
|
moveApplyRefs: (applyRefs) => post("/admin/api/assets/move", { applyRefs }),
|
|
98
|
+
mediaOperations: () => call("/admin/api/assets/media"),
|
|
99
|
+
resumeMediaOperation: (operationId, releaseId) => post("/admin/api/assets/media", { action: "resume", operationId, releaseId }),
|
|
100
|
+
cancelMediaOperation: (operationId) => post("/admin/api/assets/media", { action: "cancel", operationId }),
|
|
84
101
|
usage: (target) => post("/admin/api/assets/usage", target),
|
|
85
102
|
/* `refsOnly` pulisce solo i riferimenti nelle pagine (un commit, niente file spostati);
|
|
86
103
|
* `skipRefs` sposta i file e non tocca il repo. Servono a spezzare una cancellazione grande
|
|
87
104
|
* in più richieste senza fare un commit — cioè una build — per ogni gruppo. */
|
|
88
|
-
remove: (target, opts = {}) =>
|
|
105
|
+
remove: async (target, opts = {}) => {
|
|
106
|
+
const operationId = crypto.randomUUID();
|
|
107
|
+
const sourceKeys = target.prefixes?.length ? null : [...new Set(target.keys ?? [])];
|
|
108
|
+
const requestChecksum = sourceKeys?.length
|
|
109
|
+
? await canonicalChecksum({ action: "media.operation", operationId, type: "delete", sourceKeys, sourcePrefix: null, destinationPrefix: null })
|
|
110
|
+
: undefined;
|
|
111
|
+
return post("/admin/api/assets/delete", { ...target, operationId, ...(requestChecksum ? { requestChecksum } : {}), ...opts });
|
|
112
|
+
},
|
|
89
113
|
// ---- richieste dal form di contatto
|
|
90
114
|
requests: () => call("/admin/api/requests"),
|
|
91
115
|
markRequest: (id, handled) => call("/admin/api/requests", {
|
package/dist/app/lib/publish.js
CHANGED
|
@@ -22,7 +22,12 @@ export function usePublish() {
|
|
|
22
22
|
setAhead(d.ahead ?? 0);
|
|
23
23
|
setCommits((d.documents ?? []).map((document) => ({
|
|
24
24
|
message: `Modifica salvata (${document.kind})`, date: document.updatedAt ? new Date(document.updatedAt).toISOString() : null,
|
|
25
|
-
})))
|
|
25
|
+
})).concat((d.mediaOperations ?? [])
|
|
26
|
+
.filter((operation) => operation.state === "ready")
|
|
27
|
+
.map((operation) => ({
|
|
28
|
+
message: operation.type === "move" ? "Spostamento Media pronto" : "Eliminazione Media pronta",
|
|
29
|
+
date: operation.updatedAt ? new Date(operation.updatedAt).toISOString() : null,
|
|
30
|
+
}))));
|
|
26
31
|
setConfigured(true);
|
|
27
32
|
setError(null);
|
|
28
33
|
}
|
|
@@ -23,6 +23,15 @@ import { SORT_LABELS } from "./useMediaLibrary.js";
|
|
|
23
23
|
import { DRAG_TYPE, DRAG_FOLDERS, readDragged, FolderMenu } from "./MediaFolders.js";
|
|
24
24
|
import { Lightbox } from "./Lightbox.js";
|
|
25
25
|
import { UploadDialog } from "./UploadDialog.js";
|
|
26
|
+
const JOURNAL_LABELS = {
|
|
27
|
+
preparing: "Preparo le copie Media.",
|
|
28
|
+
ready: "Media pronta: pubblica per applicare la modifica.",
|
|
29
|
+
blocked_in_use: "Operazione bloccata: una bozza o la versione online usa ancora la sorgente.",
|
|
30
|
+
bound: "Pubblicazione con Media in corso.",
|
|
31
|
+
finalizing: "Pubblicazione riuscita: completo la pulizia Media.",
|
|
32
|
+
completed_with_warning: "Pubblicato con Media da completare.",
|
|
33
|
+
failed: "Operazione Media interrotta prima della pubblicazione.",
|
|
34
|
+
};
|
|
26
35
|
/* Scaricare più file significa più download: il browser ne consente una raffica breve dallo
|
|
27
36
|
* stesso gesto dell'utente, e non c'è modo di fare uno zip senza un endpoint che lo costruisca. */
|
|
28
37
|
function download(urls) {
|
|
@@ -117,7 +126,7 @@ export function MediaGrid({ lib, onPick, onCancel }) {
|
|
|
117
126
|
void lib.upload(e.dataTransfer.files);
|
|
118
127
|
}, children: [dragDepth > 0 && (_jsx("div", { className: "mg-dropzone", children: _jsxs("span", { children: ["Rilascia per caricare in ", _jsx("strong", { children: lib.prefix ? lib.labelOf(lib.prefix) : "Senza cartella" })] }) })), _jsx("header", { className: "mg-bar", children: sel.size === 0 ? (_jsxs(_Fragment, { children: [(manage || lib.multiple) && (_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: false, ref: (el) => { if (el)
|
|
119
128
|
el.indeterminate = false; }, onChange: () => lib.setAll(true) }), _jsx("span", { children: "Seleziona tutto" })] })), _jsx("span", { className: "ak-spacer" }), _jsxs("label", { className: "ak-hint", children: ["ordina per", " ", _jsx("select", { className: "mg-sort", value: lib.sortKey, onChange: (e) => lib.setSort(e.target.value), children: Object.entries(SORT_LABELS).map(([k, l]) => _jsx("option", { value: k, children: l }, k)) }), _jsx("button", { className: "ak-icon", onClick: lib.flipDir, title: "Inverti l'ordine", children: lib.sortDir === "asc" ? "↑" : "↓" })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: allOn, ref: (el) => { if (el)
|
|
120
|
-
el.indeterminate = !allOn; }, onChange: () => lib.setAll(!allOn) }), _jsxs("span", { children: [sel.size, " selezionate"] })] }), !allOn && lib.shown.length > sel.size && (_jsxs("button", { className: "mg-act is-link", onClick: () => lib.setAll(true), children: ["Seleziona tutte le ", lib.shown.length] })), manage && (_jsxs(_Fragment, { children: [lib.shown.some((f) => sel.has(f.key)) && (_jsx("button", { className: "mg-act", onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: "Scarica" })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", onClick: () => lib.setAll(false), children: "Deseleziona" })] })) }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error }), lib.progress && (_jsxs("p", { className: "mg-progress", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026 \u2014 non chiudere la pagina"] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && (_jsxs("section", { className: "mg-section", children: [_jsxs("div", { className: "mg-section-head", children: [_jsx("h2", { className: "mg-section-title", children: "Cartelle" }), manage && (_jsx("button", { className: "mg-act", onClick: () => void lib.newFolder(lib.prefix), children: "+ Cartella" }))] }), lib.subfolders.length > 0 && (_jsx("div", { className: "mg", children: lib.subfolders.map((f) => (_jsx(FolderCell, { lib: lib, folder: f, manage: manage }, f.prefix))) }))] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && lib.shown.length > 0 && (_jsx("h2", { className: "mg-section-title", children: "Foto" })), _jsx("div", { className: "mg", children: lib.shown.map((f, i) => {
|
|
129
|
+
el.indeterminate = !allOn; }, onChange: () => lib.setAll(!allOn) }), _jsxs("span", { children: [sel.size, " selezionate"] })] }), !allOn && lib.shown.length > sel.size && (_jsxs("button", { className: "mg-act is-link", onClick: () => lib.setAll(true), children: ["Seleziona tutte le ", lib.shown.length] })), manage && (_jsxs(_Fragment, { children: [lib.shown.some((f) => sel.has(f.key)) && (_jsx("button", { className: "mg-act", onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: "Scarica" })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", onClick: () => lib.setAll(false), children: "Deseleziona" })] })) }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error }), manage && lib.mediaOperations.length > 0 && (_jsx("section", { className: "mg-journal", "aria-live": "polite", "aria-label": "Stato operazioni Media", children: lib.mediaOperations.map((operation) => (_jsxs("div", { className: `mg-journal-row is-${operation.state}`, children: [_jsx("span", { className: "mg-journal-badge", children: operation.type === "move" ? "Spostamento" : "Eliminazione" }), _jsx("span", { children: JOURNAL_LABELS[operation.state] ?? "Operazione Media in attesa." }), operation.failedEntries > 0 && _jsxs("span", { className: "ak-hint", children: [operation.failedEntries, " file da riprovare"] }), _jsx("span", { className: "ak-spacer" }), operation.state === "completed_with_warning" && operation.releaseId && (_jsx("button", { className: "mg-act", disabled: lib.busy, onClick: () => void lib.resumeMedia(operation), children: "Riprendi" })), ["preparing", "ready", "blocked_in_use", "failed"].includes(operation.state) && !operation.releaseId && (_jsx("button", { className: "mg-act is-danger", disabled: lib.busy, onClick: () => void lib.cancelMedia(operation), children: "Annulla" }))] }, operation.operationId))) })), lib.progress && (_jsxs("p", { className: "mg-progress", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026 \u2014 non chiudere la pagina"] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && (_jsxs("section", { className: "mg-section", children: [_jsxs("div", { className: "mg-section-head", children: [_jsx("h2", { className: "mg-section-title", children: "Cartelle" }), manage && (_jsx("button", { className: "mg-act", onClick: () => void lib.newFolder(lib.prefix), children: "+ Cartella" }))] }), lib.subfolders.length > 0 && (_jsx("div", { className: "mg", children: lib.subfolders.map((f) => (_jsx(FolderCell, { lib: lib, folder: f, manage: manage }, f.prefix))) }))] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && lib.shown.length > 0 && (_jsx("h2", { className: "mg-section-title", children: "Foto" })), _jsx("div", { className: "mg", children: lib.shown.map((f, i) => {
|
|
121
130
|
const on = sel.has(f.key);
|
|
122
131
|
return (_jsxs("figure", { className: "mg-item" + (on ? " is-on" : ""), draggable: manage, onDragStart: (e) => {
|
|
123
132
|
// Si trascina la SELEZIONE se la foto ne fa parte, altrimenti solo quella.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AssetFile, type AssetFolder } from "../../api.js";
|
|
2
|
+
import type { MediaOperationStatus } from "../../media.js";
|
|
2
3
|
export type SortKey = "uploaded" | "capturedAt" | "name" | "size";
|
|
3
4
|
export declare const SORT_LABELS: Record<SortKey, string>;
|
|
4
5
|
export interface MediaLibrary {
|
|
@@ -11,6 +12,7 @@ export interface MediaLibrary {
|
|
|
11
12
|
selected: Set<string>;
|
|
12
13
|
busy: boolean;
|
|
13
14
|
error: string | null;
|
|
15
|
+
mediaOperations: MediaOperationStatus[];
|
|
14
16
|
/** Avanzamento di un'operazione lunga. `verb` è quello che si sta facendo ("Carico",
|
|
15
17
|
* "Cestino", "Sposto"): senza, un'attesa di trenta secondi non dice cosa sta succedendo e
|
|
16
18
|
* chi guarda ricarica la pagina — interrompendo l'operazione a metà. */
|
|
@@ -43,6 +45,8 @@ export interface MediaLibrary {
|
|
|
43
45
|
renameFolder(prefix: string, label: string): Promise<void>;
|
|
44
46
|
/** Cestina la cartella e tutto ciò che contiene. */
|
|
45
47
|
removeFolder(prefix: string): Promise<void>;
|
|
48
|
+
resumeMedia(operation: MediaOperationStatus): Promise<void>;
|
|
49
|
+
cancelMedia(operation: MediaOperationStatus): Promise<void>;
|
|
46
50
|
/** Il nome da mostrare per un prefisso: l'etichetta se c'è, altrimenti lo slug. */
|
|
47
51
|
labelOf(prefix: string): string;
|
|
48
52
|
clearError(): void;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
12
12
|
import { api, uploadQueue, ApiError } from "../../api.js";
|
|
13
13
|
import { storageKey } from "../../config.js";
|
|
14
|
+
import { contentPersistenceMode } from "../../app/site.js";
|
|
14
15
|
/* Quanti file per richiesta nelle operazioni lunghe (cestina, sposta). Ogni file costa al
|
|
15
16
|
* server tre operazioni su R2 — legge, riscrive altrove, cancella — su oggetti da qualche MB.
|
|
16
17
|
* Dieci tiene una richiesta sotto i pochi secondi, che è la soglia oltre la quale chi guarda
|
|
@@ -34,6 +35,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
34
35
|
const [busy, setBusy] = useState(false);
|
|
35
36
|
const [loaded, setLoaded] = useState(false);
|
|
36
37
|
const [error, setError] = useState(null);
|
|
38
|
+
const [mediaOperations, setMediaOperations] = useState([]);
|
|
37
39
|
const [progress, setProgress] = useState(null);
|
|
38
40
|
const [uploadOpen, setUploadOpen] = useState(false);
|
|
39
41
|
const [viewing, setViewing] = useState(null);
|
|
@@ -54,7 +56,13 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
54
56
|
setBusy(true);
|
|
55
57
|
setError(null);
|
|
56
58
|
try {
|
|
57
|
-
const [a, f] = await Promise.all([
|
|
59
|
+
const [a, f, journal] = await Promise.all([
|
|
60
|
+
api.assets(p),
|
|
61
|
+
api.folders(),
|
|
62
|
+
mode === "manage" && contentPersistenceMode() === "d1"
|
|
63
|
+
? api.mediaOperations()
|
|
64
|
+
: Promise.resolve({ ok: true, operations: [] }),
|
|
65
|
+
]);
|
|
58
66
|
if (fallback && p && !(f.folders ?? []).some((x) => p === x.prefix || p.startsWith(x.prefix))) {
|
|
59
67
|
setBusy(false);
|
|
60
68
|
return load("");
|
|
@@ -68,6 +76,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
68
76
|
setFiles(a.files ?? []);
|
|
69
77
|
setSubfolders(a.folders ?? []);
|
|
70
78
|
setFolders(f.folders ?? []);
|
|
79
|
+
setMediaOperations(journal.operations ?? []);
|
|
71
80
|
setPrefix(p);
|
|
72
81
|
setSelected(new Set());
|
|
73
82
|
setViewing(null);
|
|
@@ -88,7 +97,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
88
97
|
finally {
|
|
89
98
|
setBusy(false);
|
|
90
99
|
}
|
|
91
|
-
}, [remember]);
|
|
100
|
+
}, [remember, mode]);
|
|
92
101
|
/* Caricamento PIGRO come per la home: chi apre l'admin sulle pagine non paga l'elenco del
|
|
93
102
|
* bucket. `loaded` e non `files.length`, altrimenti una cartella vuota ritenterebbe all'infinito.
|
|
94
103
|
*
|
|
@@ -469,6 +478,44 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
469
478
|
}, [prefix, load, moveFolder]);
|
|
470
479
|
/** Cestina una cartella con tutto quello che contiene. È `remove` con una lista di uno. */
|
|
471
480
|
const removeFolder = useCallback((target) => remove([target]), [remove]);
|
|
481
|
+
const resumeMedia = useCallback(async (operation) => {
|
|
482
|
+
if (!operation.releaseId)
|
|
483
|
+
return;
|
|
484
|
+
setBusy(true);
|
|
485
|
+
setError(null);
|
|
486
|
+
let failure = null;
|
|
487
|
+
try {
|
|
488
|
+
await api.resumeMediaOperation(operation.operationId, operation.releaseId);
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
failure = e instanceof ApiError ? e.message : String(e);
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
setBusy(false);
|
|
495
|
+
await load(prefix);
|
|
496
|
+
if (failure)
|
|
497
|
+
setError(failure);
|
|
498
|
+
}
|
|
499
|
+
}, [load, prefix]);
|
|
500
|
+
const cancelMedia = useCallback(async (operation) => {
|
|
501
|
+
if (!confirm("Annullare questa operazione Media? Le copie già preparate resteranno disponibili nella libreria."))
|
|
502
|
+
return;
|
|
503
|
+
setBusy(true);
|
|
504
|
+
setError(null);
|
|
505
|
+
let failure = null;
|
|
506
|
+
try {
|
|
507
|
+
await api.cancelMediaOperation(operation.operationId);
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
failure = e instanceof ApiError ? e.message : String(e);
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
setBusy(false);
|
|
514
|
+
await load(prefix);
|
|
515
|
+
if (failure)
|
|
516
|
+
setError(failure);
|
|
517
|
+
}
|
|
518
|
+
}, [load, prefix]);
|
|
472
519
|
const step = useCallback((delta) => {
|
|
473
520
|
setViewing((i) => {
|
|
474
521
|
if (i === null)
|
|
@@ -478,7 +525,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
478
525
|
});
|
|
479
526
|
}, [shown.length]);
|
|
480
527
|
return {
|
|
481
|
-
mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, progress,
|
|
528
|
+
mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, mediaOperations, progress,
|
|
482
529
|
sortKey, sortDir, uploadOpen, viewing,
|
|
483
530
|
go: (p) => void load(p),
|
|
484
531
|
reload: () => void load(prefix),
|
|
@@ -489,7 +536,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
489
536
|
setUploadOpen,
|
|
490
537
|
view: setViewing,
|
|
491
538
|
step,
|
|
492
|
-
move, remove, newFolder, renameFolder, removeFolder, labelOf,
|
|
539
|
+
move, remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
|
|
493
540
|
clearError: () => setError(null),
|
|
494
541
|
};
|
|
495
542
|
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/* D1-mode Media bridge. It deliberately owns no site schema: the site
|
|
2
|
+
* validator remains the authority for every draft revision generated by an
|
|
3
|
+
* explicit Media operation. Git is never consulted from this module. */
|
|
4
|
+
import {
|
|
5
|
+
canonicalChecksum, canonicalJson, contentSaveRequestChecksum, D1ContentRepository,
|
|
6
|
+
} from "../../persistence.js";
|
|
7
|
+
import {
|
|
8
|
+
D1MediaOperationRepository, prepareMediaCopy,
|
|
9
|
+
} from "../../media.js";
|
|
10
|
+
import { validateContentDocument } from "../site.js";
|
|
11
|
+
import { isKey, moveToTrash, sanitizeName } from "./assets.js";
|
|
12
|
+
|
|
13
|
+
const MEDIA_PREFIX = "/media/";
|
|
14
|
+
|
|
15
|
+
function mediaUrl(key) { return MEDIA_PREFIX + key.split("/").map(encodeURIComponent).join("/"); }
|
|
16
|
+
|
|
17
|
+
function editor(data) {
|
|
18
|
+
const payload = data?.cloudflareAccess?.JWT?.payload;
|
|
19
|
+
if (!payload?.sub) throw new Error("unauthenticated");
|
|
20
|
+
return { subject: String(payload.sub), label: String(payload.email || payload.name || "Admin").slice(0, 200) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function operationId(value) {
|
|
24
|
+
if (value === undefined || value === null || value === "") return crypto.randomUUID();
|
|
25
|
+
if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) throw new Error("invalid_media_operation");
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
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) {
|
|
90
|
+
return ((await db.prepare(`SELECT document.document_id AS documentId, document.kind, document.locale,
|
|
91
|
+
document.draft_revision_id AS draftRevisionId, published.revision_id AS publishedRevisionId,
|
|
92
|
+
draft.payload_json AS draftPayload, draft.is_tombstone AS draftTombstone,
|
|
93
|
+
published.payload_json AS publishedPayload, published.is_tombstone AS publishedTombstone,
|
|
94
|
+
COALESCE(draft.schema_version, published.schema_version) AS schemaVersion
|
|
95
|
+
FROM content_documents document
|
|
96
|
+
LEFT JOIN content_revisions draft ON draft.document_id = document.document_id AND draft.revision_id = document.draft_revision_id
|
|
97
|
+
LEFT JOIN content_site_state state ON state.singleton = 1
|
|
98
|
+
LEFT JOIN content_release_entries entry ON entry.release_id = state.current_release_id AND entry.document_id = document.document_id
|
|
99
|
+
LEFT JOIN content_revisions published ON published.document_id = entry.document_id AND published.revision_id = entry.revision_id
|
|
100
|
+
WHERE document.abandoned_at IS NULL ORDER BY document.document_id`).all()).results ?? []);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
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
|
+
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);
|
|
114
|
+
if (!rewritten.changed) continue;
|
|
115
|
+
const normalized = validateContentDocument({
|
|
116
|
+
documentId: row.documentId, kind: row.kind, locale: row.locale, schemaVersion: row.schemaVersion,
|
|
117
|
+
tombstone: false, payload: rewritten.value,
|
|
118
|
+
});
|
|
119
|
+
commands.push({
|
|
120
|
+
revisionId: crypto.randomUUID(), documentId: row.documentId,
|
|
121
|
+
baseRevisionId: useDraft ? row.draftRevisionId : row.publishedRevisionId,
|
|
122
|
+
expectedDraftRevisionId: row.draftRevisionId, kind: row.kind, locale: row.locale,
|
|
123
|
+
schemaVersion: row.schemaVersion, tombstone: false, payload: normalized.payload,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const who = editor(data);
|
|
127
|
+
const repository = new D1ContentRepository(db);
|
|
128
|
+
/* Every command was fully parsed and site-validated before the first write.
|
|
129
|
+
* A stale CAS leaves sources untouched; an already-created R2 destination is
|
|
130
|
+
* deliberately a reusable visible asset, never an orphan cleanup target. */
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
await repository.saveMany(await Promise.all(commands.map(async (command) => ({
|
|
133
|
+
...command, payloadChecksum: await canonicalChecksum(command.payload), requestChecksum: await contentSaveRequestChecksum(command),
|
|
134
|
+
now, authorSubject: who.subject, authorLabel: who.label,
|
|
135
|
+
}))));
|
|
136
|
+
return { documents: commands.map((command) => ({ documentId: command.documentId, kind: command.kind })) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function keys(input) {
|
|
140
|
+
const unique = [...new Set((Array.isArray(input) ? input : []).filter(isKey).filter((key) => !key.startsWith("_trash/")))];
|
|
141
|
+
if (!unique.length) throw new Error("invalid_media_operation");
|
|
142
|
+
return unique;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function uniqueOperationKey(bucket, prefix, filename, reserved) {
|
|
146
|
+
const name = sanitizeName(filename);
|
|
147
|
+
const dot = name.lastIndexOf(".");
|
|
148
|
+
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
149
|
+
const extension = dot > 0 ? name.slice(dot) : "";
|
|
150
|
+
for (let suffix = 1; suffix <= 999; suffix += 1) {
|
|
151
|
+
const candidate = suffix === 1 ? `${prefix}${name}` : `${prefix}${base}-${suffix}${extension}`;
|
|
152
|
+
if (reserved.has(candidate) || await bucket.head(candidate)) continue;
|
|
153
|
+
reserved.add(candidate);
|
|
154
|
+
return candidate;
|
|
155
|
+
}
|
|
156
|
+
throw new Error("media_destination_collision");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function createOperation({ db, data, type, id, entries, sourcePrefix = null, destinationPrefix = null, clientChecksum = null }) {
|
|
160
|
+
const who = editor(data);
|
|
161
|
+
const requestChecksum = await canonicalChecksum({
|
|
162
|
+
action: "media.operation", operationId: id, type,
|
|
163
|
+
sourceKeys: entries.map((entry) => entry.sourceKey), sourcePrefix, destinationPrefix,
|
|
164
|
+
});
|
|
165
|
+
if (clientChecksum && clientChecksum !== requestChecksum) throw new Error("invalid_media_operation");
|
|
166
|
+
const journal = new D1MediaOperationRepository(db);
|
|
167
|
+
const created = await journal.create({ operationId: id, requestChecksum, type, entries, sourcePrefix, destinationPrefix, authorSubject: who.subject, now: Date.now() });
|
|
168
|
+
return { journal, created };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function inspectSource(bucket, entry) {
|
|
172
|
+
const object = await bucket.get(entry.sourceKey);
|
|
173
|
+
if (!object) throw new Error("media_source_missing");
|
|
174
|
+
const bytes = await object.arrayBuffer();
|
|
175
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
176
|
+
const objectChecksum = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
177
|
+
return {
|
|
178
|
+
...entry,
|
|
179
|
+
objectChecksum,
|
|
180
|
+
etag: object.etag ?? null,
|
|
181
|
+
metadataJson: canonicalJson({ httpMetadata: object.httpMetadata ?? {}, customMetadata: object.customMetadata ?? {} }),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function d1MoveMedia({ env, data, body }) {
|
|
186
|
+
if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
|
|
187
|
+
let destination = String(body.dest || "").trim();
|
|
188
|
+
if (destination && !destination.endsWith("/")) destination += "/";
|
|
189
|
+
if (!destination || !/^(?:[A-Za-z0-9._-]+\/)+$/.test(destination) || destination.startsWith("_trash/")) throw new Error("invalid_media_operation");
|
|
190
|
+
const sourceKeys = keys(body.keys).filter((key) => key.slice(0, key.lastIndexOf("/") + 1) !== destination);
|
|
191
|
+
const id = operationId(body.operationId);
|
|
192
|
+
if (!sourceKeys.length) return { operationId: id, repeated: false, moved: 0, urlMap: {}, documents: [] };
|
|
193
|
+
const entries = [];
|
|
194
|
+
const reserved = new Set();
|
|
195
|
+
for (const sourceKey of sourceKeys) {
|
|
196
|
+
const base = sourceKey.split("/").pop();
|
|
197
|
+
const destinationKey = await uniqueOperationKey(env.MEDIA, destination, base, reserved);
|
|
198
|
+
entries.push({ entryId: crypto.randomUUID(), sourceKey, destinationKey });
|
|
199
|
+
}
|
|
200
|
+
const { journal, created } = await createOperation({ db: env.CONTENT_DB, data, type: "move", id, entries, sourcePrefix: null, destinationPrefix: destination, clientChecksum: body.requestChecksum });
|
|
201
|
+
if (created === "repeated") {
|
|
202
|
+
const stored = await journal.entries(id);
|
|
203
|
+
return {
|
|
204
|
+
operationId: id, repeated: true, moved: stored.length,
|
|
205
|
+
urlMap: Object.fromEntries(stored.filter((entry) => entry.destinationKey).map((entry) => [mediaUrl(entry.sourceKey), mediaUrl(entry.destinationKey)])),
|
|
206
|
+
documents: [],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
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() });
|
|
211
|
+
const rewritten = await rewriteDocuments({ db: env.CONTENT_DB, data, replacements: Object.fromEntries(entries.map((entry) => [entry.sourceKey, entry.destinationKey])) });
|
|
212
|
+
return { operationId: id, repeated: false, moved: entries.length, urlMap: Object.fromEntries(entries.map((entry) => [mediaUrl(entry.sourceKey), mediaUrl(entry.destinationKey)])), documents: rewritten.documents };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
await journal.cancel(id, Date.now()).catch(() => null);
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function d1DeleteMedia({ env, data, body }) {
|
|
220
|
+
if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
|
|
221
|
+
const sourceKeys = keys(body.keys);
|
|
222
|
+
const id = operationId(body.operationId);
|
|
223
|
+
const entries = sourceKeys.map((sourceKey) => ({ entryId: crypto.randomUUID(), sourceKey }));
|
|
224
|
+
const { journal, created } = await createOperation({ db: env.CONTENT_DB, data, type: "delete", id, entries, clientChecksum: body.requestChecksum });
|
|
225
|
+
if (created === "repeated") return { operationId: id, repeated: true, trashed: (await journal.entries(id)).length, documents: [] };
|
|
226
|
+
try {
|
|
227
|
+
const inspected = await Promise.all(entries.map((entry) => inspectSource(env.MEDIA, entry)));
|
|
228
|
+
const rewritten = await rewriteDocuments({ db: env.CONTENT_DB, data, removals: sourceKeys });
|
|
229
|
+
for (const entry of inspected) await journal.readyDeleteEntry({ ...entry, operationId: id, now: Date.now() });
|
|
230
|
+
return { operationId: id, repeated: false, trashed: entries.length, documents: rewritten.documents };
|
|
231
|
+
} catch (error) {
|
|
232
|
+
await journal.cancel(id, Date.now()).catch(() => null);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function d1PendingSourceKeys(db) {
|
|
238
|
+
return new Set(await new D1MediaOperationRepository(db).activeSourceKeys());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Usage is read from the public release, every current draft, and an
|
|
242
|
+
* in-flight immutable candidate. It never reads Git in D1 mode. */
|
|
243
|
+
export async function d1MediaUsage(db, sourceKeys) {
|
|
244
|
+
const keys = [...new Set(sourceKeys.filter(isKey))];
|
|
245
|
+
if (!keys.length) return { usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] };
|
|
246
|
+
const rows = (await db.prepare(`SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
|
|
247
|
+
FROM content_documents document
|
|
248
|
+
JOIN content_revisions revision ON revision.document_id = document.document_id AND revision.revision_id = document.draft_revision_id
|
|
249
|
+
UNION
|
|
250
|
+
SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
|
|
251
|
+
FROM content_site_state state
|
|
252
|
+
JOIN content_release_entries entry ON entry.release_id = state.current_release_id
|
|
253
|
+
JOIN content_documents document ON document.document_id = entry.document_id
|
|
254
|
+
JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
|
|
255
|
+
UNION
|
|
256
|
+
SELECT document.kind, document.document_id AS documentId, revision.payload_json AS payload
|
|
257
|
+
FROM content_releases release
|
|
258
|
+
JOIN content_release_entries entry ON entry.release_id = release.release_id
|
|
259
|
+
JOIN content_documents document ON document.document_id = entry.document_id
|
|
260
|
+
JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
|
|
261
|
+
WHERE release.status = 'publishing'`).all()).results ?? [];
|
|
262
|
+
const usage = {};
|
|
263
|
+
const pages = new Set();
|
|
264
|
+
for (const key of keys) {
|
|
265
|
+
const url = mediaUrl(key);
|
|
266
|
+
const locations = [...new Set(rows.filter((row) => typeof row.payload === "string" && row.payload.includes(url))
|
|
267
|
+
.map((row) => `${row.kind} · ${row.documentId}`))];
|
|
268
|
+
if (locations.length) { usage[key] = locations; locations.forEach((location) => pages.add(location)); }
|
|
269
|
+
}
|
|
270
|
+
return { usage, pages: [...pages], count: Object.keys(usage).length, selectedCount: keys.length, blockers: [] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function finalizeD1MediaRelease({ env, releaseId, now = Date.now() }) {
|
|
274
|
+
if (!env.CONTENT_DB || !env.MEDIA) return { completed: 0, failed: 0, skipped: "binding non configurati" };
|
|
275
|
+
const journal = new D1MediaOperationRepository(env.CONTENT_DB);
|
|
276
|
+
const work = await journal.finalizationWork(releaseId);
|
|
277
|
+
const byOperation = new Map();
|
|
278
|
+
for (const entry of work) {
|
|
279
|
+
const group = byOperation.get(entry.operationId) || [];
|
|
280
|
+
group.push(entry); byOperation.set(entry.operationId, group);
|
|
281
|
+
}
|
|
282
|
+
let completed = 0, failed = 0;
|
|
283
|
+
for (const [id, entries] of byOperation) {
|
|
284
|
+
const first = entries[0];
|
|
285
|
+
if (first.operationState === "completed_with_warning") await journal.resumeFinalization(id, releaseId, now);
|
|
286
|
+
else await journal.beginFinalization(id, releaseId, now);
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
try {
|
|
289
|
+
if (entry.type === "delete") await moveToTrash(env.MEDIA, entry.sourceKey);
|
|
290
|
+
else await env.MEDIA.delete(entry.sourceKey);
|
|
291
|
+
await journal.recordFinalizedEntry({ operationId: id, entryId: entry.entryId, now, outcome: "completed" });
|
|
292
|
+
completed += 1;
|
|
293
|
+
} catch {
|
|
294
|
+
await journal.recordFinalizedEntry({ operationId: id, entryId: entry.entryId, now, outcome: "failed", safeError: "La pulizia del Media verrà ritentata." });
|
|
295
|
+
failed += 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { completed, failed };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function resumeD1MediaOperation({ env, data, operationId: id, releaseId }) {
|
|
303
|
+
if (!env.CONTENT_DB || !env.MEDIA) throw new Error("content_unavailable");
|
|
304
|
+
editor(data);
|
|
305
|
+
if (typeof id !== "string" || typeof releaseId !== "string") throw new Error("invalid_media_operation");
|
|
306
|
+
const journal = new D1MediaOperationRepository(env.CONTENT_DB);
|
|
307
|
+
await journal.resumeFinalization(id, releaseId, Date.now());
|
|
308
|
+
return finalizeD1MediaRelease({ env, releaseId, now: Date.now() });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function cancelD1MediaOperation({ env, data, operationId: id }) {
|
|
312
|
+
if (!env.CONTENT_DB) throw new Error("content_unavailable");
|
|
313
|
+
editor(data);
|
|
314
|
+
if (typeof id !== "string") throw new Error("invalid_media_operation");
|
|
315
|
+
await new D1MediaOperationRepository(env.CONTENT_DB).cancel(id, Date.now());
|
|
316
|
+
return { cancelled: true };
|
|
317
|
+
}
|
|
@@ -23,6 +23,8 @@ import { mediaUrl } from "../../_shared/refs.js";
|
|
|
23
23
|
import { rewriteMediaReferences } from "../../_shared/media-content.js";
|
|
24
24
|
import { getBase, DRAFT } from "../../_shared/github.js";
|
|
25
25
|
import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
|
|
26
|
+
import { contentPersistenceMode } from "../../site.js";
|
|
27
|
+
import { d1DeleteMedia, d1PendingSourceKeys } from "../../_shared/d1-media.js";
|
|
26
28
|
|
|
27
29
|
const enc = new TextEncoder();
|
|
28
30
|
|
|
@@ -90,7 +92,7 @@ export async function verifyRefsPreflight(env, token, keys, currentCommit) {
|
|
|
90
92
|
return keys.every((key) => allowed.has(key));
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
export async function onRequestPost({ request, env }) {
|
|
95
|
+
export async function onRequestPost({ request, env, data }) {
|
|
94
96
|
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
95
97
|
let body;
|
|
96
98
|
try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
@@ -115,6 +117,22 @@ export async function onRequestPost({ request, env }) {
|
|
|
115
117
|
const keyList = [...keys].filter((k) => !k.startsWith("_trash/"));
|
|
116
118
|
if (!keyList.length && !keepKeys.length) return json({ ok: true, trashed: 0, pagesUpdated: [] });
|
|
117
119
|
|
|
120
|
+
if (contentPersistenceMode() === "d1") {
|
|
121
|
+
try {
|
|
122
|
+
/* A legacy client may repeat its old refsOnly/skipRefs sequence. Once a
|
|
123
|
+
* D1 operation owns a source, later chunks can only remove .keep files;
|
|
124
|
+
* they cannot manufacture a second destructive operation. */
|
|
125
|
+
const pending = await d1PendingSourceKeys(env.CONTENT_DB);
|
|
126
|
+
const operable = keyList.filter((key) => !pending.has(key));
|
|
127
|
+
const result = operable.length ? await d1DeleteMedia({ env, data, body: { ...body, keys: operable } }) : null;
|
|
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 });
|
|
130
|
+
} 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);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
const refsOnly = body.refsOnly === true;
|
|
119
137
|
const skipRefs = body.skipRefs === true;
|
|
120
138
|
if (keyList.length && (!env.GITHUB_TOKEN || !env.GITHUB_REPO)) {
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { json, TRASH, listKeysUnder } from "../../_shared/assets.js";
|
|
6
6
|
import { pendingSourceKeys } from "../../_shared/pending-media.js";
|
|
7
|
+
import { contentPersistenceMode } from "../../site.js";
|
|
8
|
+
import { d1PendingSourceKeys } from "../../_shared/d1-media.js";
|
|
7
9
|
|
|
8
10
|
export async function onRequestGet({ env }) {
|
|
9
11
|
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
10
12
|
const keys = await listKeysUnder(env.MEDIA, "");
|
|
11
|
-
const pending = await pendingSourceKeys(env.MEDIA);
|
|
13
|
+
const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
|
|
12
14
|
const counts = {};
|
|
13
15
|
for (const k of keys) {
|
|
14
16
|
if (k.startsWith(TRASH) || k.startsWith("_pending-media/") || pending.has(k)) continue;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* D1 Media operation status and bounded recovery. These routes exist only
|
|
2
|
+
* after D1 cutover; Git-mode Media keeps its established pending-marker flow. */
|
|
3
|
+
import { json } from "../../_shared/assets.js";
|
|
4
|
+
import { D1MediaOperationRepository } from "../../../media.js";
|
|
5
|
+
import { contentPersistenceMode } from "../../site.js";
|
|
6
|
+
import { cancelD1MediaOperation, resumeD1MediaOperation } from "../../_shared/d1-media.js";
|
|
7
|
+
|
|
8
|
+
function unavailable() { return json({ ok: false, code: "feature_inactive", error: "Journal Media D1 non attivo." }, 404); }
|
|
9
|
+
function failure(error) {
|
|
10
|
+
const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_operation_unavailable" ? "media_operation_unavailable" : "media_operation_invalid";
|
|
11
|
+
return json({ ok: false, code, error: code === "unauthenticated" ? "Sessione non valida: ricarica la pagina per rientrare." : "L'operazione Media non può essere completata." }, code === "unauthenticated" ? 401 : 409);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function onRequestGet({ env }) {
|
|
15
|
+
if (contentPersistenceMode() !== "d1") return unavailable();
|
|
16
|
+
if (!env.CONTENT_DB) return json({ ok: false, code: "content_unavailable", error: "Persistenza contenuti non disponibile." }, 503);
|
|
17
|
+
try { return json({ ok: true, operations: await new D1MediaOperationRepository(env.CONTENT_DB).statuses() }); }
|
|
18
|
+
catch (error) { return failure(error); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function onRequestPost({ request, env, data }) {
|
|
22
|
+
if (contentPersistenceMode() !== "d1") return unavailable();
|
|
23
|
+
let body;
|
|
24
|
+
try { body = await request.json(); } catch { return json({ ok: false, code: "media_operation_invalid", error: "JSON non valido." }, 400); }
|
|
25
|
+
try {
|
|
26
|
+
if (body?.action === "cancel") return json({ ok: true, ...(await cancelD1MediaOperation({ env, data, operationId: body.operationId })) });
|
|
27
|
+
if (body?.action === "resume") return json({ ok: true, ...(await resumeD1MediaOperation({ env, data, operationId: body.operationId, releaseId: body.releaseId })) });
|
|
28
|
+
throw new Error("invalid_media_operation");
|
|
29
|
+
} catch (error) { return failure(error); }
|
|
30
|
+
}
|
|
@@ -14,6 +14,8 @@ import { mediaUrl } from "../../_shared/refs.js";
|
|
|
14
14
|
import { rewriteMediaReferences } from "../../_shared/media-content.js";
|
|
15
15
|
import { getBase } from "../../_shared/github.js";
|
|
16
16
|
import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
|
|
17
|
+
import { contentPersistenceMode } from "../../site.js";
|
|
18
|
+
import { d1MoveMedia } from "../../_shared/d1-media.js";
|
|
17
19
|
|
|
18
20
|
function mediaKey(url) {
|
|
19
21
|
if (typeof url !== "string" || !url.startsWith("/media/")) return null;
|
|
@@ -128,8 +130,27 @@ async function confirmRewrittenCommit(env, commitSha) {
|
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
export async function onRequestPost({ request, env }) {
|
|
133
|
+
export async function onRequestPost({ request, env, data }) {
|
|
132
134
|
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
135
|
+
let body;
|
|
136
|
+
try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
137
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
138
|
+
return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (contentPersistenceMode() === "d1") {
|
|
142
|
+
/* The former two-step Git protocol must never become a second editorial
|
|
143
|
+
* writer after cutover. A stale browser's applyRefs call is harmless: the
|
|
144
|
+
* first D1 command already created the revisions and journal intent. */
|
|
145
|
+
if (Object.hasOwn(body, "applyRefs")) return json({ ok: true, moved: 0, pagesUpdated: [], failed: [], d1: true });
|
|
146
|
+
try {
|
|
147
|
+
const result = await d1MoveMedia({ env, data, body });
|
|
148
|
+
return json({ ok: true, ...result, pagesUpdated: result.documents.map((document) => document.kind), failed: [], warning: "Le sorgenti restano disponibili fino alla pubblicazione." });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
|
|
151
|
+
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);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
133
154
|
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
|
|
134
155
|
return json({
|
|
135
156
|
ok: false,
|
|
@@ -137,11 +158,6 @@ export async function onRequestPost({ request, env }) {
|
|
|
137
158
|
error: "Non posso spostare media senza aggiornare i riferimenti nel sito.",
|
|
138
159
|
}, 503);
|
|
139
160
|
}
|
|
140
|
-
let body;
|
|
141
|
-
try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
142
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
143
|
-
return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
|
|
144
|
-
}
|
|
145
161
|
|
|
146
162
|
/* Seconda fase: le copie esistono già. Prima le verifichiamo TUTTE, poi aggiorniamo il repo,
|
|
147
163
|
* infine cancelliamo gli originali. */
|
|
@@ -7,10 +7,11 @@ import { mediaUrl } from "../../_shared/refs.js";
|
|
|
7
7
|
import {
|
|
8
8
|
readMediaSources, summarizeMediaUsage, planMediaRewrite,
|
|
9
9
|
} from "../../_shared/media-content.js";
|
|
10
|
+
import { contentPersistenceMode } from "../../site.js";
|
|
11
|
+
import { d1MediaUsage } from "../../_shared/d1-media.js";
|
|
10
12
|
|
|
11
13
|
export async function onRequestPost({ request, env }) {
|
|
12
14
|
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
13
|
-
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
|
|
14
15
|
let body;
|
|
15
16
|
try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
16
17
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -27,6 +28,9 @@ export async function onRequestPost({ request, env }) {
|
|
|
27
28
|
return json({ ok: true, usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] });
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
if (contentPersistenceMode() === "d1") return json({ ok: true, ...(await d1MediaUsage(env.CONTENT_DB, keyList)) });
|
|
32
|
+
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
|
|
33
|
+
|
|
30
34
|
const { sources } = await readMediaSources(env);
|
|
31
35
|
const summary = summarizeMediaUsage(sources, keyList, mediaUrl);
|
|
32
36
|
const plan = planMediaRewrite(sources, { removeUrls: keyList.map(mediaUrl) });
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { isPrefix, json, listKeysUnder } from "../_shared/assets.js";
|
|
5
5
|
import { sizeFromBucket } from "../_shared/imagesize.js";
|
|
6
6
|
import { pendingSourceKeys } from "../_shared/pending-media.js";
|
|
7
|
+
import { contentPersistenceMode } from "../site.js";
|
|
8
|
+
import { d1PendingSourceKeys } from "../_shared/d1-media.js";
|
|
7
9
|
|
|
8
10
|
const IMG = /\.(webp|jpe?g|png|avif|gif)$/i;
|
|
9
11
|
|
|
@@ -12,7 +14,7 @@ export async function onRequestGet({ request, env }) {
|
|
|
12
14
|
const url = new URL(request.url);
|
|
13
15
|
let prefix = (url.searchParams.get("prefix") || "").trim();
|
|
14
16
|
if (!isPrefix(prefix)) return json({ ok: false, error: `prefisso non valido: ${prefix}` }, 400);
|
|
15
|
-
const pending = await pendingSourceKeys(env.MEDIA);
|
|
17
|
+
const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
|
|
16
18
|
|
|
17
19
|
/* `recursive=1` guarda TUTTO quello che c'è sotto, a qualunque profondità: serve a chi deve
|
|
18
20
|
* spostare o cancellare una cartella intera, che altrimenti si porterebbe dietro solo il primo
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isTerminalDeploymentPhase,
|
|
6
6
|
} from "../../deployment.js";
|
|
7
7
|
import { deploymentFingerprint, releaseExportFromStored } from "../../release.js";
|
|
8
|
+
import { finalizeD1MediaRelease } from "../_shared/d1-media.js";
|
|
8
9
|
|
|
9
10
|
const MAX_BODY_BYTES = 8_000;
|
|
10
11
|
const claimLifetimeMs = 20 * 60 * 1_000;
|
|
@@ -147,7 +148,13 @@ export async function handleInternalContentCi({ request, env }) {
|
|
|
147
148
|
}
|
|
148
149
|
if (path === "/internal/deployment/finalize" && request.method === "POST") {
|
|
149
150
|
await attempts.finalize({ ...identity, intentKind: attempt.intentKind, tokenChecksum: checksum, now });
|
|
150
|
-
|
|
151
|
+
/* The release is already authoritative once this returns. Media cleanup
|
|
152
|
+
* is deliberately best-effort and journaled: a warning must not pretend
|
|
153
|
+
* to roll back a verified public deployment. */
|
|
154
|
+
const media = attempt.intentKind === "content"
|
|
155
|
+
? await finalizeD1MediaRelease({ env, releaseId: identity.releaseId, now }).catch(() => ({ completed: 0, failed: 0, warning: true }))
|
|
156
|
+
: undefined;
|
|
157
|
+
return response({ ok: true, attempt: { ...identity, phase: "succeeded" }, ...(media ? { media } : {}) });
|
|
151
158
|
}
|
|
152
159
|
if (path === "/internal/deployment/fail" && request.method === "POST") {
|
|
153
160
|
const detail = failure[input.code];
|
package/dist/functions/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import * as assetsDelete from "./api/assets/delete.js";
|
|
|
30
30
|
import * as assetsFolder from "./api/assets/folder.js";
|
|
31
31
|
import * as assetsFolders from "./api/assets/folders.js";
|
|
32
32
|
import * as assetsMove from "./api/assets/move.js";
|
|
33
|
+
import * as assetsMedia from "./api/assets/media.js";
|
|
33
34
|
import * as assetsPreview from "./api/assets/preview.js";
|
|
34
35
|
import * as assetsTrash from "./api/assets/trash.js";
|
|
35
36
|
import * as assetsTrashDelete from "./api/assets/trash/delete.js";
|
|
@@ -66,6 +67,7 @@ const ROUTES = {
|
|
|
66
67
|
"assets/folder": { mod: null, h: assetsFolder },
|
|
67
68
|
"assets/folders": { mod: null, h: assetsFolders },
|
|
68
69
|
"assets/move": { mod: null, h: assetsMove },
|
|
70
|
+
"assets/media": { mod: null, h: assetsMedia },
|
|
69
71
|
"assets/preview": { mod: null, h: assetsPreview },
|
|
70
72
|
"assets/trash": { mod: null, h: assetsTrash },
|
|
71
73
|
"assets/trash/delete": { mod: null, h: assetsTrashDelete },
|
package/dist/media.d.ts
CHANGED
|
@@ -52,6 +52,30 @@ export type CreateMediaOperation = Readonly<{
|
|
|
52
52
|
now: number;
|
|
53
53
|
entries: readonly MediaOperationEntryInput[];
|
|
54
54
|
}>;
|
|
55
|
+
export type BoundMediaEntry = Readonly<{
|
|
56
|
+
operationId: string;
|
|
57
|
+
type: MediaOperationType;
|
|
58
|
+
operationState: MediaOperationState;
|
|
59
|
+
entryId: string;
|
|
60
|
+
sourceKey: string;
|
|
61
|
+
destinationKey: string | null;
|
|
62
|
+
finalizationState: "pending" | "completed" | "failed" | "cancelled";
|
|
63
|
+
}>;
|
|
64
|
+
export type MediaOperationStatus = Readonly<{
|
|
65
|
+
operationId: string;
|
|
66
|
+
type: MediaOperationType;
|
|
67
|
+
state: MediaOperationState;
|
|
68
|
+
releaseId: string | null;
|
|
69
|
+
updatedAt: number;
|
|
70
|
+
failedEntries: number;
|
|
71
|
+
}>;
|
|
72
|
+
export type StoredMediaOperationEntry = Readonly<{
|
|
73
|
+
entryId: string;
|
|
74
|
+
sourceKey: string;
|
|
75
|
+
destinationKey: string | null;
|
|
76
|
+
preparationState: string;
|
|
77
|
+
finalizationState: string;
|
|
78
|
+
}>;
|
|
55
79
|
/** SQL journal adapter. R2 copy/delete happens around this adapter; it never
|
|
56
80
|
* infers an object as removable simply because a caller stopped mentioning it. */
|
|
57
81
|
export declare class D1MediaOperationRepository {
|
|
@@ -83,6 +107,34 @@ export declare class D1MediaOperationRepository {
|
|
|
83
107
|
}): Promise<"pending" | "settled">;
|
|
84
108
|
blockReintroducedSources(sourceKeys: readonly string[], now: number): Promise<number>;
|
|
85
109
|
cancel(operationId: string, now: number): Promise<void>;
|
|
110
|
+
/** Sources are hidden from the normal library only while an unresolved
|
|
111
|
+
* operation owns them. The query deliberately never derives removability
|
|
112
|
+
* from this list. */
|
|
113
|
+
activeSourceKeys(): Promise<readonly string[]>;
|
|
114
|
+
/** Candidates bind every ready operation whose source has been rewritten;
|
|
115
|
+
* the release repository repeats the frozen-manifest no-source guard in the
|
|
116
|
+
* same transaction as the binding. */
|
|
117
|
+
readyOperationIds(): Promise<readonly string[]>;
|
|
118
|
+
/** A Discard block is reversible only when the current effective documents
|
|
119
|
+
* no longer contain any owned source. Re-evaluation changes journal state;
|
|
120
|
+
* it never synthesizes or rewrites content. */
|
|
121
|
+
refreshBlockedOperations(now: number): Promise<number>;
|
|
122
|
+
statuses(): Promise<readonly MediaOperationStatus[]>;
|
|
123
|
+
entries(operationId: string): Promise<readonly StoredMediaOperationEntry[]>;
|
|
124
|
+
/** Work is named by its immutable release binding: there is intentionally no
|
|
125
|
+
* "latest operation" cleanup path. */
|
|
126
|
+
finalizationWork(releaseId: string): Promise<readonly BoundMediaEntry[]>;
|
|
127
|
+
/** A warning is a live release with incomplete housekeeping. Retrying resets
|
|
128
|
+
* only failed effects, never an already-completed entry. */
|
|
129
|
+
resumeFinalization(operationId: string, releaseId: string, now: number): Promise<void>;
|
|
130
|
+
readyDeleteEntry(input: {
|
|
131
|
+
operationId: string;
|
|
132
|
+
entryId: string;
|
|
133
|
+
now: number;
|
|
134
|
+
objectChecksum: string;
|
|
135
|
+
etag: string | null;
|
|
136
|
+
metadataJson: string | null;
|
|
137
|
+
}): Promise<"pending" | "ready">;
|
|
86
138
|
}
|
|
87
139
|
type R2Metadata = Record<string, string>;
|
|
88
140
|
export type MediaR2Object = {
|
package/dist/media.js
CHANGED
|
@@ -164,7 +164,7 @@ export class D1MediaOperationRepository {
|
|
|
164
164
|
}
|
|
165
165
|
async beginFinalization(operationId, releaseId, now) {
|
|
166
166
|
const result = await this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?
|
|
167
|
-
WHERE operation_id = ? AND state
|
|
167
|
+
WHERE operation_id = ? AND state IN ('bound', 'finalizing') AND EXISTS (SELECT 1 FROM content_release_media_operations
|
|
168
168
|
WHERE operation_id = ? AND release_id = ?) AND EXISTS (SELECT 1 FROM content_releases
|
|
169
169
|
WHERE release_id = ? AND status = 'published')`)
|
|
170
170
|
.bind(now, operationId, operationId, releaseId, releaseId).run();
|
|
@@ -213,6 +213,108 @@ export class D1MediaOperationRepository {
|
|
|
213
213
|
if (results[0]?.meta?.changes !== 1)
|
|
214
214
|
throw new Error("media_operation_unavailable");
|
|
215
215
|
}
|
|
216
|
+
/** Sources are hidden from the normal library only while an unresolved
|
|
217
|
+
* operation owns them. The query deliberately never derives removability
|
|
218
|
+
* from this list. */
|
|
219
|
+
async activeSourceKeys() {
|
|
220
|
+
const rows = (await this.db.prepare(`SELECT DISTINCT entry.source_key AS sourceKey
|
|
221
|
+
FROM media_operation_entries entry
|
|
222
|
+
JOIN media_operations operation ON operation.operation_id = entry.operation_id
|
|
223
|
+
WHERE operation.state IN ('ready', 'bound', 'finalizing', 'completed_with_warning')
|
|
224
|
+
AND entry.finalization_state NOT IN ('completed', 'cancelled')
|
|
225
|
+
ORDER BY entry.source_key`).all()).results ?? [];
|
|
226
|
+
return rows.map((row) => row.sourceKey);
|
|
227
|
+
}
|
|
228
|
+
/** Candidates bind every ready operation whose source has been rewritten;
|
|
229
|
+
* the release repository repeats the frozen-manifest no-source guard in the
|
|
230
|
+
* same transaction as the binding. */
|
|
231
|
+
async readyOperationIds() {
|
|
232
|
+
await this.refreshBlockedOperations(Date.now());
|
|
233
|
+
const rows = (await this.db.prepare("SELECT operation_id AS operationId FROM media_operations WHERE state = 'ready' ORDER BY operation_id")
|
|
234
|
+
.all()).results ?? [];
|
|
235
|
+
return rows.map((row) => row.operationId);
|
|
236
|
+
}
|
|
237
|
+
/** A Discard block is reversible only when the current effective documents
|
|
238
|
+
* no longer contain any owned source. Re-evaluation changes journal state;
|
|
239
|
+
* it never synthesizes or rewrites content. */
|
|
240
|
+
async refreshBlockedOperations(now) {
|
|
241
|
+
const result = await this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
|
|
242
|
+
WHERE state = 'blocked_in_use' AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
|
|
243
|
+
JOIN content_documents document
|
|
244
|
+
LEFT JOIN content_revisions draft ON draft.document_id = document.document_id AND draft.revision_id = document.draft_revision_id
|
|
245
|
+
LEFT JOIN content_site_state site_state ON site_state.singleton = 1
|
|
246
|
+
LEFT JOIN content_release_entries published_entry ON published_entry.release_id = site_state.current_release_id
|
|
247
|
+
AND published_entry.document_id = document.document_id
|
|
248
|
+
LEFT JOIN content_revisions published ON published.document_id = published_entry.document_id
|
|
249
|
+
AND published.revision_id = published_entry.revision_id
|
|
250
|
+
WHERE source.operation_id = media_operations.operation_id
|
|
251
|
+
AND instr(COALESCE(draft.payload_json, published.payload_json, ''), '/media/' || source.source_key) > 0)`)
|
|
252
|
+
.bind(now).run();
|
|
253
|
+
return Number(result.meta?.changes ?? 0);
|
|
254
|
+
}
|
|
255
|
+
async statuses() {
|
|
256
|
+
return (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type, operation.state,
|
|
257
|
+
binding.release_id AS releaseId, operation.updated_at AS updatedAt,
|
|
258
|
+
SUM(CASE WHEN entry.finalization_state = 'failed' THEN 1 ELSE 0 END) AS failedEntries
|
|
259
|
+
FROM media_operations operation
|
|
260
|
+
LEFT JOIN content_release_media_operations binding ON binding.operation_id = operation.operation_id
|
|
261
|
+
JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
|
|
262
|
+
WHERE operation.state NOT IN ('completed', 'cancelled')
|
|
263
|
+
GROUP BY operation.operation_id, operation.type, operation.state, binding.release_id, operation.updated_at
|
|
264
|
+
ORDER BY operation.updated_at DESC, operation.operation_id`).all()).results ?? [];
|
|
265
|
+
}
|
|
266
|
+
async entries(operationId) {
|
|
267
|
+
return (await this.db.prepare(`SELECT entry_id AS entryId, source_key AS sourceKey,
|
|
268
|
+
destination_key AS destinationKey, preparation_state AS preparationState,
|
|
269
|
+
finalization_state AS finalizationState
|
|
270
|
+
FROM media_operation_entries WHERE operation_id = ? ORDER BY entry_id`)
|
|
271
|
+
.bind(operationId).all()).results ?? [];
|
|
272
|
+
}
|
|
273
|
+
/** Work is named by its immutable release binding: there is intentionally no
|
|
274
|
+
* "latest operation" cleanup path. */
|
|
275
|
+
async finalizationWork(releaseId) {
|
|
276
|
+
const rows = (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type, operation.state AS operationState,
|
|
277
|
+
entry.entry_id AS entryId, entry.source_key AS sourceKey, entry.destination_key AS destinationKey,
|
|
278
|
+
entry.finalization_state AS finalizationState
|
|
279
|
+
FROM content_release_media_operations binding
|
|
280
|
+
JOIN media_operations operation ON operation.operation_id = binding.operation_id
|
|
281
|
+
JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
|
|
282
|
+
WHERE binding.release_id = ? AND operation.state IN ('bound', 'finalizing', 'completed_with_warning')
|
|
283
|
+
AND entry.finalization_state IN ('pending', 'failed')
|
|
284
|
+
ORDER BY operation.operation_id, entry.entry_id`).bind(releaseId).all()).results ?? [];
|
|
285
|
+
return rows;
|
|
286
|
+
}
|
|
287
|
+
/** A warning is a live release with incomplete housekeeping. Retrying resets
|
|
288
|
+
* only failed effects, never an already-completed entry. */
|
|
289
|
+
async resumeFinalization(operationId, releaseId, now) {
|
|
290
|
+
const resume = this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?, retry_count = retry_count + 1
|
|
291
|
+
WHERE operation_id = ? AND state = 'completed_with_warning'
|
|
292
|
+
AND EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = ? AND release_id = ?)
|
|
293
|
+
AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'published')`)
|
|
294
|
+
.bind(now, operationId, operationId, releaseId, releaseId);
|
|
295
|
+
const reset = this.db.prepare(`UPDATE media_operation_entries SET finalization_state = 'pending', safe_error = NULL
|
|
296
|
+
WHERE operation_id = ? AND finalization_state = 'failed'
|
|
297
|
+
AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'finalizing')`)
|
|
298
|
+
.bind(operationId, operationId);
|
|
299
|
+
const results = await this.db.batch([resume, reset]);
|
|
300
|
+
if (results[0]?.meta?.changes !== 1)
|
|
301
|
+
throw new Error("media_operation_unavailable");
|
|
302
|
+
}
|
|
303
|
+
async readyDeleteEntry(input) {
|
|
304
|
+
const entry = this.db.prepare(`UPDATE media_operation_entries
|
|
305
|
+
SET object_checksum = ?, etag = ?, metadata_json = ?, preparation_state = 'ready'
|
|
306
|
+
WHERE operation_id = ? AND entry_id = ? AND preparation_state = 'pending'
|
|
307
|
+
AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND type = 'delete' AND state = 'preparing')`)
|
|
308
|
+
.bind(input.objectChecksum, input.etag, input.metadataJson, input.operationId, input.entryId, input.operationId);
|
|
309
|
+
const ready = this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
|
|
310
|
+
WHERE operation_id = ? AND type = 'delete' AND state = 'preparing' AND NOT EXISTS (SELECT 1 FROM media_operation_entries
|
|
311
|
+
WHERE operation_id = ? AND preparation_state != 'ready')`)
|
|
312
|
+
.bind(input.now, input.operationId, input.operationId);
|
|
313
|
+
const results = await this.db.batch([entry, ready]);
|
|
314
|
+
if (results[0]?.meta?.changes !== 1)
|
|
315
|
+
throw new Error("media_operation_unavailable");
|
|
316
|
+
return results[1]?.meta?.changes === 1 ? "ready" : "pending";
|
|
317
|
+
}
|
|
216
318
|
}
|
|
217
319
|
const MEDIA_CHECKSUM_METADATA = "adminkit-sha256";
|
|
218
320
|
async function bytesChecksum(bytes) {
|
package/dist/persistence.d.ts
CHANGED
|
@@ -115,6 +115,10 @@ export type ReleaseInput = {
|
|
|
115
115
|
documentId: string;
|
|
116
116
|
revisionId: string;
|
|
117
117
|
}[];
|
|
118
|
+
/** Operations are bound in the same D1 transaction as the immutable
|
|
119
|
+
* manifest. The SQL guard rejects any candidate still containing a source
|
|
120
|
+
* URL, so a caller cannot make cleanup race a public reference. */
|
|
121
|
+
mediaOperationIds?: readonly string[];
|
|
118
122
|
};
|
|
119
123
|
export type ReleaseDeploymentInput = ReleaseInput & {
|
|
120
124
|
attempt: {
|
|
@@ -157,6 +161,11 @@ export declare class D1ContentRepository {
|
|
|
157
161
|
* no "latest" variant: a deployment attempt must name what it exports. */
|
|
158
162
|
getReleaseExport(releaseId: string): Promise<StoredReleaseExport | null>;
|
|
159
163
|
save(input: SaveInput): Promise<"created" | "repeated">;
|
|
164
|
+
/** Multi-document editorial gestures (such as a Media rewrite) must not
|
|
165
|
+
* leave half their draft revisions behind when a later CAS discovers an
|
|
166
|
+
* editor's concurrent save. D1 batches are transactional, so we validate
|
|
167
|
+
* every identity and build every guarded statement before issuing one batch. */
|
|
168
|
+
saveMany(inputs: readonly SaveInput[]): Promise<void>;
|
|
160
169
|
discard(documentId: string, expectedDraftRevisionId: string | null, baselineRevisionId: string | null, now: number): Promise<void>;
|
|
161
170
|
publish(input: ReleaseInput): Promise<"created" | "repeated">;
|
|
162
171
|
/** Candidate release and deployment-lane reservation are inseparable: a
|
package/dist/persistence.js
CHANGED
|
@@ -183,12 +183,79 @@ export class D1ContentRepository {
|
|
|
183
183
|
throw new Error("stale_revision");
|
|
184
184
|
return "created";
|
|
185
185
|
}
|
|
186
|
+
/** Multi-document editorial gestures (such as a Media rewrite) must not
|
|
187
|
+
* leave half their draft revisions behind when a later CAS discovers an
|
|
188
|
+
* editor's concurrent save. D1 batches are transactional, so we validate
|
|
189
|
+
* every identity and build every guarded statement before issuing one batch. */
|
|
190
|
+
async saveMany(inputs) {
|
|
191
|
+
if (!inputs.length || new Set(inputs.map((input) => input.documentId)).size !== inputs.length
|
|
192
|
+
|| new Set(inputs.map((input) => input.revisionId)).size !== inputs.length)
|
|
193
|
+
throw new Error("invalid_document");
|
|
194
|
+
const statements = [];
|
|
195
|
+
const guards = [];
|
|
196
|
+
for (const input of inputs) {
|
|
197
|
+
const existing = await this.db.prepare("SELECT revision_id, request_checksum FROM content_revisions WHERE revision_id = ?").bind(input.revisionId).first();
|
|
198
|
+
if (existing) {
|
|
199
|
+
assertIdempotent({ requestChecksum: existing.request_checksum }, input.requestChecksum);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if ((input.payload === null) === !input.tombstone)
|
|
203
|
+
throw new Error("invalid_document");
|
|
204
|
+
const document = await this.db.prepare("SELECT document_id, kind, locale, draft_revision_id, abandoned_at FROM content_documents WHERE document_id = ?").bind(input.documentId).first();
|
|
205
|
+
if (document && (document.kind !== input.kind || document.locale !== input.locale))
|
|
206
|
+
throw new Error("invalid_document");
|
|
207
|
+
const expectedDraftRevisionId = input.expectedDraftRevisionId === undefined ? input.baseRevisionId : input.expectedDraftRevisionId;
|
|
208
|
+
if (document && document.draft_revision_id !== expectedDraftRevisionId)
|
|
209
|
+
throw new Error("stale_revision");
|
|
210
|
+
const payload = input.payload === null ? null : canonicalJson(input.payload);
|
|
211
|
+
guards.push({ documentId: input.documentId, kind: input.kind, locale: input.locale, expectedDraftRevisionId, revisionId: input.revisionId });
|
|
212
|
+
const hasBase = expectedDraftRevisionId !== null;
|
|
213
|
+
const guard = hasBase ? "draft_revision_id = ?" : "draft_revision_id IS NULL";
|
|
214
|
+
statements.push(this.db.prepare(`INSERT INTO content_documents (document_id, kind, locale, translation_group_id, draft_revision_id, created_at, updated_at)
|
|
215
|
+
SELECT ?, ?, ?, ?, NULL, ?, ? WHERE NOT EXISTS (SELECT 1 FROM content_documents WHERE document_id = ?)`)
|
|
216
|
+
.bind(input.documentId, input.kind, input.locale, input.translationGroupId ?? null, input.now, input.now, input.documentId));
|
|
217
|
+
statements.push(this.db.prepare(`INSERT INTO content_revisions (revision_id, document_id, base_revision_id, payload_json, is_tombstone, schema_version, payload_checksum, request_checksum, author_subject, author_label, created_at)
|
|
218
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM content_documents WHERE document_id = ? AND ${guard}`)
|
|
219
|
+
.bind(input.revisionId, input.documentId, input.baseRevisionId, payload, Number(input.tombstone), input.schemaVersion, input.payloadChecksum, input.requestChecksum, input.authorSubject, input.authorLabel, input.now, input.documentId, ...(hasBase ? [expectedDraftRevisionId] : [])));
|
|
220
|
+
statements.push(this.db.prepare(`UPDATE content_documents SET draft_revision_id = ?, updated_at = ?, abandoned_at = NULL WHERE document_id = ? AND ${guard}`)
|
|
221
|
+
.bind(input.revisionId, input.now, input.documentId, ...(hasBase ? [expectedDraftRevisionId] : [])));
|
|
222
|
+
}
|
|
223
|
+
if (!statements.length)
|
|
224
|
+
return;
|
|
225
|
+
const guardSql = guards.map(() => `(NOT EXISTS (SELECT 1 FROM content_documents WHERE document_id = ?) AND ? IS NULL
|
|
226
|
+
OR EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND kind = ? AND locale = ? AND draft_revision_id IS ?))
|
|
227
|
+
AND NOT EXISTS (SELECT 1 FROM content_revisions WHERE revision_id = ?)`).join(" AND ");
|
|
228
|
+
/* A zero-row CAS is not a SQL error and therefore would not roll a D1
|
|
229
|
+
* batch back. This statement turns any stale precondition into a CHECK
|
|
230
|
+
* violation before the first revision write; singleton=2 can never be a
|
|
231
|
+
* valid site-state row. */
|
|
232
|
+
const abortOnStale = this.db.prepare(`INSERT INTO content_site_state
|
|
233
|
+
(singleton, current_release_id, schema_generation, updated_at)
|
|
234
|
+
SELECT 2, NULL, 0, ? WHERE NOT (${guardSql})`)
|
|
235
|
+
.bind(inputs[0].now, ...guards.flatMap((guard) => [guard.documentId, guard.expectedDraftRevisionId,
|
|
236
|
+
guard.documentId, guard.kind, guard.locale, guard.expectedDraftRevisionId, guard.revisionId]));
|
|
237
|
+
const results = await this.db.batch([abortOnStale, ...statements]);
|
|
238
|
+
for (let index = 1; index < results.length; index += 3) {
|
|
239
|
+
if (results[index + 1]?.meta?.changes !== 1 || results[index + 2]?.meta?.changes !== 1)
|
|
240
|
+
throw new Error("stale_revision");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
186
243
|
async discard(documentId, expectedDraftRevisionId, baselineRevisionId, now) {
|
|
187
244
|
const expected = expectedDraftRevisionId === null ? "draft_revision_id IS NULL" : "draft_revision_id = ?";
|
|
188
245
|
const set = baselineRevisionId === null ? null : baselineRevisionId;
|
|
189
246
|
const update = this.db.prepare(`UPDATE content_documents SET draft_revision_id = ?, abandoned_at = CASE WHEN ? IS NULL THEN ? ELSE NULL END, updated_at = ? WHERE document_id = ? AND ${expected}`)
|
|
190
247
|
.bind(set, set, now, now, documentId, ...(expectedDraftRevisionId === null ? [] : [expectedDraftRevisionId]));
|
|
191
|
-
|
|
248
|
+
/* Restoring a baseline can reintroduce a source that a ready destructive
|
|
249
|
+
* operation was about to remove. This lives in the same transaction as the
|
|
250
|
+
* pointer CAS: Discard still succeeds, but the operation becomes visibly
|
|
251
|
+
* blocked rather than deleting a newly referenced public asset. */
|
|
252
|
+
const blockReintroduced = this.db.prepare(`UPDATE media_operations SET state = 'blocked_in_use', updated_at = ?
|
|
253
|
+
WHERE state = 'ready' AND ? IS NOT NULL AND EXISTS (SELECT 1 FROM media_operation_entries entry
|
|
254
|
+
JOIN content_revisions baseline ON baseline.revision_id = ? AND baseline.document_id = ?
|
|
255
|
+
WHERE entry.operation_id = media_operations.operation_id AND baseline.payload_json IS NOT NULL
|
|
256
|
+
AND instr(baseline.payload_json, '/media/' || entry.source_key) > 0)`)
|
|
257
|
+
.bind(now, baselineRevisionId, baselineRevisionId, documentId);
|
|
258
|
+
const [result] = await this.db.batch([update, blockReintroduced]);
|
|
192
259
|
if (result?.meta?.changes === 1)
|
|
193
260
|
return;
|
|
194
261
|
const current = await this.db.prepare("SELECT document_id, draft_revision_id, abandoned_at FROM content_documents WHERE document_id = ?").bind(documentId).first();
|
|
@@ -252,20 +319,56 @@ export class D1ContentRepository {
|
|
|
252
319
|
: [];
|
|
253
320
|
const manifest = new Map(inherited.map((entry) => [entry.document_id, entry.revision_id]));
|
|
254
321
|
const selected = [...input.entries];
|
|
255
|
-
|
|
322
|
+
const mediaOperationIds = [...new Set(input.mediaOperationIds ?? [])];
|
|
323
|
+
if (!selected.length && !mediaOperationIds.length)
|
|
256
324
|
throw new Error("invalid_document");
|
|
257
325
|
for (const entry of selected)
|
|
258
326
|
manifest.set(entry.documentId, entry.revisionId);
|
|
259
|
-
const selectionGuard = selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ");
|
|
327
|
+
const selectionGuard = selected.length ? selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ") : "1 = 1";
|
|
260
328
|
const candidate = this.db.prepare(`INSERT INTO content_releases (release_id, base_release_id, request_checksum, status, active_slot, manifest_checksum, schema_package_version, materializer_version, created_by_subject, created_at)
|
|
261
329
|
SELECT ?, current_release_id, ?, 'publishing', 1, ?, ?, ?, ?, ? FROM content_site_state
|
|
262
330
|
WHERE singleton = 1 AND current_release_id IS ? AND NOT EXISTS (SELECT 1 FROM content_releases WHERE active_slot = 1)
|
|
263
331
|
AND NOT EXISTS (SELECT 1 FROM production_deployment_attempts WHERE active_slot = 1) AND ${selectionGuard}`)
|
|
264
332
|
.bind(input.releaseId, input.requestChecksum, input.manifestChecksum, input.schemaPackageVersion, input.materializerVersion, input.authorSubject, input.now, state.current_release_id, ...selected.flatMap((entry) => [entry.documentId, entry.revisionId]));
|
|
265
|
-
const statements = [
|
|
333
|
+
const statements = [];
|
|
334
|
+
if (mediaOperationIds.length) {
|
|
335
|
+
const revisionIds = [...manifest.values()];
|
|
336
|
+
const eligible = mediaOperationIds.map(() => `(
|
|
337
|
+
EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'ready')
|
|
338
|
+
AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
|
|
339
|
+
JOIN content_revisions revision ON revision.revision_id IN (${revisionIds.map(() => "?").join(",")})
|
|
340
|
+
WHERE source.operation_id = ? AND revision.payload_json IS NOT NULL
|
|
341
|
+
AND instr(revision.payload_json, '/media/' || source.source_key) > 0))`).join(" AND ");
|
|
342
|
+
/* As with multi-document Save, zero affected rows do not abort a D1
|
|
343
|
+
* batch. Convert an ineligible Media binding into a CHECK violation
|
|
344
|
+
* before the candidate exists, so no release/attempt can commit alone. */
|
|
345
|
+
statements.push(this.db.prepare(`INSERT INTO content_site_state
|
|
346
|
+
(singleton, current_release_id, schema_generation, updated_at)
|
|
347
|
+
SELECT 2, NULL, 0, ? WHERE NOT (${eligible})`)
|
|
348
|
+
.bind(input.now, ...mediaOperationIds.flatMap((operationId) => [operationId, ...revisionIds, operationId])));
|
|
349
|
+
}
|
|
350
|
+
const candidateIndex = statements.length;
|
|
351
|
+
statements.push(candidate);
|
|
266
352
|
for (const [documentId, revisionId] of manifest)
|
|
267
353
|
statements.push(this.db.prepare("INSERT INTO content_release_entries (release_id, document_id, revision_id) SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM content_revisions WHERE document_id = ? AND revision_id = ?)")
|
|
268
354
|
.bind(input.releaseId, documentId, revisionId, documentId, revisionId));
|
|
355
|
+
for (const operationId of mediaOperationIds) {
|
|
356
|
+
/* `instr` is intentionally evaluated against the frozen release entries,
|
|
357
|
+
* not drafts or a mutable current pointer. A URL is JSON-escaped neither
|
|
358
|
+
* by `/` nor by the canonical serializer, so the exact R2 public prefix
|
|
359
|
+
* remains a reliable conservative guard. */
|
|
360
|
+
statements.push(this.db.prepare(`UPDATE media_operations SET state = 'bound', updated_at = ?
|
|
361
|
+
WHERE operation_id = ? AND state = 'ready'
|
|
362
|
+
AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
|
|
363
|
+
JOIN content_release_entries entry ON entry.release_id = ?
|
|
364
|
+
JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
|
|
365
|
+
WHERE source.operation_id = media_operations.operation_id
|
|
366
|
+
AND revision.payload_json IS NOT NULL AND instr(revision.payload_json, '/media/' || source.source_key) > 0)`)
|
|
367
|
+
.bind(input.now, operationId, input.releaseId));
|
|
368
|
+
statements.push(this.db.prepare(`INSERT INTO content_release_media_operations (release_id, operation_id)
|
|
369
|
+
SELECT ?, ? WHERE EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'bound')`)
|
|
370
|
+
.bind(input.releaseId, operationId, operationId));
|
|
371
|
+
}
|
|
269
372
|
statements.push(this.db.prepare(`INSERT INTO production_deployment_attempts
|
|
270
373
|
(attempt_id, intent_kind, requested_release_id, requested_code_sha, code_sha, release_id, expected_fingerprint, phase, active_slot, created_at, updated_at)
|
|
271
374
|
SELECT ?, ?, ?, ?, ?, ?, ?, 'waiting', 1, ?, ?
|
|
@@ -273,7 +376,7 @@ export class D1ContentRepository {
|
|
|
273
376
|
AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')`)
|
|
274
377
|
.bind(input.attempt.attemptId, input.attempt.intentKind, input.attempt.requestedReleaseId, input.attempt.requestedCodeSha, input.attempt.codeSha, input.releaseId, input.attempt.expectedFingerprint, input.now, input.now, input.releaseId));
|
|
275
378
|
const results = await this.db.batch(statements);
|
|
276
|
-
if (results[
|
|
379
|
+
if (results[candidateIndex]?.meta?.changes !== 1 || results.slice(candidateIndex + 1, -1).some((result) => result.meta?.changes !== 1)
|
|
277
380
|
|| results.at(-1)?.meta?.changes !== 1)
|
|
278
381
|
throw new Error("deployment_lane_busy");
|
|
279
382
|
return "created";
|
package/dist/theme.css
CHANGED
|
@@ -370,6 +370,18 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
|
|
|
370
370
|
background: var(--ak-panel); border: 1px solid var(--ak-accent);
|
|
371
371
|
font: 500 var(--ak-fs-small)/1.4 var(--ak-font); color: var(--ak-ink);
|
|
372
372
|
}
|
|
373
|
+
.mg-journal { display: flex; flex-direction: column; gap: 6px; }
|
|
374
|
+
.mg-journal-row {
|
|
375
|
+
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
|
|
376
|
+
margin: 0; padding: 8px 11px; border: 1px solid var(--ak-line); border-radius: var(--ak-radius);
|
|
377
|
+
background: var(--ak-panel); font: 500 var(--ak-fs-small)/1.4 var(--ak-font); color: var(--ak-ink);
|
|
378
|
+
}
|
|
379
|
+
.mg-journal-row.is-blocked_in_use, .mg-journal-row.is-completed_with_warning { border-color: var(--ak-accent); }
|
|
380
|
+
.mg-journal-row.is-failed { border-color: var(--ak-danger); }
|
|
381
|
+
.mg-journal-badge {
|
|
382
|
+
padding: 3px 6px; border-radius: 999px; background: var(--ak-bg);
|
|
383
|
+
color: var(--ak-ink-quiet); font: 600 10px/1 var(--ak-font); text-transform: uppercase; letter-spacing: .05em;
|
|
384
|
+
}
|
|
373
385
|
|
|
374
386
|
/* Celle di ingombro uguale, immagine INTERA dentro. Prima erano quadrati con `object-fit: cover`:
|
|
375
387
|
un verticale sembrava un quadrato e per riconoscere una foto bisognava aprirla. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardinali-studio/adminkit",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Motore riusabile di un pannello di amministrazione per siti Hugo su Cloudflare Pages: codec del front matter guidato dallo schema, form React, libreria media su R2. NIENTE di specifico di un sito qui dentro.",
|
|
6
6
|
"license": "UNLICENSED",
|