@aduptive/strapi-blockscene 2.0.0-alpha.5 → 2.0.0-alpha.7
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/README.md +432 -39
- package/dist/admin.cjs +4207 -1330
- package/dist/admin.mjs +4218 -1317
- package/dist/server.cjs +1102 -20
- package/package.json +1 -1
package/dist/server.cjs
CHANGED
|
@@ -144,21 +144,39 @@ var require_settings = __commonJS({
|
|
|
144
144
|
confirmDelete: true,
|
|
145
145
|
hiddenBlocks: "strip",
|
|
146
146
|
duplicate: true,
|
|
147
|
-
clipboard: true
|
|
147
|
+
clipboard: true,
|
|
148
|
+
// Edit view labels that are still the raw attribute name read as "Mobile columns count" (see README "Field labels").
|
|
149
|
+
friendlyLabels: true,
|
|
150
|
+
// Page preview pane (Strapi 5): which toolbar controls show, in order, and the width menu's entries.
|
|
151
|
+
previewToolbar: ["modes", "history", "devices", "status", "actions"],
|
|
152
|
+
previewDevices: ["fit", "mobile", "tablet", "desktop"]
|
|
148
153
|
},
|
|
149
154
|
// Per content type (only the ones with a Dynamic Zone): { enabled: false } turns the plugin off there;
|
|
150
155
|
// previewMode overrides editor.previewMode as the mode the edit view opens in. Absent means the global behaviour.
|
|
151
|
-
contentTypes: {}
|
|
156
|
+
contentTypes: {},
|
|
157
|
+
// Version history (Strapi 5, see README "Version history"): off until turned on. contentTypes: 'all' (every api:: type)
|
|
158
|
+
// or a list of uids. Snapshots are kept retentionDays and at most maxSnapshots per document; events eventDays; deleted
|
|
159
|
+
// documents stay in the trash trashDays (fixed per document when it is deleted).
|
|
160
|
+
history: { enabled: false, contentTypes: "all", retentionDays: 90, maxSnapshots: 100, eventDays: 365, trashDays: 90 }
|
|
152
161
|
};
|
|
153
162
|
var ICONS = ["text", "tag", "seo", "settings", "image", "link", "palette", "list", "globe", "info"];
|
|
154
163
|
var SIDEBAR_POSITIONS = ["left", "right", "bottom"];
|
|
155
164
|
var OPENS = ["modal", "drawer"];
|
|
156
165
|
var LABEL = /^[^<>]{1,40}$/;
|
|
166
|
+
var TOOLBAR = ["modes", "history", "devices", "status", "actions"];
|
|
167
|
+
var DEVICE_NAMES = ["fit", "mobile", "tablet", "desktop"];
|
|
168
|
+
var previewToolbar = (value) => Array.isArray(value) && value.every((id) => TOOLBAR.includes(id)) && new Set(value).size === value.length;
|
|
169
|
+
var DEVICE_LABEL = /^[^<>]{1,24}$/;
|
|
170
|
+
var customDevice = (item) => item && typeof item === "object" && !Array.isArray(item) && Object.keys(item).every((key) => ["label", "width"].includes(key)) && typeof item.label === "string" && DEVICE_LABEL.test(item.label.trim()) && Number.isInteger(item.width) && item.width >= 240 && item.width <= 3840;
|
|
171
|
+
var previewDevices = (value) => Array.isArray(value) && value.length >= 1 && value.length <= 8 && value.every((item) => DEVICE_NAMES.includes(item) || customDevice(item)) && new Set(value.map((item) => typeof item === "string" ? item : item.width)).size === value.length;
|
|
172
|
+
var cleanDevices = (value) => value.map((item) => typeof item === "string" ? item : { label: item.label.trim(), width: item.width });
|
|
157
173
|
var sidebarItem = (item, attributes) => item && typeof item === "object" && !Array.isArray(item) && Object.keys(item).every((key) => ["label", "icon", "open", "fields"].includes(key)) && typeof item.label === "string" && LABEL.test(item.label.trim()) && (item.icon === void 0 || item.icon === "" || ICONS.includes(item.icon)) && OPENS.includes(item.open) && Array.isArray(item.fields) && item.fields.length > 0 && item.fields.length <= 30 && item.fields.every((field) => typeof field === "string" && (!attributes || attributes.includes(field)));
|
|
158
174
|
var TYPE_KEYS = {
|
|
159
175
|
enabled: (value) => typeof value === "boolean",
|
|
160
176
|
previewMode: (value) => PREVIEW_MODES.includes(value),
|
|
161
177
|
sidebarPosition: (value) => SIDEBAR_POSITIONS.includes(value),
|
|
178
|
+
previewToolbar,
|
|
179
|
+
previewDevices,
|
|
162
180
|
sidebar: (value, attributes) => Array.isArray(value) && value.length <= 12 && value.every((item) => sidebarItem(item, attributes))
|
|
163
181
|
};
|
|
164
182
|
var typeMap = (types) => Array.isArray(types) ? Object.fromEntries(types.map((uid) => [uid, null])) : types || {};
|
|
@@ -168,6 +186,16 @@ var require_settings = __commonJS({
|
|
|
168
186
|
var FIELD_UID = /^(plugin|global)::[\w.-]+$/;
|
|
169
187
|
var lazyFields = (value) => Array.isArray(value) && value.length <= 20 && value.every((uid) => typeof uid === "string" && FIELD_UID.test(uid));
|
|
170
188
|
var UID = /^[a-z0-9-]+\.[a-z0-9-]+$/;
|
|
189
|
+
var API_UID = /^api::[\w-]+\.[\w-]+$/;
|
|
190
|
+
var days = (value) => Number.isInteger(value) && value >= 1 && value <= 3650;
|
|
191
|
+
var HISTORY_KEYS = {
|
|
192
|
+
enabled: (value) => typeof value === "boolean",
|
|
193
|
+
contentTypes: (value, uids) => value === "all" || Array.isArray(value) && value.length <= 500 && value.every((uid) => typeof uid === "string" && API_UID.test(uid) && (!uids || uids.includes(uid))),
|
|
194
|
+
retentionDays: days,
|
|
195
|
+
maxSnapshots: (value) => Number.isInteger(value) && value >= 1 && value <= 1e3,
|
|
196
|
+
eventDays: days,
|
|
197
|
+
trashDays: days
|
|
198
|
+
};
|
|
171
199
|
var TYPOLOGIES2 = ["hero", "text", "media", "listing", "cards", "cta", "form", "layout"];
|
|
172
200
|
var TAG = /^[^<>\n]{1,24}$/;
|
|
173
201
|
var cleanTags = (value) => Array.isArray(value) ? [...new Set(value.filter((tag) => typeof tag === "string" && TAG.test(tag.trim())).map((tag) => tag.trim()))].slice(0, 10) : [];
|
|
@@ -254,7 +282,7 @@ var require_settings = __commonJS({
|
|
|
254
282
|
groups: safeGroups2(config.groups, config.componentUids)
|
|
255
283
|
};
|
|
256
284
|
}
|
|
257
|
-
function validateSettings2(input, componentUids2, contentTypeUids2 = []) {
|
|
285
|
+
function validateSettings2(input, componentUids2, contentTypeUids2 = [], historyTypes = null) {
|
|
258
286
|
const types = typeMap(contentTypeUids2);
|
|
259
287
|
if (!input || typeof input !== "object" || Array.isArray(input)) fail2("Settings must be an object");
|
|
260
288
|
if (JSON.stringify(input).length > 64 * 1024) fail2("Settings payload too large");
|
|
@@ -267,9 +295,9 @@ var require_settings = __commonJS({
|
|
|
267
295
|
}
|
|
268
296
|
for (const [key, value] of Object.entries(input.editor || {})) {
|
|
269
297
|
if (!(key in DEFAULTS2.editor)) fail2(`Unknown editor option "${key}"`);
|
|
270
|
-
const valid = key === "initialState" ? INITIAL_STATES.includes(value) : key === "previewMode" ? PREVIEW_MODES.includes(value) : key === "hiddenBlocks" ? HIDDEN_MODES.includes(value) : key === "previewUrl" ? value === "" || typeof value === "string" && PREVIEW_URL.test(value) : key === "blockPreviewUrl" ? blockPreviewUrl(value) : key === "lazyFields" ? lazyFields(value) : typeof value === "boolean";
|
|
298
|
+
const valid = key === "initialState" ? INITIAL_STATES.includes(value) : key === "previewMode" ? PREVIEW_MODES.includes(value) : key === "hiddenBlocks" ? HIDDEN_MODES.includes(value) : key === "previewUrl" ? value === "" || typeof value === "string" && PREVIEW_URL.test(value) : key === "blockPreviewUrl" ? blockPreviewUrl(value) : key === "lazyFields" ? lazyFields(value) : key === "previewToolbar" ? previewToolbar(value) : key === "previewDevices" ? previewDevices(value) : typeof value === "boolean";
|
|
271
299
|
if (!valid) fail2(`Invalid value for editor option "${key}"`);
|
|
272
|
-
out.editor[key] = key === "previewUrl" && value ? String(new URL(value).href).replace(/\/$/, "") : key === "lazyFields" ? [...new Set(value)] : value;
|
|
300
|
+
out.editor[key] = key === "previewUrl" && value ? String(new URL(value).href).replace(/\/$/, "") : key === "lazyFields" ? [...new Set(value)] : key === "previewDevices" ? cleanDevices(value) : value;
|
|
273
301
|
}
|
|
274
302
|
const components = input.components || {};
|
|
275
303
|
if (typeof components !== "object" || Array.isArray(components)) fail2("components must be an object");
|
|
@@ -303,11 +331,19 @@ var require_settings = __commonJS({
|
|
|
303
331
|
if (!(key in TYPE_KEYS)) fail2(`Unknown content type setting "${key}"`);
|
|
304
332
|
if (!TYPE_KEYS[key](value, types[uid])) fail2(`Invalid value for "${key}" of "${uid}"`);
|
|
305
333
|
}
|
|
306
|
-
if (Object.keys(entry).length) out.contentTypes[uid] = structuredClone(entry);
|
|
334
|
+
if (Object.keys(entry).length) out.contentTypes[uid] = { ...structuredClone(entry), ...entry.previewDevices && { previewDevices: cleanDevices(entry.previewDevices) } };
|
|
335
|
+
}
|
|
336
|
+
const history = input.history || {};
|
|
337
|
+
if (typeof history !== "object" || Array.isArray(history)) fail2("history must be an object");
|
|
338
|
+
for (const [key, value] of Object.entries(history)) {
|
|
339
|
+
if (!(key in HISTORY_KEYS)) fail2(`Unknown history option "${key}"`);
|
|
340
|
+
if (!HISTORY_KEYS[key](value, historyTypes)) fail2(`Invalid value for history option "${key}"`);
|
|
341
|
+
out.history[key] = Array.isArray(value) ? [...new Set(value)] : value;
|
|
307
342
|
}
|
|
343
|
+
if (out.history.eventDays < out.history.retentionDays) fail2("history.eventDays must be at least history.retentionDays");
|
|
308
344
|
return out;
|
|
309
345
|
}
|
|
310
|
-
function mergeSaved2(saved, componentUids2, contentTypeUids2 = []) {
|
|
346
|
+
function mergeSaved2(saved, componentUids2, contentTypeUids2 = [], historyTypes = null) {
|
|
311
347
|
const types = typeMap(contentTypeUids2);
|
|
312
348
|
const out = structuredClone(DEFAULTS2);
|
|
313
349
|
if (!saved || typeof saved !== "object") return out;
|
|
@@ -320,6 +356,8 @@ var require_settings = __commonJS({
|
|
|
320
356
|
if (key === "hiddenBlocks" && !HIDDEN_MODES.includes(value)) continue;
|
|
321
357
|
if (key === "previewUrl" && value && !PREVIEW_URL.test(value)) continue;
|
|
322
358
|
if (key === "blockPreviewUrl" && !blockPreviewUrl(value)) continue;
|
|
359
|
+
if (key === "previewToolbar" && !previewToolbar(value)) continue;
|
|
360
|
+
if (key === "previewDevices" && !previewDevices(value)) continue;
|
|
323
361
|
if (key === "lazyFields") {
|
|
324
362
|
if (!Array.isArray(value)) continue;
|
|
325
363
|
out.editor[key] = [...new Set(value.filter((uid) => lazyFields([uid])))].slice(0, 20);
|
|
@@ -343,24 +381,60 @@ var require_settings = __commonJS({
|
|
|
343
381
|
const clean = Object.fromEntries(Object.entries(entry).map(([key, value]) => [key, fix(key, value)]).filter(([key, value]) => TYPE_KEYS[key]?.(value, types[uid])));
|
|
344
382
|
if (Object.keys(clean).length) out.contentTypes[uid] = clean;
|
|
345
383
|
}
|
|
384
|
+
for (const key of Object.keys(HISTORY_KEYS)) {
|
|
385
|
+
let value = saved.history?.[key];
|
|
386
|
+
if (key === "contentTypes" && Array.isArray(value)) value = value.filter((uid) => HISTORY_KEYS.contentTypes([uid], historyTypes));
|
|
387
|
+
if (value !== void 0 && HISTORY_KEYS[key](value, historyTypes)) out.history[key] = value;
|
|
388
|
+
}
|
|
389
|
+
out.history.eventDays = Math.max(out.history.eventDays, out.history.retentionDays);
|
|
346
390
|
return out;
|
|
347
391
|
}
|
|
348
392
|
var ENTRY_KEYS = ["components", "contentTypes"];
|
|
393
|
+
var UNSET_WHEN_EMPTY = ["previewUrl", "blockPreviewUrl"];
|
|
394
|
+
var withoutEmpty = (editor) => Object.fromEntries(Object.entries(editor).filter(([key, value]) => !(UNSET_WHEN_EMPTY.includes(key) && value === "")));
|
|
349
395
|
function layer2(base, saved) {
|
|
350
396
|
if (!base) return saved;
|
|
351
397
|
const doc = saved && typeof saved === "object" ? saved : {};
|
|
352
|
-
|
|
398
|
+
const own = (key) => doc[key] && typeof doc[key] === "object" ? key === "editor" ? withoutEmpty(doc[key]) : doc[key] : {};
|
|
399
|
+
return Object.fromEntries(Object.keys(DEFAULTS2).map((key) => [key, { ...base[key], ...own(key) }]));
|
|
353
400
|
}
|
|
354
401
|
function overrides2(next, base) {
|
|
355
402
|
if (!base) return next;
|
|
356
403
|
return Object.fromEntries(Object.keys(DEFAULTS2).map((key) => {
|
|
357
|
-
const entries = Object.entries(next[key] || {}).filter(([name, value]) => JSON.stringify(value) !== JSON.stringify(base[key]?.[name]));
|
|
404
|
+
const entries = Object.entries(next[key] || {}).filter(([name, value]) => JSON.stringify(value) !== JSON.stringify(base[key]?.[name]) && !(key === "editor" && UNSET_WHEN_EMPTY.includes(name) && value === ""));
|
|
358
405
|
if (ENTRY_KEYS.includes(key)) {
|
|
359
406
|
for (const uid of Object.keys(base[key] || {})) if (!(uid in (next[key] || {}))) entries.push([uid, {}]);
|
|
360
407
|
}
|
|
361
408
|
return [key, Object.fromEntries(entries)];
|
|
362
409
|
}));
|
|
363
410
|
}
|
|
411
|
+
var FIELD_TEXT = { label: 80, description: 300, placeholder: 120, help: 500 };
|
|
412
|
+
var LOCALE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
413
|
+
var plain = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
414
|
+
var fieldText = (value, max) => typeof value === "string" ? value.trim() !== "" && value.length <= max : plain(value) && Object.keys(value).length > 0 && Object.keys(value).length <= 30 && Object.entries(value).every(([locale, text]) => LOCALE.test(locale) && fieldText(text, max));
|
|
415
|
+
function validateFields2(input, schemas = {}, stale = []) {
|
|
416
|
+
if (input === null || input === void 0) return {};
|
|
417
|
+
if (!plain(input)) fail2("fields must be an object");
|
|
418
|
+
if (JSON.stringify(input).length > 256 * 1024) fail2("fields config too large");
|
|
419
|
+
const out = {};
|
|
420
|
+
for (const [uid, entries] of Object.entries(input)) {
|
|
421
|
+
if (!plain(entries)) fail2(`Invalid entry for "${uid}"`);
|
|
422
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
423
|
+
if (!plain(entry)) fail2(`Invalid entry for "${uid}.${name}"`);
|
|
424
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
425
|
+
if (!(key in FIELD_TEXT)) fail2(`Unknown field setting "${key}" of "${uid}.${name}"`);
|
|
426
|
+
if (!fieldText(value, FIELD_TEXT[key])) fail2(`Invalid ${key} of "${uid}.${name}": a string of 1 to ${FIELD_TEXT[key]} characters, or { "<locale>": string }`);
|
|
427
|
+
}
|
|
428
|
+
if (!schemas[uid]?.includes(name)) {
|
|
429
|
+
stale.push(`${uid}.${name}`);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
;
|
|
433
|
+
(out[uid] ||= {})[name] = structuredClone(entry);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
364
438
|
var PREFS = { starred: 200, recent: 20 };
|
|
365
439
|
function validatePrefs2(input, componentUids2) {
|
|
366
440
|
if (!input || typeof input !== "object" || Array.isArray(input)) fail2("Preferences must be an object");
|
|
@@ -373,7 +447,7 @@ var require_settings = __commonJS({
|
|
|
373
447
|
return out;
|
|
374
448
|
}
|
|
375
449
|
var mergePrefs2 = (saved, componentUids2) => Object.fromEntries(Object.entries(PREFS).map(([key, max]) => [key, Array.isArray(saved?.[key]) ? [...new Set(saved[key].filter((uid) => componentUids2.includes(uid)))].slice(0, max) : []]));
|
|
376
|
-
module2.exports = { PLUGIN: PLUGIN2, HIDDEN_MODES, TEMPLATES: TEMPLATES2, TYPOLOGIES: TYPOLOGIES2, guessTypology, facetsOf, validatePrefs: validatePrefs2, mergePrefs: mergePrefs2, ICONS, DEFAULTS: DEFAULTS2, catalog: catalog2, validateSettings: validateSettings2, mergeSaved: mergeSaved2, layer: layer2, overrides: overrides2, safeUrl: safeUrl2, fail: fail2 };
|
|
450
|
+
module2.exports = { PLUGIN: PLUGIN2, HIDDEN_MODES, TEMPLATES: TEMPLATES2, TYPOLOGIES: TYPOLOGIES2, guessTypology, facetsOf, validatePrefs: validatePrefs2, mergePrefs: mergePrefs2, ICONS, DEFAULTS: DEFAULTS2, catalog: catalog2, validateSettings: validateSettings2, validateFields: validateFields2, mergeSaved: mergeSaved2, layer: layer2, overrides: overrides2, safeUrl: safeUrl2, fail: fail2 };
|
|
377
451
|
}
|
|
378
452
|
});
|
|
379
453
|
|
|
@@ -396,7 +470,7 @@ var require_hidden = __commonJS({
|
|
|
396
470
|
skipped.push(uid);
|
|
397
471
|
continue;
|
|
398
472
|
}
|
|
399
|
-
if (!current) schema.attributes[name] = { type: "boolean", default: false,
|
|
473
|
+
if (!current) schema.attributes[name] = { type: "boolean", default: false, configurable: false };
|
|
400
474
|
added.push(uid);
|
|
401
475
|
}
|
|
402
476
|
return { added, skipped };
|
|
@@ -463,10 +537,960 @@ var require_hidden = __commonJS({
|
|
|
463
537
|
}
|
|
464
538
|
});
|
|
465
539
|
|
|
540
|
+
// server/diff.js
|
|
541
|
+
var require_diff = __commonJS({
|
|
542
|
+
"server/diff.js"(exports2, module2) {
|
|
543
|
+
"use strict";
|
|
544
|
+
var IGNORED = ["id", "documentId", "locale", "localizations", "createdAt", "updatedAt", "publishedAt", "firstPublishedAt", "createdBy", "updatedBy", "strapi_stage", "strapi_assignee", "__temp_key__", "__pivot"];
|
|
545
|
+
var morph = (attr) => String(attr?.relation || "").toLowerCase().includes("morph");
|
|
546
|
+
var kept = (name, attr) => attr && !IGNORED.includes(name) && attr.type !== "password" && !(attr.type === "relation" && (morph(attr) || attr.visible === false));
|
|
547
|
+
var empty = (value) => value === null || value === void 0 || value === "";
|
|
548
|
+
function snapshotPopulate(uid, schemaOf, stack = []) {
|
|
549
|
+
const schema = schemaOf(uid);
|
|
550
|
+
if (!schema || stack.includes(uid)) return {};
|
|
551
|
+
const scalars = (component) => Object.entries(schemaOf(component)?.attributes || {}).filter(([name, attr]) => kept(name, attr) && !["relation", "media", "component", "dynamiczone"].includes(attr.type)).map(([name]) => name);
|
|
552
|
+
const nested = (component) => ({ select: scalars(component).length ? scalars(component) : ["id"], populate: snapshotPopulate(component, schemaOf, [...stack, uid]) });
|
|
553
|
+
const out = {};
|
|
554
|
+
for (const [name, attr] of Object.entries(schema.attributes || {})) {
|
|
555
|
+
if (!kept(name, attr)) continue;
|
|
556
|
+
if (attr.type === "relation") out[name] = { select: ["documentId", "locale"] };
|
|
557
|
+
else if (attr.type === "media") out[name] = { select: ["id"] };
|
|
558
|
+
else if (attr.type === "component") out[name] = nested(attr.component);
|
|
559
|
+
else if (attr.type === "dynamiczone") out[name] = { on: Object.fromEntries((attr.components || []).map((component) => [component, nested(component)])) };
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
function canonical(data, schema, schemaOf, { relations = true } = {}, depth = 0) {
|
|
564
|
+
const out = {};
|
|
565
|
+
if (!data || typeof data !== "object" || depth > 20) return out;
|
|
566
|
+
const idOf = (file) => file && typeof file === "object" ? file.id : file;
|
|
567
|
+
const ref = (item) => item && typeof item === "object" && item.documentId ? { documentId: item.documentId, ...item.locale && { locale: item.locale } } : null;
|
|
568
|
+
for (const [name, attr] of Object.entries(schema?.attributes || {})) {
|
|
569
|
+
const value = data[name];
|
|
570
|
+
if (!kept(name, attr) || empty(value)) continue;
|
|
571
|
+
if (attr.type === "relation") {
|
|
572
|
+
if (!relations) continue;
|
|
573
|
+
const refs = (Array.isArray(value) ? value : [value]).map(ref).filter(Boolean);
|
|
574
|
+
if (refs.length) out[name] = Array.isArray(value) ? refs : refs[0];
|
|
575
|
+
} else if (attr.type === "media") {
|
|
576
|
+
const ids = (Array.isArray(value) ? value : [value]).map(idOf).filter((id) => !empty(id));
|
|
577
|
+
if (ids.length) out[name] = attr.multiple ? ids : ids[0];
|
|
578
|
+
} else if (attr.type === "component") {
|
|
579
|
+
const child = schemaOf(attr.component);
|
|
580
|
+
if (attr.repeatable) {
|
|
581
|
+
if (Array.isArray(value) && value.length) out[name] = value.map((item) => canonical(item, child, schemaOf, { relations }, depth + 1));
|
|
582
|
+
} else out[name] = canonical(value, child, schemaOf, { relations }, depth + 1);
|
|
583
|
+
} else if (attr.type === "dynamiczone") {
|
|
584
|
+
if (Array.isArray(value) && value.length) out[name] = value.filter((row) => row && typeof row.__component === "string").map((row) => ({ __component: row.__component, ...canonical(row, schemaOf(row.__component), schemaOf, { relations }, depth + 1) }));
|
|
585
|
+
} else out[name] = value;
|
|
586
|
+
}
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
var stable = (value) => JSON.stringify(value, (_, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v);
|
|
590
|
+
var same = (a, b) => stable(a ?? null) === stable(b ?? null);
|
|
591
|
+
function diffRows(before = [], after = []) {
|
|
592
|
+
const a = before.map(stable), b = after.map(stable);
|
|
593
|
+
const n = a.length, m = b.length;
|
|
594
|
+
if (n * m > 25e4) return after.map((row, to) => ({ op: a[to] === b[to] ? "same" : "changed", from: to < n ? to : void 0, to, uid: row.__component }));
|
|
595
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
596
|
+
for (let i2 = n - 1; i2 >= 0; i2--) for (let j2 = m - 1; j2 >= 0; j2--) lcs[i2][j2] = a[i2] === b[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
|
|
597
|
+
const out = [];
|
|
598
|
+
const gap = (fromRows2, toRows2) => {
|
|
599
|
+
const removed = [...fromRows2];
|
|
600
|
+
for (const to of toRows2) {
|
|
601
|
+
const k = removed.findIndex((from) => before[from].__component === after[to].__component);
|
|
602
|
+
if (k === -1) out.push({ op: "added", to, uid: after[to].__component });
|
|
603
|
+
else out.push({ op: "changed", from: removed.splice(k, 1)[0], to, uid: after[to].__component });
|
|
604
|
+
}
|
|
605
|
+
for (const from of removed) out.push({ op: "removed", from, uid: before[from].__component });
|
|
606
|
+
};
|
|
607
|
+
let i = 0, j = 0, fromRows = [], toRows = [];
|
|
608
|
+
while (i < n || j < m) {
|
|
609
|
+
if (i < n && j < m && a[i] === b[j]) {
|
|
610
|
+
gap(fromRows, toRows);
|
|
611
|
+
fromRows = [];
|
|
612
|
+
toRows = [];
|
|
613
|
+
out.push({ op: "same", from: i, to: j, uid: after[j].__component });
|
|
614
|
+
i++;
|
|
615
|
+
j++;
|
|
616
|
+
} else if (j < m && (i >= n || lcs[i][j + 1] >= lcs[i + 1][j])) toRows.push(j++);
|
|
617
|
+
else fromRows.push(i++);
|
|
618
|
+
}
|
|
619
|
+
gap(fromRows, toRows);
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
function summarize(before, after, schema) {
|
|
623
|
+
const zones = Object.entries(schema?.attributes || {}).filter(([, attr]) => attr?.type === "dynamiczone").map(([name]) => name);
|
|
624
|
+
const blocks = { added: 0, removed: 0, changed: 0 };
|
|
625
|
+
if (!before) return { initial: true, fields: [], blocks };
|
|
626
|
+
const names = [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after || {})])];
|
|
627
|
+
const fields = names.filter((name) => !zones.includes(name) && !same(before[name], after?.[name])).sort();
|
|
628
|
+
for (const name of zones) for (const { op } of diffRows(before[name], after?.[name])) if (op !== "same") blocks[op]++;
|
|
629
|
+
return { fields, blocks };
|
|
630
|
+
}
|
|
631
|
+
module2.exports = { IGNORED, kept, snapshotPopulate, canonical, stable, same, diffRows, summarize };
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// server/trash.js
|
|
636
|
+
var require_trash = __commonJS({
|
|
637
|
+
"server/trash.js"(exports2, module2) {
|
|
638
|
+
"use strict";
|
|
639
|
+
var { AsyncLocalStorage } = require("node:async_hooks");
|
|
640
|
+
var { PLUGIN: PLUGIN2 } = require_settings();
|
|
641
|
+
var { kept, canonical, stable } = require_diff();
|
|
642
|
+
var history = () => require_history();
|
|
643
|
+
var TRASH_UID = `plugin::${PLUGIN2}.trash`;
|
|
644
|
+
var STATUSES = ["trashed", "restored"];
|
|
645
|
+
var DAY = 24 * 60 * 60 * 1e3;
|
|
646
|
+
var BATCH = 500;
|
|
647
|
+
var restoring = new AsyncLocalStorage();
|
|
648
|
+
var trashContentType2 = { schema: {
|
|
649
|
+
kind: "collectionType",
|
|
650
|
+
collectionName: "blockscene_trash",
|
|
651
|
+
info: { singularName: "trash", pluralName: "trashes", displayName: "Blockscene trash" },
|
|
652
|
+
options: { draftAndPublish: false },
|
|
653
|
+
pluginOptions: { "content-manager": { visible: false }, "content-type-builder": { visible: false }, i18n: { localized: false } },
|
|
654
|
+
attributes: {
|
|
655
|
+
contentType: { type: "string", required: true },
|
|
656
|
+
relatedDocumentId: { type: "string", required: true },
|
|
657
|
+
// The document's main field (Content Manager setting) at delete time, for the list and the search.
|
|
658
|
+
title: { type: "string" },
|
|
659
|
+
// The locales the delete removed ([null] for a type without i18n).
|
|
660
|
+
locales: { type: "json" },
|
|
661
|
+
// [{ locale, status: 'draft' | 'published' | null (no Draft & Publish), snapshot }]: canonical snapshots (server/diff.js).
|
|
662
|
+
entries: { type: "json" },
|
|
663
|
+
// Links other documents' and blocks' unidirectional relations had to the deleted rows (see `incomingRows`).
|
|
664
|
+
incoming: { type: "json" },
|
|
665
|
+
actor: { type: "string", required: true },
|
|
666
|
+
actorName: { type: "string" },
|
|
667
|
+
deletedAt: { type: "datetime", required: true },
|
|
668
|
+
// deletedAt + trash retention at delete time; the nightly purge deletes the row after it.
|
|
669
|
+
expiresAt: { type: "datetime", required: true },
|
|
670
|
+
status: { type: "enumeration", enum: STATUSES, required: true, default: "trashed" },
|
|
671
|
+
restoredAs: { type: "string" },
|
|
672
|
+
restoredAt: { type: "datetime" },
|
|
673
|
+
size: { type: "integer" }
|
|
674
|
+
},
|
|
675
|
+
indexes: [{ name: "blockscene_trash_list", columns: ["status", "content_type", "deleted_at"] }, { name: "blockscene_trash_expiry", columns: ["expires_at"] }]
|
|
676
|
+
} };
|
|
677
|
+
var expiresAt = (deletedAt, days) => new Date(new Date(deletedAt).getTime() + days * DAY);
|
|
678
|
+
var isObject = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
679
|
+
var blank = (value) => value === null || value === void 0 || value === "" || Array.isArray(value) && !value.length;
|
|
680
|
+
var toMany = (attr) => /ToMany$|^manyWay$/.test(String(attr?.relation || ""));
|
|
681
|
+
var toOne = (attr) => ["oneToOne", "manyToOne"].includes(attr?.relation);
|
|
682
|
+
var uniqueFields = (schema) => Object.entries(schema?.attributes || {}).filter(([name, attr]) => kept(name, attr) && (attr.unique === true || attr.type === "uid")).map(([name]) => name);
|
|
683
|
+
function chooseEntries(entries) {
|
|
684
|
+
const chosen = /* @__PURE__ */ new Map();
|
|
685
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
686
|
+
const locale = entry?.locale ?? null, current = chosen.get(locale);
|
|
687
|
+
if (!current || current.status === "published" && entry.status !== "published") chosen.set(locale, entry);
|
|
688
|
+
}
|
|
689
|
+
return chosen;
|
|
690
|
+
}
|
|
691
|
+
function restorePayload(snapshot, schema, schemaOf, { media = /* @__PURE__ */ new Set(), relation = () => false } = {}) {
|
|
692
|
+
const report = { removed: [], media: 0, relations: 0, blocks: 0 };
|
|
693
|
+
const ref = (item) => ({ documentId: item.documentId, ...item.locale && { locale: item.locale } });
|
|
694
|
+
const build = (data, schema2, top, depth) => {
|
|
695
|
+
const out = {};
|
|
696
|
+
if (!isObject(data) || depth > 20) return out;
|
|
697
|
+
for (const [name, value] of Object.entries(data)) {
|
|
698
|
+
if (name === "__component") continue;
|
|
699
|
+
const attr = schema2?.attributes?.[name];
|
|
700
|
+
const drop = () => {
|
|
701
|
+
if (top) report.removed.push(name);
|
|
702
|
+
};
|
|
703
|
+
if (!kept(name, attr)) {
|
|
704
|
+
drop();
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const list = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
708
|
+
if (attr.type === "relation") {
|
|
709
|
+
const refs = list.filter((item) => isObject(item) && typeof item.documentId === "string");
|
|
710
|
+
const found = refs.filter((item) => relation(attr.target, item));
|
|
711
|
+
report.relations += refs.length - found.length;
|
|
712
|
+
out[name] = toMany(attr) ? found.map(ref) : found[0] ? ref(found[0]) : null;
|
|
713
|
+
} else if (attr.type === "media") {
|
|
714
|
+
const ids = list.filter(Number.isInteger), found = ids.filter((id) => media.has(id));
|
|
715
|
+
report.media += ids.length - found.length;
|
|
716
|
+
out[name] = attr.multiple ? found : found[0] ?? null;
|
|
717
|
+
} else if (attr.type === "component") {
|
|
718
|
+
const child = schemaOf(attr.component);
|
|
719
|
+
if (!child) {
|
|
720
|
+
drop();
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
out[name] = attr.repeatable ? list.filter(isObject).map((item) => build(item, child, false, depth + 1)) : isObject(value) ? build(value, child, false, depth + 1) : null;
|
|
724
|
+
} else if (attr.type === "dynamiczone") {
|
|
725
|
+
const rows = list.filter((row) => isObject(row) && typeof row.__component === "string");
|
|
726
|
+
const allowed = rows.filter((row) => (attr.components || []).includes(row.__component) && schemaOf(row.__component));
|
|
727
|
+
report.blocks += rows.length - allowed.length;
|
|
728
|
+
out[name] = allowed.map((row) => ({ __component: row.__component, ...build(row, schemaOf(row.__component), false, depth + 1) }));
|
|
729
|
+
} else if (attr.type === "enumeration" && !(attr.enum || []).includes(value)) drop();
|
|
730
|
+
else out[name] = value;
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
};
|
|
734
|
+
return { data: build(snapshot, schema, true, 0), report };
|
|
735
|
+
}
|
|
736
|
+
function planRestore(entries, { schema, schemaOf, locales = null, defaultLocale = null, existing = null, singleTaken = false, taken = () => false, media, relation }) {
|
|
737
|
+
const blocking = [], warnings = [], steps = [];
|
|
738
|
+
const published = (Array.isArray(entries) ? entries : []).filter((entry) => entry?.status === "published").map((entry) => entry.locale ?? null);
|
|
739
|
+
if (published.length) warnings.push({ code: "published", locales: published });
|
|
740
|
+
if (singleTaken && !existing) blocking.push({ code: "singleExists" });
|
|
741
|
+
for (const [locale, entry] of chooseEntries(entries)) {
|
|
742
|
+
if (locales && !locales.includes(locale)) {
|
|
743
|
+
warnings.push({ code: "localeRemoved", locale });
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (existing?.includes(locale)) {
|
|
747
|
+
blocking.push({ code: "localeExists", locale });
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const { data, report } = restorePayload(entry.snapshot, schema, schemaOf, { media, relation });
|
|
751
|
+
for (const field of uniqueFields(schema)) if (!blank(data[field]) && taken(field, locale, data[field])) blocking.push({ code: "unique", locale, field, value: String(data[field]).slice(0, 100) });
|
|
752
|
+
const required = Object.entries(schema?.attributes || {}).filter(([name, attr]) => attr?.required && kept(name, attr) && blank(data[name])).map(([name]) => name);
|
|
753
|
+
if (required.length) warnings.push({ code: "required", locale, fields: required });
|
|
754
|
+
if (report.removed.length) warnings.push({ code: "fieldsRemoved", locale, fields: report.removed });
|
|
755
|
+
for (const code of ["media", "relations", "blocks"]) if (report[code]) warnings.push({ code, locale, count: report[code] });
|
|
756
|
+
steps.push({ locale, data });
|
|
757
|
+
}
|
|
758
|
+
if (!steps.length && !blocking.length) blocking.push({ code: "nothing" });
|
|
759
|
+
steps.sort((a, b) => (b.locale === defaultLocale) - (a.locale === defaultLocale));
|
|
760
|
+
return { blocking, warnings, steps };
|
|
761
|
+
}
|
|
762
|
+
var incomingSources = (uid, models) => models.flatMap((model) => Object.entries(model.attributes || {}).filter(([, attr]) => attr?.type === "relation" && attr.target === uid && !attr.inversedBy && !attr.mappedBy && attr.joinTable).map(([field, attr]) => ({ owner: model.uid, kind: model.kind, field, joinTable: attr.joinTable, single: toOne(attr) })));
|
|
763
|
+
function incomingRows(source, links, deleted, owners = /* @__PURE__ */ new Map()) {
|
|
764
|
+
const { joinColumn, inverseJoinColumn, orderColumnName } = source.joinTable;
|
|
765
|
+
return links.filter((link) => deleted.has(link[inverseJoinColumn.name]) && !(source.kind === "contentType" && source.owner === source.target && deleted.has(link[joinColumn.name]))).map((link) => {
|
|
766
|
+
const target = deleted.get(link[inverseJoinColumn.name]), owner = owners.get(link[joinColumn.name]);
|
|
767
|
+
return {
|
|
768
|
+
owner: source.owner,
|
|
769
|
+
kind: source.kind,
|
|
770
|
+
field: source.field,
|
|
771
|
+
ownerId: link[joinColumn.name],
|
|
772
|
+
...source.kind === "contentType" && { ownerDocumentId: owner?.documentId ?? null, ownerLocale: owner?.locale ?? null },
|
|
773
|
+
locale: target.locale ?? null,
|
|
774
|
+
published: Boolean(target.published),
|
|
775
|
+
order: orderColumnName ? link[orderColumnName] ?? null : null
|
|
776
|
+
};
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
function previewOf(snapshot, schema, schemaOf) {
|
|
780
|
+
const fields = [], blocks = [];
|
|
781
|
+
const text = (value) => {
|
|
782
|
+
const s = typeof value === "string" ? value : stable(value);
|
|
783
|
+
return s.length > 200 ? `${s.slice(0, 199)}\u2026` : s;
|
|
784
|
+
};
|
|
785
|
+
for (const [name, value] of Object.entries(isObject(snapshot) ? snapshot : {})) {
|
|
786
|
+
const attr = schema?.attributes?.[name];
|
|
787
|
+
if (attr?.type === "dynamiczone") {
|
|
788
|
+
for (const row of Array.isArray(value) ? value : []) blocks.push({ zone: name, uid: row?.__component, name: schemaOf(row?.__component)?.info?.displayName || row?.__component });
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const count = Array.isArray(value) ? value.length : 1;
|
|
792
|
+
fields.push({ name, type: attr?.type || "removed", ...["relation", "media", "component"].includes(attr?.type) ? { count } : { value: text(value) } });
|
|
793
|
+
}
|
|
794
|
+
return { fields, blocks };
|
|
795
|
+
}
|
|
796
|
+
function trashService2({ strapi }) {
|
|
797
|
+
const events = () => strapi.plugin(PLUGIN2).service("history");
|
|
798
|
+
const rows = () => strapi.db.query(TRASH_UID);
|
|
799
|
+
const schemaOf = (uid) => uid && (strapi.getModel?.(uid) || strapi.components?.[uid] || strapi.contentTypes?.[uid]);
|
|
800
|
+
const localized = (uid) => Boolean(schemaOf(uid)?.pluginOptions?.i18n?.localized);
|
|
801
|
+
const draftAndPublish = (uid) => schemaOf(uid)?.options?.draftAndPublish === true;
|
|
802
|
+
const models = () => [...Object.values(strapi.contentTypes || {}).map((model) => ({ uid: model.uid, kind: "contentType" })), ...Object.values(strapi.components || {}).map((model) => ({ uid: model.uid, kind: "component" }))].map((model) => ({ ...model, attributes: strapi.db.metadata.get(model.uid)?.attributes })).filter((model) => model.attributes);
|
|
803
|
+
const table = (trx, name) => strapi.db.getConnection(name).transacting(trx);
|
|
804
|
+
const lookups = async (uid, snapshots) => {
|
|
805
|
+
const ids = /* @__PURE__ */ new Set(), refs = [];
|
|
806
|
+
for (const snapshot of snapshots) {
|
|
807
|
+
const found = history().refsOf(snapshot, schemaOf(uid), schemaOf);
|
|
808
|
+
found.media.forEach((id) => ids.add(id));
|
|
809
|
+
refs.push(...found.relations);
|
|
810
|
+
}
|
|
811
|
+
const files = ids.size ? await strapi.db.query("plugin::upload.file").findMany({ select: ["id"], where: { id: { $in: [...ids] } } }) : [];
|
|
812
|
+
const present = /* @__PURE__ */ new Set();
|
|
813
|
+
for (const target of [...new Set(refs.map((ref) => ref.target))]) {
|
|
814
|
+
if (!strapi.contentTypes?.[target]) continue;
|
|
815
|
+
const found = await strapi.db.query(target).findMany({ select: ["documentId", "locale"], where: {
|
|
816
|
+
documentId: { $in: [...new Set(refs.filter((ref) => ref.target === target).map((ref) => ref.documentId))] },
|
|
817
|
+
...draftAndPublish(target) && { publishedAt: { $null: true } }
|
|
818
|
+
} });
|
|
819
|
+
for (const row of found) {
|
|
820
|
+
present.add(`${target}|${row.documentId}|${row.locale || ""}`);
|
|
821
|
+
present.add(`${target}|${row.documentId}|*`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return { media: new Set(files.map((file) => file.id)), relation: (target, ref) => present.has(`${target}|${ref.documentId}|${localized(target) && ref.locale ? ref.locale : "*"}`) };
|
|
825
|
+
};
|
|
826
|
+
const resolveIncoming = async (uid, incoming, locales, trx) => {
|
|
827
|
+
const out = { links: [], gone: 0, published: 0, replaced: 0 };
|
|
828
|
+
for (const link of Array.isArray(incoming) ? incoming : []) {
|
|
829
|
+
if (!locales.includes(link.locale)) continue;
|
|
830
|
+
if (link.published) {
|
|
831
|
+
out.published++;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const attr = strapi.db.metadata.get(link.owner)?.attributes?.[link.field];
|
|
835
|
+
if (!attr?.joinTable || attr.target !== uid) {
|
|
836
|
+
out.gone++;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
let ownerId = null;
|
|
840
|
+
if (link.kind === "component") ownerId = (await strapi.db.query(link.owner).findOne({ select: ["id"], where: { id: link.ownerId } }))?.id ?? null;
|
|
841
|
+
else if (strapi.contentTypes?.[link.owner] && link.ownerDocumentId) ownerId = (await strapi.db.query(link.owner).findOne({ select: ["id"], where: {
|
|
842
|
+
documentId: link.ownerDocumentId,
|
|
843
|
+
...localized(link.owner) && { locale: link.ownerLocale },
|
|
844
|
+
...draftAndPublish(link.owner) && { publishedAt: { $null: true } }
|
|
845
|
+
} }))?.id ?? null;
|
|
846
|
+
if (!ownerId) {
|
|
847
|
+
out.gone++;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const { joinTable } = attr;
|
|
851
|
+
if (toOne(attr) && await table(trx, joinTable.name).where(joinTable.joinColumn.name, ownerId).first()) {
|
|
852
|
+
out.replaced++;
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
out.links.push({ ...link, ownerRowId: ownerId, joinTable });
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
};
|
|
859
|
+
const self = {
|
|
860
|
+
TRASH_UID,
|
|
861
|
+
// Before the delete, in its transaction: the links other documents and blocks have to the rows about to be deleted.
|
|
862
|
+
async incoming(uid, deleted) {
|
|
863
|
+
if (!deleted.size) return [];
|
|
864
|
+
return strapi.db.transaction(async ({ trx }) => {
|
|
865
|
+
const out = [];
|
|
866
|
+
for (const source of incomingSources(uid, models())) {
|
|
867
|
+
const links = await table(trx, source.joinTable.name).select("*").whereIn(source.joinTable.inverseJoinColumn.name, [...deleted.keys()]);
|
|
868
|
+
if (!links.length) continue;
|
|
869
|
+
const owners = source.kind === "contentType" && strapi.contentTypes?.[source.owner] ? new Map((await strapi.db.query(source.owner).findMany({
|
|
870
|
+
select: ["id", "documentId", "locale"],
|
|
871
|
+
where: { id: { $in: [...new Set(links.map((link) => link[source.joinTable.joinColumn.name]))] } }
|
|
872
|
+
})).map((row) => [row.id, row])) : /* @__PURE__ */ new Map();
|
|
873
|
+
out.push(...incomingRows({ ...source, target: uid }, links, deleted, owners));
|
|
874
|
+
}
|
|
875
|
+
return out;
|
|
876
|
+
});
|
|
877
|
+
},
|
|
878
|
+
// After the delete, in its transaction: the trash row. drafts/published: the stored rows read before the delete.
|
|
879
|
+
async record({ uid, documentId, locales, drafts, published, incoming, actor, days }) {
|
|
880
|
+
const dp = draftAndPublish(uid);
|
|
881
|
+
const entries = [...drafts.map((row) => [row, dp ? "draft" : null]), ...published.map((row) => [row, "published"])].map(([row, status]) => ({ locale: row.locale ?? null, status, snapshot: canonical(row, schemaOf(uid), schemaOf) }));
|
|
882
|
+
const main = await events().mainField(uid);
|
|
883
|
+
const first = chooseEntries(entries);
|
|
884
|
+
const source = first.get(await events().scope(uid).then((list) => list?.[0] ?? null).catch(() => null)) || [...first.values()][0];
|
|
885
|
+
const title = main && source?.snapshot?.[main] != null ? String(source.snapshot[main]).slice(0, 255) : null;
|
|
886
|
+
const deletedAt = /* @__PURE__ */ new Date();
|
|
887
|
+
return rows().create({ data: {
|
|
888
|
+
contentType: uid,
|
|
889
|
+
relatedDocumentId: documentId,
|
|
890
|
+
title,
|
|
891
|
+
locales: locales || [...new Set(entries.map((entry) => entry.locale))],
|
|
892
|
+
entries,
|
|
893
|
+
incoming,
|
|
894
|
+
...actor,
|
|
895
|
+
deletedAt,
|
|
896
|
+
expiresAt: expiresAt(deletedAt, days),
|
|
897
|
+
status: "trashed",
|
|
898
|
+
size: Buffer.byteLength(stable({ entries, incoming }))
|
|
899
|
+
} });
|
|
900
|
+
},
|
|
901
|
+
// One page of trash rows, without their snapshots. `types`: the content types the user may read.
|
|
902
|
+
async list({ contentType, status = "trashed", page = 1, pageSize = 20, types }) {
|
|
903
|
+
const where = { status, contentType: contentType ? contentType : { $in: types } };
|
|
904
|
+
if (contentType && !types.includes(contentType)) return { results: [], pagination: { page, pageSize, total: 0, pageCount: 0 } };
|
|
905
|
+
const [results, total] = await Promise.all([
|
|
906
|
+
rows().findMany({
|
|
907
|
+
select: ["id", "contentType", "relatedDocumentId", "title", "locales", "actor", "actorName", "deletedAt", "expiresAt", "status", "restoredAs", "restoredAt"],
|
|
908
|
+
where,
|
|
909
|
+
orderBy: [{ deletedAt: "desc" }, { id: "desc" }],
|
|
910
|
+
offset: (page - 1) * pageSize,
|
|
911
|
+
limit: pageSize
|
|
912
|
+
}),
|
|
913
|
+
rows().count({ where })
|
|
914
|
+
]);
|
|
915
|
+
return { results, pagination: { page, pageSize, total, pageCount: Math.ceil(total / pageSize) } };
|
|
916
|
+
},
|
|
917
|
+
async find(id) {
|
|
918
|
+
return rows().findOne({ where: { id } });
|
|
919
|
+
},
|
|
920
|
+
// One row for the preview: fields and blocks of each entry, and how many links it held.
|
|
921
|
+
preview(row) {
|
|
922
|
+
const schema = schemaOf(row.contentType);
|
|
923
|
+
const { entries, incoming, ...meta } = row;
|
|
924
|
+
return {
|
|
925
|
+
...meta,
|
|
926
|
+
incoming: (incoming || []).length,
|
|
927
|
+
entries: (entries || []).map((entry) => ({ locale: entry.locale ?? null, status: entry.status ?? null, ...previewOf(entry.snapshot, schema, schemaOf) }))
|
|
928
|
+
};
|
|
929
|
+
},
|
|
930
|
+
// The pre-check: every lookup a restore needs, then planRestore. Read-only; the restore runs it again in its transaction.
|
|
931
|
+
async plan(row) {
|
|
932
|
+
const uid = row.contentType, schema = schemaOf(uid);
|
|
933
|
+
if (!schema || !strapi.contentTypes?.[uid]) return { blocking: [{ code: "typeGone" }], warnings: [], steps: [], incoming: { links: [], gone: 0, published: 0, replaced: 0 } };
|
|
934
|
+
const i18n = localized(uid);
|
|
935
|
+
const locales = i18n ? (await strapi.plugin("i18n").service("locales").find()).map((locale) => locale.code) : null;
|
|
936
|
+
const defaultLocale = i18n ? await strapi.plugin("i18n").service("locales").getDefaultLocale() : null;
|
|
937
|
+
const current = await strapi.db.query(uid).findMany({ select: ["locale"], where: { documentId: row.relatedDocumentId } });
|
|
938
|
+
const existing = current.length ? [...new Set(current.map((entry) => i18n ? entry.locale : null))] : null;
|
|
939
|
+
const singleTaken = schema.kind === "singleType" && !existing && Boolean(await strapi.db.query(uid).findOne({ select: ["id"] }));
|
|
940
|
+
const chosen = [...chooseEntries(row.entries).values()];
|
|
941
|
+
const { media, relation } = await lookups(uid, chosen.map((entry) => entry.snapshot));
|
|
942
|
+
const taken = /* @__PURE__ */ new Set();
|
|
943
|
+
for (const entry of chosen) for (const field of uniqueFields(schema)) {
|
|
944
|
+
const value = entry.snapshot?.[field];
|
|
945
|
+
if (blank(value) || typeof value === "object") continue;
|
|
946
|
+
const other = await strapi.db.query(uid).findOne({ select: ["id"], where: { [field]: value, ...i18n && { locale: entry.locale }, ...existing && { documentId: { $ne: row.relatedDocumentId } } } });
|
|
947
|
+
if (other) taken.add(stable([field, entry.locale ?? null, value]));
|
|
948
|
+
}
|
|
949
|
+
const plan = planRestore(row.entries, { schema, schemaOf, locales, defaultLocale, existing, singleTaken, media, relation, taken: (field, locale, value) => taken.has(stable([field, locale ?? null, value])) });
|
|
950
|
+
const incoming = await strapi.db.transaction(({ trx }) => resolveIncoming(uid, row.incoming, plan.steps.map((step) => step.locale), trx));
|
|
951
|
+
return { ...plan, documentId: existing ? row.relatedDocumentId : null, incoming };
|
|
952
|
+
},
|
|
953
|
+
// What the pre-check endpoint returns: the plan without the data it would write.
|
|
954
|
+
async check(row) {
|
|
955
|
+
if (row.status !== "trashed") return { status: row.status, restoredAs: row.restoredAs, blocking: [{ code: "restored" }], warnings: [] };
|
|
956
|
+
const { steps, incoming: { links, ...incoming }, ...plan } = await self.plan(row);
|
|
957
|
+
return { ...plan, locales: steps.map((step) => step.locale), incoming: { ...incoming, reconnect: links.length } };
|
|
958
|
+
},
|
|
959
|
+
// Restore as a draft, in one transaction: claim the row (a second restore of the same row, concurrent or later, gets
|
|
960
|
+
// `already` with the first one's document), check again, recreate (default locale first, the others under the same
|
|
961
|
+
// documentId), put the links back, record `restore` events. A blocking problem throws `conflict` and nothing is written.
|
|
962
|
+
async restore(id, { actor, userId }) {
|
|
963
|
+
return restoring.run(true, () => strapi.db.transaction(async ({ trx }) => {
|
|
964
|
+
const claimed = await rows().updateMany({ where: { id, status: "trashed" }, data: { status: "restored", restoredAt: /* @__PURE__ */ new Date() } });
|
|
965
|
+
const row = await rows().findOne({ where: { id } });
|
|
966
|
+
if (!row) return null;
|
|
967
|
+
if (!claimed?.count) return { already: true, documentId: row.restoredAs || null };
|
|
968
|
+
const plan = await self.plan(row);
|
|
969
|
+
if (plan.blocking.length) {
|
|
970
|
+
const error = new Error("conflict");
|
|
971
|
+
error.conflict = { blocking: plan.blocking, warnings: plan.warnings };
|
|
972
|
+
throw error;
|
|
973
|
+
}
|
|
974
|
+
const uid = row.contentType, documents = strapi.documents(uid);
|
|
975
|
+
const creator = userId ? { createdBy: userId, updatedBy: userId } : {};
|
|
976
|
+
let documentId = plan.documentId;
|
|
977
|
+
for (const { locale, data } of plan.steps) {
|
|
978
|
+
const params = { ...locale && { locale }, data: { ...data, ...creator } };
|
|
979
|
+
if (documentId) await documents.update({ documentId, ...params });
|
|
980
|
+
else documentId = (await documents.create(params)).documentId;
|
|
981
|
+
}
|
|
982
|
+
const restored = await events().rows(uid, documentId, localized(uid) ? plan.steps.map((step) => step.locale) : null);
|
|
983
|
+
const rowOf = new Map(restored.map((entry) => [entry.locale ?? null, entry]));
|
|
984
|
+
for (const link of plan.incoming.links) {
|
|
985
|
+
const target = rowOf.get(link.locale);
|
|
986
|
+
if (!target) continue;
|
|
987
|
+
const { joinColumn, inverseJoinColumn, orderColumnName } = link.joinTable;
|
|
988
|
+
await table(trx, link.joinTable.name).insert({ [joinColumn.name]: link.ownerRowId, [inverseJoinColumn.name]: target.id, ...orderColumnName && link.order != null && { [orderColumnName]: link.order } });
|
|
989
|
+
}
|
|
990
|
+
for (const entry of restored) await events().record({ uid, documentId, locale: entry.locale ?? null, action: "restore", entry, actor, extra: { restoredFrom: row.relatedDocumentId, trash: row.id } });
|
|
991
|
+
await rows().update({ where: { id }, data: { restoredAs: documentId } });
|
|
992
|
+
const { links, ...incoming } = plan.incoming;
|
|
993
|
+
return { documentId, locales: plan.steps.map((step) => step.locale), warnings: plan.warnings, incoming: { ...incoming, reconnected: links.length } };
|
|
994
|
+
}));
|
|
995
|
+
},
|
|
996
|
+
// Delete forever (one row), recorded as a `purge` event. Never touches media files.
|
|
997
|
+
async remove(row, actor) {
|
|
998
|
+
return strapi.db.transaction(async () => {
|
|
999
|
+
const { count } = await rows().deleteMany({ where: { id: row.id } });
|
|
1000
|
+
if (count) await events().record({ uid: row.contentType, documentId: row.relatedDocumentId, action: "purge", actor, extra: { trash: row.id, title: row.title } });
|
|
1001
|
+
return count;
|
|
1002
|
+
});
|
|
1003
|
+
},
|
|
1004
|
+
// Nightly, in batches, idempotent: rows past expiresAt are deleted (restored ones too). Never touches media files.
|
|
1005
|
+
async purge(now = Date.now()) {
|
|
1006
|
+
let count = 0;
|
|
1007
|
+
for (; ; ) {
|
|
1008
|
+
const ids = (await rows().findMany({ select: ["id"], where: { expiresAt: { $lt: new Date(now) } }, orderBy: { id: "asc" }, limit: BATCH })).map((row) => row.id);
|
|
1009
|
+
if (ids.length) await rows().deleteMany({ where: { id: { $in: ids } } });
|
|
1010
|
+
count += ids.length;
|
|
1011
|
+
if (ids.length < BATCH) return count;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
return self;
|
|
1016
|
+
}
|
|
1017
|
+
var ID = /^[1-9]\d{0,15}$/;
|
|
1018
|
+
var API_UID = /^api::[\w-]+\.[\w-]+$/;
|
|
1019
|
+
var int = (value, fallback, max) => {
|
|
1020
|
+
if (value === void 0 || value === "") return fallback;
|
|
1021
|
+
const n = Number(value);
|
|
1022
|
+
return Number.isInteger(n) && n >= 1 && n <= max ? n : NaN;
|
|
1023
|
+
};
|
|
1024
|
+
var trashController2 = ({ strapi }) => {
|
|
1025
|
+
const service = () => strapi.plugin(PLUGIN2).service("trash");
|
|
1026
|
+
const { can, readableTypes, actorOf } = history();
|
|
1027
|
+
const load = async (ctx, action = "read") => {
|
|
1028
|
+
if (!ID.test(String(ctx.params?.id || ""))) {
|
|
1029
|
+
ctx.badRequest("Invalid trash entry");
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
const row = await service().find(Number(ctx.params.id));
|
|
1033
|
+
if (!row || !can(strapi, ctx, row.contentType, "read") || action !== "read" && !can(strapi, ctx, row.contentType, action)) {
|
|
1034
|
+
ctx.notFound();
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
return row;
|
|
1038
|
+
};
|
|
1039
|
+
return {
|
|
1040
|
+
async list(ctx) {
|
|
1041
|
+
const { contentType = "", status = "trashed" } = ctx.query || {};
|
|
1042
|
+
const page = int(ctx.query?.page, 1, 1e4), pageSize = int(ctx.query?.pageSize, 20, 100);
|
|
1043
|
+
if (typeof contentType !== "string" || contentType && !API_UID.test(contentType) || !STATUSES.includes(status) || Number.isNaN(page) || Number.isNaN(pageSize)) return ctx.badRequest("Invalid filters");
|
|
1044
|
+
const types = readableTypes(strapi, ctx);
|
|
1045
|
+
ctx.body = {
|
|
1046
|
+
...await service().list({ contentType, status, page, pageSize, types }),
|
|
1047
|
+
types: types.map((uid) => ({ uid, displayName: strapi.contentTypes[uid].info?.displayName || uid, kind: strapi.contentTypes[uid].kind, localized: Boolean(strapi.contentTypes[uid].pluginOptions?.i18n?.localized) }))
|
|
1048
|
+
};
|
|
1049
|
+
},
|
|
1050
|
+
async find(ctx) {
|
|
1051
|
+
const row = await load(ctx);
|
|
1052
|
+
if (row) ctx.body = service().preview(row);
|
|
1053
|
+
},
|
|
1054
|
+
async check(ctx) {
|
|
1055
|
+
const row = await load(ctx, "create");
|
|
1056
|
+
if (row) ctx.body = await service().check(row);
|
|
1057
|
+
},
|
|
1058
|
+
async restore(ctx) {
|
|
1059
|
+
const row = await load(ctx, "create");
|
|
1060
|
+
if (!row) return;
|
|
1061
|
+
try {
|
|
1062
|
+
const result = await service().restore(row.id, { actor: actorOf(strapi), userId: ctx.state?.user?.id });
|
|
1063
|
+
if (!result) return ctx.notFound();
|
|
1064
|
+
ctx.body = result;
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
if (!error?.conflict) throw error;
|
|
1067
|
+
ctx.status = 409;
|
|
1068
|
+
ctx.body = { data: null, error: { status: 409, name: "ConflictError", message: "The document cannot be restored as it is: nothing was written.", details: error.conflict } };
|
|
1069
|
+
}
|
|
1070
|
+
},
|
|
1071
|
+
async remove(ctx) {
|
|
1072
|
+
const row = await load(ctx, "delete");
|
|
1073
|
+
if (!row) return;
|
|
1074
|
+
ctx.body = { deleted: await service().remove(row, actorOf(strapi)) };
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
};
|
|
1078
|
+
module2.exports = { TRASH_UID, STATUSES, restoring, trashContentType: trashContentType2, expiresAt, uniqueFields, chooseEntries, restorePayload, planRestore, incomingSources, incomingRows, previewOf, trashService: trashService2, trashController: trashController2 };
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// server/history.js
|
|
1083
|
+
var require_history = __commonJS({
|
|
1084
|
+
"server/history.js"(exports2, module2) {
|
|
1085
|
+
"use strict";
|
|
1086
|
+
var { createHash } = require("node:crypto");
|
|
1087
|
+
var { PLUGIN: PLUGIN2 } = require_settings();
|
|
1088
|
+
var { snapshotPopulate, canonical, stable, summarize } = require_diff();
|
|
1089
|
+
var { TRASH_UID, restoring } = require_trash();
|
|
1090
|
+
var EVENT_UID = `plugin::${PLUGIN2}.event`;
|
|
1091
|
+
var ACTIONS = ["create", "update", "publish", "unpublish", "discard", "delete", "restore", "purge"];
|
|
1092
|
+
var CAPTURED = { create: "create", clone: "create", update: "update", publish: "publish", unpublish: "unpublish", discardDraft: "discard", delete: "delete" };
|
|
1093
|
+
var CRON2 = "blocksceneHistoryPurge";
|
|
1094
|
+
var BATCH = 500;
|
|
1095
|
+
var DAY = 24 * 60 * 60 * 1e3;
|
|
1096
|
+
var LIST_LIMIT = 100;
|
|
1097
|
+
var ACTORS_LIMIT = 500;
|
|
1098
|
+
var SEARCH_LIMIT = 200;
|
|
1099
|
+
var eventContentType2 = { schema: {
|
|
1100
|
+
kind: "collectionType",
|
|
1101
|
+
collectionName: "blockscene_events",
|
|
1102
|
+
info: { singularName: "event", pluralName: "events", displayName: "Blockscene event" },
|
|
1103
|
+
options: { draftAndPublish: false },
|
|
1104
|
+
pluginOptions: { "content-manager": { visible: false }, "content-type-builder": { visible: false }, i18n: { localized: false } },
|
|
1105
|
+
attributes: {
|
|
1106
|
+
contentType: { type: "string", required: true },
|
|
1107
|
+
relatedDocumentId: { type: "string", required: true },
|
|
1108
|
+
locale: { type: "string" },
|
|
1109
|
+
action: { type: "enumeration", enum: ACTIONS, required: true },
|
|
1110
|
+
// 'admin:<id>', 'token:<id>', 'user:<id>' (Users & Permissions) or 'system'; the name as it was at that moment.
|
|
1111
|
+
actor: { type: "string", required: true },
|
|
1112
|
+
actorName: { type: "string" },
|
|
1113
|
+
at: { type: "datetime", required: true },
|
|
1114
|
+
// { fields: [names], blocks: { added, removed, changed }, initial? } vs the previous snapshot; { missing: true } when the capture failed;
|
|
1115
|
+
// restore: + { restoredFrom, trash }; purge: { trash, title }.
|
|
1116
|
+
summary: { type: "json" },
|
|
1117
|
+
// Null when identical to the previous snapshot of the same locale (same hash) or purged.
|
|
1118
|
+
snapshot: { type: "json" },
|
|
1119
|
+
hash: { type: "string" },
|
|
1120
|
+
size: { type: "integer" }
|
|
1121
|
+
},
|
|
1122
|
+
indexes: [{ name: "blockscene_events_lookup", columns: ["content_type", "related_document_id", "locale", "at"] }]
|
|
1123
|
+
} };
|
|
1124
|
+
var fail2 = (message, Kind = "ApplicationError") => {
|
|
1125
|
+
let E;
|
|
1126
|
+
try {
|
|
1127
|
+
E = require("@strapi/utils").errors[Kind];
|
|
1128
|
+
} catch {
|
|
1129
|
+
E = class extends Error {
|
|
1130
|
+
constructor(m) {
|
|
1131
|
+
super(m);
|
|
1132
|
+
this.name = Kind;
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
throw new E(message);
|
|
1137
|
+
};
|
|
1138
|
+
var hashOf = (text) => createHash("sha256").update(text).digest("hex");
|
|
1139
|
+
var covers2 = (history, uid) => Boolean(history?.enabled) && uid.startsWith("api::") && (history.contentTypes === "all" || Array.isArray(history.contentTypes) && history.contentTypes.includes(uid));
|
|
1140
|
+
var retention = ({ retentionDays, eventDays }, now = Date.now()) => ({ snapshotsBefore: new Date(now - retentionDays * DAY), eventsBefore: new Date(now - eventDays * DAY) });
|
|
1141
|
+
function actorOf(strapi) {
|
|
1142
|
+
let ctx;
|
|
1143
|
+
try {
|
|
1144
|
+
ctx = strapi.requestContext?.get?.();
|
|
1145
|
+
} catch {
|
|
1146
|
+
ctx = null;
|
|
1147
|
+
}
|
|
1148
|
+
const strategy = ctx?.state?.auth?.strategy?.name, user = ctx?.state?.user, token = ctx?.state?.auth?.credentials;
|
|
1149
|
+
const name = (value) => String(value || "").slice(0, 255);
|
|
1150
|
+
if (strategy === "admin" && user?.id) return { actor: `admin:${user.id}`, actorName: name([user.firstname, user.lastname].filter(Boolean).join(" ") || user.username || user.email) };
|
|
1151
|
+
if (strategy === "api-token" && token?.id) return { actor: `token:${token.id}`, actorName: name(token.name) };
|
|
1152
|
+
if (user?.id) return { actor: `user:${user.id}`, actorName: name(user.username || user.email) };
|
|
1153
|
+
return { actor: "system", actorName: "" };
|
|
1154
|
+
}
|
|
1155
|
+
function refsOf(snapshot, schema, schemaOf) {
|
|
1156
|
+
const media = /* @__PURE__ */ new Set(), relations = [];
|
|
1157
|
+
const walk = (data, schema2, depth) => {
|
|
1158
|
+
if (!data || typeof data !== "object" || depth > 20) return;
|
|
1159
|
+
for (const [name, attr] of Object.entries(schema2?.attributes || {})) {
|
|
1160
|
+
const value = data[name];
|
|
1161
|
+
if (value == null) continue;
|
|
1162
|
+
const list = Array.isArray(value) ? value : [value];
|
|
1163
|
+
if (attr.type === "media") list.forEach((id) => Number.isInteger(id) && media.add(id));
|
|
1164
|
+
else if (attr.type === "relation" && attr.target) list.forEach((ref) => ref?.documentId && relations.push({ target: attr.target, ...ref }));
|
|
1165
|
+
else if (attr.type === "component") list.forEach((item) => walk(item, schemaOf(attr.component), depth + 1));
|
|
1166
|
+
else if (attr.type === "dynamiczone") list.forEach((row) => walk(row, schemaOf(row?.__component), depth + 1));
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
walk(snapshot, schema, 0);
|
|
1170
|
+
return { media: [...media], relations };
|
|
1171
|
+
}
|
|
1172
|
+
function historyService2({ strapi }) {
|
|
1173
|
+
const plugin = () => strapi.plugin(PLUGIN2);
|
|
1174
|
+
const schemaOf = (uid) => uid && (strapi.getModel?.(uid) || strapi.components?.[uid] || strapi.contentTypes?.[uid]);
|
|
1175
|
+
const localized = (uid) => Boolean(schemaOf(uid)?.pluginOptions?.i18n?.localized);
|
|
1176
|
+
const draftAndPublish = (uid) => schemaOf(uid)?.options?.draftAndPublish === true;
|
|
1177
|
+
const events = () => strapi.db.query(EVENT_UID);
|
|
1178
|
+
const actors = async () => {
|
|
1179
|
+
const meta = strapi.db.metadata.get(EVENT_UID);
|
|
1180
|
+
const column = (name) => meta.attributes[name].columnName || name;
|
|
1181
|
+
return strapi.db.connection(meta.tableName).select({ actor: column("actor") }).max({ actorName: column("actorName") }).groupBy(column("actor")).orderBy(column("actor")).limit(ACTORS_LIMIT);
|
|
1182
|
+
};
|
|
1183
|
+
const scope = async (uid, locale) => {
|
|
1184
|
+
if (!localized(uid)) return [null];
|
|
1185
|
+
if (locale === "*") return null;
|
|
1186
|
+
if (Array.isArray(locale) && locale.length) return locale;
|
|
1187
|
+
if (typeof locale === "string" && locale) return [locale];
|
|
1188
|
+
return [await strapi.plugin("i18n").service("locales").getDefaultLocale()];
|
|
1189
|
+
};
|
|
1190
|
+
const rows = async (uid, documentId, locales, published = false) => !documentId ? [] : strapi.db.query(uid).findMany({
|
|
1191
|
+
where: {
|
|
1192
|
+
documentId,
|
|
1193
|
+
...locales && localized(uid) && { locale: { $in: locales } },
|
|
1194
|
+
...draftAndPublish(uid) && { publishedAt: published ? { $notNull: true } : { $null: true } }
|
|
1195
|
+
},
|
|
1196
|
+
populate: snapshotPopulate(uid, schemaOf)
|
|
1197
|
+
});
|
|
1198
|
+
const self = {
|
|
1199
|
+
EVENT_UID,
|
|
1200
|
+
async config() {
|
|
1201
|
+
return (await plugin().service("settings").get()).history;
|
|
1202
|
+
},
|
|
1203
|
+
covers: covers2,
|
|
1204
|
+
// One event. The snapshot is stored unless identical to the latest stored one of the same document and locale.
|
|
1205
|
+
async record({ uid, documentId, locale = null, action, entry, actor, missing = false, extra = null }) {
|
|
1206
|
+
let summary = missing ? { missing: true } : null, snapshot = null, hash = null, size = 0;
|
|
1207
|
+
if (entry) {
|
|
1208
|
+
const content = canonical(entry, schemaOf(uid), schemaOf);
|
|
1209
|
+
const text = stable(content);
|
|
1210
|
+
hash = hashOf(text);
|
|
1211
|
+
const previous = await events().findOne({ select: ["hash", "snapshot"], where: { contentType: uid, relatedDocumentId: documentId, locale, size: { $gt: 0 } }, orderBy: [{ at: "desc" }, { id: "desc" }] });
|
|
1212
|
+
summary = summarize(previous?.snapshot || null, content, schemaOf(uid));
|
|
1213
|
+
if (previous?.hash !== hash) {
|
|
1214
|
+
snapshot = content;
|
|
1215
|
+
size = Buffer.byteLength(text);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (extra) summary = { ...summary, ...extra };
|
|
1219
|
+
return events().create({ data: { contentType: uid, relatedDocumentId: documentId, locale, action, ...actor, at: /* @__PURE__ */ new Date(), summary, snapshot, hash, size } });
|
|
1220
|
+
},
|
|
1221
|
+
// After a save or publish: one event per affected entry (drafts, or the published versions after a publish).
|
|
1222
|
+
async captureSave({ uid, action, documentId, locales, published, actor }) {
|
|
1223
|
+
if (!documentId) return;
|
|
1224
|
+
for (const entry of await rows(uid, documentId, locales, published)) await self.record({ uid, documentId, locale: entry.locale ?? null, action, entry, actor });
|
|
1225
|
+
},
|
|
1226
|
+
scope,
|
|
1227
|
+
rows,
|
|
1228
|
+
// The Content Manager's main field of a content type when it is a plain value (what list views show), else null.
|
|
1229
|
+
async mainField(uid) {
|
|
1230
|
+
let name = null;
|
|
1231
|
+
try {
|
|
1232
|
+
name = (await strapi.plugin("content-manager").service("content-types").findConfiguration(strapi.contentTypes[uid]))?.settings?.mainField;
|
|
1233
|
+
} catch {
|
|
1234
|
+
name = null;
|
|
1235
|
+
}
|
|
1236
|
+
return name && name !== "id" && schemaOf(uid)?.attributes?.[name] && !["relation", "media", "component", "dynamiczone", "password", "json", "blocks"].includes(schemaOf(uid).attributes[name].type) ? name : null;
|
|
1237
|
+
},
|
|
1238
|
+
// Latest events of one document and locale, newest first; `stored`: its snapshot (or an identical one) can be loaded.
|
|
1239
|
+
async list({ uid, documentId, locale }) {
|
|
1240
|
+
const where = { contentType: uid, relatedDocumentId: documentId, locale: locale || null };
|
|
1241
|
+
const [list, kept] = await Promise.all([
|
|
1242
|
+
events().findMany({ select: ["id", "action", "actor", "actorName", "at", "summary", "hash", "size"], where, orderBy: [{ at: "desc" }, { id: "desc" }], limit: LIST_LIMIT }),
|
|
1243
|
+
events().findMany({ select: ["hash"], where: { ...where, size: { $gt: 0 } } })
|
|
1244
|
+
]);
|
|
1245
|
+
const hashes = new Set(kept.map((row) => row.hash));
|
|
1246
|
+
return list.map(({ hash, size, ...event }) => ({ ...event, stored: Boolean(hash && hashes.has(hash)) }));
|
|
1247
|
+
},
|
|
1248
|
+
// One event with its snapshot (from an identical event when this one was deduplicated) and what loading it needs:
|
|
1249
|
+
// the media files and relation targets that still exist (relation labels from the Content Manager's main field).
|
|
1250
|
+
async find(id) {
|
|
1251
|
+
const event = await events().findOne({ where: { id } });
|
|
1252
|
+
if (!event) return null;
|
|
1253
|
+
const { snapshot: own, ...meta } = event;
|
|
1254
|
+
const snapshot = own || event.hash && (await events().findOne({ select: ["snapshot"], where: { contentType: event.contentType, relatedDocumentId: event.relatedDocumentId, locale: event.locale, hash: event.hash, size: { $gt: 0 } } }))?.snapshot || null;
|
|
1255
|
+
if (!snapshot) return { event: meta, snapshot: null, media: {}, relations: {} };
|
|
1256
|
+
const refs = refsOf(snapshot, schemaOf(event.contentType), schemaOf);
|
|
1257
|
+
const files = refs.media.length ? await strapi.db.query("plugin::upload.file").findMany({ where: { id: { $in: refs.media } } }) : [];
|
|
1258
|
+
const relations = {};
|
|
1259
|
+
for (const target of [...new Set(refs.relations.map((ref) => ref.target))]) {
|
|
1260
|
+
if (!strapi.contentTypes?.[target]) continue;
|
|
1261
|
+
const mainField = await self.mainField(target), scalar = Boolean(mainField);
|
|
1262
|
+
const found = await strapi.db.query(target).findMany({
|
|
1263
|
+
select: ["id", "documentId", "locale", ...scalar ? [mainField] : []],
|
|
1264
|
+
where: { documentId: { $in: [...new Set(refs.relations.filter((ref) => ref.target === target).map((ref) => ref.documentId))] }, ...draftAndPublish(target) && { publishedAt: { $null: true } } }
|
|
1265
|
+
});
|
|
1266
|
+
relations[target] = found.map((row) => ({ id: row.id, documentId: row.documentId, locale: row.locale || null, label: scalar && row[mainField] != null ? String(row[mainField]) : void 0, mainField: scalar ? mainField : void 0 }));
|
|
1267
|
+
}
|
|
1268
|
+
return { event: meta, snapshot, media: Object.fromEntries(files.map((file) => [file.id, file])), relations };
|
|
1269
|
+
},
|
|
1270
|
+
// Content activity: one page of events (no snapshots), newest first, with each document's title and whether it still
|
|
1271
|
+
// exists (the list links to it). `types`: the content types the user may read. `q`: documents whose main field
|
|
1272
|
+
// contains it now, or whose trash entry's title does (at most SEARCH_LIMIT per content type).
|
|
1273
|
+
async activity({ actor, contentType, action, from, to, q, page, pageSize, types }) {
|
|
1274
|
+
const empty = { results: [], pagination: { page, pageSize, total: 0, pageCount: 0 }, actors: [] };
|
|
1275
|
+
if (contentType && !types.includes(contentType)) return empty;
|
|
1276
|
+
const where = {
|
|
1277
|
+
contentType: contentType || { $in: types },
|
|
1278
|
+
...actor && { actor },
|
|
1279
|
+
...action && { action },
|
|
1280
|
+
...(from || to) && { at: { ...from && { $gte: from }, ...to && { $lte: to } } }
|
|
1281
|
+
};
|
|
1282
|
+
if (q) {
|
|
1283
|
+
const matches = [];
|
|
1284
|
+
for (const uid of contentType ? [contentType] : types) {
|
|
1285
|
+
const main = await self.mainField(uid);
|
|
1286
|
+
const found = new Set(main ? (await strapi.db.query(uid).findMany({ select: ["documentId"], where: { [main]: { $containsi: q } }, limit: SEARCH_LIMIT })).map((row) => row.documentId) : []);
|
|
1287
|
+
for (const row of await strapi.db.query(TRASH_UID).findMany({ select: ["relatedDocumentId"], where: { contentType: uid, title: { $containsi: q } }, limit: SEARCH_LIMIT })) found.add(row.relatedDocumentId);
|
|
1288
|
+
if (found.size) matches.push({ contentType: uid, relatedDocumentId: { $in: [...found] } });
|
|
1289
|
+
}
|
|
1290
|
+
if (!matches.length) return { ...empty, actors: await actors() };
|
|
1291
|
+
where.$or = matches;
|
|
1292
|
+
}
|
|
1293
|
+
const [list, total] = await Promise.all([
|
|
1294
|
+
events().findMany({ select: ["id", "contentType", "relatedDocumentId", "locale", "action", "actor", "actorName", "at", "summary"], where, orderBy: [{ at: "desc" }, { id: "desc" }], offset: (page - 1) * pageSize, limit: pageSize }),
|
|
1295
|
+
events().count({ where })
|
|
1296
|
+
]);
|
|
1297
|
+
const docs = /* @__PURE__ */ new Map();
|
|
1298
|
+
for (const uid of [...new Set(list.map((event) => event.contentType))]) {
|
|
1299
|
+
const ids = [...new Set(list.filter((event) => event.contentType === uid).map((event) => event.relatedDocumentId))];
|
|
1300
|
+
const main = strapi.contentTypes?.[uid] ? await self.mainField(uid) : null;
|
|
1301
|
+
const found = strapi.contentTypes?.[uid] ? await strapi.db.query(uid).findMany({ select: ["documentId", "locale", ...main ? [main] : []], where: { documentId: { $in: ids } } }) : [];
|
|
1302
|
+
for (const row of found) {
|
|
1303
|
+
const key = `${uid}|${row.documentId}`;
|
|
1304
|
+
docs.set(key, [...docs.get(key) || [], { locale: row.locale ?? null, title: main && row[main] != null ? String(row[main]) : null }]);
|
|
1305
|
+
}
|
|
1306
|
+
const gone = ids.filter((id) => !docs.has(`${uid}|${id}`));
|
|
1307
|
+
if (gone.length) {
|
|
1308
|
+
for (const row of await strapi.db.query(TRASH_UID).findMany({ select: ["relatedDocumentId", "title"], where: { contentType: uid, relatedDocumentId: { $in: gone } }, orderBy: { id: "desc" } }))
|
|
1309
|
+
if (!docs.has(`trash|${uid}|${row.relatedDocumentId}`)) docs.set(`trash|${uid}|${row.relatedDocumentId}`, row.title);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const results = list.map((event) => {
|
|
1313
|
+
const rows2 = docs.get(`${event.contentType}|${event.relatedDocumentId}`) || [];
|
|
1314
|
+
const own = rows2.find((row) => row.locale === (event.locale ?? null));
|
|
1315
|
+
if (event.action === "purge") return { ...event, exists: false, title: event.summary?.title ?? null };
|
|
1316
|
+
return { ...event, exists: Boolean(own), title: (own || rows2[0])?.title ?? docs.get(`trash|${event.contentType}|${event.relatedDocumentId}`) ?? null };
|
|
1317
|
+
});
|
|
1318
|
+
return { results, pagination: { page, pageSize, total, pageCount: Math.ceil(total / pageSize) }, actors: await actors() };
|
|
1319
|
+
},
|
|
1320
|
+
// Nightly, in batches, idempotent (two instances running it is harmless): events past eventDays are deleted;
|
|
1321
|
+
// snapshots past retentionDays, or beyond maxSnapshots per document (newest kept), are emptied. Never touches media.
|
|
1322
|
+
async purge(now = Date.now()) {
|
|
1323
|
+
const history = await self.config();
|
|
1324
|
+
const { snapshotsBefore, eventsBefore } = retention(history, now);
|
|
1325
|
+
const counts = { events: 0, expired: 0, excess: 0 };
|
|
1326
|
+
const batches = async (key, query, apply) => {
|
|
1327
|
+
for (; ; ) {
|
|
1328
|
+
const ids = (await events().findMany({ select: ["id"], limit: BATCH, ...query })).map((row) => row.id);
|
|
1329
|
+
if (ids.length) await apply({ id: { $in: ids } });
|
|
1330
|
+
counts[key] += ids.length;
|
|
1331
|
+
if (ids.length < BATCH) return;
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
const empty = (where) => events().updateMany({ where, data: { snapshot: null, size: 0 } });
|
|
1335
|
+
await batches("events", { where: { at: { $lt: eventsBefore } }, orderBy: { id: "asc" } }, (where) => events().deleteMany({ where }));
|
|
1336
|
+
await batches("expired", { where: { at: { $lt: snapshotsBefore }, size: { $gt: 0 } }, orderBy: { id: "asc" } }, empty);
|
|
1337
|
+
const meta = strapi.db.metadata.get(EVENT_UID);
|
|
1338
|
+
const column = (name) => meta.attributes[name].columnName || name;
|
|
1339
|
+
const crowded = await strapi.db.connection(meta.tableName).select({ uid: column("contentType"), documentId: column("relatedDocumentId") }).where(column("size"), ">", 0).groupBy(column("contentType"), column("relatedDocumentId")).havingRaw("count(*) > ?", [history.maxSnapshots]);
|
|
1340
|
+
for (const { uid, documentId } of crowded) await batches("excess", {
|
|
1341
|
+
where: { contentType: uid, relatedDocumentId: documentId, size: { $gt: 0 } },
|
|
1342
|
+
orderBy: [{ at: "desc" }, { id: "desc" }],
|
|
1343
|
+
offset: history.maxSnapshots
|
|
1344
|
+
}, empty);
|
|
1345
|
+
return counts;
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
return self;
|
|
1349
|
+
}
|
|
1350
|
+
function registerHistory2(strapi) {
|
|
1351
|
+
if (!strapi.documents?.use) return;
|
|
1352
|
+
const service = () => strapi.plugin(PLUGIN2).service("history");
|
|
1353
|
+
const warn = (message, error) => strapi.log.error(`[${PLUGIN2}] ${message}: ${error?.message || error}`);
|
|
1354
|
+
strapi.documents.use(async (ctx, next) => {
|
|
1355
|
+
const action = CAPTURED[ctx.action];
|
|
1356
|
+
if (!action || !String(ctx.uid || "").startsWith("api::") || strapi.plugin(PLUGIN2).config("disabled") === true || restoring.getStore()) return next();
|
|
1357
|
+
const history = service();
|
|
1358
|
+
const params = ctx.params || {};
|
|
1359
|
+
if (action === "delete") {
|
|
1360
|
+
const config = params.documentId ? await history.config() : null;
|
|
1361
|
+
if (!config || !history.covers(config, ctx.uid)) return next();
|
|
1362
|
+
return strapi.db.transaction(async () => {
|
|
1363
|
+
const actor2 = actorOf(strapi), trash = strapi.plugin(PLUGIN2).service("trash");
|
|
1364
|
+
const published = strapi.getModel(ctx.uid)?.options?.draftAndPublish === true;
|
|
1365
|
+
let locales2, entries, live, incoming;
|
|
1366
|
+
try {
|
|
1367
|
+
locales2 = await history.scope(ctx.uid, params.locale);
|
|
1368
|
+
entries = await history.rows(ctx.uid, params.documentId, locales2);
|
|
1369
|
+
live = published ? await history.rows(ctx.uid, params.documentId, locales2, true) : [];
|
|
1370
|
+
incoming = await trash.incoming(ctx.uid, new Map([...entries.map((row) => [row.id, { locale: row.locale, published: false }]), ...live.map((row) => [row.id, { locale: row.locale, published: true }])]));
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
warn("reading the document before a delete failed", error);
|
|
1373
|
+
fail2(`The version history could not record this delete, so nothing was deleted (${error.message}).`);
|
|
1374
|
+
}
|
|
1375
|
+
const result2 = await next();
|
|
1376
|
+
try {
|
|
1377
|
+
for (const entry of entries) await history.record({ uid: ctx.uid, documentId: params.documentId, locale: entry.locale ?? null, action, entry, actor: actor2 });
|
|
1378
|
+
if (entries.length || live.length) await trash.record({ uid: ctx.uid, documentId: params.documentId, locales: locales2, drafts: entries, published: live, incoming, actor: actor2, days: config.trashDays });
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
warn("recording a delete failed, the delete was rolled back", error);
|
|
1381
|
+
fail2(`The version history could not record this delete, so nothing was deleted (${error.message}).`);
|
|
1382
|
+
}
|
|
1383
|
+
return result2;
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
let covered = false;
|
|
1387
|
+
try {
|
|
1388
|
+
covered = history.covers(await history.config(), ctx.uid);
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
warn("reading the history settings failed, this change is not recorded", error);
|
|
1391
|
+
}
|
|
1392
|
+
if (!covered) return next();
|
|
1393
|
+
const actor = actorOf(strapi);
|
|
1394
|
+
const result = await next();
|
|
1395
|
+
const documentId = ["create", "update"].includes(action) ? result?.documentId || params.documentId : params.documentId;
|
|
1396
|
+
const locales = ["publish", "unpublish", "discard"].includes(action) ? [...new Set((result?.entries || []).map((entry) => entry?.locale ?? null))] : ctx.action === "clone" ? null : await history.scope(ctx.uid, params.locale).catch(() => [result?.locale ?? null]);
|
|
1397
|
+
const steps = [[action, action === "publish"]];
|
|
1398
|
+
if (["create", "update"].includes(ctx.action) && params.status === "published" && strapi.getModel(ctx.uid)?.options?.draftAndPublish) steps.push(["publish", true]);
|
|
1399
|
+
for (const [step, published] of steps) {
|
|
1400
|
+
if (locales && !locales.length) continue;
|
|
1401
|
+
try {
|
|
1402
|
+
await history.captureSave({ uid: ctx.uid, action: step, documentId, locales, published, actor });
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
warn(`recording "${step}" of ${ctx.uid} ${documentId} failed, the change was saved without a version`, error);
|
|
1405
|
+
try {
|
|
1406
|
+
if (documentId) for (const locale of locales || [null]) await history.record({ uid: ctx.uid, documentId, locale, action: step, actor, missing: true });
|
|
1407
|
+
} catch (again) {
|
|
1408
|
+
warn("marking the missing version failed too", again);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return result;
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
function registerPurge2(strapi) {
|
|
1416
|
+
if (!strapi.documents?.use || !strapi.cron?.add) return;
|
|
1417
|
+
strapi.cron.add({ [CRON2]: { options: "0 3 * * *", task: async () => {
|
|
1418
|
+
try {
|
|
1419
|
+
const counts = await strapi.plugin(PLUGIN2).service("history").purge();
|
|
1420
|
+
if (counts.events || counts.expired || counts.excess) strapi.log.info(`[${PLUGIN2}] history purge: ${counts.events} events deleted, ${counts.expired + counts.excess} snapshots emptied`);
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
strapi.log.error(`[${PLUGIN2}] history purge failed: ${error?.message || error}`);
|
|
1423
|
+
}
|
|
1424
|
+
try {
|
|
1425
|
+
const count = await strapi.plugin(PLUGIN2).service("trash").purge();
|
|
1426
|
+
if (count) strapi.log.info(`[${PLUGIN2}] trash purge: ${count} expired entries deleted`);
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
strapi.log.error(`[${PLUGIN2}] trash purge failed: ${error?.message || error}`);
|
|
1429
|
+
}
|
|
1430
|
+
} } });
|
|
1431
|
+
}
|
|
1432
|
+
var DOCUMENT_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1433
|
+
var LOCALE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
1434
|
+
var can = (strapi, ctx, uid, action = "read") => {
|
|
1435
|
+
try {
|
|
1436
|
+
return !strapi.plugin("content-manager").service("permission-checker").create({ userAbility: ctx.state.userAbility, model: uid }).cannot[action]();
|
|
1437
|
+
} catch {
|
|
1438
|
+
return false;
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
var canRead = (strapi, ctx, uid) => can(strapi, ctx, uid);
|
|
1442
|
+
var readableTypes = (strapi, ctx) => Object.keys(strapi.contentTypes || {}).filter((uid) => uid.startsWith("api::") && canRead(strapi, ctx, uid));
|
|
1443
|
+
var ACTOR = /^(system|(admin|token|user):\d{1,15})$/;
|
|
1444
|
+
var int = (value, fallback, max) => {
|
|
1445
|
+
if (value === void 0 || value === "") return fallback;
|
|
1446
|
+
const n = Number(value);
|
|
1447
|
+
return Number.isInteger(n) && n >= 1 && n <= max ? n : NaN;
|
|
1448
|
+
};
|
|
1449
|
+
var date = (value) => {
|
|
1450
|
+
if (!value) return null;
|
|
1451
|
+
const time = Date.parse(value);
|
|
1452
|
+
return typeof value === "string" && value.length <= 40 && Number.isFinite(time) ? new Date(time) : NaN;
|
|
1453
|
+
};
|
|
1454
|
+
var historyController2 = ({ strapi }) => ({
|
|
1455
|
+
async list(ctx) {
|
|
1456
|
+
const { uid, documentId } = ctx.params || {};
|
|
1457
|
+
const locale = ctx.query?.locale || "";
|
|
1458
|
+
if (!String(uid).startsWith("api::") || !strapi.contentTypes?.[uid] || !DOCUMENT_ID.test(documentId || "") || locale && !LOCALE.test(locale)) return ctx.badRequest("Invalid content type, document or locale");
|
|
1459
|
+
if (!canRead(strapi, ctx, uid)) return ctx.forbidden();
|
|
1460
|
+
const history = strapi.plugin(PLUGIN2).service("history");
|
|
1461
|
+
ctx.body = { results: await history.list({ uid, documentId, locale: (await history.scope(uid, locale || void 0))[0] }) };
|
|
1462
|
+
},
|
|
1463
|
+
async find(ctx) {
|
|
1464
|
+
const id = Number(ctx.params?.id);
|
|
1465
|
+
if (!Number.isInteger(id) || id <= 0) return ctx.badRequest("Invalid event");
|
|
1466
|
+
const found = await strapi.plugin(PLUGIN2).service("history").find(id);
|
|
1467
|
+
if (!found || !canRead(strapi, ctx, found.event.contentType)) return ctx.notFound();
|
|
1468
|
+
ctx.body = found;
|
|
1469
|
+
},
|
|
1470
|
+
async activity(ctx) {
|
|
1471
|
+
const { actor = "", contentType = "", action = "", q = "" } = ctx.query || {};
|
|
1472
|
+
const page = int(ctx.query?.page, 1, 1e4), pageSize = int(ctx.query?.pageSize, 25, 100), from = date(ctx.query?.from), to = date(ctx.query?.to);
|
|
1473
|
+
if ([actor, contentType, action, q].some((value) => typeof value !== "string") || actor && !ACTOR.test(actor) || contentType && !/^api::[\w-]+\.[\w-]+$/.test(contentType) || action && !ACTIONS.includes(action) || q.length > 100 || [page, pageSize, from, to].some((value) => Number.isNaN(value))) return ctx.badRequest("Invalid filters");
|
|
1474
|
+
const history = strapi.plugin(PLUGIN2).service("history");
|
|
1475
|
+
const types = readableTypes(strapi, ctx);
|
|
1476
|
+
ctx.body = {
|
|
1477
|
+
...await history.activity({ actor, contentType, action, from, to, q: q.trim(), page, pageSize, types }),
|
|
1478
|
+
enabled: Boolean((await history.config()).enabled) && strapi.plugin(PLUGIN2).config("disabled") !== true,
|
|
1479
|
+
types: types.map((uid) => ({ uid, displayName: strapi.contentTypes[uid].info?.displayName || uid, kind: strapi.contentTypes[uid].kind, localized: Boolean(strapi.contentTypes[uid].pluginOptions?.i18n?.localized) }))
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
module2.exports = { EVENT_UID, ACTIONS, CAPTURED, CRON: CRON2, BATCH, eventContentType: eventContentType2, covers: covers2, retention, actorOf, refsOf, can, readableTypes, historyService: historyService2, registerHistory: registerHistory2, registerPurge: registerPurge2, historyController: historyController2 };
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
|
|
466
1487
|
// server/index.js
|
|
467
|
-
var { PLUGIN, TEMPLATES, TYPOLOGIES, DEFAULTS, catalog, validateSettings, mergeSaved, layer, overrides, validatePrefs, mergePrefs, safeUrl, fail } = require_settings();
|
|
1488
|
+
var { PLUGIN, TEMPLATES, TYPOLOGIES, DEFAULTS, catalog, validateSettings, validateFields, mergeSaved, layer, overrides, validatePrefs, mergePrefs, safeUrl, fail } = require_settings();
|
|
468
1489
|
var { safeGroups, validateGroups } = require_groups();
|
|
469
1490
|
var { hiddenName, injectHidden, registerHiddenStrip } = require_hidden();
|
|
1491
|
+
var { CRON, covers, eventContentType, historyService, historyController, registerHistory, registerPurge } = require_history();
|
|
1492
|
+
var { trashContentType, trashService, trashController } = require_trash();
|
|
1493
|
+
var STRAPI5 = true;
|
|
470
1494
|
var store = (strapi) => strapi.store({ type: "plugin", name: PLUGIN });
|
|
471
1495
|
var LEGACY_PLUGIN = "block-picker";
|
|
472
1496
|
async function readSettings(strapi) {
|
|
@@ -491,6 +1515,7 @@ async function resolveMedia(strapi, settings) {
|
|
|
491
1515
|
return resolved;
|
|
492
1516
|
}
|
|
493
1517
|
var contentTypeUids = (strapi) => Object.fromEntries(Object.entries(strapi.contentTypes || {}).filter(([uid, schema]) => uid.startsWith("api::") && zonesOf(schema).length).map(([uid, schema]) => [uid, Object.keys(schema.attributes || {})]));
|
|
1518
|
+
var apiUids = (strapi) => Object.keys(strapi.contentTypes || {}).filter((uid) => uid.startsWith("api::"));
|
|
494
1519
|
var zonesOf = (schema) => Object.entries(schema?.attributes || {}).filter(([, attr]) => attr?.type === "dynamiczone").map(([name]) => name);
|
|
495
1520
|
var label = (error) => `row ${error.index + 1}: ${error.code === "closeBeforeOpen" ? `close marker ${error.uid} has no open marker before it` : error.code === "mismatch" ? `close marker ${error.uid} does not match the open group ${error.open} (expected ${error.expected})` : `group ${error.uid} is not closed (expected ${error.expected})`}`;
|
|
496
1521
|
function checkZones(strapi, uid, entry, groups) {
|
|
@@ -584,6 +1609,8 @@ module.exports = {
|
|
|
584
1609
|
groups: null,
|
|
585
1610
|
// Project defaults from code: same shape as the stored settings, e.g. require('./blockscene.json'). See README "Settings page".
|
|
586
1611
|
settings: null,
|
|
1612
|
+
// Edit view field texts from code (never stored): { uid: { attribute: { label, description, placeholder } } }. See README "Field labels".
|
|
1613
|
+
fields: null,
|
|
587
1614
|
// "Hide on the site": boolean attribute added to every Dynamic Zone component (a DB column); false adds nothing.
|
|
588
1615
|
hiddenAttribute: "bsHidden"
|
|
589
1616
|
},
|
|
@@ -601,12 +1628,29 @@ module.exports = {
|
|
|
601
1628
|
async bootstrap({ strapi }) {
|
|
602
1629
|
await strapi.admin.services.permission.actionProvider.registerMany([
|
|
603
1630
|
{ section: "plugins", displayName: "Read gallery settings", uid: "settings.read", pluginName: PLUGIN },
|
|
604
|
-
{ section: "plugins", displayName: "Change gallery settings", uid: "settings.update", pluginName: PLUGIN }
|
|
1631
|
+
{ section: "plugins", displayName: "Change gallery settings", uid: "settings.update", pluginName: PLUGIN },
|
|
1632
|
+
...STRAPI5 ? [
|
|
1633
|
+
["history.read", "Read version history"],
|
|
1634
|
+
["activity.read", "Read content activity"],
|
|
1635
|
+
["trash.read", "Read the trash"],
|
|
1636
|
+
["trash.restore", "Restore from the trash"],
|
|
1637
|
+
["trash.purge", "Delete from the trash forever"]
|
|
1638
|
+
].map(([uid, displayName]) => ({ section: "plugins", displayName, uid, pluginName: PLUGIN })) : []
|
|
605
1639
|
]);
|
|
606
1640
|
strapi.plugin(PLUGIN).service("settings").projectDefaults();
|
|
1641
|
+
strapi.plugin(PLUGIN).service("settings").fields();
|
|
607
1642
|
registerPublishGuard(strapi);
|
|
608
1643
|
registerHiddenStrip(strapi, hiddenName(strapi.plugin(PLUGIN).config("hiddenAttribute")), strapi.plugin(PLUGIN));
|
|
1644
|
+
if (STRAPI5) {
|
|
1645
|
+
registerHistory(strapi);
|
|
1646
|
+
registerPurge(strapi);
|
|
1647
|
+
}
|
|
609
1648
|
},
|
|
1649
|
+
destroy({ strapi }) {
|
|
1650
|
+
if (STRAPI5) strapi.cron?.remove?.(CRON);
|
|
1651
|
+
},
|
|
1652
|
+
// Version history events and the trash (Strapi 5): hidden from the Content Manager and the Content-Type Builder.
|
|
1653
|
+
contentTypes: STRAPI5 ? { event: eventContentType, trash: trashContentType } : {},
|
|
610
1654
|
services: { settings: ({ strapi }) => {
|
|
611
1655
|
let project;
|
|
612
1656
|
const projectDefaults = () => {
|
|
@@ -614,18 +1658,33 @@ module.exports = {
|
|
|
614
1658
|
const code = strapi.plugin(PLUGIN).config("settings");
|
|
615
1659
|
project = null;
|
|
616
1660
|
if (code) try {
|
|
617
|
-
project = validateSettings(code, componentUids(strapi), contentTypeUids(strapi));
|
|
1661
|
+
project = validateSettings(code, componentUids(strapi), contentTypeUids(strapi), apiUids(strapi));
|
|
618
1662
|
} catch (error) {
|
|
619
1663
|
strapi.log?.warn(`[${PLUGIN}] "settings" config ignored: ${error.message}. Using the built-in defaults under the saved settings.`);
|
|
620
1664
|
}
|
|
621
1665
|
return project;
|
|
622
1666
|
};
|
|
623
|
-
|
|
1667
|
+
let fields;
|
|
1668
|
+
const fieldTexts = () => {
|
|
1669
|
+
if (fields !== void 0) return fields;
|
|
1670
|
+
const schemas = Object.fromEntries(Object.entries({ ...strapi.contentTypes, ...strapi.components }).map(([uid, schema]) => [uid, Object.keys(schema?.attributes || {})]));
|
|
1671
|
+
const stale = [];
|
|
1672
|
+
try {
|
|
1673
|
+
fields = validateFields(strapi.plugin(PLUGIN).config("fields"), schemas, stale);
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
fields = {};
|
|
1676
|
+
strapi.log?.warn(`[${PLUGIN}] "fields" config ignored: ${error.message}. Field labels fall back to the Content Manager's own.`);
|
|
1677
|
+
}
|
|
1678
|
+
if (stale.length) strapi.log?.warn(`[${PLUGIN}] "fields" entries for fields the schema no longer has were skipped: ${stale.slice(0, 20).join(", ")}${stale.length > 20 ? ` and ${stale.length - 20} more` : ""}.`);
|
|
1679
|
+
return fields;
|
|
1680
|
+
};
|
|
1681
|
+
const get = async () => mergeSaved(layer(projectDefaults(), await readSettings(strapi)), componentUids(strapi), contentTypeUids(strapi), apiUids(strapi));
|
|
624
1682
|
return {
|
|
625
1683
|
projectDefaults,
|
|
626
1684
|
get,
|
|
1685
|
+
fields: fieldTexts,
|
|
627
1686
|
async set(value) {
|
|
628
|
-
const next = validateSettings(value, componentUids(strapi), contentTypeUids(strapi));
|
|
1687
|
+
const next = validateSettings(value, componentUids(strapi), contentTypeUids(strapi), apiUids(strapi));
|
|
629
1688
|
for (const [uid, entry] of Object.entries(next.components)) {
|
|
630
1689
|
if (entry.mediaId && !await findMedia(strapi, entry.mediaId).catch(() => null)) fail(`Media for "${uid}" does not exist`);
|
|
631
1690
|
}
|
|
@@ -639,7 +1698,7 @@ module.exports = {
|
|
|
639
1698
|
return get();
|
|
640
1699
|
}
|
|
641
1700
|
};
|
|
642
|
-
} },
|
|
1701
|
+
}, ...STRAPI5 && { history: historyService, trash: trashService } },
|
|
643
1702
|
controllers: {
|
|
644
1703
|
catalog: ({ strapi }) => ({
|
|
645
1704
|
async find(ctx) {
|
|
@@ -666,11 +1725,16 @@ module.exports = {
|
|
|
666
1725
|
};
|
|
667
1726
|
}
|
|
668
1727
|
const hidden = hiddenName(plugin.config("hiddenAttribute")) || null;
|
|
1728
|
+
const types = Object.fromEntries(Object.entries(strapi.contentTypes || {}).filter(([uid]) => !uid.startsWith("admin::")).map(([uid, schema]) => [uid, [schema.info?.displayName || "", ...Object.keys(schema.attributes || {})]]));
|
|
1729
|
+
const history = STRAPI5 && strapi.documents?.use && !base.disabled && settings.history.enabled ? { contentTypes: apiUids(strapi).filter((uid) => covers(settings.history, uid)) } : null;
|
|
669
1730
|
ctx.body = {
|
|
670
1731
|
...base,
|
|
671
1732
|
palette: settings.palette,
|
|
672
1733
|
contentTypes: settings.contentTypes,
|
|
673
1734
|
hiddenAttribute: hidden,
|
|
1735
|
+
fields: plugin.service("settings").fields(),
|
|
1736
|
+
types,
|
|
1737
|
+
history,
|
|
674
1738
|
editor: { ...settings.editor, enabled: settings.editor.enabled && !base.disabled, ...!hidden && { hiddenBlocks: "off" } }
|
|
675
1739
|
};
|
|
676
1740
|
}
|
|
@@ -702,7 +1766,9 @@ module.exports = {
|
|
|
702
1766
|
hiddenAttribute: hiddenName(strapi.plugin(PLUGIN).config("hiddenAttribute")) || null,
|
|
703
1767
|
blockPreviewAvailable: strapi.plugin(PLUGIN).config("blockPreview") === true,
|
|
704
1768
|
defaults: DEFAULTS,
|
|
705
|
-
projectDefaults: strapi.plugin(PLUGIN).service("settings").projectDefaults()
|
|
1769
|
+
projectDefaults: strapi.plugin(PLUGIN).service("settings").projectDefaults(),
|
|
1770
|
+
// Version history (Strapi 5 only): the content types it can cover.
|
|
1771
|
+
historyTypes: STRAPI5 && strapi.documents?.use ? apiUids(strapi).map((uid) => ({ uid, displayName: strapi.contentTypes[uid].info?.displayName || uid })) : null
|
|
706
1772
|
};
|
|
707
1773
|
},
|
|
708
1774
|
async update(ctx) {
|
|
@@ -725,7 +1791,8 @@ module.exports = {
|
|
|
725
1791
|
await store(strapi).set({ key: `prefs:${id}`, value });
|
|
726
1792
|
ctx.body = value;
|
|
727
1793
|
}
|
|
728
|
-
})
|
|
1794
|
+
}),
|
|
1795
|
+
...STRAPI5 && { history: historyController, trash: trashController }
|
|
729
1796
|
},
|
|
730
1797
|
routes: { admin: { type: "admin", routes: [
|
|
731
1798
|
{ method: "GET", path: "/catalog", handler: "catalog.find", config: { policies: ["admin::isAuthenticatedAdmin"] } },
|
|
@@ -738,6 +1805,21 @@ module.exports = {
|
|
|
738
1805
|
name: "admin::hasPermissions",
|
|
739
1806
|
config: { actions: [`plugin::${PLUGIN}.settings.${action}`] }
|
|
740
1807
|
}] }
|
|
741
|
-
}))
|
|
1808
|
+
})),
|
|
1809
|
+
...STRAPI5 ? [
|
|
1810
|
+
["GET", "/history/:uid/:documentId", "history.list", "history.read"],
|
|
1811
|
+
["GET", "/history-events/:id", "history.find", "history.read"],
|
|
1812
|
+
["GET", "/activity", "history.activity", "activity.read"],
|
|
1813
|
+
["GET", "/trash", "trash.list", "trash.read"],
|
|
1814
|
+
["GET", "/trash/:id", "trash.find", "trash.read"],
|
|
1815
|
+
["GET", "/trash/:id/check", "trash.check", "trash.restore"],
|
|
1816
|
+
["POST", "/trash/:id/restore", "trash.restore", "trash.restore"],
|
|
1817
|
+
["DELETE", "/trash/:id", "trash.remove", "trash.purge"]
|
|
1818
|
+
].map(([method, path, handler, action]) => ({
|
|
1819
|
+
method,
|
|
1820
|
+
path,
|
|
1821
|
+
handler,
|
|
1822
|
+
config: { policies: ["admin::isAuthenticatedAdmin", { name: "admin::hasPermissions", config: { actions: [`plugin::${PLUGIN}.${action}`] } }] }
|
|
1823
|
+
})) : []
|
|
742
1824
|
] } }
|
|
743
1825
|
};
|