@qkix/strapi-plugin-rewind 0.1.0

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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/dist/_chunks/en-BHfx2OWt.js +1 -0
  4. package/dist/_chunks/en-DCctmu-a.mjs +7 -0
  5. package/dist/admin/index.js +1 -0
  6. package/dist/admin/index.mjs +254 -0
  7. package/dist/admin/src/components/ChangesDialog.d.ts +21 -0
  8. package/dist/admin/src/components/HistoryPanel.d.ts +17 -0
  9. package/dist/admin/src/diffRegistry.d.ts +45 -0
  10. package/dist/admin/src/index.d.ts +18 -0
  11. package/dist/admin/src/pluginId.d.ts +6 -0
  12. package/dist/server/index.js +2 -0
  13. package/dist/server/index.mjs +930 -0
  14. package/dist/server/src/bootstrap.d.ts +5 -0
  15. package/dist/server/src/config/index.d.ts +58 -0
  16. package/dist/server/src/content-types/index.d.ts +95 -0
  17. package/dist/server/src/controllers/index.d.ts +12 -0
  18. package/dist/server/src/controllers/version.d.ts +11 -0
  19. package/dist/server/src/destroy.d.ts +9 -0
  20. package/dist/server/src/index.d.ts +166 -0
  21. package/dist/server/src/routes/admin.d.ts +16 -0
  22. package/dist/server/src/routes/index.d.ts +14 -0
  23. package/dist/server/src/services/diff.d.ts +55 -0
  24. package/dist/server/src/services/index.d.ts +35 -0
  25. package/dist/server/src/services/lock.d.ts +20 -0
  26. package/dist/server/src/services/restore.d.ts +44 -0
  27. package/dist/server/src/services/retention.d.ts +18 -0
  28. package/dist/server/src/services/serializer.d.ts +65 -0
  29. package/dist/server/src/services/snapshot.d.ts +17 -0
  30. package/dist/server/src/utils/backfillLabels.d.ts +18 -0
  31. package/dist/server/src/utils/captureContext.d.ts +45 -0
  32. package/dist/server/src/utils/persistTable.d.ts +15 -0
  33. package/dist/server/src/utils/textDiff.d.ts +50 -0
  34. package/dist/server/src/utils/thinning.d.ts +35 -0
  35. package/dist/server/src/utils/versionLabel.d.ts +14 -0
  36. package/package.json +115 -0
