@qkix/strapi-plugin-rewind 0.3.0 → 0.4.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.
- package/README.md +19 -4
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +167 -163
- package/dist/admin/src/diffRegistry.d.ts +16 -10
- package/dist/server/index.js +2 -2
- package/dist/server/index.mjs +442 -386
- package/dist/server/src/services/serializer.d.ts +48 -3
- package/dist/server/src/utils/versionLabel.d.ts +19 -3
- package/package.json +1 -1
|
@@ -4,6 +4,20 @@ export interface DiffSpan {
|
|
|
4
4
|
op: DiffOp;
|
|
5
5
|
value: string;
|
|
6
6
|
}
|
|
7
|
+
/** One end of a relation, as a version recorded it. */
|
|
8
|
+
export interface RelationRef {
|
|
9
|
+
documentId?: string;
|
|
10
|
+
/** Media is addressed by numeric id rather than by documentId. */
|
|
11
|
+
id?: number;
|
|
12
|
+
targetUid: string;
|
|
13
|
+
/**
|
|
14
|
+
* What the target was called when the version was taken.
|
|
15
|
+
*
|
|
16
|
+
* Absent on versions written before Rewind recorded it, and on a target whose
|
|
17
|
+
* type has no field that reads as a name - so always render a fallback.
|
|
18
|
+
*/
|
|
19
|
+
label?: string;
|
|
20
|
+
}
|
|
7
21
|
export interface FieldChange {
|
|
8
22
|
field: string;
|
|
9
23
|
type: string;
|
|
@@ -13,16 +27,8 @@ export interface FieldChange {
|
|
|
13
27
|
before?: unknown;
|
|
14
28
|
after?: unknown;
|
|
15
29
|
spans?: DiffSpan[];
|
|
16
|
-
linked?:
|
|
17
|
-
|
|
18
|
-
id?: number;
|
|
19
|
-
targetUid: string;
|
|
20
|
-
}[];
|
|
21
|
-
unlinked?: {
|
|
22
|
-
documentId?: string;
|
|
23
|
-
id?: number;
|
|
24
|
-
targetUid: string;
|
|
25
|
-
}[];
|
|
30
|
+
linked?: RelationRef[];
|
|
31
|
+
unlinked?: RelationRef[];
|
|
26
32
|
}
|
|
27
33
|
export type DiffRenderer = React.ComponentType<{
|
|
28
34
|
change: FieldChange;
|
package/dist/server/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";const
|
|
2
|
-
ON ${t} (content_type, related_document_id, locale, created_at)`)}catch(t){e.log.warn(`[rewind] could not create the lookup index, queries will be slower: ${t instanceof Error?t.message:String(t)}`)}},he=(e,t)=>{t.retention?.enabled!==!1&&e.cron.add({rewindPrune:{async task(){const n=e.plugin("rewind").service("lock");if(await n.acquire("prune",30*60*1e3))try{const r=await e.plugin("rewind").service("retention").prune();r.deleted>0&&e.log.info(`[rewind] thinned ${r.deleted} version(s) across ${r.documents} document(s)`)}catch(r){e.log.error("[rewind] thinning failed",r)}finally{await n.release("prune")}},options:{rule:t.cron??"0 3 * * *"}}})},we={default:{contentTypes:[],trackApiWrites:!1,retention:{enabled:!0,keepAllDays:7,dailyUntilDays:30,maxAgeDays:365,keepAnchors:!0},cron:"0 3 * * *"},validator(e={}){const{contentTypes:t,retention:n}=e;if(n){const{keepAllDays:o=7,dailyUntilDays:a=30,maxAgeDays:s=365}=n;if(!(o<=a&&a<=s))throw new Error(`[rewind] config.retention needs keepAllDays <= dailyUntilDays <= maxAgeDays (received ${o}, ${a}, ${s})`)}if(t===void 0)return;if(!Array.isArray(t)||t.some(o=>typeof o!="string"))throw new Error('[rewind] config.contentTypes must be an array of content type uids, e.g. ["api::article.article"]');const r=t.filter(o=>!o.startsWith("api::"));if(r.length>0)throw new Error(`[rewind] config.contentTypes only supports api:: content types, received: ${r.join(", ")}`)}},be="collectionType",Te="rewind_versions",ve={singularName:"version",pluralName:"versions",displayName:"Rewind Version",description:"One snapshot of one document, in one locale, at one point in time."},De={draftAndPublish:!1,comment:"Managed by strapi-plugin-rewind. Do not edit manually."},Ae={"content-manager":{visible:!1},"content-type-builder":{visible:!1},i18n:{localized:!1}},Ie={contentType:{type:"string",required:!0},relatedDocumentId:{type:"string",required:!0},locale:{type:"string"},status:{type:"enumeration",enum:["draft","published","modified"],required:!0},label:{type:"string"},data:{type:"json",required:!0},schemaSnapshot:{type:"json",required:!0},relations:{type:"json"},contentHash:{type:"string",required:!0},origin:{type:"enumeration",enum:["create","update","clone","publish","unpublish","discardDraft","restore"],required:!0},pinned:{type:"boolean",default:!1},userId:{type:"integer"}},ke={kind:be,collectionName:Te,info:ve,options:De,pluginOptions:Ae,attributes:Ie},Oe={version:{schema:ke}},Se=({strapi:e})=>{e.cron.remove("rewindPrune")},qe=["id","contentType","relatedDocumentId","locale","status","label","origin","userId","pinned","contentHash","createdAt"],Ee=({strapi:e})=>({async find(t){const{contentType:n,documentId:r,locale:o,page:a=1,pageSize:s=10}=t.query;if(!n||!r)return t.badRequest("contentType and documentId are required.");const i={contentType:n,relatedDocumentId:r,...o?{locale:o}:{}},c=Math.min(Number(s)||10,100),l=(Math.max(Number(a)||1,1)-1)*c,[d,f]=await Promise.all([e.db.query(v).findMany({select:qe,where:i,orderBy:{createdAt:"desc"},limit:c,offset:l}),e.db.query(v).count({where:i})]);t.body={data:await B(e,d),meta:{page:Number(a)||1,pageSize:c,total:f}}},async findOne(t){const n=await e.db.query(v).findOne({where:{id:Number(t.params.id)}});if(!n)return t.notFound("No such version.");t.body={data:(await B(e,[n]))[0]}},async diff(t){const{against:n}=t.query;if(n!==void 0&&!Number.isInteger(Number(n)))return t.badRequest("against must be a version id.");try{t.body={data:await e.plugin("rewind").service("diff").between(Number(t.params.id),n?Number(n):void 0)}}catch(r){return t.badRequest(r instanceof Error?r.message:"Could not compare these versions.")}},async preview(t){try{t.body={data:await e.plugin("rewind").service("restore").preview(Number(t.params.id))}}catch(n){return t.badRequest(n instanceof Error?n.message:"Preview failed.")}},async pin(t){const{pinned:n}=t.request.body??{};if(typeof n!="boolean")return t.badRequest("pinned must be true or false.");try{t.body={data:await e.plugin("rewind").service("pin").set(Number(t.params.id),n)}}catch(r){return t.notFound(r instanceof Error?r.message:"No such version.")}},async restore(t){try{t.body={data:await e.plugin("rewind").service("restore").apply(Number(t.params.id),t.state?.user?.id??null)}}catch(n){return t.badRequest(n instanceof Error?n.message:"Restore failed.")}}}),B=async(e,t)=>{const n=[...new Set(t.map(a=>a.userId).filter(Boolean))];if(n.length===0)return t.map(a=>({...a,user:null}));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]));return t.map(a=>{const s=o.get(a.userId);return{...a,user:s?{id:s.id,name:[s.firstname,s.lastname].filter(Boolean).join(" ")||s.email}:null}})},Re={version:Ee},$e={type:"admin",routes:[{method:"GET",path:"/versions",handler:"version.find",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"GET",path:"/versions/:id",handler:"version.findOne",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"GET",path:"/versions/:id/diff",handler:"version.diff",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"GET",path:"/versions/:id/preview",handler:"version.preview",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"PUT",path:"/versions/:id/pin",handler:"version.pin",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"POST",path:"/versions/:id/restore",handler:"version.restore",config:{policies:["admin::isAuthenticatedAdmin"]}}]},Ne={admin:$e},L=e=>e.match(/\S+\s*/g)??[],z=2500,_e=(e,t)=>{const n=L(e),r=L(t);if(n.length>z||r.length>z)return[{op:"removed",value:e},{op:"added",value:t}];const o=Array.from({length:n.length+1},()=>new Array(r.length+1).fill(0));for(let l=n.length-1;l>=0;l-=1)for(let d=r.length-1;d>=0;d-=1)o[l][d]=n[l]===r[d]?o[l+1][d+1]+1:Math.max(o[l+1][d],o[l][d+1]);const a=[],s=(l,d)=>{const f=a[a.length-1];f&&f.op===l?f.value+=d:a.push({op:l,value:d})};let i=0,c=0;for(;i<n.length&&c<r.length;)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);for(;i<n.length;)s("removed",n[i++]);for(;c<r.length;)s("added",r[c++]);return a},Fe=new Set(["id","__component","type","format","level","language","url"]),R=(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=>R(n,t+1)).filter(Boolean).join(" "):typeof e=="object"?Object.entries(e).filter(([n])=>!Fe.has(n)).map(([,n])=>R(n,t+1)).filter(Boolean).join(" "):"",P=e=>e.replace(/\s+/g," ").trim(),je=6,Ce=(e,t=je)=>{const n=[];return e.forEach((r,o)=>{if(r.op!=="equal"){n.push(r);return}const a=r.value.match(/\S+\s*/g)??[];if(a.length<=t*2+1){n.push(r);return}const s=o===0,i=o===e.length-1,c=s?[]:a.slice(0,t),l=i?[]:a.slice(-t);c.length&&n.push({op:"equal",value:c.join("")}),n.push({op:"equal",value:" … "}),l.length&&n.push({op:"equal",value:l.join("")})}),n},Me=new Set(["string","text","integer","biginteger","float","decimal","boolean","date","datetime","time","enumeration","uid","email","password"]),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,q=e=>`${e.targetUid}:${e.documentId??e.id}`,Be=({strapi:e})=>{const t=o=>o&&{id:o.id,label:o.label??null,createdAt:o.createdAt,origin:o.origin},n=async o=>{const a=await e.db.query(v).findOne({where:{id:o}});if(!a)throw new Error(`No version with id ${o}`);return a},r=async o=>{const[a]=await e.db.query(v).findMany({where:{contentType:o.contentType,relatedDocumentId:o.relatedDocumentId,locale:o.locale,id:{$lt:o.id}},orderBy:{id:"desc"},limit:1});return a??null};return{async between(o,a){const s=await n(o),i=a?await n(a):null;if(i&&(i.contentType!==s.contentType||i.relatedDocumentId!==s.relatedDocumentId||i.locale!==s.locale))throw new Error("Those two versions are not of the same document and locale.");let c,l;i?(c=i.id<s.id?i:s,l=i.id<s.id?s:i):(c=await r(s),l=s);const d=e.getModel(l.contentType)?.attributes,f=l.data??{},b=c?.data??{},h=l.relations??{},m=c?.relations??{},T=[];for(const u of new Set([...Object.keys(b),...Object.keys(f)])){const y=b[u],w=f[u];if(JSON.stringify(y)===JSON.stringify(w))continue;const p=d?.[u]?.type??"unknown",D=d?.[u]?.customField,A=y===void 0?"added":w===void 0?"removed":"changed";if(Me.has(p)&&!Ue(p,y??w)){T.push({field:u,type:p,customField:D,kind:A,before:y,after:w});continue}const I=P(R(y)),k=P(R(w));if(I===k){T.push({field:u,type:p,customField:D,kind:A,before:void 0,after:void 0});continue}T.push({field:u,type:p,customField:D,kind:A,spans:Ce(_e(I,k))})}for(const u of new Set([...Object.keys(m),...Object.keys(h)])){const y=m[u]??[],w=h[u]??[],p=new Set(y.map(q)),D=new Set(w.map(q)),A=w.filter(k=>!p.has(q(k))),I=y.filter(k=>!D.has(q(k)));A.length===0&&I.length===0||T.push({field:u,type:d?.[u]?.type??"relation",kind:"changed",linked:A,unlinked:I})}return{from:c?t(c):null,to:t(l),changes:T,identical:T.length===0}}}},Le=({strapi:e})=>{const t=n=>({type:"plugin",name:"rewind",key:`lock:${n}`});return{async acquire(n,r){const o=await e.store.get(t(n));return o&&o.expires>Date.now()?!1:(await e.store.set({...t(n),value:{expires:Date.now()+r}}),!0)},async release(n){await e.store.set({...t(n),value:{expires:0}})}}},ze=({strapi:e})=>({async set(t,n){if(!Number.isInteger(t))throw new Error("A version id is required.");const r=await e.db.query(v).findOne({where:{id:t},select:["id","pinned"]});if(!r)throw new Error(`No version with id ${t}`);return!!r.pinned===n?{id:t,pinned:n}:(await e.db.query(v).update({where:{id:t},data:{pinned:n}}),{id:t,pinned:n})}}),G={keepAllDays:7,dailyUntilDays:30,maxAgeDays:365,keepAnchors:!0},Pe=new Set(["publish","unpublish","discardDraft","restore"]),V=24*60*60*1e3,Ye=(e,t)=>(t.getTime()-new Date(e).getTime())/V,He=e=>e.toISOString().slice(0,10),We=e=>{const t=new Date(Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())),n=t.getUTCDay()||7;t.setUTCDate(t.getUTCDate()+4-n);const r=new Date(Date.UTC(t.getUTCFullYear(),0,1)),o=Math.ceil(((t.getTime()-r.getTime())/V+1)/7);return`${t.getUTCFullYear()}-W${o}`},Ke=(e,t,n=G)=>{const r=new Map,o=[],a=[...e].sort((s,i)=>new Date(i.createdAt).getTime()-new Date(s.createdAt).getTime());for(const s of a){if(s.pinned||n.keepAnchors&&Pe.has(s.origin))continue;const i=Ye(s.createdAt,t);if(i<=n.keepAllDays)continue;if(i>n.maxAgeDays){o.push(s.id);continue}const c=new Date(s.createdAt),l=i<=n.dailyUntilDays?`d:${He(c)}`:`w:${We(c)}`;r.has(l)?o.push(s.id):r.set(l,s)}return o},Y=500,Ge=({strapi:e})=>{const t=()=>e.db.query(v),n=async r=>{const o=await t().findMany({select:["contentType","relatedDocumentId","locale"],where:{createdAt:{$lt:r}}}),a=new Map;for(const s of o){const i=`${s.contentType}|${s.relatedDocumentId}|${s.locale??""}`;a.has(i)||a.set(i,{contentType:s.contentType,relatedDocumentId:s.relatedDocumentId,locale:s.locale??null})}return[...a.values()]};return{async prune(r=new Date){const o=e.config.get("plugin::rewind"),a={...G,...o?.retention};if(o?.retention?.enabled===!1)return{scanned:0,deleted:0,documents:0};const s=new Date(r.getTime()-a.keepAllDays*24*60*60*1e3),i=await n(s);let c=0,l=0;for(const d of i){const f=await t().findMany({select:["id","createdAt","origin","pinned"],where:{contentType:d.contentType,relatedDocumentId:d.relatedDocumentId,locale:d.locale}});c+=f.length;const b=Ke(f,r,a);for(let h=0;h<b.length;h+=Y){const m=b.slice(h,h+Y);await t().deleteMany({where:{id:{$in:m}}}),l+=m.length}}return{scanned:c,deleted:l,documents:i.length}}}},Ve=({strapi:e})=>{const t=s=>e.getModel(s),n=async s=>{const i=await e.db.query(v).findOne({where:{id:s}});if(!i)throw new Error(`No version with id ${s}`);return i},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({where:{documentId:s.documentId,...s.locale?{locale:s.locale}:{}}});return{async preview(s){const i=await n(s),c=i.contentType,l=t(c).attributes,d=i.schemaSnapshot?.attributes??{},f=i.data??{},b=i.relations??{},h=Object.keys(l).filter(g=>!E.includes(g)),m=Object.keys(d),T=m.filter(g=>!h.includes(g)),u=h.filter(g=>!m.includes(g)),y=m.filter(g=>h.includes(g)),w=y.filter(g=>_(l[g])),p=y.filter(g=>!w.includes(g)),D=o(c)?p.filter(g=>!r(l[g])):[],A=[],I=[];for(const[g,X]of Object.entries(b))if(p.includes(g))for(const S of X)await a(S)||(S.targetUid==="plugin::upload.file"?I.push(S):A.push(S));const k=D.length?await e.plugin("i18n")?.service("locales").find():[];return{fieldsToRestore:p.filter(g=>g in f),fieldsKeptAsIs:u,fieldsDropped:T,crossLocaleFields:D,unsupportedFields:w,brokenRelations:A,missingMedia:I,affectedLocales:k.map(g=>g.code)}},async apply(s,i){const c=await n(s),l=c.contentType,d=await this.preview(s),f=[];await e.plugin("rewind").service("snapshot").capture({uid:l,relatedDocumentId:c.relatedDocumentId,locale:c.locale,origin:"restore",before:null},i);const b={},h=c.data??{},m=c.relations??{};for(const u of d.fieldsToRestore)b[u]=h[u];const T=new Set([...d.brokenRelations,...d.missingMedia].map(u=>`${u.targetUid}:${u.documentId??u.id}`));for(const[u,y]of Object.entries(m)){if(d.unsupportedFields.includes(u)||d.fieldsDropped.includes(u))continue;const w=y.filter(p=>!T.has(`${p.targetUid}:${p.documentId??p.id}`));w.length!==y.length&&f.push(`${y.length-w.length} target(s) of "${u}" no longer exist and were skipped.`),b[u]=w.map(p=>p.targetUid==="plugin::upload.file"?p.id:p.documentId)}return d.fieldsDropped.length&&f.push(`Skipped ${d.fieldsDropped.length} field(s) no longer in the model: ${d.fieldsDropped.join(", ")}.`),d.crossLocaleFields.length&&f.push(`${d.crossLocaleFields.join(", ")} are not localised, so they changed in every locale.`),await ce(async()=>{await e.documents(l).update({documentId:c.relatedDocumentId,locale:c.locale??void 0,status:"draft",data:b})}),{documentId:c.relatedDocumentId,warnings:f}}}},Xe={snapshot:ie,restore:Ve,diff:Be,retention:Ge,lock:Le,pin:ze},Je={bootstrap:me,destroy:Se,config:we,contentTypes:Oe,controllers:Re,routes:Ne,services:Xe};module.exports=Je;
|
|
1
|
+
"use strict";const x=require("node:crypto"),ee=require("node:async_hooks"),W=e=>{const t=new Map;return async n=>{if(t.has(n))return t.get(n);let i;try{i=(await e.plugin("content-manager").service("content-types").findConfiguration(e.contentTypes[n]))?.settings?.mainField}catch{}return t.set(n,i),i}},K=["title","name","label"],G=e=>{const t=W(e);return async(n,i)=>{if(!i)return null;const r=[await t(n),...K].filter(Boolean);for(const s of r){const a=i[s];if(typeof a=="string"&&a.trim())return a.trim().slice(0,255)}return null}},te=["id","documentId","locale"],q=["createdAt","updatedAt","publishedAt","createdBy","updatedBy","strapi_stage","strapi_assignee","locale","localizations"],S=(e,t)=>e.getModel(t),ne=e=>e?.type==="relation"||e?.type==="media",$=e=>e?.type==="relation"&&String(e.relation??"").toLowerCase().startsWith("morph"),_="plugin::upload.file",oe=new Set(["string","uid","email","text","enumeration"]),se=async(e,t,n)=>{const i=new Map,o=async r=>{if(!r||i.has(r))return;let s;try{s=S(e,r).attributes}catch{return}const a=d=>!!d&&oe.has(s[d]?.type),l=[await n(r),...K].find(a);l&&i.set(r,l)};for(const r of Object.values(S(e,t).attributes))r?.type==="media"?await o(_):r?.type==="relation"&&!$(r)&&await o(r.target);return i},ie=e=>Object.fromEntries(Object.entries(e).map(([t,n])=>[t,n.map(({label:i,...o})=>o)])),j=(e,t)=>Object.entries(S(e,t).attributes).filter(([,n])=>!["relation","media","component","dynamiczone"].includes(n.type)).map(([n])=>n),F=(e,t,n=!0,i)=>{const o=S(e,t),r=n?"select":"fields",s=a=>{const l=i?.get(a);return l?[l]:[]};return Object.entries(o.attributes).reduce((a,[l,d])=>{switch(d.type){case"relation":{if($(d))break;a[l]={[r]:["documentId","locale","publishedAt",...s(d.target)]};break}case"media":{a[l]={[r]:["id",...s(_)]};break}case"component":{a[l]={populate:F(e,d.component,n),[r]:j(e,d.component)};break}case"dynamiczone":{a[l]={on:(d.components??[]).reduce((c,u)=>(c[u]={populate:F(e,u,n),[r]:j(e,u)},c),{})};break}}return a},{})},re=(e,t)=>{const n={},i=r=>{if(n[r])return;const s=S(e,r).attributes;n[r]=N(s,q);for(const a of Object.values(s))if(a.type==="component"&&i(a.component),a.type==="dynamiczone")for(const l of a.components??[])i(l)},o=S(e,t).attributes;for(const r of Object.values(o))if(r.type==="component"&&i(r.component),r.type==="dynamiczone")for(const s of r.components??[])i(s);return{attributes:N(o,q),components:n}},N=(e,t)=>Object.fromEntries(Object.entries(e??{}).filter(([n])=>!t.includes(n))),ae=(e,t,n,i)=>{const o=Array.isArray(e)?e:[e],r=s=>{const a=i?s[i]:void 0;return typeof a=="string"&&a.trim()?{label:a.trim().slice(0,255)}:{}};return o.filter(s=>!!s).map(s=>n?{id:s.id,targetUid:t,...r(s)}:{documentId:s.documentId,locale:s.locale??null,targetUid:t,...r(s)})},ce=(e,t,n,i)=>{const o=S(e,t).attributes,r={},s={},a=N(n,[...q,...te]);for(const[l,d]of Object.entries(a)){const c=o[l];if(c&&ne(c)){if($(c))continue;const u=c.type==="media"?_:c.target;s[l]=ae(d,u,c.type==="media",i?.get(u));continue}r[l]=c?V(e,c,d):d}return{data:r,relations:s,schemaSnapshot:re(e,t)}},V=(e,t,n)=>{if(n==null)return n;if(t.type==="component"){const i=o=>M(e,t.component,o);return Array.isArray(n)?n.map(o=>i(o)):i(n)}return t.type==="dynamiczone"&&Array.isArray(n)?n.map(i=>{const o=i;return M(e,o.__component,o)}):n},M=(e,t,n)=>{if(!n||!t)return n;const i=S(e,t).attributes,o={};for(const[r,s]of Object.entries(n)){if(r==="id")continue;const a=i[r];o[r]=a?V(e,a,s):s}return o},D="plugin::rewind.version",le=new Set(["publish","unpublish","discardDraft","restore"]),de=e=>x.createHash("sha256").update(JSON.stringify(e)).digest("hex"),ue=({strapi:e})=>{const t=G(e),n=W(e),i=new Map,o=c=>{let u=i.get(c);return u||(u=se(e,c,n),i.set(c,u)),u},r=c=>!!e.contentTypes[c]?.options?.draftAndPublish,s=c=>!!(e.getModel(c)?.pluginOptions?.i18n?.localized??e.contentTypes[c]?.pluginOptions?.i18n?.localized),a=async(c,u,f)=>{const y=s(c)&&f.filter(Boolean).length>0?{locale:{$in:f.filter(Boolean)}}:{};return e.db.query(c).findMany({where:{documentId:u,...y,...r(c)?{publishedAt:null}:{}},populate:F(e,c,!0,await o(c))})},l=async(c,u)=>{try{const f=e.plugin("content-manager").service("document-metadata"),y=await f.getMetadata(c,u);return f.getStatus(u,y.availableStatus)}catch{return"draft"}},d=async(c,u,f)=>e.db.query(D).findOne({where:{contentType:c,relatedDocumentId:u,locale:f},orderBy:{createdAt:"desc"}});return{readRows:a,async captureAll(c,u){for(const f of c)await this.capture(f,u)},async capture(c,u){const{uid:f,relatedDocumentId:y,locale:g,origin:T}=c,p=c.before??await a(f,y,[g]);for(const h of p){const b=h.locale??null,{data:m,relations:v,schemaSnapshot:A}=ce(e,f,h,await o(f)),I=de({data:m,relations:ie(v)});!le.has(T)&&(await d(f,y,b))?.contentHash===I||await e.db.query(D).create({data:{contentType:f,relatedDocumentId:y,locale:b,status:await l(f,h),label:await t(f,m),data:m,relations:v,schemaSnapshot:A,contentHash:I,origin:T,userId:u,pinned:!1}})}}}},C=["restore","publish","unpublish","discardDraft","clone","create","update"],B=new WeakMap,fe=e=>{let t=B.get(e);return t||(t={intents:[],flushRegistered:!1},B.set(e,t)),t},pe=e=>{const t=new Map;for(const n of e){const i=`${n.uid}:${n.relatedDocumentId}:${n.locale??""}`,o=t.get(i);if(!o){t.set(i,n);continue}const r=C.indexOf(n.origin)<C.indexOf(o.origin);t.set(i,{...r?n:o,before:o.before??n.before})}return[...t.values()]},X=new ee.AsyncLocalStorage,me=e=>X.run({active:!0},e),ye=()=>X.getStore()?.active===!0,ge=200,he=async(e,t)=>{const n=e.db.query(t),i=await n.count({where:{label:null}});if(i===0)return 0;e.log.info(`[rewind] labelling ${i} older version(s)…`);const o=G(e);let r=0;for(;;){const s=await n.findMany({select:["id","contentType","data"],where:{label:null},limit:ge});if(s.length===0)break;for(const a of s){const l=await o(a.contentType,a.data);await n.update({where:{id:a.id},data:{label:l??""}}),r+=1}}return e.log.info(`[rewind] labelled ${r} older version(s)`),r},L={type:"core",key:"persisted_tables"},we=async(e,t)=>{const n=await e.store.get(L)??[];n.map(o=>typeof o=="string"?o:o.name).includes(t)||await e.store.set({...L,value:[...n,{name:t}]})},be=["create","update","clone","publish","unpublish","discardDraft"],Te=async({strapi:e})=>{const t=e.config.get("plugin::rewind");await we(e,"rewind_versions"),await De(e),he(e,D).catch(o=>e.log.warn(`[rewind] could not label older versions: ${o instanceof Error?o.message:String(o)}`)),Ae(e,t);const n=()=>e.plugin("rewind").service("snapshot"),i=o=>{if(!be.includes(o.action))return!1;const r=o.contentType?.uid??o.uid??"";return!(!r.startsWith("api::")||!t.contentTypes.includes(r)||ye()||!t.trackApiWrites&&!e.requestContext.get()?.request?.url?.startsWith("/content-manager"))};e.documents.use(async(o,r)=>{if(!i(o))return r();const s=o.contentType?.uid??o.uid,a=o.params?.locale??null,l=o.action==="discardDraft"&&o.params?.documentId?await n().readRows(s,o.params.documentId,[a]):null,d=await r(),c=o.action==="create"||o.action==="clone"?d?.documentId:o.params?.documentId;if(!c)return d;const u=ve(e);return await e.db.transaction(async({trx:f,onCommit:y})=>{const g=fe(f);g.intents.push({uid:s,relatedDocumentId:c,locale:a,origin:o.action,before:l}),!g.flushRegistered&&(g.flushRegistered=!0,y(()=>{const T=g.intents.splice(0,g.intents.length);n().captureAll(pe(T),u).catch(p=>e.log.error("[rewind] failed to record a version",p))}))}),d})},ve=e=>e.requestContext.get()?.state?.user?.id??null,De=async e=>{try{const{tableName:t}=e.db.metadata.get(D);await e.db.connection.raw(`CREATE INDEX IF NOT EXISTS idx_rewind_lookup
|
|
2
|
+
ON ${t} (content_type, related_document_id, locale, created_at)`)}catch(t){e.log.warn(`[rewind] could not create the lookup index, queries will be slower: ${t instanceof Error?t.message:String(t)}`)}},Ae=(e,t)=>{t.retention?.enabled!==!1&&e.cron.add({rewindPrune:{async task(){const n=e.plugin("rewind").service("lock");if(await n.acquire("prune",30*60*1e3))try{const i=await e.plugin("rewind").service("retention").prune();i.deleted>0&&e.log.info(`[rewind] thinned ${i.deleted} version(s) across ${i.documents} document(s)`)}catch(i){e.log.error("[rewind] thinning failed",i)}finally{await n.release("prune")}},options:{rule:t.cron??"0 3 * * *"}}})},Ie={default:{contentTypes:[],trackApiWrites:!1,retention:{enabled:!0,keepAllDays:7,dailyUntilDays:30,maxAgeDays:365,keepAnchors:!0},cron:"0 3 * * *"},validator(e={}){const{contentTypes:t,retention:n}=e;if(n){const{keepAllDays:o=7,dailyUntilDays:r=30,maxAgeDays:s=365}=n;if(!(o<=r&&r<=s))throw new Error(`[rewind] config.retention needs keepAllDays <= dailyUntilDays <= maxAgeDays (received ${o}, ${r}, ${s})`)}if(t===void 0)return;if(!Array.isArray(t)||t.some(o=>typeof o!="string"))throw new Error('[rewind] config.contentTypes must be an array of content type uids, e.g. ["api::article.article"]');const i=t.filter(o=>!o.startsWith("api::"));if(i.length>0)throw new Error(`[rewind] config.contentTypes only supports api:: content types, received: ${i.join(", ")}`)}},Oe="collectionType",Se="rewind_versions",ke={singularName:"version",pluralName:"versions",displayName:"Rewind Version",description:"One snapshot of one document, in one locale, at one point in time."},Ee={draftAndPublish:!1,comment:"Managed by strapi-plugin-rewind. Do not edit manually."},qe={"content-manager":{visible:!1},"content-type-builder":{visible:!1},i18n:{localized:!1}},Re={contentType:{type:"string",required:!0},relatedDocumentId:{type:"string",required:!0},locale:{type:"string"},status:{type:"enumeration",enum:["draft","published","modified"],required:!0},label:{type:"string"},data:{type:"json",required:!0},schemaSnapshot:{type:"json",required:!0},relations:{type:"json"},contentHash:{type:"string",required:!0},origin:{type:"enumeration",enum:["create","update","clone","publish","unpublish","discardDraft","restore"],required:!0},pinned:{type:"boolean",default:!1},userId:{type:"integer"}},$e={kind:Oe,collectionName:Se,info:ke,options:Ee,pluginOptions:qe,attributes:Re},Fe={version:{schema:$e}},Ne=({strapi:e})=>{e.cron.remove("rewindPrune")},_e=["id","contentType","relatedDocumentId","locale","status","label","origin","userId","pinned","contentHash","createdAt"],je=({strapi:e})=>({async find(t){const{contentType:n,documentId:i,locale:o,page:r=1,pageSize:s=10}=t.query;if(!n||!i)return t.badRequest("contentType and documentId are required.");const a={contentType:n,relatedDocumentId:i,...o?{locale:o}:{}},l=Math.min(Number(s)||10,100),d=(Math.max(Number(r)||1,1)-1)*l,[c,u]=await Promise.all([e.db.query(D).findMany({select:_e,where:a,orderBy:{createdAt:"desc"},limit:l,offset:d}),e.db.query(D).count({where:a})]);t.body={data:await U(e,c),meta:{page:Number(r)||1,pageSize:l,total:u}}},async findOne(t){const n=await e.db.query(D).findOne({where:{id:Number(t.params.id)}});if(!n)return t.notFound("No such version.");t.body={data:(await U(e,[n]))[0]}},async diff(t){const{against:n}=t.query;if(n!==void 0&&!Number.isInteger(Number(n)))return t.badRequest("against must be a version id.");try{t.body={data:await e.plugin("rewind").service("diff").between(Number(t.params.id),n?Number(n):void 0)}}catch(i){return t.badRequest(i instanceof Error?i.message:"Could not compare these versions.")}},async preview(t){try{t.body={data:await e.plugin("rewind").service("restore").preview(Number(t.params.id))}}catch(n){return t.badRequest(n instanceof Error?n.message:"Preview failed.")}},async pin(t){const{pinned:n}=t.request.body??{};if(typeof n!="boolean")return t.badRequest("pinned must be true or false.");try{t.body={data:await e.plugin("rewind").service("pin").set(Number(t.params.id),n)}}catch(i){return t.notFound(i instanceof Error?i.message:"No such version.")}},async restore(t){try{t.body={data:await e.plugin("rewind").service("restore").apply(Number(t.params.id),t.state?.user?.id??null)}}catch(n){return t.badRequest(n instanceof Error?n.message:"Restore failed.")}}}),U=async(e,t)=>{const n=[...new Set(t.map(r=>r.userId).filter(Boolean))];if(n.length===0)return t.map(r=>({...r,user:null}));const i=await e.db.query("admin::user").findMany({select:["id","firstname","lastname","email"],where:{id:n}}),o=new Map(i.map(r=>[r.id,r]));return t.map(r=>{const s=o.get(r.userId);return{...r,user:s?{id:s.id,name:[s.firstname,s.lastname].filter(Boolean).join(" ")||s.email}:null}})},Me={version:je},Ce={type:"admin",routes:[{method:"GET",path:"/versions",handler:"version.find",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"GET",path:"/versions/:id",handler:"version.findOne",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"GET",path:"/versions/:id/diff",handler:"version.diff",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"GET",path:"/versions/:id/preview",handler:"version.preview",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"PUT",path:"/versions/:id/pin",handler:"version.pin",config:{policies:["admin::isAuthenticatedAdmin"]}},{method:"POST",path:"/versions/:id/restore",handler:"version.restore",config:{policies:["admin::isAuthenticatedAdmin"]}}]},Be={admin:Ce},z=e=>e.match(/\S+\s*/g)??[],P=2500,Le=(e,t)=>{const n=z(e),i=z(t);if(n.length>P||i.length>P)return[{op:"removed",value:e},{op:"added",value:t}];const o=Array.from({length:n.length+1},()=>new Array(i.length+1).fill(0));for(let d=n.length-1;d>=0;d-=1)for(let c=i.length-1;c>=0;c-=1)o[d][c]=n[d]===i[c]?o[d+1][c+1]+1:Math.max(o[d+1][c],o[d][c+1]);const r=[],s=(d,c)=>{const u=r[r.length-1];u&&u.op===d?u.value+=c:r.push({op:d,value:c})};let a=0,l=0;for(;a<n.length&&l<i.length;)n[a]===i[l]?(s("equal",n[a]),a+=1,l+=1):o[a+1][l]>=o[a][l+1]?(s("removed",n[a]),a+=1):(s("added",i[l]),l+=1);for(;a<n.length;)s("removed",n[a++]);for(;l<i.length;)s("added",i[l++]);return r},Ue=new Set(["id","__component","type","format","level","language","url"]),R=(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=>R(n,t+1)).filter(Boolean).join(" "):typeof e=="object"?Object.entries(e).filter(([n])=>!Ue.has(n)).map(([,n])=>R(n,t+1)).filter(Boolean).join(" "):"",Y=e=>e.replace(/\s+/g," ").trim(),ze=6,Pe=(e,t=ze)=>{const n=[];return e.forEach((i,o)=>{if(i.op!=="equal"){n.push(i);return}const r=i.value.match(/\S+\s*/g)??[];if(r.length<=t*2+1){n.push(i);return}const s=o===0,a=o===e.length-1,l=s?[]:r.slice(0,t),d=a?[]:r.slice(-t);l.length&&n.push({op:"equal",value:l.join("")}),n.push({op:"equal",value:" … "}),d.length&&n.push({op:"equal",value:d.join("")})}),n},Ye=new Set(["string","text","integer","biginteger","float","decimal","boolean","date","datetime","time","enumeration","uid","email","password"]),He=(e,t)=>e==="text"||e==="richtext"||e==="blocks"||e==="json"||e==="customField"||e==="component"||e==="dynamiczone"||e==="string"&&typeof t=="string"&&t.length>80,E=e=>`${e.targetUid}:${e.documentId??e.id}`,We=({strapi:e})=>{const t=o=>o&&{id:o.id,label:o.label??null,createdAt:o.createdAt,origin:o.origin},n=async o=>{const r=await e.db.query(D).findOne({where:{id:o}});if(!r)throw new Error(`No version with id ${o}`);return r},i=async o=>{const[r]=await e.db.query(D).findMany({where:{contentType:o.contentType,relatedDocumentId:o.relatedDocumentId,locale:o.locale,id:{$lt:o.id}},orderBy:{id:"desc"},limit:1});return r??null};return{async between(o,r){const s=await n(o),a=r?await n(r):null;if(a&&(a.contentType!==s.contentType||a.relatedDocumentId!==s.relatedDocumentId||a.locale!==s.locale))throw new Error("Those two versions are not of the same document and locale.");let l,d;a?(l=a.id<s.id?a:s,d=a.id<s.id?s:a):(l=await i(s),d=s);const c=e.getModel(d.contentType)?.attributes,u=d.data??{},f=l?.data??{},y=d.relations??{},g=l?.relations??{},T=[];for(const p of new Set([...Object.keys(f),...Object.keys(u)])){const h=f[p],b=u[p];if(JSON.stringify(h)===JSON.stringify(b))continue;const m=c?.[p]?.type??"unknown",v=c?.[p]?.customField,A=h===void 0?"added":b===void 0?"removed":"changed";if(Ye.has(m)&&!He(m,h??b)){T.push({field:p,type:m,customField:v,kind:A,before:h,after:b});continue}const I=Y(R(h)),O=Y(R(b));if(I===O){T.push({field:p,type:m,customField:v,kind:A,before:void 0,after:void 0});continue}T.push({field:p,type:m,customField:v,kind:A,spans:Pe(Le(I,O))})}for(const p of new Set([...Object.keys(g),...Object.keys(y)])){const h=g[p]??[],b=y[p]??[],m=new Set(h.map(E)),v=new Set(b.map(E)),A=b.filter(O=>!m.has(E(O))),I=h.filter(O=>!v.has(E(O)));A.length===0&&I.length===0||T.push({field:p,type:c?.[p]?.type??"relation",kind:"changed",linked:A,unlinked:I})}return{from:l?t(l):null,to:t(d),changes:T,identical:T.length===0}}}},Ke=({strapi:e})=>{const t=n=>({type:"plugin",name:"rewind",key:`lock:${n}`});return{async acquire(n,i){const o=await e.store.get(t(n));return o&&o.expires>Date.now()?!1:(await e.store.set({...t(n),value:{expires:Date.now()+i}}),!0)},async release(n){await e.store.set({...t(n),value:{expires:0}})}}},Ge=({strapi:e})=>({async set(t,n){if(!Number.isInteger(t))throw new Error("A version id is required.");const i=await e.db.query(D).findOne({where:{id:t},select:["id","pinned"]});if(!i)throw new Error(`No version with id ${t}`);return!!i.pinned===n?{id:t,pinned:n}:(await e.db.query(D).update({where:{id:t},data:{pinned:n}}),{id:t,pinned:n})}}),J={keepAllDays:7,dailyUntilDays:30,maxAgeDays:365,keepAnchors:!0},Ve=new Set(["publish","unpublish","discardDraft","restore"]),Z=24*60*60*1e3,Xe=(e,t)=>(t.getTime()-new Date(e).getTime())/Z,Je=e=>e.toISOString().slice(0,10),Ze=e=>{const t=new Date(Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())),n=t.getUTCDay()||7;t.setUTCDate(t.getUTCDate()+4-n);const i=new Date(Date.UTC(t.getUTCFullYear(),0,1)),o=Math.ceil(((t.getTime()-i.getTime())/Z+1)/7);return`${t.getUTCFullYear()}-W${o}`},Qe=(e,t,n=J)=>{const i=new Map,o=[],r=[...e].sort((s,a)=>new Date(a.createdAt).getTime()-new Date(s.createdAt).getTime());for(const s of r){if(s.pinned||n.keepAnchors&&Ve.has(s.origin))continue;const a=Xe(s.createdAt,t);if(a<=n.keepAllDays)continue;if(a>n.maxAgeDays){o.push(s.id);continue}const l=new Date(s.createdAt),d=a<=n.dailyUntilDays?`d:${Je(l)}`:`w:${Ze(l)}`;i.has(d)?o.push(s.id):i.set(d,s)}return o},H=500,xe=({strapi:e})=>{const t=()=>e.db.query(D),n=async i=>{const o=await t().findMany({select:["contentType","relatedDocumentId","locale"],where:{createdAt:{$lt:i}}}),r=new Map;for(const s of o){const a=`${s.contentType}|${s.relatedDocumentId}|${s.locale??""}`;r.has(a)||r.set(a,{contentType:s.contentType,relatedDocumentId:s.relatedDocumentId,locale:s.locale??null})}return[...r.values()]};return{async prune(i=new Date){const o=e.config.get("plugin::rewind"),r={...J,...o?.retention};if(o?.retention?.enabled===!1)return{scanned:0,deleted:0,documents:0};const s=new Date(i.getTime()-r.keepAllDays*24*60*60*1e3),a=await n(s);let l=0,d=0;for(const c of a){const u=await t().findMany({select:["id","createdAt","origin","pinned"],where:{contentType:c.contentType,relatedDocumentId:c.relatedDocumentId,locale:c.locale}});l+=u.length;const f=Qe(u,i,r);for(let y=0;y<f.length;y+=H){const g=f.slice(y,y+H);await t().deleteMany({where:{id:{$in:g}}}),d+=g.length}}return{scanned:l,deleted:d,documents:a.length}}}},et=e=>{const t=e.map(i=>i.label).filter(Boolean);if(t.length===0)return`${e.length} target(s)`;const n=e.length-t.length;return n?`${t.join(", ")} and ${n} more`:t.join(", ")},tt=({strapi:e})=>{const t=s=>e.getModel(s),n=async s=>{const a=await e.db.query(D).findOne({where:{id:s}});if(!a)throw new Error(`No version with id ${s}`);return a},i=s=>s?.pluginOptions?.i18n?.localized===!0,o=s=>e.contentTypes[s]?.pluginOptions?.i18n?.localized===!0,r=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({where:{documentId:s.documentId,...s.locale?{locale:s.locale}:{}}});return{async preview(s){const a=await n(s),l=a.contentType,d=t(l).attributes,c=a.schemaSnapshot?.attributes??{},u=a.data??{},f=a.relations??{},y=Object.keys(d).filter(w=>!q.includes(w)),g=Object.keys(c),T=g.filter(w=>!y.includes(w)),p=y.filter(w=>!g.includes(w)),h=g.filter(w=>y.includes(w)),b=h.filter(w=>$(d[w])),m=h.filter(w=>!b.includes(w)),v=o(l)?m.filter(w=>!i(d[w])):[],A=[],I=[];for(const[w,Q]of Object.entries(f))if(m.includes(w))for(const k of Q)await r(k)||(k.targetUid==="plugin::upload.file"?I.push(k):A.push(k));const O=v.length?await e.plugin("i18n")?.service("locales").find():[];return{fieldsToRestore:m.filter(w=>w in u),fieldsKeptAsIs:p,fieldsDropped:T,crossLocaleFields:v,unsupportedFields:b,brokenRelations:A,missingMedia:I,affectedLocales:O.map(w=>w.code)}},async apply(s,a){const l=await n(s),d=l.contentType,c=await this.preview(s),u=[];await e.plugin("rewind").service("snapshot").capture({uid:d,relatedDocumentId:l.relatedDocumentId,locale:l.locale,origin:"restore",before:null},a);const f={},y=l.data??{},g=l.relations??{};for(const p of c.fieldsToRestore)f[p]=y[p];const T=new Set([...c.brokenRelations,...c.missingMedia].map(p=>`${p.targetUid}:${p.documentId??p.id}`));for(const[p,h]of Object.entries(g)){if(c.unsupportedFields.includes(p)||c.fieldsDropped.includes(p))continue;const b=h.filter(m=>!T.has(`${m.targetUid}:${m.documentId??m.id}`));if(b.length!==h.length){const m=h.filter(v=>!b.includes(v));u.push(`${et(m)} of "${p}" no longer exist and were skipped.`)}f[p]=b.map(m=>m.targetUid==="plugin::upload.file"?m.id:m.documentId)}return c.fieldsDropped.length&&u.push(`Skipped ${c.fieldsDropped.length} field(s) no longer in the model: ${c.fieldsDropped.join(", ")}.`),c.crossLocaleFields.length&&u.push(`${c.crossLocaleFields.join(", ")} are not localised, so they changed in every locale.`),await me(async()=>{await e.documents(d).update({documentId:l.relatedDocumentId,locale:l.locale??void 0,status:"draft",data:f})}),{documentId:l.relatedDocumentId,warnings:u}}}},nt={snapshot:ue,restore:tt,diff:We,retention:xe,lock:Ke,pin:Ge},ot={bootstrap:Te,destroy:Ne,config:Ie,contentTypes:Fe,controllers:Me,routes:Be,services:nt};module.exports=ot;
|