@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 qkix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # Rewind
2
+
3
+ Document version history for Strapi 5. Every save is snapshotted, and any
4
+ snapshot can be put back.
5
+
6
+ <img src="https://raw.githubusercontent.com/qkix/strapi-plugins/main/packages/strapi-plugin-rewind/docs/edit-view.png" alt="The history panel beside a document in the Strapi edit view" width="900" />
7
+
8
+ The panel sits in the edit view, next to the document it belongs to. Each entry
9
+ is a point you can go back to: what the document said, when, and who saved it.
10
+
11
+ ## What this is, and what it is not
12
+
13
+ Strapi sells a feature called **Content History** on its Growth and Enterprise
14
+ plans. Rewind is **not that feature**, is not a drop-in replacement for it, and
15
+ does not unlock it.
16
+
17
+ It is the poor cousin. If you pay for Growth, use Strapi's — it is deeper, it is
18
+ supported by the people who wrote the CMS, and it will stay in step with Strapi
19
+ in a way a third-party plugin cannot promise. Rewind exists for everyone on
20
+ Community Edition, who today has nothing at all: no way to see what a document
21
+ said last Tuesday, and no way to get it back after someone pastes over it.
22
+
23
+ A few things it does that the paid feature does not, mostly because they were
24
+ cheap to add once the plumbing existed:
25
+
26
+ - **Restoring keeps fields that were added after the version was taken.**
27
+ Strapi's restore sets them to `null`, which quietly loses whatever was in
28
+ them.
29
+ - **Discarding a draft is recorded before the draft is gone**, so the work you
30
+ threw away is still recoverable. A snapshot taken afterwards can only ever
31
+ contain the published copy you already had.
32
+ - **A save that changed nothing does not create a version.** Strapi writes a row
33
+ either way.
34
+ - **The schema snapshot descends into nested components.** Strapi's stops one
35
+ level down and carries an open TODO about it.
36
+ - **Writes from outside the Content Manager can be versioned** (opt-in), where
37
+ Strapi only ever records admin-panel edits.
38
+
39
+ And a good deal it does _not_ do — see [Limits](#limits).
40
+
41
+ ### On not touching the paywall
42
+
43
+ Rewind never reads or writes `strapi.ee`, never inspects a licence, and never
44
+ enables a gated feature. It is an independent implementation writing to its own
45
+ table, and it works the same whether or not you have a Strapi licence. Strapi's
46
+ history code is MIT-licensed and was read while working out what the problem
47
+ actually is — the way you would read any open-source implementation before
48
+ writing your own — but the code here is written for this plugin.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ npm install @qkix/strapi-plugin-rewind
54
+ ```
55
+
56
+ ```ts
57
+ // config/plugins.ts
58
+ export default {
59
+ rewind: {
60
+ enabled: true,
61
+ config: {
62
+ // Nothing is tracked until you say so.
63
+ contentTypes: ['api::article.article'],
64
+ },
65
+ },
66
+ };
67
+ ```
68
+
69
+ That empty default is deliberate. A plugin that starts writing a database row on
70
+ every save the moment it is installed is a plugin that gets uninstalled after
71
+ the first disk alert.
72
+
73
+ ## What gets recorded
74
+
75
+ Six actions on a tracked content type: `create`, `update`, `clone`, `publish`,
76
+ `unpublish` and `discardDraft`. Each version stores the document's content, its
77
+ relations, and a snapshot of the schema at the time — the last of which is what
78
+ lets a restore tell "this field was empty" apart from "this field did not exist
79
+ yet".
80
+
81
+ Publishing records **one** version, not two, even though the Content Manager
82
+ saves the draft and publishes it as two separate operations.
83
+
84
+ A save that is rolled back records nothing.
85
+
86
+ <img src="https://raw.githubusercontent.com/qkix/strapi-plugins/main/packages/strapi-plugin-rewind/docs/panel.png" alt="History entries labelled Replaced by restore, Draft discarded and Edited" width="320" />
87
+
88
+ The badge says what happened to the document. **Replaced by restore** marks the
89
+ state a restore was about to overwrite — the undo point for that restore.
90
+
91
+ ## Restoring
92
+
93
+ <img src="https://raw.githubusercontent.com/qkix/strapi-plugins/main/packages/strapi-plugin-rewind/docs/restore.png" alt="The restore confirmation dialog" width="400" />
94
+
95
+ Restore writes to the **draft only**. The document moves to _Modified_, and
96
+ publishing stays something a person decides to do.
97
+
98
+ Before anything is written, the state being replaced is itself recorded, so a
99
+ restore can always be undone.
100
+
101
+ The panel shows a preview before asking you to confirm, because the interesting
102
+ part of a restore is what it will _not_ do:
103
+
104
+ - fields not present in that version keep their current values
105
+ - fields no longer in the model are skipped
106
+ - links to documents or media that have since been deleted are dropped, and
107
+ reported
108
+ - **fields that are not translated per locale change in every locale at once** —
109
+ restoring the Polish version of a document rewrites a shared field for every
110
+ other language too. This is not a choice the plugin makes; a non-localised
111
+ field physically has one value. The dialog names those fields and counts the
112
+ locales before you commit.
113
+
114
+ ## Seeing what changed
115
+
116
+ Every entry in the panel has a **What changed** link, comparing that version
117
+ with the one saved immediately before it.
118
+
119
+ <img src="https://raw.githubusercontent.com/qkix/strapi-plugins/main/packages/strapi-plugin-rewind/docs/changes.png" alt="A word-level diff: 'put back' struck through and replaced by 'restored in one click', and 'ten' by 'twelve'" width="760" />
120
+
121
+ Scalars are shown as a plain before and after. Prose — including rich text
122
+ stored as JSON — is compared word by word, with the unchanged stretches
123
+ collapsed, so a one-word edit in a long article reads as one word rather than
124
+ as the whole article with something green in it somewhere. Relations are
125
+ reported as linked and unlinked.
126
+
127
+ Where a field's stored value changed but its readable text did not — a mark
128
+ applied, blocks reordered — it says so, rather than showing an empty diff.
129
+
130
+ ### Rendering a field type properly
131
+
132
+ Pulling the words out of a rich-text field tells an editor _that_ a paragraph
133
+ changed, and nothing about a block moving or an image being swapped. Only the
134
+ package that owns the format can show that, so the mapping is a registry:
135
+
136
+ ```ts
137
+ import { registerDiffRenderer } from '@qkix/strapi-plugin-rewind/strapi-admin';
138
+
139
+ registerDiffRenderer('plugin::better-blocks.better-blocks', BlocksDiff);
140
+ ```
141
+
142
+ Keyed by a custom field's uid, or by an attribute `type`. A uid wins over a
143
+ type, since every custom field stores itself as `json`.
144
+
145
+ ## Configuration
146
+
147
+ | Option | Default | Meaning |
148
+ | ---------------- | ------- | --------------------------------------------------------------------------------------------------------------- |
149
+ | `contentTypes` | `[]` | Which content types to version. Empty means none. |
150
+ | `trackApiWrites` | `false` | Also version writes from outside the Content Manager (REST, GraphQL, programmatic). `userId` is null for those. |
151
+
152
+ ## Limits
153
+
154
+ Worth knowing before you install it, not after:
155
+
156
+ - **Polymorphic relations are skipped**, and reported as unsupported in the
157
+ restore preview rather than silently mangled.
158
+ - **Only the Document Service is visible.** Writes made through
159
+ `strapi.db.query()` or the legacy entity service bypass the middleware
160
+ entirely and cannot be captured at any setting.
161
+ - **Nothing pins a version from the panel yet.** The column is there and prune
162
+ honours it, but there is no button; pin by setting `pinned` on the row.
163
+ - **Deleted relation targets lose their names.** A version stores which document
164
+ a relation pointed at, not what it was called, so a link to something since
165
+ deleted shows as missing rather than as "the article it used to be".
166
+ - **Media is referenced, never copied.** Delete the file and the version knows
167
+ the file is gone; it cannot bring it back.
168
+
169
+ ## Keeping your history when the plugin is off
170
+
171
+ Strapi's schema sync drops tables belonging to content types it no longer sees.
172
+ Disable this plugin for a single boot and the history would go with it — so on
173
+ boot Rewind adds its table to Strapi's `persisted_tables` list, which is the
174
+ same mechanism Strapi uses to protect its own. Nothing to configure.
175
+
176
+ ## Requirements
177
+
178
+ Strapi 5, Node 20 or 22.
179
+
180
+ ## License
181
+
182
+ MIT.
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e={"plugin.name":"Rewind","panel.title":"History"};exports.default=e;
@@ -0,0 +1,7 @@
1
+ const e = {
2
+ "plugin.name": "Rewind",
3
+ "panel.title": "History"
4
+ };
5
+ export {
6
+ e as default
7
+ };
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const e=require("react/jsx-runtime"),q=require("react"),E=require("@strapi/admin/strapi-admin"),t=require("@strapi/design-system");function H(n){if(n&&n.__esModule)return n;const r=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(n){for(const i in n)if(i!=="default"){const a=Object.getOwnPropertyDescriptor(n,i);Object.defineProperty(r,i,a.get?a:{enumerable:!0,get:()=>n[i]})}}return r.default=n,Object.freeze(r)}const l=H(q),G=(n,r,i)=>{const a=n[r];return a?typeof a=="function"?a():Promise.resolve(a):new Promise((j,d)=>{(typeof queueMicrotask=="function"?queueMicrotask:setTimeout)(d.bind(null,new Error("Unknown variable dynamic import: "+r+(r.split("/").length!==i?". Note that variables only represent file names one level deep.":""))))})},D=new Map,W=(n,r)=>{D.set(n,r)},b=n=>D.get(n),z=({spans:n})=>e.jsx(t.Typography,{variant:"omega",tag:"p",children:n.map((r,i)=>r.op==="equal"?e.jsx(t.Typography,{variant:"omega",textColor:"neutral600",children:r.value},i):e.jsx(t.Typography,{variant:"omega",textColor:r.op==="added"?"success600":"danger600",style:r.op==="removed"?{textDecoration:"line-through"}:{fontWeight:600},children:r.value},i))}),S=n=>n==null||n===""?"(empty)":typeof n=="object"?JSON.stringify(n):String(n),U=({change:n})=>{const r=(n.customField?b(n.customField):void 0)??b(n.type);return r?e.jsx(r,{change:n}):n.spans?e.jsx(z,{spans:n.spans}):n.linked||n.unlinked?e.jsxs(t.Flex,{direction:"column",alignItems:"flex-start",gap:1,children:[n.linked?.length?e.jsxs(t.Typography,{variant:"pi",textColor:"success600",children:["+ ",n.linked.length," linked"]}):null,n.unlinked?.length?e.jsxs(t.Typography,{variant:"pi",textColor:"danger600",children:["− ",n.unlinked.length," unlinked"]}):null]}):n.before!==void 0||n.after!==void 0?e.jsxs(t.Flex,{gap:2,alignItems:"center",wrap:"wrap",children:[e.jsx(t.Typography,{variant:"omega",textColor:"danger600",style:{textDecoration:"line-through"},children:S(n.before)}),e.jsx(t.Typography,{variant:"omega",textColor:"neutral500",children:"→"}),e.jsx(t.Typography,{variant:"omega",textColor:"success600",children:S(n.after)})]}):e.jsx(t.Typography,{variant:"pi",textColor:"neutral600",children:"Formatting or structure changed; the text is the same."})},K=({diff:n,loading:r,onClose:i})=>e.jsx(t.Modal.Root,{open:!0,onOpenChange:i,children:e.jsxs(t.Modal.Content,{children:[e.jsx(t.Modal.Header,{children:e.jsx(t.Modal.Title,{children:"What changed"})}),e.jsx(t.Modal.Body,{children:r?e.jsx(t.Flex,{justifyContent:"center",padding:4,children:e.jsx(t.Loader,{small:!0,children:"Comparing"})}):n?e.jsxs(t.Flex,{direction:"column",alignItems:"stretch",gap:4,children:[e.jsx(t.Typography,{variant:"pi",textColor:"neutral600",children:n.from?"Compared with the version saved just before it.":"This is the earliest version of the document, so everything in it is new."}),n.identical?e.jsx(t.Typography,{textColor:"neutral600",children:"Nothing changed between these two versions."}):n.changes.map(a=>e.jsxs(t.Box,{children:[e.jsx(t.Typography,{variant:"sigma",textColor:"neutral600",children:a.field}),e.jsx(t.Box,{paddingTop:1,children:e.jsx(U,{change:a})})]},a.field))]}):e.jsx(t.Typography,{textColor:"danger600",children:"Could not compare these versions."})}),e.jsx(t.Modal.Footer,{children:e.jsx(t.Button,{variant:"tertiary",onClick:i,children:"Close"})})]})}),u="rewind",V={create:"Created",update:"Edited",clone:"Cloned",publish:"Published",unpublish:"Unpublished",discardDraft:"Draft discarded",restore:"Replaced by restore"},k=new Set(["publish","unpublish","discardDraft","restore"]),J=10,Z=n=>{const r=new Date(n),i=Math.round((Date.now()-r.getTime())/6e4);return i<1?"just now":i<60?`${i} min ago`:i<60*24?`${Math.round(i/60)} h ago`:r.toLocaleString()},Q=({model:n,documentId:r,updatedAt:i})=>{const{get:a,post:j}=E.useFetchClient(),[d,R]=l.useState([]),[F,I]=l.useState(0),[B,L]=l.useState(1),[f,m]=l.useState(!0),[h,p]=l.useState(null),[o,g]=l.useState(null),[v,C]=l.useState(!1),[M,w]=l.useState(!1),[P,x]=l.useState(null),[O,T]=l.useState(!1),y=l.useCallback(async s=>{m(!0),p(null);try{const{data:c}=await a(`/${u}/versions`,{params:{contentType:n,documentId:r,page:s,pageSize:J}});R(A=>s===1?c.data:[...A,...c.data]),I(c.meta.total),L(s)}catch{p("Could not load the history for this document.")}finally{m(!1)}},[a,n,r]);l.useEffect(()=>{y(1)},[y,i]);const _=async s=>{w(!0),T(!0),x(null);try{const{data:c}=await a(`/${u}/versions/${s.id}/diff`);x(c.data)}catch{x(null)}finally{T(!1)}},N=async s=>{try{const{data:c}=await a(`/${u}/versions/${s.id}/preview`);g({version:s,preview:c.data})}catch{p("Could not work out what restoring this version would change.")}},$=async()=>{if(o){C(!0);try{await j(`/${u}/versions/${o.version.id}/restore`,{}),window.location.reload()}catch{p("The restore failed. Nothing was changed."),C(!1),g(null)}}};return f&&d.length===0?e.jsx(t.Flex,{justifyContent:"center",padding:4,children:e.jsx(t.Loader,{small:!0,children:"Loading history"})}):h&&d.length===0?e.jsx(t.Typography,{variant:"pi",textColor:"danger600",children:h}):d.length===0?e.jsx(t.Typography,{variant:"pi",textColor:"neutral600",children:"No versions yet. The next time this document is saved, a version will appear here."}):e.jsxs(t.Flex,{direction:"column",alignItems:"stretch",gap:2,width:"100%",children:[d.map(s=>e.jsxs(t.Box,{paddingBottom:2,children:[e.jsxs(t.Flex,{justifyContent:"space-between",alignItems:"center",gap:2,children:[e.jsxs(t.Flex,{direction:"column",alignItems:"flex-start",gap:1,grow:1,minWidth:0,children:[e.jsxs(t.Flex,{gap:2,alignItems:"center",children:[e.jsx(t.Badge,{backgroundColor:k.has(s.origin)?"primary100":"neutral150",textColor:k.has(s.origin)?"primary600":"neutral600",children:V[s.origin]??s.origin}),s.locale?e.jsx(t.Typography,{variant:"pi",textColor:"neutral500",children:s.locale}):null]}),s.label?e.jsx(t.Box,{width:"100%",children:e.jsx(t.Typography,{variant:"omega",textColor:"neutral800",ellipsis:!0,children:s.label})}):null,e.jsxs(t.Typography,{variant:"pi",textColor:"neutral600",children:[Z(s.createdAt),s.user?` · ${s.user.name}`:""]}),e.jsx(t.TextButton,{onClick:()=>_(s),children:"What changed"})]}),e.jsx(t.Box,{shrink:0,children:e.jsx(t.Button,{size:"S",variant:"tertiary",onClick:()=>N(s),children:"Restore"})})]}),e.jsx(t.Box,{paddingTop:2,children:e.jsx(t.Divider,{})})]},s.id)),d.length<F?e.jsx(t.Button,{size:"S",variant:"tertiary",loading:f,onClick:()=>y(B+1),children:"Show older versions"}):null,h?e.jsx(t.Typography,{variant:"pi",textColor:"danger600",children:h}):null,M?e.jsx(K,{diff:P,loading:O,onClose:()=>w(!1)}):null,e.jsx(t.Dialog.Root,{open:o!==null,onOpenChange:()=>g(null),children:e.jsxs(t.Dialog.Content,{children:[e.jsx(t.Dialog.Header,{children:"Restore this version?"}),e.jsx(t.Dialog.Body,{children:e.jsxs(t.Flex,{direction:"column",alignItems:"flex-start",gap:2,children:[e.jsx(t.Typography,{children:"The current draft is saved as a version first, so this can be undone. The published version is not touched."}),o?.preview.crossLocaleFields.length?e.jsxs(t.Typography,{textColor:"danger600",children:[o.preview.crossLocaleFields.join(", ")," ",o.preview.crossLocaleFields.length===1?"is":"are"," not translated per locale, so restoring"," ",o.preview.crossLocaleFields.length===1?"it":"them"," changes"," ",o.preview.affectedLocales.length," locales at once:"," ",o.preview.affectedLocales.join(", "),"."]}):null,o?.preview.fieldsKeptAsIs.length?e.jsxs(t.Typography,{variant:"pi",textColor:"neutral600",children:["Added since this version and left untouched:"," ",o.preview.fieldsKeptAsIs.join(", "),"."]}):null,o?.preview.fieldsDropped.length?e.jsxs(t.Typography,{variant:"pi",textColor:"neutral600",children:["No longer in the model and skipped: ",o.preview.fieldsDropped.join(", "),"."]}):null,o?.preview.brokenRelations.length||o?.preview.missingMedia.length?e.jsxs(t.Typography,{variant:"pi",textColor:"warning600",children:[(o.preview.brokenRelations.length??0)+(o.preview.missingMedia.length??0)," ","linked item(s) no longer exist and will be left out."]}):null]})}),e.jsxs(t.Dialog.Footer,{children:[e.jsx(t.Dialog.Cancel,{children:e.jsx(t.Button,{variant:"tertiary",disabled:v,children:"Cancel"})}),e.jsx(t.Button,{onClick:$,loading:v,children:"Restore"})]})]})})]})},X=({model:n,documentId:r,document:i})=>r?{title:"History",content:e.jsx(Q,{model:n,documentId:r,updatedAt:i?.updatedAt})}:null,Y={register(n){n.registerPlugin({id:u,name:u,isReady:!0})},bootstrap(n){n.getPlugin("content-manager").apis.addEditViewSidePanel([X])},async registerTrads({locales:n}){return Promise.all(n.map(async r=>{try{const{default:i}=await G(Object.assign({"./translations/en.json":()=>Promise.resolve().then(()=>require("../_chunks/en-BHfx2OWt.js"))}),`./translations/${r}.json`,3);return{data:i,locale:r}}catch{return{data:{},locale:r}}}))}};exports.PLUGIN_ID=u;exports.default=Y;exports.registerDiffRenderer=W;
@@ -0,0 +1,254 @@
1
+ import { jsx as t, jsxs as o } from "react/jsx-runtime";
2
+ import * as d from "react";
3
+ import { useFetchClient as K } from "@strapi/admin/strapi-admin";
4
+ import { Modal as g, Flex as c, Loader as $, Typography as r, Box as f, Button as v, Badge as V, TextButton as J, Divider as Z, Dialog as p } from "@strapi/design-system";
5
+ const Q = (e, n, a) => {
6
+ const s = e[n];
7
+ return s ? typeof s == "function" ? s() : Promise.resolve(s) : new Promise((k, u) => {
8
+ (typeof queueMicrotask == "function" ? queueMicrotask : setTimeout)(
9
+ u.bind(
10
+ null,
11
+ new Error(
12
+ "Unknown variable dynamic import: " + n + (n.split("/").length !== a ? ". Note that variables only represent file names one level deep." : "")
13
+ )
14
+ )
15
+ );
16
+ });
17
+ }, N = /* @__PURE__ */ new Map(), de = (e, n) => {
18
+ N.set(e, n);
19
+ }, L = (e) => N.get(e), X = ({ spans: e }) => /* @__PURE__ */ t(r, { variant: "omega", tag: "p", children: e.map((n, a) => n.op === "equal" ? /* @__PURE__ */ t(r, { variant: "omega", textColor: "neutral600", children: n.value }, a) : /* @__PURE__ */ t(
20
+ r,
21
+ {
22
+ variant: "omega",
23
+ textColor: n.op === "added" ? "success600" : "danger600",
24
+ style: n.op === "removed" ? { textDecoration: "line-through" } : { fontWeight: 600 },
25
+ children: n.value
26
+ },
27
+ a
28
+ )) }), F = (e) => e == null || e === "" ? "(empty)" : typeof e == "object" ? JSON.stringify(e) : String(e), Y = ({ change: e }) => {
29
+ const n = (e.customField ? L(e.customField) : void 0) ?? L(e.type);
30
+ return n ? /* @__PURE__ */ t(n, { change: e }) : e.spans ? /* @__PURE__ */ t(X, { spans: e.spans }) : e.linked || e.unlinked ? /* @__PURE__ */ o(c, { direction: "column", alignItems: "flex-start", gap: 1, children: [
31
+ e.linked?.length ? /* @__PURE__ */ o(r, { variant: "pi", textColor: "success600", children: [
32
+ "+ ",
33
+ e.linked.length,
34
+ " linked"
35
+ ] }) : null,
36
+ e.unlinked?.length ? /* @__PURE__ */ o(r, { variant: "pi", textColor: "danger600", children: [
37
+ "− ",
38
+ e.unlinked.length,
39
+ " unlinked"
40
+ ] }) : null
41
+ ] }) : e.before !== void 0 || e.after !== void 0 ? /* @__PURE__ */ o(c, { gap: 2, alignItems: "center", wrap: "wrap", children: [
42
+ /* @__PURE__ */ t(
43
+ r,
44
+ {
45
+ variant: "omega",
46
+ textColor: "danger600",
47
+ style: { textDecoration: "line-through" },
48
+ children: F(e.before)
49
+ }
50
+ ),
51
+ /* @__PURE__ */ t(r, { variant: "omega", textColor: "neutral500", children: "→" }),
52
+ /* @__PURE__ */ t(r, { variant: "omega", textColor: "success600", children: F(e.after) })
53
+ ] }) : /* @__PURE__ */ t(r, { variant: "pi", textColor: "neutral600", children: "Formatting or structure changed; the text is the same." });
54
+ }, ee = ({
55
+ diff: e,
56
+ loading: n,
57
+ onClose: a
58
+ }) => /* @__PURE__ */ t(g.Root, { open: !0, onOpenChange: a, children: /* @__PURE__ */ o(g.Content, { children: [
59
+ /* @__PURE__ */ t(g.Header, { children: /* @__PURE__ */ t(g.Title, { children: "What changed" }) }),
60
+ /* @__PURE__ */ t(g.Body, { children: n ? /* @__PURE__ */ t(c, { justifyContent: "center", padding: 4, children: /* @__PURE__ */ t($, { small: !0, children: "Comparing" }) }) : e ? /* @__PURE__ */ o(c, { direction: "column", alignItems: "stretch", gap: 4, children: [
61
+ /* @__PURE__ */ t(r, { variant: "pi", textColor: "neutral600", children: e.from ? "Compared with the version saved just before it." : "This is the earliest version of the document, so everything in it is new." }),
62
+ e.identical ? /* @__PURE__ */ t(r, { textColor: "neutral600", children: "Nothing changed between these two versions." }) : e.changes.map((s) => /* @__PURE__ */ o(f, { children: [
63
+ /* @__PURE__ */ t(r, { variant: "sigma", textColor: "neutral600", children: s.field }),
64
+ /* @__PURE__ */ t(f, { paddingTop: 1, children: /* @__PURE__ */ t(Y, { change: s }) })
65
+ ] }, s.field))
66
+ ] }) : /* @__PURE__ */ t(r, { textColor: "danger600", children: "Could not compare these versions." }) }),
67
+ /* @__PURE__ */ t(g.Footer, { children: /* @__PURE__ */ t(v, { variant: "tertiary", onClick: a, children: "Close" }) })
68
+ ] }) }), m = "rewind", te = {
69
+ create: "Created",
70
+ update: "Edited",
71
+ clone: "Cloned",
72
+ publish: "Published",
73
+ unpublish: "Unpublished",
74
+ discardDraft: "Draft discarded",
75
+ restore: "Replaced by restore"
76
+ }, P = /* @__PURE__ */ new Set(["publish", "unpublish", "discardDraft", "restore"]), ne = 10, re = (e) => {
77
+ const n = new Date(e), a = Math.round((Date.now() - n.getTime()) / 6e4);
78
+ return a < 1 ? "just now" : a < 60 ? `${a} min ago` : a < 60 * 24 ? `${Math.round(a / 60)} h ago` : n.toLocaleString();
79
+ }, ie = ({
80
+ model: e,
81
+ documentId: n,
82
+ /**
83
+ * Changes whenever the Content Manager re-reads the document, which is what
84
+ * it does after a save. Used purely as a refresh signal: without it the panel
85
+ * fetches once on mount and then quietly goes stale, so the version you just
86
+ * created by saving does not show up until the page is reloaded.
87
+ */
88
+ updatedAt: a
89
+ }) => {
90
+ const { get: s, post: k } = K(), [u, A] = d.useState([]), [O, B] = d.useState(0), [E, M] = d.useState(1), [S, R] = d.useState(!0), [C, w] = d.useState(null), [l, y] = d.useState(null), [T, D] = d.useState(!1), [_, I] = d.useState(!1), [H, x] = d.useState(null), [W, j] = d.useState(!1), b = d.useCallback(
91
+ async (i) => {
92
+ R(!0), w(null);
93
+ try {
94
+ const { data: h } = await s(`/${m}/versions`, {
95
+ params: {
96
+ contentType: e,
97
+ documentId: n,
98
+ page: i,
99
+ pageSize: ne
100
+ }
101
+ });
102
+ A((U) => i === 1 ? h.data : [...U, ...h.data]), B(h.meta.total), M(i);
103
+ } catch {
104
+ w("Could not load the history for this document.");
105
+ } finally {
106
+ R(!1);
107
+ }
108
+ },
109
+ [s, e, n]
110
+ );
111
+ d.useEffect(() => {
112
+ b(1);
113
+ }, [b, a]);
114
+ const G = async (i) => {
115
+ I(!0), j(!0), x(null);
116
+ try {
117
+ const { data: h } = await s(
118
+ `/${m}/versions/${i.id}/diff`
119
+ );
120
+ x(h.data);
121
+ } catch {
122
+ x(null);
123
+ } finally {
124
+ j(!1);
125
+ }
126
+ }, q = async (i) => {
127
+ try {
128
+ const { data: h } = await s(
129
+ `/${m}/versions/${i.id}/preview`
130
+ );
131
+ y({ version: i, preview: h.data });
132
+ } catch {
133
+ w("Could not work out what restoring this version would change.");
134
+ }
135
+ }, z = async () => {
136
+ if (l) {
137
+ D(!0);
138
+ try {
139
+ await k(`/${m}/versions/${l.version.id}/restore`, {}), window.location.reload();
140
+ } catch {
141
+ w("The restore failed. Nothing was changed."), D(!1), y(null);
142
+ }
143
+ }
144
+ };
145
+ return S && u.length === 0 ? /* @__PURE__ */ t(c, { justifyContent: "center", padding: 4, children: /* @__PURE__ */ t($, { small: !0, children: "Loading history" }) }) : C && u.length === 0 ? /* @__PURE__ */ t(r, { variant: "pi", textColor: "danger600", children: C }) : u.length === 0 ? /* @__PURE__ */ t(r, { variant: "pi", textColor: "neutral600", children: "No versions yet. The next time this document is saved, a version will appear here." }) : /* @__PURE__ */ o(c, { direction: "column", alignItems: "stretch", gap: 2, width: "100%", children: [
146
+ u.map((i) => /* @__PURE__ */ o(f, { paddingBottom: 2, children: [
147
+ /* @__PURE__ */ o(c, { justifyContent: "space-between", alignItems: "center", gap: 2, children: [
148
+ /* @__PURE__ */ o(c, { direction: "column", alignItems: "flex-start", gap: 1, grow: 1, minWidth: 0, children: [
149
+ /* @__PURE__ */ o(c, { gap: 2, alignItems: "center", children: [
150
+ /* @__PURE__ */ t(
151
+ V,
152
+ {
153
+ backgroundColor: P.has(i.origin) ? "primary100" : "neutral150",
154
+ textColor: P.has(i.origin) ? "primary600" : "neutral600",
155
+ children: te[i.origin] ?? i.origin
156
+ }
157
+ ),
158
+ i.locale ? /* @__PURE__ */ t(r, { variant: "pi", textColor: "neutral500", children: i.locale }) : null
159
+ ] }),
160
+ i.label ? /* @__PURE__ */ t(f, { width: "100%", children: /* @__PURE__ */ t(r, { variant: "omega", textColor: "neutral800", ellipsis: !0, children: i.label }) }) : null,
161
+ /* @__PURE__ */ o(r, { variant: "pi", textColor: "neutral600", children: [
162
+ re(i.createdAt),
163
+ i.user ? ` · ${i.user.name}` : ""
164
+ ] }),
165
+ /* @__PURE__ */ t(J, { onClick: () => G(i), children: "What changed" })
166
+ ] }),
167
+ /* @__PURE__ */ t(f, { shrink: 0, children: /* @__PURE__ */ t(v, { size: "S", variant: "tertiary", onClick: () => q(i), children: "Restore" }) })
168
+ ] }),
169
+ /* @__PURE__ */ t(f, { paddingTop: 2, children: /* @__PURE__ */ t(Z, {}) })
170
+ ] }, i.id)),
171
+ u.length < O ? /* @__PURE__ */ t(v, { size: "S", variant: "tertiary", loading: S, onClick: () => b(E + 1), children: "Show older versions" }) : null,
172
+ C ? /* @__PURE__ */ t(r, { variant: "pi", textColor: "danger600", children: C }) : null,
173
+ _ ? /* @__PURE__ */ t(
174
+ ee,
175
+ {
176
+ diff: H,
177
+ loading: W,
178
+ onClose: () => I(!1)
179
+ }
180
+ ) : null,
181
+ /* @__PURE__ */ t(p.Root, { open: l !== null, onOpenChange: () => y(null), children: /* @__PURE__ */ o(p.Content, { children: [
182
+ /* @__PURE__ */ t(p.Header, { children: "Restore this version?" }),
183
+ /* @__PURE__ */ t(p.Body, { children: /* @__PURE__ */ o(c, { direction: "column", alignItems: "flex-start", gap: 2, children: [
184
+ /* @__PURE__ */ t(r, { children: "The current draft is saved as a version first, so this can be undone. The published version is not touched." }),
185
+ l?.preview.crossLocaleFields.length ? /* @__PURE__ */ o(r, { textColor: "danger600", children: [
186
+ l.preview.crossLocaleFields.join(", "),
187
+ " ",
188
+ l.preview.crossLocaleFields.length === 1 ? "is" : "are",
189
+ " not translated per locale, so restoring",
190
+ " ",
191
+ l.preview.crossLocaleFields.length === 1 ? "it" : "them",
192
+ " changes",
193
+ " ",
194
+ l.preview.affectedLocales.length,
195
+ " locales at once:",
196
+ " ",
197
+ l.preview.affectedLocales.join(", "),
198
+ "."
199
+ ] }) : null,
200
+ l?.preview.fieldsKeptAsIs.length ? /* @__PURE__ */ o(r, { variant: "pi", textColor: "neutral600", children: [
201
+ "Added since this version and left untouched:",
202
+ " ",
203
+ l.preview.fieldsKeptAsIs.join(", "),
204
+ "."
205
+ ] }) : null,
206
+ l?.preview.fieldsDropped.length ? /* @__PURE__ */ o(r, { variant: "pi", textColor: "neutral600", children: [
207
+ "No longer in the model and skipped: ",
208
+ l.preview.fieldsDropped.join(", "),
209
+ "."
210
+ ] }) : null,
211
+ l?.preview.brokenRelations.length || l?.preview.missingMedia.length ? /* @__PURE__ */ o(r, { variant: "pi", textColor: "warning600", children: [
212
+ (l.preview.brokenRelations.length ?? 0) + (l.preview.missingMedia.length ?? 0),
213
+ " ",
214
+ "linked item(s) no longer exist and will be left out."
215
+ ] }) : null
216
+ ] }) }),
217
+ /* @__PURE__ */ o(p.Footer, { children: [
218
+ /* @__PURE__ */ t(p.Cancel, { children: /* @__PURE__ */ t(v, { variant: "tertiary", disabled: T, children: "Cancel" }) }),
219
+ /* @__PURE__ */ t(v, { onClick: z, loading: T, children: "Restore" })
220
+ ] })
221
+ ] }) })
222
+ ] });
223
+ }, oe = ({
224
+ model: e,
225
+ documentId: n,
226
+ document: a
227
+ }) => n ? {
228
+ title: "History",
229
+ content: /* @__PURE__ */ t(ie, { model: e, documentId: n, updatedAt: a?.updatedAt })
230
+ } : null, ce = {
231
+ register(e) {
232
+ e.registerPlugin({ id: m, name: m, isReady: !0 });
233
+ },
234
+ bootstrap(e) {
235
+ e.getPlugin("content-manager").apis.addEditViewSidePanel([oe]);
236
+ },
237
+ async registerTrads({ locales: e }) {
238
+ return Promise.all(
239
+ e.map(async (n) => {
240
+ try {
241
+ const { default: a } = await Q(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("../_chunks/en-DCctmu-a.mjs") }), `./translations/${n}.json`, 3);
242
+ return { data: a, locale: n };
243
+ } catch {
244
+ return { data: {}, locale: n };
245
+ }
246
+ })
247
+ );
248
+ }
249
+ };
250
+ export {
251
+ m as PLUGIN_ID,
252
+ ce as default,
253
+ de as registerDiffRenderer
254
+ };
@@ -0,0 +1,21 @@
1
+ import * as React from 'react';
2
+ import { type FieldChange } from '../diffRegistry';
3
+ export interface VersionDiff {
4
+ from: {
5
+ id: number;
6
+ label: string | null;
7
+ origin: string;
8
+ } | null;
9
+ to: {
10
+ id: number;
11
+ label: string | null;
12
+ origin: string;
13
+ };
14
+ changes: FieldChange[];
15
+ identical: boolean;
16
+ }
17
+ export declare const ChangesDialog: ({ diff, loading, onClose, }: {
18
+ diff: VersionDiff | null;
19
+ loading: boolean;
20
+ onClose: () => void;
21
+ }) => React.JSX.Element;
@@ -0,0 +1,17 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * The panel itself.
4
+ *
5
+ * Returning null while there is no `documentId` keeps it out of the way on the
6
+ * create screen, where there is nothing to have a history of yet.
7
+ */
8
+ export declare const HistoryPanel: ({ model, documentId, document, }: {
9
+ model: string;
10
+ documentId?: string;
11
+ document?: {
12
+ updatedAt?: string;
13
+ };
14
+ }) => {
15
+ title: string;
16
+ content: React.JSX.Element;
17
+ } | null;
@@ -0,0 +1,45 @@
1
+ import type * as React from 'react';
2
+ export type DiffOp = 'equal' | 'added' | 'removed';
3
+ export interface DiffSpan {
4
+ op: DiffOp;
5
+ value: string;
6
+ }
7
+ export interface FieldChange {
8
+ field: string;
9
+ type: string;
10
+ /** Set when the attribute is a custom field; more specific than `type`. */
11
+ customField?: string;
12
+ kind: 'added' | 'removed' | 'changed';
13
+ before?: unknown;
14
+ after?: unknown;
15
+ spans?: DiffSpan[];
16
+ linked?: {
17
+ documentId?: string;
18
+ id?: number;
19
+ targetUid: string;
20
+ }[];
21
+ unlinked?: {
22
+ documentId?: string;
23
+ id?: number;
24
+ targetUid: string;
25
+ }[];
26
+ }
27
+ export type DiffRenderer = React.ComponentType<{
28
+ change: FieldChange;
29
+ }>;
30
+ /**
31
+ * Lets another package render the diff for its own field type.
32
+ *
33
+ * Rewind compares a rich-text field by pulling the readable words out of the
34
+ * JSON, which tells an editor *that* a paragraph changed but nothing about
35
+ * blocks moving, an image being swapped, or a table gaining a row. The package
36
+ * that owns the field format is the only one that can show that properly, so
37
+ * the type-to-component mapping is a registry rather than a switch:
38
+ *
39
+ * import { registerDiffRenderer } from '@qkix/strapi-plugin-rewind/strapi-admin';
40
+ * registerDiffRenderer('plugin::better-blocks.better-blocks', BlocksDiff);
41
+ *
42
+ * Keyed by the attribute's `type`, or by a custom field's uid.
43
+ */
44
+ export declare const registerDiffRenderer: (type: string, renderer: DiffRenderer) => void;
45
+ export declare const getDiffRenderer: (type: string) => DiffRenderer | undefined;
@@ -0,0 +1,18 @@
1
+ declare const _default: {
2
+ register(app: any): void;
3
+ bootstrap(app: any): void;
4
+ registerTrads({ locales }: {
5
+ locales: string[];
6
+ }): Promise<{
7
+ data: any;
8
+ locale: string;
9
+ }[]>;
10
+ };
11
+ export default _default;
12
+ export { PLUGIN_ID } from './pluginId';
13
+ /**
14
+ * The hook a package owning a field format uses to render its own diff — see
15
+ * diffRegistry for why this is a registry and not a switch.
16
+ */
17
+ export { registerDiffRenderer } from './diffRegistry';
18
+ export type { DiffRenderer, FieldChange, DiffSpan } from './diffRegistry';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Stored in nothing user-facing yet, but the same rule applies as to any plugin
3
+ * id: it appears in route paths and permission names, so changing it later
4
+ * breaks installs rather than merely renaming a thing.
5
+ */
6
+ export declare const PLUGIN_ID = "rewind";