@@ -0,0 +1,930 @@
1
+ import { createHash as x } from "node:crypto";
2
+ import { AsyncLocalStorage as J } from "node:async_hooks";
3
+ const Z = ["id", "documentId", "locale"], S = [
4
+ "createdAt",
5
+ "updatedAt",
6
+ "publishedAt",
7
+ "createdBy",
8
+ "updatedBy",
9
+ "strapi_stage",
10
+ "strapi_assignee",
11
+ "locale",
12
+ "localizations"
13
+ ], I = (e, t) => e.getModel(t), Q = (e) => e?.type === "relation" || e?.type === "media", $ = (e) => e?.type === "relation" && String(e.relation ?? "").toLowerCase().startsWith("morph"), N = (e, t) => Object.entries(I(e, t).attributes).filter(
14
+ ([, n]) => !["relation", "media", "component", "dynamiczone"].includes(n.type)
15
+ ).map(([n]) => n), q = (e, t, n = !0) => {
16
+ const r = I(e, t), o = n ? "select" : "fields";
17
+ return Object.entries(r.attributes).reduce(
18
+ (a, [s, i]) => {
19
+ switch (i.type) {
20
+ case "relation": {
21
+ if ($(i)) break;
22
+ a[s] = { [o]: ["documentId", "locale", "publishedAt"] };
23
+ break;
24
+ }
25
+ case "media": {
26
+ a[s] = { [o]: ["id"] };
27
+ break;
28
+ }
29
+ case "component": {
30
+ a[s] = {
31
+ populate: q(e, i.component, n),
32
+ [o]: N(e, i.component)
33
+ };
34
+ break;
35
+ }
36
+ case "dynamiczone": {
37
+ a[s] = {
38
+ on: (i.components ?? []).reduce(
39
+ (c, l) => (c[l] = {
40
+ populate: q(e, l, n),
41
+ [o]: N(e, l)
42
+ }, c),
43
+ {}
44
+ )
45
+ };
46
+ break;
47
+ }
48
+ }
49
+ return a;
50
+ },
51
+ {}
52
+ );
53
+ }, ee = (e, t) => {
54
+ const n = {}, r = (a) => {
55
+ if (n[a]) return;
56
+ const s = I(e, a).attributes;
57
+ n[a] = R(s, S);
58
+ for (const i of Object.values(s))
59
+ if (i.type === "component" && r(i.component), i.type === "dynamiczone")
60
+ for (const c of i.components ?? []) r(c);
61
+ }, o = I(e, t).attributes;
62
+ for (const a of Object.values(o))
63
+ if (a.type === "component" && r(a.component), a.type === "dynamiczone")
64
+ for (const s of a.components ?? []) r(s);
65
+ return {
66
+ attributes: R(o, S),
67
+ components: n
68
+ };
69
+ }, R = (e, t) => Object.fromEntries(Object.entries(e ?? {}).filter(([n]) => !t.includes(n))), te = (e, t, n) => (Array.isArray(e) ? e : [e]).filter((o) => !!o).map(
70
+ (o) => n ? { id: o.id, targetUid: t } : {
71
+ documentId: o.documentId,
72
+ locale: o.locale ?? null,
73
+ targetUid: t
74
+ }
75
+ ), ne = (e, t, n) => {
76
+ const r = I(e, t).attributes, o = {}, a = {}, s = R(n, [...S, ...Z]);
77
+ for (const [i, c] of Object.entries(s)) {
78
+ const l = r[i];
79
+ if (l && Q(l)) {
80
+ if ($(l)) continue;
81
+ const d = l.type === "media" ? "plugin::upload.file" : l.target;
82
+ a[i] = te(c, d, l.type === "media");
83
+ continue;
84
+ }
85
+ o[i] = l ? Y(e, l, c) : c;
86
+ }
87
+ return { data: o, relations: a, schemaSnapshot: ee(e, t) };
88
+ }, Y = (e, t, n) => {
89
+ if (n == null) return n;
90
+ if (t.type === "component") {
91
+ const r = (o) => _(e, t.component, o);
92
+ return Array.isArray(n) ? n.map((o) => r(o)) : r(n);
93
+ }
94
+ return t.type === "dynamiczone" && Array.isArray(n) ? n.map((r) => {
95
+ const o = r;
96
+ return _(e, o.__component, o);
97
+ }) : n;
98
+ }, _ = (e, t, n) => {
99
+ if (!n || !t) return n;
100
+ const r = I(e, t).attributes, o = {};
101
+ for (const [a, s] of Object.entries(n)) {
102
+ if (a === "id") continue;
103
+ const i = r[a];
104
+ o[a] = i ? Y(e, i, s) : s;
105
+ }
106
+ return o;
107
+ }, H = (e) => {
108
+ const t = /* @__PURE__ */ new Map(), n = async (r) => {
109
+ if (t.has(r)) return t.get(r);
110
+ let o;
111
+ try {
112
+ o = (await e.plugin("content-manager").service("content-types").findConfiguration(e.contentTypes[r]))?.settings?.mainField;
113
+ } catch {
114
+ }
115
+ return t.set(r, o), o;
116
+ };
117
+ return async (r, o) => {
118
+ if (!o) return null;
119
+ const s = [await n(r), "title", "name", "label"].filter(Boolean);
120
+ for (const i of s) {
121
+ const c = o[i];
122
+ if (typeof c == "string" && c.trim())
123
+ return c.trim().slice(0, 255);
124
+ }
125
+ return null;
126
+ };
127
+ }, v = "plugin::rewind.version", oe = /* @__PURE__ */ new Set(["publish", "unpublish", "discardDraft", "restore"]), se = (e) => x("sha256").update(JSON.stringify(e)).digest("hex"), ie = ({ strapi: e }) => {
128
+ const t = H(e), n = (i) => !!e.contentTypes[i]?.options?.draftAndPublish, r = (i) => !!(e.getModel(i)?.pluginOptions?.i18n?.localized ?? e.contentTypes[i]?.pluginOptions?.i18n?.localized), o = async (i, c, l) => {
129
+ const d = r(i) && l.filter(Boolean).length > 0 ? { locale: { $in: l.filter(Boolean) } } : {};
130
+ return e.db.query(i).findMany({
131
+ where: {
132
+ documentId: c,
133
+ ...d,
134
+ ...n(i) ? { publishedAt: null } : {}
135
+ },
136
+ populate: q(e, i)
137
+ });
138
+ }, a = async (i, c) => {
139
+ try {
140
+ const l = e.plugin("content-manager").service("document-metadata"), d = await l.getMetadata(i, c);
141
+ return l.getStatus(c, d.availableStatus);
142
+ } catch {
143
+ return "draft";
144
+ }
145
+ }, s = async (i, c, l) => e.db.query(v).findOne({
146
+ where: { contentType: i, relatedDocumentId: c, locale: l },
147
+ orderBy: { createdAt: "desc" }
148
+ });
149
+ return {
150
+ readRows: o,
151
+ /**
152
+ * Writes one version per affected locale, for each buffered intent.
153
+ *
154
+ * Called from the transaction's commit hook and never awaited by the write
155
+ * path — a failure here must not be able to reach the editor's save.
156
+ */
157
+ async captureAll(i, c) {
158
+ for (const l of i)
159
+ await this.capture(l, c);
160
+ },
161
+ async capture(i, c) {
162
+ const { uid: l, relatedDocumentId: d, locale: p, origin: b } = i, m = i.before ?? await o(l, d, [p]);
163
+ for (const u of m) {
164
+ const h = u.locale ?? null, { data: f, relations: w, schemaSnapshot: T } = ne(e, l, u), g = se({ data: f, relations: w });
165
+ !oe.has(b) && (await s(l, d, h))?.contentHash === g || await e.db.query(v).create({
166
+ data: {
167
+ contentType: l,
168
+ relatedDocumentId: d,
169
+ locale: h,
170
+ status: await a(l, u),
171
+ label: await t(l, f),
172
+ data: f,
173
+ relations: w,
174
+ schemaSnapshot: T,
175
+ contentHash: g,
176
+ origin: b,
177
+ userId: c,
178
+ pinned: !1
179
+ }
180
+ });
181
+ }
182
+ }
183
+ };
184
+ }, j = [
185
+ "restore",
186
+ "publish",
187
+ "unpublish",
188
+ "discardDraft",
189
+ "clone",
190
+ "create",
191
+ "update"
192
+ ], C = /* @__PURE__ */ new WeakMap(), re = (e) => {
193
+ let t = C.get(e);
194
+ return t || (t = { intents: [], flushRegistered: !1 }, C.set(e, t)), t;
195
+ }, ae = (e) => {
196
+ const t = /* @__PURE__ */ new Map();
197
+ for (const n of e) {
198
+ const r = `${n.uid}:${n.relatedDocumentId}:${n.locale ?? ""}`, o = t.get(r);
199
+ if (!o) {
200
+ t.set(r, n);
201
+ continue;
202
+ }
203
+ const a = j.indexOf(n.origin) < j.indexOf(o.origin);
204
+ t.set(r, {
205
+ // A winning `publish` still wants the rows an earlier `discardDraft`
206
+ // captured — losing them would lose the only copy of that state.
207
+ ...a ? n : o,
208
+ before: o.before ?? n.before
209
+ });
210
+ }
211
+ return [...t.values()];
212
+ }, W = new J(), ce = (e) => W.run({ active: !0 }, e), le = () => W.getStore()?.active === !0, de = 200, ue = async (e, t) => {
213
+ const n = e.db.query(t), r = await n.count({ where: { label: null } });
214
+ if (r === 0) return 0;
215
+ e.log.info(`[rewind] labelling ${r} older version(s)…`);
216
+ const o = H(e);
217
+ let a = 0;
218
+ for (; ; ) {
219
+ const s = await n.findMany({
220
+ select: ["id", "contentType", "data"],
221
+ where: { label: null },
222
+ limit: de
223
+ });
224
+ if (s.length === 0) break;
225
+ for (const i of s) {
226
+ const c = await o(i.contentType, i.data);
227
+ await n.update({
228
+ where: { id: i.id },
229
+ data: { label: c ?? "" }
230
+ }), a += 1;
231
+ }
232
+ }
233
+ return e.log.info(`[rewind] labelled ${a} older version(s)`), a;
234
+ }, M = { type: "core", key: "persisted_tables" }, fe = async (e, t) => {
235
+ const n = await e.store.get(M) ?? [];
236
+ n.map((o) => typeof o == "string" ? o : o.name).includes(t) || await e.store.set({
237
+ ...M,
238
+ value: [...n, { name: t }]
239
+ });
240
+ }, pe = [
241
+ "create",
242
+ "update",
243
+ "clone",
244
+ "publish",
245
+ "unpublish",
246
+ "discardDraft"
247
+ ], me = async ({ strapi: e }) => {
248
+ const t = e.config.get("plugin::rewind");
249
+ await fe(e, "rewind_versions"), await ye(e), ue(e, v).catch(
250
+ (o) => e.log.warn(
251
+ `[rewind] could not label older versions: ${o instanceof Error ? o.message : String(o)}`
252
+ )
253
+ ), he(e, t);
254
+ const n = () => e.plugin("rewind").service("snapshot"), r = (o) => {
255
+ if (!pe.includes(o.action)) return !1;
256
+ const a = o.contentType?.uid ?? o.uid ?? "";
257
+ return !(!a.startsWith("api::") || !t.contentTypes.includes(a) || le() || !t.trackApiWrites && !e.requestContext.get()?.request?.url?.startsWith("/content-manager"));
258
+ };
259
+ e.documents.use(async (o, a) => {
260
+ if (!r(o)) return a();
261
+ const s = o.contentType?.uid ?? o.uid, i = o.params?.locale ?? null, c = o.action === "discardDraft" && o.params?.documentId ? await n().readRows(s, o.params.documentId, [i]) : null, l = await a(), d = o.action === "create" || o.action === "clone" ? l?.documentId : o.params?.documentId;
262
+ if (!d) return l;
263
+ const p = ge(e);
264
+ return await e.db.transaction(async ({ trx: b, onCommit: m }) => {
265
+ const u = re(b);
266
+ u.intents.push({
267
+ uid: s,
268
+ relatedDocumentId: d,
269
+ locale: i,
270
+ origin: o.action,
271
+ before: c
272
+ }), !u.flushRegistered && (u.flushRegistered = !0, m(() => {
273
+ const h = u.intents.splice(0, u.intents.length);
274
+ n().captureAll(ae(h), p).catch(
275
+ (f) => e.log.error("[rewind] failed to record a version", f)
276
+ );
277
+ }));
278
+ }), l;
279
+ });
280
+ }, ge = (e) => e.requestContext.get()?.state?.user?.id ?? null, ye = async (e) => {
281
+ try {
282
+ const { tableName: t } = e.db.metadata.get(v);
283
+ await e.db.connection.raw(
284
+ `CREATE INDEX IF NOT EXISTS idx_rewind_lookup
285
+ ON ${t} (content_type, related_document_id, locale, created_at)`
286
+ );
287
+ } catch (t) {
288
+ e.log.warn(
289
+ `[rewind] could not create the lookup index, queries will be slower: ${t instanceof Error ? t.message : String(t)}`
290
+ );
291
+ }
292
+ }, he = (e, t) => {
293
+ t.retention?.enabled !== !1 && e.cron.add({
294
+ rewindPrune: {
295
+ async task() {
296
+ const n = e.plugin("rewind").service("lock");
297
+ if (await n.acquire("prune", 30 * 60 * 1e3))
298
+ try {
299
+ const r = await e.plugin("rewind").service("retention").prune();
300
+ r.deleted > 0 && e.log.info(
301
+ `[rewind] thinned ${r.deleted} version(s) across ${r.documents} document(s)`
302
+ );
303
+ } catch (r) {
304
+ e.log.error("[rewind] thinning failed", r);
305
+ } finally {
306
+ await n.release("prune");
307
+ }
308
+ },
309
+ options: { rule: t.cron ?? "0 3 * * *" }
310
+ }
311
+ });
312
+ }, we = {
313
+ default: {
314
+ /**
315
+ * Content types to version, by uid. Empty means nothing is tracked.
316
+ *
317
+ * The default is deliberate. A plugin that silently starts writing a row on
318
+ * every save the moment it is installed is a plugin that gets uninstalled
319
+ * after the first disk alert.
320
+ *
321
+ * 'rewind': {
322
+ * enabled: true,
323
+ * config: { contentTypes: ['api::article.article'] },
324
+ * }
325
+ */
326
+ contentTypes: [],
327
+ /**
328
+ * Whether to version writes that do not come from the Content Manager.
329
+ *
330
+ * Strapi's own history ignores them, so `false` keeps the out-of-the-box
331
+ * behaviour familiar. With `true`, REST/GraphQL and programmatic writes are
332
+ * versioned too, with `createdById` left null.
333
+ *
334
+ * Either way this only ever sees the Document Service. Writes made through
335
+ * `strapi.db.query()` or the legacy entity service do not pass through the
336
+ * middleware and cannot be captured.
337
+ */
338
+ trackApiWrites: !1,
339
+ /**
340
+ * How long history is kept.
341
+ *
342
+ * On by default, because a version table that only grows is a problem the
343
+ * plugin would be creating rather than solving. The defaults are gentle:
344
+ * a full week of every save, then a version a day for a month, then one a
345
+ * week — and anchors (publish, unpublish, discard, restore) and pinned
346
+ * versions are never removed at all, whatever their age.
347
+ *
348
+ * Set `enabled: false` to keep everything forever and manage it yourself.
349
+ */
350
+ retention: {
351
+ enabled: !0,
352
+ keepAllDays: 7,
353
+ dailyUntilDays: 30,
354
+ maxAgeDays: 365,
355
+ keepAnchors: !0
356
+ },
357
+ /** When the thinning runs. Standard cron, in the server's timezone. */
358
+ cron: "0 3 * * *"
359
+ },
360
+ validator(e = {}) {
361
+ const { contentTypes: t, retention: n } = e;
362
+ if (n) {
363
+ const { keepAllDays: o = 7, dailyUntilDays: a = 30, maxAgeDays: s = 365 } = n;
364
+ if (!(o <= a && a <= s))
365
+ throw new Error(
366
+ `[rewind] config.retention needs keepAllDays <= dailyUntilDays <= maxAgeDays (received ${o}, ${a}, ${s})`
367
+ );
368
+ }
369
+ if (t === void 0) return;
370
+ if (!Array.isArray(t) || t.some((o) => typeof o != "string"))
371
+ throw new Error(
372
+ '[rewind] config.contentTypes must be an array of content type uids, e.g. ["api::article.article"]'
373
+ );
374
+ const r = t.filter((o) => !o.startsWith("api::"));
375
+ if (r.length > 0)
376
+ throw new Error(
377
+ `[rewind] config.contentTypes only supports api:: content types, received: ${r.join(", ")}`
378
+ );
379
+ }
380
+ }, be = "collectionType", Te = "rewind_versions", ve = {
381
+ singularName: "version",
382
+ pluralName: "versions",
383
+ displayName: "Rewind Version",
384
+ description: "One snapshot of one document, in one locale, at one point in time."
385
+ }, De = {
386
+ draftAndPublish: !1,
387
+ comment: "Managed by strapi-plugin-rewind. Do not edit manually."
388
+ }, Ae = {
389
+ "content-manager": {
390
+ visible: !1
391
+ },
392
+ "content-type-builder": {
393
+ visible: !1
394
+ },
395
+ i18n: {
396
+ localized: !1
397
+ }
398
+ }, Ie = {
399
+ contentType: {
400
+ type: "string",
401
+ required: !0
402
+ },
403
+ relatedDocumentId: {
404
+ type: "string",
405
+ required: !0
406
+ },
407
+ locale: {
408
+ type: "string"
409
+ },
410
+ status: {
411
+ type: "enumeration",
412
+ enum: [
413
+ "draft",
414
+ "published",
415
+ "modified"
416
+ ],
417
+ required: !0
418
+ },
419
+ label: {
420
+ type: "string"
421
+ },
422
+ data: {
423
+ type: "json",
424
+ required: !0
425
+ },
426
+ schemaSnapshot: {
427
+ type: "json",
428
+ required: !0
429
+ },
430
+ relations: {
431
+ type: "json"
432
+ },
433
+ contentHash: {
434
+ type: "string",
435
+ required: !0
436
+ },
437
+ origin: {
438
+ type: "enumeration",
439
+ enum: [
440
+ "create",
441
+ "update",
442
+ "clone",
443
+ "publish",
444
+ "unpublish",
445
+ "discardDraft",
446
+ "restore"
447
+ ],
448
+ required: !0
449
+ },
450
+ pinned: {
451
+ type: "boolean",
452
+ default: !1
453
+ },
454
+ userId: {
455
+ type: "integer"
456
+ }
457
+ }, ke = {
458
+ kind: be,
459
+ collectionName: Te,
460
+ info: ve,
461
+ options: De,
462
+ pluginOptions: Ae,
463
+ attributes: Ie
464
+ }, Oe = {
465
+ version: { schema: ke }
466
+ }, Se = ({ strapi: e }) => {
467
+ e.cron.remove("rewindPrune");
468
+ }, Ee = [
469
+ "id",
470
+ "contentType",
471
+ "relatedDocumentId",
472
+ "locale",
473
+ "status",
474
+ "label",
475
+ "origin",
476
+ "userId",
477
+ "pinned",
478
+ "contentHash",
479
+ "createdAt"
480
+ ], qe = ({ strapi: e }) => ({
481
+ async find(t) {
482
+ const { contentType: n, documentId: r, locale: o, page: a = 1, pageSize: s = 10 } = t.query;
483
+ if (!n || !r)
484
+ return t.badRequest("contentType and documentId are required.");
485
+ const i = {
486
+ contentType: n,
487
+ relatedDocumentId: r,
488
+ ...o ? { locale: o } : {}
489
+ }, c = Math.min(Number(s) || 10, 100), l = (Math.max(Number(a) || 1, 1) - 1) * c, [d, p] = await Promise.all([
490
+ e.db.query(v).findMany({
491
+ select: Ee,
492
+ where: i,
493
+ orderBy: { createdAt: "desc" },
494
+ limit: c,
495
+ offset: l
496
+ }),
497
+ e.db.query(v).count({ where: i })
498
+ ]);
499
+ t.body = {
500
+ data: await U(e, d),
501
+ meta: { page: Number(a) || 1, pageSize: c, total: p }
502
+ };
503
+ },
504
+ async findOne(t) {
505
+ const n = await e.db.query(v).findOne({ where: { id: Number(t.params.id) } });
506
+ if (!n) return t.notFound("No such version.");
507
+ t.body = { data: (await U(e, [n]))[0] };
508
+ },
509
+ async diff(t) {
510
+ const { against: n } = t.query;
511
+ try {
512
+ t.body = {
513
+ data: await e.plugin("rewind").service("diff").between(Number(t.params.id), n ? Number(n) : void 0)
514
+ };
515
+ } catch (r) {
516
+ return t.badRequest(
517
+ r instanceof Error ? r.message : "Could not compare these versions."
518
+ );
519
+ }
520
+ },
521
+ async preview(t) {
522
+ try {
523
+ t.body = {
524
+ data: await e.plugin("rewind").service("restore").preview(Number(t.params.id))
525
+ };
526
+ } catch (n) {
527
+ return t.badRequest(n instanceof Error ? n.message : "Preview failed.");
528
+ }
529
+ },
530
+ async restore(t) {
531
+ try {
532
+ t.body = {
533
+ data: await e.plugin("rewind").service("restore").apply(Number(t.params.id), t.state?.user?.id ?? null)
534
+ };
535
+ } catch (n) {
536
+ return t.badRequest(n instanceof Error ? n.message : "Restore failed.");
537
+ }
538
+ }
539
+ }), U = async (e, t) => {
540
+ const n = [...new Set(t.map((a) => a.userId).filter(Boolean))];
541
+ if (n.length === 0) return t.map((a) => ({ ...a, user: null }));
542
+ const r = await e.db.query("admin::user").findMany({ select: ["id", "firstname", "lastname", "email"], where: { id: n } }), o = new Map(r.map((a) => [a.id, a]));
543
+ return t.map((a) => {
544
+ const s = o.get(a.userId);
545
+ return {
546
+ ...a,
547
+ user: s ? {
548
+ id: s.id,
549
+ name: [s.firstname, s.lastname].filter(Boolean).join(" ") || s.email
550
+ } : null
551
+ };
552
+ });
553
+ }, Re = {
554
+ version: qe
555
+ }, $e = {
556
+ type: "admin",
557
+ routes: [
558
+ {
559
+ method: "GET",
560
+ path: "/versions",
561
+ handler: "version.find",
562
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
563
+ },
564
+ {
565
+ method: "GET",
566
+ path: "/versions/:id",
567
+ handler: "version.findOne",
568
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
569
+ },
570
+ {
571
+ method: "GET",
572
+ path: "/versions/:id/diff",
573
+ handler: "version.diff",
574
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
575
+ },
576
+ {
577
+ method: "GET",
578
+ path: "/versions/:id/preview",
579
+ handler: "version.preview",
580
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
581
+ },
582
+ {
583
+ method: "POST",
584
+ path: "/versions/:id/restore",
585
+ handler: "version.restore",
586
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
587
+ }
588
+ ]
589
+ }, Fe = {
590
+ admin: $e
591
+ }, B = (e) => e.match(/\S+\s*/g) ?? [], L = 2500, Ne = (e, t) => {
592
+ const n = B(e), r = B(t);
593
+ if (n.length > L || r.length > L)
594
+ return [
595
+ { op: "removed", value: e },
596
+ { op: "added", value: t }
597
+ ];
598
+ const o = Array.from(
599
+ { length: n.length + 1 },
600
+ () => new Array(r.length + 1).fill(0)
601
+ );
602
+ for (let l = n.length - 1; l >= 0; l -= 1)
603
+ for (let d = r.length - 1; d >= 0; d -= 1)
604
+ o[l][d] = n[l] === r[d] ? o[l + 1][d + 1] + 1 : Math.max(o[l + 1][d], o[l][d + 1]);
605
+ const a = [], s = (l, d) => {
606
+ const p = a[a.length - 1];
607
+ p && p.op === l ? p.value += d : a.push({ op: l, value: d });
608
+ };
609
+ let i = 0, c = 0;
610
+ for (; i < n.length && c < r.length; )
611
+ n[i] === r[c] ? (s("equal", n[i]), i += 1, c += 1) : o[i + 1][c] >= o[i][c + 1] ? (s("removed", n[i]), i += 1) : (s("added", r[c]), c += 1);
612
+ for (; i < n.length; ) s("removed", n[i++]);
613
+ for (; c < r.length; ) s("added", r[c++]);
614
+ return a;
615
+ }, _e = /* @__PURE__ */ new Set([
616
+ "id",
617
+ "__component",
618
+ "type",
619
+ "format",
620
+ "level",
621
+ "language",
622
+ "url"
623
+ ]), E = (e, t = 0) => t > 12 || e == null ? "" : typeof e == "string" ? e : typeof e == "boolean" ? "" : typeof e == "number" ? String(e) : Array.isArray(e) ? e.map((n) => E(n, t + 1)).filter(Boolean).join(" ") : typeof e == "object" ? Object.entries(e).filter(([n]) => !_e.has(n)).map(([, n]) => E(n, t + 1)).filter(Boolean).join(" ") : "", z = (e) => e.replace(/\s+/g, " ").trim(), je = 6, Ce = (e, t = je) => {
624
+ const n = [];
625
+ return e.forEach((r, o) => {
626
+ if (r.op !== "equal") {
627
+ n.push(r);
628
+ return;
629
+ }
630
+ const a = r.value.match(/\S+\s*/g) ?? [];
631
+ if (a.length <= t * 2 + 1) {
632
+ n.push(r);
633
+ return;
634
+ }
635
+ const s = o === 0, i = o === e.length - 1, c = s ? [] : a.slice(0, t), l = i ? [] : a.slice(-t);
636
+ c.length && n.push({ op: "equal", value: c.join("") }), n.push({ op: "equal", value: " … " }), l.length && n.push({ op: "equal", value: l.join("") });
637
+ }), n;
638
+ }, Me = /* @__PURE__ */ new Set([
639
+ "string",
640
+ "text",
641
+ "integer",
642
+ "biginteger",
643
+ "float",
644
+ "decimal",
645
+ "boolean",
646
+ "date",
647
+ "datetime",
648
+ "time",
649
+ "enumeration",
650
+ "uid",
651
+ "email",
652
+ "password"
653
+ ]), Ue = (e, t) => e === "text" || e === "richtext" || e === "blocks" || e === "json" || e === "customField" || e === "component" || e === "dynamiczone" || e === "string" && typeof t == "string" && t.length > 80, O = (e) => `${e.targetUid}:${e.documentId ?? e.id}`, Be = ({ strapi: e }) => {
654
+ const t = (o) => o && {
655
+ id: o.id,
656
+ label: o.label ?? null,
657
+ createdAt: o.createdAt,
658
+ origin: o.origin
659
+ }, n = async (o) => {
660
+ const a = await e.db.query(v).findOne({ where: { id: o } });
661
+ if (!a) throw new Error(`No version with id ${o}`);
662
+ return a;
663
+ }, r = async (o) => {
664
+ const [a] = await e.db.query(v).findMany({
665
+ where: {
666
+ contentType: o.contentType,
667
+ relatedDocumentId: o.relatedDocumentId,
668
+ locale: o.locale,
669
+ id: { $lt: o.id }
670
+ },
671
+ orderBy: { id: "desc" },
672
+ limit: 1
673
+ });
674
+ return a ?? null;
675
+ };
676
+ return {
677
+ /**
678
+ * What changed between a version and the one before it.
679
+ *
680
+ * Comparing against the previous version — rather than against the document
681
+ * as it stands — is what makes a history readable: each entry answers "what
682
+ * did this edit do?". The other question, "what would restoring this
683
+ * change?", is what `restore.preview()` already answers.
684
+ */
685
+ async between(o, a) {
686
+ const s = await n(o), i = a ? await n(a) : await r(s), c = e.getModel(s.contentType)?.attributes, l = s.data ?? {}, d = i?.data ?? {}, p = s.relations ?? {}, b = i?.relations ?? {}, m = [];
687
+ for (const u of /* @__PURE__ */ new Set([...Object.keys(d), ...Object.keys(l)])) {
688
+ const h = d[u], f = l[u];
689
+ if (JSON.stringify(h) === JSON.stringify(f)) continue;
690
+ const w = c?.[u]?.type ?? "unknown", T = c?.[u]?.customField, g = h === void 0 ? "added" : f === void 0 ? "removed" : "changed";
691
+ if (Me.has(w) && !Ue(w, h ?? f)) {
692
+ m.push({ field: u, type: w, customField: T, kind: g, before: h, after: f });
693
+ continue;
694
+ }
695
+ const D = z(E(h)), A = z(E(f));
696
+ if (D === A) {
697
+ m.push({ field: u, type: w, customField: T, kind: g, before: void 0, after: void 0 });
698
+ continue;
699
+ }
700
+ m.push({
701
+ field: u,
702
+ type: w,
703
+ customField: T,
704
+ kind: g,
705
+ // Collapsed here rather than in the panel: sending an entire article
706
+ // to highlight one word wastes the payload as well as the reader's
707
+ // attention.
708
+ spans: Ce(Ne(D, A))
709
+ });
710
+ }
711
+ for (const u of /* @__PURE__ */ new Set([...Object.keys(b), ...Object.keys(p)])) {
712
+ const h = b[u] ?? [], f = p[u] ?? [], w = new Set(h.map(O)), T = new Set(f.map(O)), g = f.filter((A) => !w.has(O(A))), D = h.filter((A) => !T.has(O(A)));
713
+ g.length === 0 && D.length === 0 || m.push({
714
+ field: u,
715
+ type: c?.[u]?.type ?? "relation",
716
+ kind: "changed",
717
+ linked: g,
718
+ unlinked: D
719
+ });
720
+ }
721
+ return {
722
+ from: i ? t(i) : null,
723
+ to: t(s),
724
+ changes: m,
725
+ identical: m.length === 0
726
+ };
727
+ }
728
+ };
729
+ }, Le = ({ strapi: e }) => {
730
+ const t = (n) => ({
731
+ type: "plugin",
732
+ name: "rewind",
733
+ key: `lock:${n}`
734
+ });
735
+ return {
736
+ async acquire(n, r) {
737
+ const o = await e.store.get(t(n));
738
+ return o && o.expires > Date.now() ? !1 : (await e.store.set({
739
+ ...t(n),
740
+ value: { expires: Date.now() + r }
741
+ }), !0);
742
+ },
743
+ async release(n) {
744
+ await e.store.set({ ...t(n), value: { expires: 0 } });
745
+ }
746
+ };
747
+ }, K = {
748
+ keepAllDays: 7,
749
+ dailyUntilDays: 30,
750
+ maxAgeDays: 365,
751
+ keepAnchors: !0
752
+ }, ze = /* @__PURE__ */ new Set(["publish", "unpublish", "discardDraft", "restore"]), G = 24 * 60 * 60 * 1e3, Pe = (e, t) => (t.getTime() - new Date(e).getTime()) / G, Ye = (e) => e.toISOString().slice(0, 10), He = (e) => {
753
+ const t = new Date(Date.UTC(e.getUTCFullYear(), e.getUTCMonth(), e.getUTCDate())), n = t.getUTCDay() || 7;
754
+ t.setUTCDate(t.getUTCDate() + 4 - n);
755
+ const r = new Date(Date.UTC(t.getUTCFullYear(), 0, 1)), o = Math.ceil(((t.getTime() - r.getTime()) / G + 1) / 7);
756
+ return `${t.getUTCFullYear()}-W${o}`;
757
+ }, We = (e, t, n = K) => {
758
+ const r = /* @__PURE__ */ new Map(), o = [], a = [...e].sort(
759
+ (s, i) => new Date(i.createdAt).getTime() - new Date(s.createdAt).getTime()
760
+ );
761
+ for (const s of a) {
762
+ if (s.pinned || n.keepAnchors && ze.has(s.origin)) continue;
763
+ const i = Pe(s.createdAt, t);
764
+ if (i <= n.keepAllDays) continue;
765
+ if (i > n.maxAgeDays) {
766
+ o.push(s.id);
767
+ continue;
768
+ }
769
+ const c = new Date(s.createdAt), l = i <= n.dailyUntilDays ? `d:${Ye(c)}` : `w:${He(c)}`;
770
+ r.has(l) ? o.push(s.id) : r.set(l, s);
771
+ }
772
+ return o;
773
+ }, P = 500, Ke = ({ strapi: e }) => {
774
+ const t = () => e.db.query(v), n = async (r) => {
775
+ const o = await t().findMany({
776
+ select: ["contentType", "relatedDocumentId", "locale"],
777
+ where: { createdAt: { $lt: r } }
778
+ }), a = /* @__PURE__ */ new Map();
779
+ for (const s of o) {
780
+ const i = `${s.contentType}|${s.relatedDocumentId}|${s.locale ?? ""}`;
781
+ a.has(i) || a.set(i, {
782
+ contentType: s.contentType,
783
+ relatedDocumentId: s.relatedDocumentId,
784
+ locale: s.locale ?? null
785
+ });
786
+ }
787
+ return [...a.values()];
788
+ };
789
+ return {
790
+ /**
791
+ * Thins the history according to the configured policy.
792
+ *
793
+ * Returns what it did rather than logging and forgetting, so the cron can
794
+ * report it and a test can assert on it.
795
+ */
796
+ async prune(r = /* @__PURE__ */ new Date()) {
797
+ const o = e.config.get("plugin::rewind"), a = { ...K, ...o?.retention };
798
+ if (o?.retention?.enabled === !1)
799
+ return { scanned: 0, deleted: 0, documents: 0 };
800
+ const s = new Date(r.getTime() - a.keepAllDays * 24 * 60 * 60 * 1e3), i = await n(s);
801
+ let c = 0, l = 0;
802
+ for (const d of i) {
803
+ const p = await t().findMany({
804
+ select: ["id", "createdAt", "origin", "pinned"],
805
+ where: {
806
+ contentType: d.contentType,
807
+ relatedDocumentId: d.relatedDocumentId,
808
+ locale: d.locale
809
+ }
810
+ });
811
+ c += p.length;
812
+ const b = We(p, r, a);
813
+ for (let m = 0; m < b.length; m += P) {
814
+ const u = b.slice(m, m + P);
815
+ await t().deleteMany({ where: { id: { $in: u } } }), l += u.length;
816
+ }
817
+ }
818
+ return { scanned: c, deleted: l, documents: i.length };
819
+ }
820
+ };
821
+ }, Ge = ({ strapi: e }) => {
822
+ const t = (s) => e.getModel(s), n = async (s) => {
823
+ const i = await e.db.query(v).findOne({ where: { id: s } });
824
+ if (!i)
825
+ throw new Error(`No version with id ${s}`);
826
+ return i;
827
+ }, r = (s) => s?.pluginOptions?.i18n?.localized === !0, o = (s) => e.contentTypes[s]?.pluginOptions?.i18n?.localized === !0, a = async (s) => s.targetUid === "plugin::upload.file" ? !!await e.db.query("plugin::upload.file").findOne({ where: { id: s.id } }) : !!await e.db.query(s.targetUid).findOne({
828
+ where: {
829
+ documentId: s.documentId,
830
+ ...s.locale ? { locale: s.locale } : {}
831
+ }
832
+ });
833
+ return {
834
+ /**
835
+ * What restoring this version would actually do.
836
+ *
837
+ * A separate call, and the UI shows it before asking for confirmation.
838
+ * Restore without a preview of the consequences is a trap rather than a
839
+ * tool — `crossLocaleFields` in particular, where restoring the Polish
840
+ * version of a document quietly rewrites a field in every other language.
841
+ */
842
+ async preview(s) {
843
+ const i = await n(s), c = i.contentType, l = t(c).attributes, d = i.schemaSnapshot?.attributes ?? {}, p = i.data ?? {}, b = i.relations ?? {}, m = Object.keys(l).filter((y) => !S.includes(y)), u = Object.keys(d), h = u.filter((y) => !m.includes(y)), f = m.filter((y) => !u.includes(y)), w = u.filter((y) => m.includes(y)), T = w.filter((y) => $(l[y])), g = w.filter((y) => !T.includes(y)), D = o(c) ? g.filter((y) => !r(l[y])) : [], A = [], F = [];
844
+ for (const [y, X] of Object.entries(b))
845
+ if (g.includes(y))
846
+ for (const k of X)
847
+ await a(k) || (k.targetUid === "plugin::upload.file" ? F.push(k) : A.push(k));
848
+ const V = D.length ? await e.plugin("i18n")?.service("locales").find() : [];
849
+ return {
850
+ fieldsToRestore: g.filter((y) => y in p),
851
+ fieldsKeptAsIs: f,
852
+ fieldsDropped: h,
853
+ crossLocaleFields: D,
854
+ unsupportedFields: T,
855
+ brokenRelations: A,
856
+ missingMedia: F,
857
+ affectedLocales: V.map((y) => y.code)
858
+ };
859
+ },
860
+ /**
861
+ * Writes the version back, to the draft only.
862
+ *
863
+ * The document lands in "modified" and publishing stays a deliberate act —
864
+ * restoring must never change what the public sees on its own.
865
+ */
866
+ async apply(s, i) {
867
+ const c = await n(s), l = c.contentType, d = await this.preview(s), p = [];
868
+ await e.plugin("rewind").service("snapshot").capture(
869
+ {
870
+ uid: l,
871
+ relatedDocumentId: c.relatedDocumentId,
872
+ locale: c.locale,
873
+ origin: "restore",
874
+ before: null
875
+ },
876
+ i
877
+ );
878
+ const b = {}, m = c.data ?? {}, u = c.relations ?? {};
879
+ for (const f of d.fieldsToRestore)
880
+ b[f] = m[f];
881
+ const h = new Set(
882
+ [...d.brokenRelations, ...d.missingMedia].map(
883
+ (f) => `${f.targetUid}:${f.documentId ?? f.id}`
884
+ )
885
+ );
886
+ for (const [f, w] of Object.entries(u)) {
887
+ if (d.unsupportedFields.includes(f) || d.fieldsDropped.includes(f)) continue;
888
+ const T = w.filter(
889
+ (g) => !h.has(`${g.targetUid}:${g.documentId ?? g.id}`)
890
+ );
891
+ T.length !== w.length && p.push(
892
+ `${w.length - T.length} target(s) of "${f}" no longer exist and were skipped.`
893
+ ), b[f] = T.map(
894
+ (g) => g.targetUid === "plugin::upload.file" ? g.id : g.documentId
895
+ );
896
+ }
897
+ return d.fieldsDropped.length && p.push(
898
+ `Skipped ${d.fieldsDropped.length} field(s) no longer in the model: ${d.fieldsDropped.join(", ")}.`
899
+ ), d.crossLocaleFields.length && p.push(
900
+ `${d.crossLocaleFields.join(", ")} are not localised, so they changed in every locale.`
901
+ ), await ce(async () => {
902
+ await e.documents(l).update({
903
+ documentId: c.relatedDocumentId,
904
+ locale: c.locale ?? void 0,
905
+ // Explicit, though update() forces draft anyway: passing 'published'
906
+ // here would publish, and that must never happen by accident.
907
+ status: "draft",
908
+ data: b
909
+ });
910
+ }), { documentId: c.relatedDocumentId, warnings: p };
911
+ }
912
+ };
913
+ }, Ve = {
914
+ snapshot: ie,
915
+ restore: Ge,
916
+ diff: Be,
917
+ retention: Ke,
918
+ lock: Le
919
+ }, Je = {
920
+ bootstrap: me,
921
+ destroy: Se,
922
+ config: we,
923
+ contentTypes: Oe,
924
+ controllers: Re,
925
+ routes: Fe,
926
+ services: Ve
927
+ };
928
+ export {
929
+ Je as default
930
+ };