@aduptive/strapi-blockscene 1.0.0-alpha.6 → 1.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/dist/server.cjs CHANGED
@@ -146,24 +146,37 @@ var require_settings = __commonJS({
146
146
  duplicate: true,
147
147
  clipboard: true,
148
148
  // Edit view labels that are still the raw attribute name read as "Mobile columns count" (see README "Field labels").
149
- friendlyLabels: true
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"]
150
153
  },
151
154
  // Per content type (only the ones with a Dynamic Zone): { enabled: false } turns the plugin off there;
152
155
  // previewMode overrides editor.previewMode as the mode the edit view opens in. Absent means the global behaviour.
153
156
  contentTypes: {},
154
157
  // Version history (Strapi 5, see README "Version history"): off until turned on. contentTypes: 'all' (every api:: type)
155
- // or a list of uids. Snapshots are kept retentionDays and at most maxSnapshots per document; events eventDays.
156
- history: { enabled: false, contentTypes: "all", retentionDays: 90, maxSnapshots: 100, eventDays: 365 }
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 }
157
161
  };
158
162
  var ICONS = ["text", "tag", "seo", "settings", "image", "link", "palette", "list", "globe", "info"];
159
163
  var SIDEBAR_POSITIONS = ["left", "right", "bottom"];
160
164
  var OPENS = ["modal", "drawer"];
161
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 });
162
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)));
163
174
  var TYPE_KEYS = {
164
175
  enabled: (value) => typeof value === "boolean",
165
176
  previewMode: (value) => PREVIEW_MODES.includes(value),
166
177
  sidebarPosition: (value) => SIDEBAR_POSITIONS.includes(value),
178
+ previewToolbar,
179
+ previewDevices,
167
180
  sidebar: (value, attributes) => Array.isArray(value) && value.length <= 12 && value.every((item) => sidebarItem(item, attributes))
168
181
  };
169
182
  var typeMap = (types) => Array.isArray(types) ? Object.fromEntries(types.map((uid) => [uid, null])) : types || {};
@@ -180,7 +193,8 @@ var require_settings = __commonJS({
180
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))),
181
194
  retentionDays: days,
182
195
  maxSnapshots: (value) => Number.isInteger(value) && value >= 1 && value <= 1e3,
183
- eventDays: days
196
+ eventDays: days,
197
+ trashDays: days
184
198
  };
185
199
  var TYPOLOGIES2 = ["hero", "text", "media", "listing", "cards", "cta", "form", "layout"];
186
200
  var TAG = /^[^<>\n]{1,24}$/;
@@ -281,9 +295,9 @@ var require_settings = __commonJS({
281
295
  }
282
296
  for (const [key, value] of Object.entries(input.editor || {})) {
283
297
  if (!(key in DEFAULTS2.editor)) fail2(`Unknown editor option "${key}"`);
284
- 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";
285
299
  if (!valid) fail2(`Invalid value for editor option "${key}"`);
286
- 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;
287
301
  }
288
302
  const components = input.components || {};
289
303
  if (typeof components !== "object" || Array.isArray(components)) fail2("components must be an object");
@@ -317,7 +331,7 @@ var require_settings = __commonJS({
317
331
  if (!(key in TYPE_KEYS)) fail2(`Unknown content type setting "${key}"`);
318
332
  if (!TYPE_KEYS[key](value, types[uid])) fail2(`Invalid value for "${key}" of "${uid}"`);
319
333
  }
320
- 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) } };
321
335
  }
322
336
  const history = input.history || {};
323
337
  if (typeof history !== "object" || Array.isArray(history)) fail2("history must be an object");
@@ -342,6 +356,8 @@ var require_settings = __commonJS({
342
356
  if (key === "hiddenBlocks" && !HIDDEN_MODES.includes(value)) continue;
343
357
  if (key === "previewUrl" && value && !PREVIEW_URL.test(value)) continue;
344
358
  if (key === "blockPreviewUrl" && !blockPreviewUrl(value)) continue;
359
+ if (key === "previewToolbar" && !previewToolbar(value)) continue;
360
+ if (key === "previewDevices" && !previewDevices(value)) continue;
345
361
  if (key === "lazyFields") {
346
362
  if (!Array.isArray(value)) continue;
347
363
  out.editor[key] = [...new Set(value.filter((uid) => lazyFields([uid])))].slice(0, 20);
@@ -374,22 +390,25 @@ var require_settings = __commonJS({
374
390
  return out;
375
391
  }
376
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 === "")));
377
395
  function layer2(base, saved) {
378
396
  if (!base) return saved;
379
397
  const doc = saved && typeof saved === "object" ? saved : {};
380
- return Object.fromEntries(Object.keys(DEFAULTS2).map((key) => [key, { ...base[key], ...doc[key] && typeof doc[key] === "object" ? doc[key] : {} }]));
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) }]));
381
400
  }
