@qkix/strapi-plugin-rewind 0.1.3 → 0.2.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 +33 -19
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +145 -121
- package/dist/admin/src/diffRegistry.d.ts +1 -1
- package/dist/admin/src/index.d.ts +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.mjs +204 -171
- package/dist/server/src/config/index.d.ts +1 -1
- package/dist/server/src/content-types/index.d.ts +2 -2
- package/dist/server/src/controllers/index.d.ts +1 -0
- package/dist/server/src/controllers/version.d.ts +6 -0
- package/dist/server/src/index.d.ts +9 -0
- package/dist/server/src/services/diff.d.ts +6 -6
- package/dist/server/src/services/index.d.ts +8 -0
- package/dist/server/src/services/lock.d.ts +1 -1
- package/dist/server/src/services/pin.d.ts +19 -0
- package/dist/server/src/services/restore.d.ts +4 -4
- package/dist/server/src/services/serializer.d.ts +3 -3
- package/dist/server/src/services/snapshot.d.ts +1 -1
- package/dist/server/src/utils/backfillLabels.d.ts +1 -1
- package/dist/server/src/utils/captureContext.d.ts +1 -1
- package/dist/server/src/utils/persistTable.d.ts +2 -2
- package/dist/server/src/utils/textDiff.d.ts +2 -2
- package/package.json +4 -4
|
@@ -11,7 +11,7 @@ declare const _default: {
|
|
|
11
11
|
export default _default;
|
|
12
12
|
export { PLUGIN_ID } from './pluginId';
|
|
13
13
|
/**
|
|
14
|
-
* The hook a package owning a field format uses to render its own diff
|
|
14
|
+
* The hook a package owning a field format uses to render its own diff - see
|
|
15
15
|
* diffRegistry for why this is a registry and not a switch.
|
|
16
16
|
*/
|
|
17
17
|
export { registerDiffRenderer } from './diffRegistry';
|
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,p]=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 U(e,d),meta:{page:Number(a)||1,pageSize:c,total:p}}},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 U(e,[n]))[0]}},async diff(t){const{against:n}=t.query;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 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(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:"POST",path:"/versions/:id/restore",handler:"version.restore",config:{policies:["admin::isAuthenticatedAdmin"]}}]},_e={admin:$e},B=e=>e.match(/\S+\s*/g)??[],L=2500,Fe=(e,t)=>{const n=B(e),r=B(t);if(n.length>L||r.length>L)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 p=a[a.length-1];p&&p.op===l?p.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},Ne=new Set(["id","__component","type","format","level","language","url"]),q=(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=>q(n,t+1)).filter(Boolean).join(" "):typeof e=="object"?Object.entries(e).filter(([n])=>!Ne.has(n)).map(([,n])=>q(n,t+1)).filter(Boolean).join(" "):"",z=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,O=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):await r(s),c=e.getModel(s.contentType)?.attributes,l=s.data??{},d=i?.data??{},p=s.relations??{},b=i?.relations??{},m=[];for(const u of new Set([...Object.keys(d),...Object.keys(l)])){const h=d[u],f=l[u];if(JSON.stringify(h)===JSON.stringify(f))continue;const w=c?.[u]?.type??"unknown",T=c?.[u]?.customField,g=h===void 0?"added":f===void 0?"removed":"changed";if(Me.has(w)&&!Ue(w,h??f)){m.push({field:u,type:w,customField:T,kind:g,before:h,after:f});continue}const D=z(q(h)),A=z(q(f));if(D===A){m.push({field:u,type:w,customField:T,kind:g,before:void 0,after:void 0});continue}m.push({field:u,type:w,customField:T,kind:g,spans:Ce(Fe(D,A))})}for(const u of new Set([...Object.keys(b),...Object.keys(p)])){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)));g.length===0&&D.length===0||m.push({field:u,type:c?.[u]?.type??"relation",kind:"changed",linked:g,unlinked:D})}return{from:i?t(i):null,to:t(s),changes:m,identical:m.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}})}}},K={keepAllDays:7,dailyUntilDays:30,maxAgeDays:365,keepAnchors:!0},ze=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=>{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())/G+1)/7);return`${t.getUTCFullYear()}-W${o}`},We=(e,t,n=K)=>{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&&ze.has(s.origin))continue;const i=Pe(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:${Ye(c)}`:`w:${He(c)}`;r.has(l)?o.push(s.id):r.set(l,s)}return o},P=500,Ke=({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={...K,...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 p=await t().findMany({select:["id","createdAt","origin","pinned"],where:{contentType:d.contentType,relatedDocumentId:d.relatedDocumentId,locale:d.locale}});c+=p.length;const b=We(p,r,a);for(let m=0;m<b.length;m+=P){const u=b.slice(m,m+P);await t().deleteMany({where:{id:{$in:u}}}),l+=u.length}}return{scanned:c,deleted:l,documents:i.length}}}},Ge=({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??{},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=[],_=[];for(const[y,X]of Object.entries(b))if(g.includes(y))for(const k of X)await a(k)||(k.targetUid==="plugin::upload.file"?_.push(k):A.push(k));const V=D.length?await e.plugin("i18n")?.service("locales").find():[];return{fieldsToRestore:g.filter(y=>y in p),fieldsKeptAsIs:f,fieldsDropped:h,crossLocaleFields:D,unsupportedFields:T,brokenRelations:A,missingMedia:_,affectedLocales:V.map(y=>y.code)}},async apply(s,i){const c=await n(s),l=c.contentType,d=await this.preview(s),p=[];await e.plugin("rewind").service("snapshot").capture({uid:l,relatedDocumentId:c.relatedDocumentId,locale:c.locale,origin:"restore",before:null},i);const b={},m=c.data??{},u=c.relations??{};for(const f of d.fieldsToRestore)b[f]=m[f];const h=new Set([...d.brokenRelations,...d.missingMedia].map(f=>`${f.targetUid}:${f.documentId??f.id}`));for(const[f,w]of Object.entries(u)){if(d.unsupportedFields.includes(f)||d.fieldsDropped.includes(f))continue;const T=w.filter(g=>!h.has(`${g.targetUid}:${g.documentId??g.id}`));T.length!==w.length&&p.push(`${w.length-T.length} target(s) of "${f}" no longer exist and were skipped.`),b[f]=T.map(g=>g.targetUid==="plugin::upload.file"?g.id:g.documentId)}return d.fieldsDropped.length&&p.push(`Skipped ${d.fieldsDropped.length} field(s) no longer in the model: ${d.fieldsDropped.join(", ")}.`),d.crossLocaleFields.length&&p.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:p}}}},Ve={snapshot:ie,restore:Ge,diff:Be,retention:Ke,lock:Le},Xe={bootstrap:me,destroy:Se,config:we,contentTypes:Oe,controllers:Re,routes:_e,services:Ve};module.exports=Xe;
|
|
1
|
+
"use strict";const J=require("node:crypto"),Z=require("node:async_hooks"),Q=["id","documentId","locale"],S=["createdAt","updatedAt","publishedAt","createdBy","updatedBy","strapi_stage","strapi_assignee","locale","localizations"],I=(e,t)=>e.getModel(t),x=e=>e?.type==="relation"||e?.type==="media",$=e=>e?.type==="relation"&&String(e.relation??"").toLowerCase().startsWith("morph"),_=(e,t)=>Object.entries(I(e,t).attributes).filter(([,n])=>!["relation","media","component","dynamiczone"].includes(n.type)).map(([n])=>n),E=(e,t,n=!0)=>{const i=I(e,t),o=n?"select":"fields";return Object.entries(i.attributes).reduce((a,[s,r])=>{switch(r.type){case"relation":{if($(r))break;a[s]={[o]:["documentId","locale","publishedAt"]};break}case"media":{a[s]={[o]:["id"]};break}case"component":{a[s]={populate:E(e,r.component,n),[o]:_(e,r.component)};break}case"dynamiczone":{a[s]={on:(r.components??[]).reduce((c,l)=>(c[l]={populate:E(e,l,n),[o]:_(e,l)},c),{})};break}}return a},{})},ee=(e,t)=>{const n={},i=a=>{if(n[a])return;const s=I(e,a).attributes;n[a]=R(s,S);for(const r of Object.values(s))if(r.type==="component"&&i(r.component),r.type==="dynamiczone")for(const c of r.components??[])i(c)},o=I(e,t).attributes;for(const a of Object.values(o))if(a.type==="component"&&i(a.component),a.type==="dynamiczone")for(const s of a.components??[])i(s);return{attributes:R(o,S),components:n}},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(o=>n?{id:o.id,targetUid:t}:{documentId:o.documentId,locale:o.locale??null,targetUid:t}),ne=(e,t,n)=>{const i=I(e,t).attributes,o={},a={},s=R(n,[...S,...Q]);for(const[r,c]of Object.entries(s)){const l=i[r];if(l&&x(l)){if($(l))continue;const d=l.type==="media"?"plugin::upload.file":l.target;a[r]=te(c,d,l.type==="media");continue}o[r]=l?Y(e,l,c):c}return{data:o,relations:a,schemaSnapshot:ee(e,t)}},Y=(e,t,n)=>{if(n==null)return n;if(t.type==="component"){const i=o=>F(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 F(e,o.__component,o)}):n},F=(e,t,n)=>{if(!n||!t)return n;const i=I(e,t).attributes,o={};for(const[a,s]of Object.entries(n)){if(a==="id")continue;const r=i[a];o[a]=r?Y(e,r,s):s}return o},H=e=>{const t=new Map,n=async i=>{if(t.has(i))return t.get(i);let o;try{o=(await e.plugin("content-manager").service("content-types").findConfiguration(e.contentTypes[i]))?.settings?.mainField}catch{}return t.set(i,o),o};return async(i,o)=>{if(!o)return null;const s=[await n(i),"title","name","label"].filter(Boolean);for(const r of s){const c=o[r];if(typeof c=="string"&&c.trim())return c.trim().slice(0,255)}return null}},v="plugin::rewind.version",oe=new Set(["publish","unpublish","discardDraft","restore"]),se=e=>J.createHash("sha256").update(JSON.stringify(e)).digest("hex"),ie=({strapi:e})=>{const t=H(e),n=r=>!!e.contentTypes[r]?.options?.draftAndPublish,i=r=>!!(e.getModel(r)?.pluginOptions?.i18n?.localized??e.contentTypes[r]?.pluginOptions?.i18n?.localized),o=async(r,c,l)=>{const d=i(r)&&l.filter(Boolean).length>0?{locale:{$in:l.filter(Boolean)}}:{};return e.db.query(r).findMany({where:{documentId:c,...d,...n(r)?{publishedAt:null}:{}},populate:E(e,r)})},a=async(r,c)=>{try{const l=e.plugin("content-manager").service("document-metadata"),d=await l.getMetadata(r,c);return l.getStatus(c,d.availableStatus)}catch{return"draft"}},s=async(r,c,l)=>e.db.query(v).findOne({where:{contentType:r,relatedDocumentId:c,locale:l},orderBy:{createdAt:"desc"}});return{readRows:o,async captureAll(r,c){for(const l of r)await this.capture(l,c)},async capture(r,c){const{uid:l,relatedDocumentId:d,locale:p,origin:b}=r,m=r.before??await o(l,d,[p]);for(const u of m){const h=u.locale??null,{data:f,relations:w,schemaSnapshot:T}=ne(e,l,u),g=se({data:f,relations:w});!oe.has(b)&&(await s(l,d,h))?.contentHash===g||await e.db.query(v).create({data:{contentType:l,relatedDocumentId:d,locale:h,status:await a(l,u),label:await t(l,f),data:f,relations:w,schemaSnapshot:T,contentHash:g,origin:b,userId:c,pinned:!1}})}}}},j=["restore","publish","unpublish","discardDraft","clone","create","update"],C=new WeakMap,re=e=>{let t=C.get(e);return t||(t={intents:[],flushRegistered:!1},C.set(e,t)),t},ae=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 a=j.indexOf(n.origin)<j.indexOf(o.origin);t.set(i,{...a?n:o,before:o.before??n.before})}return[...t.values()]},W=new Z.AsyncLocalStorage,ce=e=>W.run({active:!0},e),le=()=>W.getStore()?.active===!0,de=200,ue=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=H(e);let a=0;for(;;){const s=await n.findMany({select:["id","contentType","data"],where:{label:null},limit:de});if(s.length===0)break;for(const r of s){const c=await o(r.contentType,r.data);await n.update({where:{id:r.id},data:{label:c??""}}),a+=1}}return e.log.info(`[rewind] labelled ${a} older version(s)`),a},M={type:"core",key:"persisted_tables"},fe=async(e,t)=>{const n=await e.store.get(M)??[];n.map(o=>typeof o=="string"?o:o.name).includes(t)||await e.store.set({...M,value:[...n,{name:t}]})},pe=["create","update","clone","publish","unpublish","discardDraft"],me=async({strapi:e})=>{const t=e.config.get("plugin::rewind");await fe(e,"rewind_versions"),await ye(e),ue(e,v).catch(o=>e.log.warn(`[rewind] could not label older versions: ${o instanceof Error?o.message:String(o)}`)),he(e,t);const n=()=>e.plugin("rewind").service("snapshot"),i=o=>{if(!pe.includes(o.action))return!1;const a=o.contentType?.uid??o.uid??"";return!(!a.startsWith("api::")||!t.contentTypes.includes(a)||le()||!t.trackApiWrites&&!e.requestContext.get()?.request?.url?.startsWith("/content-manager"))};e.documents.use(async(o,a)=>{if(!i(o))return a();const s=o.contentType?.uid??o.uid,r=o.params?.locale??null,c=o.action==="discardDraft"&&o.params?.documentId?await n().readRows(s,o.params.documentId,[r]):null,l=await a(),d=o.action==="create"||o.action==="clone"?l?.documentId:o.params?.documentId;if(!d)return l;const p=ge(e);return await e.db.transaction(async({trx:b,onCommit:m})=>{const u=re(b);u.intents.push({uid:s,relatedDocumentId:d,locale:r,origin:o.action,before:c}),!u.flushRegistered&&(u.flushRegistered=!0,m(()=>{const h=u.intents.splice(0,u.intents.length);n().captureAll(ae(h),p).catch(f=>e.log.error("[rewind] failed to record a version",f))}))}),l})},ge=e=>e.requestContext.get()?.state?.user?.id??null,ye=async e=>{try{const{tableName:t}=e.db.metadata.get(v);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)}`)}},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 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 * * *"}}})},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 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(", ")}`)}},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:i,locale:o,page:a=1,pageSize:s=10}=t.query;if(!n||!i)return t.badRequest("contentType and documentId are required.");const r={contentType:n,relatedDocumentId:i,...o?{locale:o}:{}},c=Math.min(Number(s)||10,100),l=(Math.max(Number(a)||1,1)-1)*c,[d,p]=await Promise.all([e.db.query(v).findMany({select:qe,where:r,orderBy:{createdAt:"desc"},limit:c,offset:l}),e.db.query(v).count({where:r})]);t.body={data:await U(e,d),meta:{page:Number(a)||1,pageSize:c,total:p}}},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 U(e,[n]))[0]}},async diff(t){const{against:n}=t.query;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(a=>a.userId).filter(Boolean))];if(n.length===0)return t.map(a=>({...a,user:null}));const i=await e.db.query("admin::user").findMany({select:["id","firstname","lastname","email"],where:{id:n}}),o=new Map(i.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},B=e=>e.match(/\S+\s*/g)??[],L=2500,_e=(e,t)=>{const n=B(e),i=B(t);if(n.length>L||i.length>L)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 l=n.length-1;l>=0;l-=1)for(let d=i.length-1;d>=0;d-=1)o[l][d]=n[l]===i[d]?o[l+1][d+1]+1:Math.max(o[l+1][d],o[l][d+1]);const a=[],s=(l,d)=>{const p=a[a.length-1];p&&p.op===l?p.value+=d:a.push({op:l,value:d})};let r=0,c=0;for(;r<n.length&&c<i.length;)n[r]===i[c]?(s("equal",n[r]),r+=1,c+=1):o[r+1][c]>=o[r][c+1]?(s("removed",n[r]),r+=1):(s("added",i[c]),c+=1);for(;r<n.length;)s("removed",n[r++]);for(;c<i.length;)s("added",i[c++]);return a},Fe=new Set(["id","__component","type","format","level","language","url"]),q=(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=>q(n,t+1)).filter(Boolean).join(" "):typeof e=="object"?Object.entries(e).filter(([n])=>!Fe.has(n)).map(([,n])=>q(n,t+1)).filter(Boolean).join(" "):"",z=e=>e.replace(/\s+/g," ").trim(),je=6,Ce=(e,t=je)=>{const n=[];return e.forEach((i,o)=>{if(i.op!=="equal"){n.push(i);return}const a=i.value.match(/\S+\s*/g)??[];if(a.length<=t*2+1){n.push(i);return}const s=o===0,r=o===e.length-1,c=s?[]:a.slice(0,t),l=r?[]: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,O=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},i=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),r=a?await n(a):await i(s),c=e.getModel(s.contentType)?.attributes,l=s.data??{},d=r?.data??{},p=s.relations??{},b=r?.relations??{},m=[];for(const u of new Set([...Object.keys(d),...Object.keys(l)])){const h=d[u],f=l[u];if(JSON.stringify(h)===JSON.stringify(f))continue;const w=c?.[u]?.type??"unknown",T=c?.[u]?.customField,g=h===void 0?"added":f===void 0?"removed":"changed";if(Me.has(w)&&!Ue(w,h??f)){m.push({field:u,type:w,customField:T,kind:g,before:h,after:f});continue}const D=z(q(h)),A=z(q(f));if(D===A){m.push({field:u,type:w,customField:T,kind:g,before:void 0,after:void 0});continue}m.push({field:u,type:w,customField:T,kind:g,spans:Ce(_e(D,A))})}for(const u of new Set([...Object.keys(b),...Object.keys(p)])){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)));g.length===0&&D.length===0||m.push({field:u,type:c?.[u]?.type??"relation",kind:"changed",linked:g,unlinked:D})}return{from:r?t(r):null,to:t(s),changes:m,identical:m.length===0}}}},Le=({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}})}}},ze=({strapi:e})=>({async set(t,n){if(!Number.isInteger(t))throw new Error("A version id is required.");const i=await e.db.query(v).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(v).update({where:{id:t},data:{pinned:n}}),{id:t,pinned:n})}}),K={keepAllDays:7,dailyUntilDays:30,maxAgeDays:365,keepAnchors:!0},Pe=new Set(["publish","unpublish","discardDraft","restore"]),G=24*60*60*1e3,Ye=(e,t)=>(t.getTime()-new Date(e).getTime())/G,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 i=new Date(Date.UTC(t.getUTCFullYear(),0,1)),o=Math.ceil(((t.getTime()-i.getTime())/G+1)/7);return`${t.getUTCFullYear()}-W${o}`},Ke=(e,t,n=K)=>{const i=new Map,o=[],a=[...e].sort((s,r)=>new Date(r.createdAt).getTime()-new Date(s.createdAt).getTime());for(const s of a){if(s.pinned||n.keepAnchors&&Pe.has(s.origin))continue;const r=Ye(s.createdAt,t);if(r<=n.keepAllDays)continue;if(r>n.maxAgeDays){o.push(s.id);continue}const c=new Date(s.createdAt),l=r<=n.dailyUntilDays?`d:${He(c)}`:`w:${We(c)}`;i.has(l)?o.push(s.id):i.set(l,s)}return o},P=500,Ge=({strapi:e})=>{const t=()=>e.db.query(v),n=async i=>{const o=await t().findMany({select:["contentType","relatedDocumentId","locale"],where:{createdAt:{$lt:i}}}),a=new Map;for(const s of o){const r=`${s.contentType}|${s.relatedDocumentId}|${s.locale??""}`;a.has(r)||a.set(r,{contentType:s.contentType,relatedDocumentId:s.relatedDocumentId,locale:s.locale??null})}return[...a.values()]};return{async prune(i=new Date){const o=e.config.get("plugin::rewind"),a={...K,...o?.retention};if(o?.retention?.enabled===!1)return{scanned:0,deleted:0,documents:0};const s=new Date(i.getTime()-a.keepAllDays*24*60*60*1e3),r=await n(s);let c=0,l=0;for(const d of r){const p=await t().findMany({select:["id","createdAt","origin","pinned"],where:{contentType:d.contentType,relatedDocumentId:d.relatedDocumentId,locale:d.locale}});c+=p.length;const b=Ke(p,i,a);for(let m=0;m<b.length;m+=P){const u=b.slice(m,m+P);await t().deleteMany({where:{id:{$in:u}}}),l+=u.length}}return{scanned:c,deleted:l,documents:r.length}}}},Ve=({strapi:e})=>{const t=s=>e.getModel(s),n=async s=>{const r=await e.db.query(v).findOne({where:{id:s}});if(!r)throw new Error(`No version with id ${s}`);return r},i=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 r=await n(s),c=r.contentType,l=t(c).attributes,d=r.schemaSnapshot?.attributes??{},p=r.data??{},b=r.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=>!i(l[y])):[],A=[],N=[];for(const[y,X]of Object.entries(b))if(g.includes(y))for(const k of X)await a(k)||(k.targetUid==="plugin::upload.file"?N.push(k):A.push(k));const V=D.length?await e.plugin("i18n")?.service("locales").find():[];return{fieldsToRestore:g.filter(y=>y in p),fieldsKeptAsIs:f,fieldsDropped:h,crossLocaleFields:D,unsupportedFields:T,brokenRelations:A,missingMedia:N,affectedLocales:V.map(y=>y.code)}},async apply(s,r){const c=await n(s),l=c.contentType,d=await this.preview(s),p=[];await e.plugin("rewind").service("snapshot").capture({uid:l,relatedDocumentId:c.relatedDocumentId,locale:c.locale,origin:"restore",before:null},r);const b={},m=c.data??{},u=c.relations??{};for(const f of d.fieldsToRestore)b[f]=m[f];const h=new Set([...d.brokenRelations,...d.missingMedia].map(f=>`${f.targetUid}:${f.documentId??f.id}`));for(const[f,w]of Object.entries(u)){if(d.unsupportedFields.includes(f)||d.fieldsDropped.includes(f))continue;const T=w.filter(g=>!h.has(`${g.targetUid}:${g.documentId??g.id}`));T.length!==w.length&&p.push(`${w.length-T.length} target(s) of "${f}" no longer exist and were skipped.`),b[f]=T.map(g=>g.targetUid==="plugin::upload.file"?g.id:g.documentId)}return d.fieldsDropped.length&&p.push(`Skipped ${d.fieldsDropped.length} field(s) no longer in the model: ${d.fieldsDropped.join(", ")}.`),d.crossLocaleFields.length&&p.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:p}}}},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;
|