382
401
  function overrides2(next, base) {
383
402
  if (!base) return next;
384
403
  return Object.fromEntries(Object.keys(DEFAULTS2).map((key) => {
385
- 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 === ""));
386
405
  if (ENTRY_KEYS.includes(key)) {
387
406
  for (const uid of Object.keys(base[key] || {})) if (!(uid in (next[key] || {}))) entries.push([uid, {}]);
388
407
  }
389
408
  return [key, Object.fromEntries(entries)];
390
409
  }));
391
410
  }
392
- var FIELD_TEXT = { label: 80, description: 300, placeholder: 120 };
411
+ var FIELD_TEXT = { label: 80, description: 300, placeholder: 120, help: 500 };
393
412
  var LOCALE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
394
413
  var plain = (value) => value && typeof value === "object" && !Array.isArray(value);
395
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));
@@ -613,6 +632,453 @@ var require_diff = __commonJS({
613
632
  }
614
633
  });
615
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
+
616
1082
  // server/history.js
617
1083
  var require_history = __commonJS({
618
1084
  "server/history.js"(exports2, module2) {
@@ -620,13 +1086,16 @@ var require_history = __commonJS({
620
1086
  var { createHash } = require("node:crypto");
621
1087
  var { PLUGIN: PLUGIN2 } = require_settings();
622
1088
  var { snapshotPopulate, canonical, stable, summarize } = require_diff();
1089
+ var { TRASH_UID, restoring } = require_trash();
623
1090
  var EVENT_UID = `plugin::${PLUGIN2}.event`;
624
- var ACTIONS = ["create", "update", "publish", "unpublish", "discard", "delete", "restore"];
1091
+ var ACTIONS = ["create", "update", "publish", "unpublish", "discard", "delete", "restore", "purge"];
625
1092
  var CAPTURED = { create: "create", clone: "create", update: "update", publish: "publish", unpublish: "unpublish", discardDraft: "discard", delete: "delete" };
626
1093
  var CRON2 = "blocksceneHistoryPurge";
627
1094
  var BATCH = 500;
628
1095
  var DAY = 24 * 60 * 60 * 1e3;
629
1096
  var LIST_LIMIT = 100;
1097
+ var ACTORS_LIMIT = 500;
1098
+ var SEARCH_LIMIT = 200;
630
1099
  var eventContentType2 = { schema: {
631
1100
  kind: "collectionType",
632
1101
  collectionName: "blockscene_events",
@@ -642,7 +1111,8 @@ var require_history = __commonJS({
642
1111
  actor: { type: "string", required: true },
643
1112
  actorName: { type: "string" },
644
1113
  at: { type: "datetime", required: true },
645
- // { fields: [names], blocks: { added, removed, changed }, initial? } vs the previous snapshot; { missing: true } when the capture failed.
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 }.
646
1116
  summary: { type: "json" },
647
1117
  // Null when identical to the previous snapshot of the same locale (same hash) or purged.
648
1118
  snapshot: { type: "json" },
@@ -705,6 +1175,11 @@ var require_history = __commonJS({
705
1175
  const localized = (uid) => Boolean(schemaOf(uid)?.pluginOptions?.i18n?.localized);
706
1176
  const draftAndPublish = (uid) => schemaOf(uid)?.options?.draftAndPublish === true;
707
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
+ };
708
1183
  const scope = async (uid, locale) => {
709
1184
  if (!localized(uid)) return [null];
710
1185
  if (locale === "*") return null;
@@ -727,7 +1202,7 @@ var require_history = __commonJS({
727
1202
  },
728
1203
  covers: covers2,
729
1204
  // One event. The snapshot is stored unless identical to the latest stored one of the same document and locale.
730
- async record({ uid, documentId, locale = null, action, entry, actor, missing = false }) {
1205
+ async record({ uid, documentId, locale = null, action, entry, actor, missing = false, extra = null }) {
731
1206
  let summary = missing ? { missing: true } : null, snapshot = null, hash = null, size = 0;
732
1207
  if (entry) {
733
1208
  const content = canonical(entry, schemaOf(uid), schemaOf);
@@ -740,6 +1215,7 @@ var require_history = __commonJS({
740
1215
  size = Buffer.byteLength(text);
741
1216
  }
742
1217
  }
1218
+ if (extra) summary = { ...summary, ...extra };
743
1219
  return events().create({ data: { contentType: uid, relatedDocumentId: documentId, locale, action, ...actor, at: /* @__PURE__ */ new Date(), summary, snapshot, hash, size } });
744
1220
  },
745
1221
  // After a save or publish: one event per affected entry (drafts, or the published versions after a publish).
@@ -749,6 +1225,16 @@ var require_history = __commonJS({
749
1225
  },
750
1226
  scope,
751
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
+ },
752
1238
  // Latest events of one document and locale, newest first; `stored`: its snapshot (or an identical one) can be loaded.
753
1239
  async list({ uid, documentId, locale }) {
754
1240
  const where = { contentType: uid, relatedDocumentId: documentId, locale: locale || null };
@@ -772,13 +1258,7 @@ var require_history = __commonJS({
772
1258
  const relations = {};
773
1259
  for (const target of [...new Set(refs.relations.map((ref) => ref.target))]) {
774
1260
  if (!strapi.contentTypes?.[target]) continue;
775
- let mainField = null;
776
- try {
777
- mainField = (await strapi.plugin("content-manager").service("content-types").findConfiguration(strapi.contentTypes[target]))?.settings?.mainField;
778
- } catch {
779
- mainField = null;
780
- }
781
- const scalar = mainField && !["relation", "media", "component", "dynamiczone", "password"].includes(schemaOf(target)?.attributes?.[mainField]?.type) && mainField !== "id";
1261
+ const mainField = await self.mainField(target), scalar = Boolean(mainField);
782
1262
  const found = await strapi.db.query(target).findMany({
783
1263
  select: ["id", "documentId", "locale", ...scalar ? [mainField] : []],
784
1264
  where: { documentId: { $in: [...new Set(refs.relations.filter((ref) => ref.target === target).map((ref) => ref.documentId))] }, ...draftAndPublish(target) && { publishedAt: { $null: true } } }
@@ -787,6 +1267,56 @@ var require_history = __commonJS({
787
1267
  }
788
1268
  return { event: meta, snapshot, media: Object.fromEntries(files.map((file) => [file.id, file])), relations };
789
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
+ },
790
1320
  // Nightly, in batches, idempotent (two instances running it is harmless): events past eventDays are deleted;
791
1321
  // snapshots past retentionDays, or beyond maxSnapshots per document (newest kept), are emptied. Never touches media.
792
1322
  async purge(now = Date.now()) {
@@ -823,16 +1353,21 @@ var require_history = __commonJS({
823
1353
  const warn = (message, error) => strapi.log.error(`[${PLUGIN2}] ${message}: ${error?.message || error}`);
824
1354
  strapi.documents.use(async (ctx, next) => {
825
1355
  const action = CAPTURED[ctx.action];
826
- if (!action || !String(ctx.uid || "").startsWith("api::") || strapi.plugin(PLUGIN2).config("disabled") === true) return next();
1356
+ if (!action || !String(ctx.uid || "").startsWith("api::") || strapi.plugin(PLUGIN2).config("disabled") === true || restoring.getStore()) return next();
827
1357
  const history = service();
828
1358
  const params = ctx.params || {};
829
1359
  if (action === "delete") {
830
- if (!params.documentId || !history.covers(await history.config(), ctx.uid)) return next();
1360
+ const config = params.documentId ? await history.config() : null;
1361
+ if (!config || !history.covers(config, ctx.uid)) return next();
831
1362
  return strapi.db.transaction(async () => {
832
- const actor2 = actorOf(strapi);
833
- let entries;
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;
834
1366
  try {
835
- entries = await history.rows(ctx.uid, params.documentId, await history.scope(ctx.uid, params.locale));
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 }])]));
836
1371
  } catch (error) {
837
1372
  warn("reading the document before a delete failed", error);
838
1373
  fail2(`The version history could not record this delete, so nothing was deleted (${error.message}).`);
@@ -840,6 +1375,7 @@ var require_history = __commonJS({
840
1375
  const result2 = await next();
841
1376
  try {
842
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 });
843
1379
  } catch (error) {
844
1380
  warn("recording a delete failed, the delete was rolled back", error);
845
1381
  fail2(`The version history could not record this delete, so nothing was deleted (${error.message}).`);
@@ -885,17 +1421,36 @@ var require_history = __commonJS({
885
1421
  } catch (error) {
886
1422
  strapi.log.error(`[${PLUGIN2}] history purge failed: ${error?.message || error}`);
887
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
+ }
888
1430
  } } });
889
1431
  }
890
1432
  var DOCUMENT_ID = /^[A-Za-z0-9_-]{1,64}$/;
891
1433
  var LOCALE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
892
- var canRead = (strapi, ctx, uid) => {
1434
+ var can = (strapi, ctx, uid, action = "read") => {
893
1435
  try {
894
- return !strapi.plugin("content-manager").service("permission-checker").create({ userAbility: ctx.state.userAbility, model: uid }).cannot.read();
1436
+ return !strapi.plugin("content-manager").service("permission-checker").create({ userAbility: ctx.state.userAbility, model: uid }).cannot[action]();
895
1437
  } catch {
896
1438
  return false;
897
1439
  }
898
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
+ };
899
1454
  var historyController2 = ({ strapi }) => ({
900
1455
  async list(ctx) {
901
1456
  const { uid, documentId } = ctx.params || {};
@@ -911,9 +1466,21 @@ var require_history = __commonJS({
911
1466
  const found = await strapi.plugin(PLUGIN2).service("history").find(id);
912
1467
  if (!found || !canRead(strapi, ctx, found.event.contentType)) return ctx.notFound();
913
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
+ };
914
1481
  }
915
1482
  });
916
- module2.exports = { EVENT_UID, ACTIONS, CAPTURED, CRON: CRON2, BATCH, eventContentType: eventContentType2, covers: covers2, retention, actorOf, refsOf, historyService: historyService2, registerHistory: registerHistory2, registerPurge: registerPurge2, historyController: historyController2 };
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 };
917
1484
  }
918
1485
  });
919
1486
 
@@ -922,6 +1489,7 @@ var { PLUGIN, TEMPLATES, TYPOLOGIES, DEFAULTS, catalog, validateSettings, valida
922
1489
  var { safeGroups, validateGroups } = require_groups();
923
1490
  var { hiddenName, injectHidden, registerHiddenStrip } = require_hidden();
924
1491
  var { CRON, covers, eventContentType, historyService, historyController, registerHistory, registerPurge } = require_history();
1492
+ var { trashContentType, trashService, trashController } = require_trash();
925
1493
  var STRAPI5 = false;
926
1494
  var store = (strapi) => strapi.store({ type: "plugin", name: PLUGIN });
927
1495
  var LEGACY_PLUGIN = "block-picker";
@@ -1061,7 +1629,13 @@ module.exports = {
1061
1629
  await strapi.admin.services.permission.actionProvider.registerMany([
1062
1630
  { section: "plugins", displayName: "Read gallery settings", uid: "settings.read", pluginName: PLUGIN },
1063
1631
  { section: "plugins", displayName: "Change gallery settings", uid: "settings.update", pluginName: PLUGIN },
1064
- ...STRAPI5 ? [{ section: "plugins", displayName: "Read version history", uid: "history.read", 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 })) : []
1065
1639
  ]);
1066
1640
  strapi.plugin(PLUGIN).service("settings").projectDefaults();
1067
1641
  strapi.plugin(PLUGIN).service("settings").fields();
@@ -1075,8 +1649,8 @@ module.exports = {
1075
1649
  destroy({ strapi }) {
1076
1650
  if (STRAPI5) strapi.cron?.remove?.(CRON);
1077
1651
  },
1078
- // Version history events (Strapi 5): hidden from the Content Manager and the Content-Type Builder.
1079
- contentTypes: STRAPI5 ? { event: eventContentType } : {},
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 } : {},
1080
1654
  services: { settings: ({ strapi }) => {
1081
1655
  let project;
1082
1656
  const projectDefaults = () => {
@@ -1124,7 +1698,7 @@ module.exports = {
1124
1698
  return get();
1125
1699
  }
1126
1700
  };
1127
- }, ...STRAPI5 && { history: historyService } },
1701
+ }, ...STRAPI5 && { history: historyService, trash: trashService } },
1128
1702
  controllers: {
1129
1703
  catalog: ({ strapi }) => ({
1130
1704
  async find(ctx) {
@@ -1218,7 +1792,7 @@ module.exports = {
1218
1792
  ctx.body = value;
1219
1793
  }
1220
1794
  }),
1221
- ...STRAPI5 && { history: historyController }
1795
+ ...STRAPI5 && { history: historyController, trash: trashController }
1222
1796
  },
1223
1797
  routes: { admin: { type: "admin", routes: [
1224
1798
  { method: "GET", path: "/catalog", handler: "catalog.find", config: { policies: ["admin::isAuthenticatedAdmin"] } },
@@ -1232,11 +1806,20 @@ module.exports = {
1232
1806
  config: { actions: [`plugin::${PLUGIN}.settings.${action}`] }
1233
1807
  }] }
1234
1808
  })),
1235
- ...STRAPI5 ? [["/history/:uid/:documentId", "list"], ["/history-events/:id", "find"]].map(([path, handler]) => ({
1236
- method: "GET",
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,
1237
1820
  path,
1238
- handler: `history.${handler}`,
1239
- config: { policies: ["admin::isAuthenticatedAdmin", { name: "admin::hasPermissions", config: { actions: [`plugin::${PLUGIN}.history.read`] } }] }
1821
+ handler,
1822
+ config: { policies: ["admin::isAuthenticatedAdmin", { name: "admin::hasPermissions", config: { actions: [`plugin::${PLUGIN}.${action}`] } }] }
1240
1823
  })) : []
1241
1824
  ] } }
1242
1825
  };