domma-cms 1.7.1 → 1.7.2
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/CHANGELOG.md +4 -0
- package/CLAUDE.md +30 -0
- package/admin/js/app.js +2 -2
- package/admin/js/lib/wordpress-import-arrange.js +1 -0
- package/admin/js/templates/docs/usage-tools.html +13 -1
- package/admin/js/templates/wordpress-import.html +223 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/wordpress-import.js +38 -0
- package/package.json +1 -1
- package/server/routes/api/wordpress-import.js +220 -0
- package/server/server.js +12 -0
- package/server/services/hooks.js +1 -1
- package/server/services/permissionRegistry.js +10 -0
- package/server/services/roles.js +1 -1
- package/server/services/tools.js +2 -1
- package/server/services/wpImport/convert.js +491 -0
- package/server/services/wpImport/fetch.js +264 -0
- package/server/services/wpImport/index.js +832 -0
- package/server/services/wpImport/plan.js +549 -0
- package/server/services/wpImport/store.js +242 -0
- package/server/services/wpImport/wxr.js +167 -0
- package/server/services/wpImport/xml.js +261 -0
- package/server/services/wpImport/zip.js +142 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import{apiRequest as q,getToken as Wt}from"../api.js";import{addBannerAction as Zt}from"../lib/plugin-chrome.js";import{helpPopovers as Fe}from"../lib/help-popover.js";import{onceClosed as Jt,openMenuAt as J,sidebarCount as C,viewLifetime as Vt}from"../lib/tool-kit.js";import{SHAPES as Ke,whileLoading as We}from"../lib/loading.js";import{fmtDateTime as Gt}from"../lib/dates.js";import{applyChoice as Xt,choiceItems as Yt,cleanOptions as pe,counts as Ze,defaultOptions as Se,draftKey as Qt,draftQuestion as ea,historyRow as ta,isDirty as aa,kindTabs as oa,liveDrafts as Je,optionsSnapshot as Ve,PLAN_FILTERS as Ge,planMatches as Xe,planRow as sa,planTiles as Ye,progressPercent as na,progressText as ra,REPORT_FILTERS as Qe,reportMatches as et,reportRow as ia,stagedRow as la,withDraft as tt}from"../lib/wordpress-import-arrange.js";const at=/^#\/wordpress-import\/?(\?.*)?$/,V="#/wordpress-import",T="/import/wordpress",de=400,ca=`
|
|
2
|
+
<div class="wpx-row" data-id="{{id}}" tabindex="0">
|
|
3
|
+
<span class="wpx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
|
|
4
|
+
<span class="wpx-chips"><span class="wpx-chip" data-bind-class="pillClass">{{pill}}</span></span>
|
|
5
|
+
<button type="button" class="wpx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
6
|
+
</div>`,ot=`
|
|
7
|
+
<div class="wpx-row" data-key="{{key}}" tabindex="0">
|
|
8
|
+
<span class="wpx-main">
|
|
9
|
+
<strong>{{title}}</strong>
|
|
10
|
+
<small>{{meta}}</small>
|
|
11
|
+
<span class="wpx-where" data-bind-hidden="!where">{{where}}</span>
|
|
12
|
+
<span class="wpx-reason" data-bind-hidden="!reason">{{reason}}</span>
|
|
13
|
+
<span class="wpx-rebuild" data-bind-hidden="!rebuild" title="Needs rebuilding">{{rebuild}}</span>
|
|
14
|
+
</span>
|
|
15
|
+
<span class="wpx-chips"><span class="wpx-chip" data-bind-class="pillCls">{{pill}}</span></span>
|
|
16
|
+
<button type="button" class="wpx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
17
|
+
</div>`,st=`
|
|
18
|
+
<div class="wpx-row" data-from="{{from}}" tabindex="0">
|
|
19
|
+
<span class="wpx-main"><span class="wpx-where">{{from}} \u2192 {{to}}</span><span class="wpx-reason" data-bind-hidden="!reason">{{reason}}</span></span>
|
|
20
|
+
<span class="wpx-chips"><span class="wpx-chip" data-bind-class="pillClass">{{pill}}</span></span>
|
|
21
|
+
<button type="button" class="wpx-kebab" aria-label="Actions for {{from}}" title="Actions">\u22EE</button>
|
|
22
|
+
</div>`,pa=`
|
|
23
|
+
<div class="wpx-row" data-id="{{id}}" tabindex="0">
|
|
24
|
+
<span class="wpx-main"><strong>{{title}}</strong><small>{{meta}}</small><span class="wpx-reason">{{summary}}</span></span>
|
|
25
|
+
<span class="wpx-chips"><span class="wpx-chip" data-bind-class="pillClass">{{pill}}</span></span>
|
|
26
|
+
<button type="button" class="wpx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
27
|
+
</div>`,nt='<div class="wpx-tile"><strong>{{value}}</strong><small>{{label}} <span data-bind-hidden="!sub">{{sub}}</span></small></div>',rt='<p class="wpx-alert"><span>{{text}}</span></p>',Le='<button type="button" data-on-click="$root.pickSeg($data)" data-bind-class="cls">{{label}} <b>{{count}}</b></button>',y=Vt(at);function it(l,n,r){return new Promise((w,g)=>{const c=new XMLHttpRequest,L=new FormData;L.append("file",n),c.open("POST",`/api${l}`);const v=Wt();v&&c.setRequestHeader("Authorization",`Bearer ${v}`),c.upload.onprogress=p=>{p.lengthComputable&&r(p.loaded/p.total)},c.onload=()=>{let p=null;try{p=JSON.parse(c.responseText)}catch{}c.status>=200&&c.status<300?w(p):g(new Error(p?.error||`The upload failed (${c.status}).`))},c.onerror=()=>g(new Error("The upload failed - the connection dropped.")),c.send(L),y.add(()=>{c.readyState!==4&&c.abort()})})}const ue=l=>l>=1048576?`${Math.round(l/1048576)} MB`:`${Math.max(1,Math.round(l/1024))} KB`,H=l=>String(l??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function da(l){const n=document.createElement("template");return n.innerHTML=l.trim(),n.content.firstElementChild}async function me(l,n){const r=E.confirm(l,n);let w=null;try{w=[...document.querySelectorAll(".dm-dialog-overlay")].at(-1)??null}catch{}const g=y.add(()=>{if(!w)return;const c=w;w=null;try{c.querySelector(".dm-dialog-cancel")?.click(),c.remove()}catch{}});try{return await r}finally{w=null,g()}}function lt(l){navigator.clipboard?.writeText(l).then(()=>E.toast("Copied.",{type:"success"}),()=>E.toast(l,{type:"info",duration:8e3}))}function fe(l,n,r,w){const g=E.contextMenu(l,{cursor:"glyph",match:n,inherit:!1,density:"compact",items:r});y.add(()=>g?.destroy?.());const c=v=>{const p=v.target.closest(n);if(!p||!l.contains(p))return;const d=v.target.closest(".wpx-kebab");if(d){J(d);return}w?.(p)},L=v=>{const p=v.target.closest(n);p&&(v.key==="F10"&&v.shiftKey?(v.preventDefault(),J(p)):v.key==="Enter"&&v.target===p&&w&&(v.preventDefault(),v.stopPropagation(),w(p)))};l.addEventListener("click",c),l.addEventListener("keydown",L),y.add(()=>{l.removeEventListener("click",c),l.removeEventListener("keydown",L)})}export const wordpressImportView={templateUrl:"/admin/js/templates/wordpress-import.html",async onMount(l){y.reset();const n=l.get(0).querySelector(".wpx")||l.get(0),r=()=>at.test(location.hash)&&n.isConnected;let w="";try{w=S.get("auth_user")?.id||""}catch{}const g=Qt(w),c=()=>{try{return g?S.get(g):null}catch{return null}},L=e=>{try{g&&S.set(g,e)}catch{}},v=n.querySelector(".wpx-loading");let p;try{p=await We(v,q(`${T}/status`),Ke.rows)}catch(e){if(!n.isConnected)return;v&&(v.hidden=!0);const t=n.querySelector(".wpx-failed");t&&(t.textContent=`Could not load the importer: ${e.message||e}. Reload the page to try again.`,t.hidden=!1);return}if(!r())return;v&&(v.hidden=!0),n.querySelector(".wpx-body").hidden=!1;const d=M.observable(p.staged||[]),P=M.observable(p.history||[]),N=M.observable(p.blog||{available:!1}),z=p.limits||{},b=M.observable(p.running||null),u=M.observable(""),k=M.observable(null),G=M.observable(!1),ve=M.observable(!1),he=M.observable(""),h=M.observable(Se(N.peek().available)),X=M.observable("all"),D=M.observable(""),we=M.observable(""),Y=M.observable(""),j=M.observable(""),Q=M.observable(""),ee=M.observable(!1);let te=!0;const ua=()=>d.peek().find(e=>e.id===u.peek())||null,be=M.computed(()=>d.value.find(e=>e.id===u.value)||null),ct=M.computed(()=>u.value?"preview":"start"),R=M.computed(()=>!!N.value.available),qe=M.computed(()=>!!u.value&&aa(h.value,R.value)),ae=e=>M.computed({read:()=>h.value[e]!==!1,write:t=>{h.value={...h.peek(),[e]:!!t}}}),A=e=>{h.value={...h.peek(),...e}},_=e=>{try{return Gt(e)}catch{return e}},pt=M.computed(()=>d.value.map(e=>la(e,_))),dt=M.computed(()=>P.value.map(e=>ta(e,_))),B=M.computed(()=>(k.value?.items||[]).map(e=>({...e,choice:h.value.choices?.[e.key]}))),ut=M.computed(()=>oa(B.value).map(e=>({...e,cls:D.value===e.id?"is-on":"",seg:"kind"}))),mt=M.computed(()=>Ze(B.value,Ge,Xe,D.value)),ft=M.computed(()=>Ge.map(e=>({...e,count:mt.value[e.id],cls:X.value===e.id?"is-on":"",seg:"filter"}))),ge=M.computed(()=>{const e=we.value.trim().toLowerCase();return B.value.filter(t=>(!D.value||t.kind===D.value)&&Xe(t,X.value)&&(!e||`${t.title} ${t.target} ${t.oldUrl}`.toLowerCase().includes(e)))}),vt=M.computed(()=>ge.value.slice(0,de).map(e=>{const t=sa(e);return{...t,pillCls:`${t.pillClass}${e.choice?" is-chosen":""}`}})),ht=M.computed(()=>ge.value.length>de?`Showing the first ${de} of ${ge.value.length} - search or filter to find the rest.`:""),wt=M.computed(()=>B.value.length?"Nothing matches.":"This export has nothing to import."),bt=M.computed(()=>Ye(k.value)),gt=M.computed(()=>(k.value?.notes||[]).map(e=>({text:e}))),yt=M.computed(()=>(k.value?.redirects||[]).slice(0,de).map(e=>({...e,pill:e.action==="skip"?"Skipped":e.action==="update"?"Update":"New",pillClass:e.action==="skip"?"is-muted":"is-ok"}))),kt=M.computed(()=>String((k.value?.redirects||[]).filter(e=>e.action!=="skip").length)),xt=M.computed(()=>k.value?.site?.title||be.value?.siteTitle||"WordPress export"),Mt=M.computed(()=>{const e=be.value,t=k.value?.site;return[t?.url||e?.siteUrl,e?.fileName,t?.wxrVersion&&`WXR ${t.wxrVersion}`,e?.at&&`uploaded ${_(e.at)}`].filter(Boolean).join(" \xB7 ")}),Tt=M.computed(()=>R.value?`Posts go to ${N.value.basePath||"/blog"}.`:N.value.reason||"No Blog is switched on here."),oe=M.computed(()=>be.value?.uploads||null),Et=M.computed(()=>{const e=oe.value;return e?`${e.fileName} - ${e.files} file${e.files===1?"":"s"}, ${ue(e.size)}${e.unsafe?`. ${e.unsafe} with unsafe names (like ../) were ignored`:""}.`:`Zip the wp-content/uploads folder and upload it here (up to ${ue(z.uploads||0)}).`}),$t=M.computed(()=>oe.value?"Replace the zip":"Upload the uploads zip"),Re=M.computed(()=>!!k.value&&!b.value&&!G.value&&!(h.value.media==="zip"&&!oe.value)),St=M.computed(()=>b.value?`Importing${b.value.siteTitle?` ${b.value.siteTitle}`:""}\u2026`:""),Lt=M.computed(()=>ra(b.value)),Ce=M.computed(()=>na(b.value)),qt=M.observable(ue(z.wxr||0)),Pe=n.querySelector(".wpx-running .wpx-bar-fill");y.add(M.effect(()=>{Pe&&(Pe.style.width=`${Ce.value}%`)}));async function ye(){const e=await q(`${T}/status`);return r()?(d.value=e.staged||[],P.value=e.history||[],N.value=e.blog||{available:!1},e.running&&!b.peek()&&(b.value=e.running,K()),e):null}let se=0,ke=null;async function ne({first:e=!1}={}){const t=u.peek();if(!t)return;const a=++se;he.value="";const s=q(`${T}/staged/${t}/preview`,{method:"POST",body:JSON.stringify({options:pe(h.peek(),R.peek())})});try{let f;if(e?(G.value=!0,f=await We(n.querySelector(".wpx-plan-loading"),s,Ke.table)):(ve.value=!0,f=await s),a!==se||!r()||u.peek()!==t)return;k.value=f.plan}catch(f){if(a!==se||!r())return;if(f.status===404){E.toast("That upload has gone - upload the export again.",{type:"warning"}),F(),ye().catch(()=>{});return}he.value=`Could not read the export: ${f.message||f}`}finally{a===se&&(G.value=!1,ve.value=!1)}}let re="";y.add(M.effect(()=>{const e=`${u.value}|${Ve(h.value)}`;if(e===re)return;const t=re.split("|")[0];re=e,!(!u.value||t!==u.value)&&(clearTimeout(ke),ke=setTimeout(()=>ne(),250))})),y.add(()=>clearTimeout(ke)),y.add(M.effect(()=>{const e=u.value,t=h.value,a=qe.value;if(!e||!te||!g)return;const s=c();L(tt(s,w,e,a?{options:pe(t,!0),at:new Date().toISOString(),was:e}:null))}));const xe=e=>{g&&L(tt(c(),w,e,null))};async function ie(e){if(!d.peek().some(m=>m.id===e))return;const t=d.peek().find(m=>m.id===e),a=Je(c(),d.peek().map(m=>m.id),w).items[e];if(te=!a,k.value=null,X.value="all",D.value="",we.value="",h.value=Se(R.peek()),re=`${e}|${Ve(h.peek())}`,u.value=e,await ne({first:!0}),!r()||u.peek()!==e||!a)return;await new Promise(m=>setTimeout(m,0));const s=ea({when:a.at?_(a.at):"",file:t?.fileName||""}),f=await me(H(s.text),{title:s.title,confirmText:s.confirmText,cancelText:s.cancelText});!r()||u.peek()!==e||(te=!0,f?xe(e):h.value=pe(a.options,R.peek()))}function F(){u.value="",k.value=null,te=!0}async function De(e=u.peek()){const t=d.peek().find(a=>a.id===e);if(t&&await me(`Discard the upload of <strong>${H(t.siteTitle||t.fileName)}</strong>? Nothing has been imported from it.`,{title:"Discard upload",confirmText:"Discard",danger:!0}))try{if(await q(`${T}/staged/${e}`,{method:"DELETE"}),xe(e),!r())return;u.peek()===e&&F(),d.value=d.peek().filter(a=>a.id!==e),C(V)}catch(a){E.toast(`Not discarded: ${a.message||a}`,{type:"error"})}}async function Ae(e){if(!e)return;if(j.value="",!/\.(xml|zip)$/i.test(e.name)){j.value="Upload the .xml file WordPress exports, or a .zip holding it.";return}if(z.wxr&&e.size>z.wxr*1.2&&/\.xml$/i.test(e.name)){j.value=`That file is over ${ue(z.wxr)}.`;return}const t=n.querySelector(".wpx-start .wpx-upload .wpx-bar-fill");Y.value=`Uploading ${e.name}\u2026`;try{const a=await it(`${T}/upload`,e,s=>{t&&(t.style.width=`${Math.round(s*100)}%`),Y.value=s<1?`Uploading ${e.name} - ${Math.round(s*100)}%`:`Reading ${e.name}\u2026`});if(!r())return;d.value=[a,...d.peek().filter(s=>s.id!==a.id)],C(V),await ie(a.id)}catch(a){r()&&(j.value=a.message||String(a))}finally{r()&&(Y.value="",t&&(t.style.width="0"))}}async function Rt(e){const t=u.peek();if(!e||!t)return;if(!/\.zip$/i.test(e.name)){E.toast("Zip the wp-content/uploads folder and upload the .zip.",{type:"warning"});return}const a=n.querySelector(".wpx-zip-fill");Q.value=`Uploading ${e.name}\u2026`;try{const s=await it(`${T}/staged/${t}/uploads`,e,f=>{a&&(a.style.width=`${Math.round(f*100)}%`),Q.value=f<1?`Uploading ${e.name} - ${Math.round(f*100)}%`:`Checking ${e.name}\u2026`});if(!r())return;d.value=d.peek().map(f=>f.id===s.id?s:f),u.peek()===t&&ne()}catch(s){r()&&E.toast(s.message||String(s),{type:"error"})}finally{r()&&(Q.value="",a&&(a.style.width="0"))}}async function Ct(){const e=u.peek();try{const t=await q(`${T}/staged/${e}/uploads`,{method:"DELETE"});if(!r())return;d.value=d.peek().map(a=>a.id===t.id?t:a),ne()}catch(t){E.toast(`Not removed: ${t.message||t}`,{type:"error"})}}async function Pt(){const e=u.peek(),t=k.peek();if(!e||!t||!Re.peek())return;const s=Ye(t).filter(m=>m.value&&m.id!=="comments").map(m=>`${m.value} ${m.label.toLowerCase()}`);if(!(!await me(`Import <strong>${H(t.site?.title||"this export")}</strong>: ${H(s.join(", ")||"nothing new")}? Pages edited here since an earlier import are left alone unless you chose Overwrite.`,{title:"Run the import",confirmText:"Run import"})||!r()||u.peek()!==e))try{const m=await q(`${T}/staged/${e}/run`,{method:"POST",body:JSON.stringify({options:pe(h.peek(),R.peek())})});if(xe(e),!r())return;b.value={...m.job,siteTitle:t.site?.title||""},F(),C(V),K()}catch(m){E.toast(`The import did not start: ${m.message||m}`,{type:"error"})}}let Me=null;function K(){clearTimeout(Me);const e=b.peek();e&&(Me=setTimeout(async()=>{try{const t=await q(`${T}/jobs/${e.id}`);if(!r())return;if(b.value={...t.job,siteTitle:e.siteTitle},t.job.status==="running")return K();if(b.value=null,await ye().catch(()=>{}),C(V),C("#/pages"),C("#/media"),!r())return;t.job.status==="done"?(E.toast("The import has finished - here is its report.",{type:"success"}),le(t.job.reportId)):(E.toast(`The import stopped: ${t.job.error||"an error"}`,{type:"error"}),t.job.reportId&&le(t.job.reportId))}catch(t){if(!r())return;if(t.status===404){b.value=null,ye().catch(()=>{});return}K()}},700))}y.add(()=>clearTimeout(Me)),b.peek()&&K();let O=!1;async function le(e){if(O||!e)return;O=!0;let t;try{t=await q(`${T}/history/${e}`)}catch(o){O=!1,E.toast(`Could not open the report: ${o.message||o}`,{type:"error"});return}if(!r()){O=!1;return}let a;try{a=E.slideover({title:H(`Report - ${t.run?.siteTitle||"WordPress import"}`),size:"lg",position:"right"})}catch(o){throw O=!1,o}const s=da(`
|
|
28
|
+
<div class="wpx wpx-report">
|
|
29
|
+
<p class="wpx-note" data-bind-text="meta.value"></p>
|
|
30
|
+
<div class="wpx-tiles" data-each="tiles.value key=id"></div>
|
|
31
|
+
<div class="wpx-note-list" data-each="notes.value key=text"></div>
|
|
32
|
+
<div class="wpx-bar-row"><div class="wpx-seg" role="group" aria-label="Show" data-each="filters.value key=id"></div>
|
|
33
|
+
<span data-help-title="The report" data-help="Every item of the export and what happened to it. Kept as HTML: the page had tables, columns or other layout Markdown cannot say - it shows as it did, and edits as HTML. Needs rebuilding: shortcodes and page builders this site has no equivalent for. Right-click (or \u22EE, or Shift+F10) an item to open it."></span></div>
|
|
34
|
+
<p class="wpx-note" data-bind-hidden="rows.value.length">Nothing matches.</p>
|
|
35
|
+
<div class="wpx-list wpx-report-items" data-each="rows.value key=key"></div>
|
|
36
|
+
<div class="wpx-card-title" data-bind-hidden="!redirects.value.length">Redirects</div>
|
|
37
|
+
<div class="wpx-list wpx-report-redirects" data-each="redirects.value key=from"></div>
|
|
38
|
+
</div>`);s.querySelector(".wpx-tiles").innerHTML=nt,s.querySelector(".wpx-note-list").innerHTML=rt,s.querySelector(".wpx-seg").innerHTML=Le,s.querySelector(".wpx-report-items").innerHTML=ot,s.querySelector(".wpx-report-redirects").innerHTML=st;const f=M.observable(t.run?.summary?.failed?"failed":"all"),m=t.items||[],je=Ze(m,Qe,et),Z=t.run?.summary||{},Ee=o=>Object.values(Z.kinds||{}).reduce((i,x)=>i+(x[o]||0),0),Ut={meta:M.observable([_(t.run?.at),t.run?.by,t.run?.file,t.run?.durationMs?`${Math.max(1,Math.round(t.run.durationMs/1e3))} s`:""].filter(Boolean).join(" \xB7 ")),tiles:M.observable([{id:"made",label:"Made",value:Ee("created"),sub:""},{id:"updated",label:"Updated",value:Ee("updated"),sub:""},{id:"kept",label:"Left as is",value:Ee("kept"),sub:""},{id:"redirects",label:"Redirects",value:(Z.redirects?.created||0)+(Z.redirects?.updated||0),sub:""},{id:"comments",label:"Comments",value:Z.comments||0,sub:""},{id:"failed",label:"Failed",value:Z.failed||0,sub:""}]),notes:M.observable((t.notes||[]).map(o=>({text:o}))),filters:M.computed(()=>Qe.filter(o=>o.id==="all"||je[o.id]).map(o=>({...o,count:je[o.id],cls:f.value===o.id?"is-on":""}))),rows:M.computed(()=>m.filter(o=>et(o,f.value)).slice(0,1e3).map(o=>{const i=ia(o);return{...i,pillCls:i.pillClass}})),redirects:M.observable((t.redirects||[]).filter(o=>o.result!=="skipped").slice(0,1e3).map(o=>({...o,reason:o.error||"",pill:o.result==="failed"?"Failed":o.result==="updated"?"Updated":"Added",pillClass:o.result==="failed"?"is-bad":"is-ok"})))};a.element.appendChild(s);const Ht=M.applyBindings(Ut,s,{methods:{pickSeg:o=>{f.value=o.id}}}),Nt=Fe(s),_e=new Map(m.map(o=>[o.key,o])),zt=s.querySelector(".wpx-report-items"),jt=o=>{const i=_e.get(o.dataset.key);if(!i)return[];const x=typeof i.target=="string"&&i.target.startsWith("/")?i.target:"",$=[{header:i.title}];return x&&i.result!=="failed"&&i.result!=="skipped"&&$.push({label:"Open on the site",icon:"external-link",action:()=>window.open(x,"_blank","noopener")}),x&&i.kind==="page"&&(i.result==="created"||i.result==="updated"||i.result==="kept")&&$.push({label:"Edit the page",icon:"edit",action:()=>{a.close(),location.hash=`#/pages/edit${x}`}}),/^https?:\/\//i.test(i.oldUrl)&&$.push({label:"Open the old address",icon:"link",action:()=>window.open(i.oldUrl,"_blank","noopener,noreferrer")}),x&&$.push({label:"Copy the new address",icon:"copy",action:()=>lt(new URL(x,location.origin).href)}),$},_t=E.contextMenu(zt,{cursor:"glyph",match:".wpx-row",inherit:!1,density:"compact",items:jt}),Bt=s.querySelector(".wpx-report-redirects"),Ft=E.contextMenu(Bt,{cursor:"glyph",match:".wpx-row",inherit:!1,density:"compact",items:o=>[{header:o.dataset.from},...o.dataset.from.includes("*")?[]:[{label:"Try it on the site",icon:"external-link",action:()=>window.open(o.dataset.from,"_blank","noopener")}],{label:"Open SEO \u203A Redirects",icon:"corner-up-right",action:()=>{a.close(),location.hash="#/seo/redirects"}}]}),$e=o=>{const i=o.target.closest(".wpx-kebab");if(i){J(i);return}const x=o.target.closest(".wpx-report-items .wpx-row");if(!x)return;const $=_e.get(x.dataset.key);$?.target?.startsWith("/")&&$.result!=="failed"&&$.result!=="skipped"&&window.open($.target,"_blank","noopener")},Be=o=>{const i=o.target.closest(".wpx-row");i&&(o.key==="F10"&&o.shiftKey?(o.preventDefault(),J(i)):o.key==="Enter"&&o.target===i&&(o.preventDefault(),o.stopPropagation(),$e({target:i})))};s.addEventListener("click",$e),s.addEventListener("keydown",Be);const Kt=y.add(()=>a.close());Jt(a,()=>{O=!1,Ht.dispose(),Nt(),_t?.destroy?.(),Ft?.destroy?.(),s.removeEventListener("click",$e),s.removeEventListener("keydown",Be),Kt()}),a.open(),I.scan(s)}async function Dt(e){const t=P.peek().find(a=>a.id===e);if(t&&await me(`Remove the import of <strong>${H(t.siteTitle||t.source)}</strong> from this list? Everything it imported stays on the site.`,{title:"Remove from the history",confirmText:"Remove"}))try{if(await q(`${T}/history/${e}`,{method:"DELETE"}),!r())return;P.value=P.peek().filter(a=>a.id!==e),C(V)}catch(a){E.toast(`Not removed: ${a.message||a}`,{type:"error"})}}n.querySelector(".wpx-staged").innerHTML=ca,n.querySelector(".wpx-items").innerHTML=ot,n.querySelector(".wpx-redirects").innerHTML=st,n.querySelector(".wpx-history").innerHTML=pa,n.querySelector(".wpx-tiles").innerHTML=nt,n.querySelector(".wpx-note-list").innerHTML=rt,n.querySelector(".wpx-kinds").innerHTML=Le,n.querySelector(".wpx-filters").innerHTML=Le;const At={mode:ct,job:b,jobTitle:St,jobText:Lt,jobPct:Ce,staged:d,stagedRows:pt,historyRows:dt,plan:k,planLoading:G,planError:he,refreshing:ve,opt:h,blogAvailable:R,blogNote:Tt,dirty:qe,canRun:Re,siteTitle:xt,siteMeta:Mt,tiles:bt,notes:gt,kinds:ut,filters:ft,rows:vt,moreText:ht,emptyText:wt,redirectRows:yt,redirectCount:kt,query:we,uploadText:Y,uploadError:j,dropping:ee,wxrLimit:qt,zipInfo:oe,zipText:Et,zipButton:$t,zipUploadText:Q,optComments:ae("comments"),optMenus:ae("menus"),optRedirects:ae("redirects"),optDrafts:ae("drafts")},It=M.applyBindings(At,n,{methods:{back:()=>F(),discard:()=>De(),run:()=>Pt(),resetChoices:()=>{h.value=Se(R.peek())},postsBlog:()=>A({posts:"blog"}),postsPages:()=>A({posts:"pages"}),postsSkip:()=>A({posts:"skip"}),mediaDownload:()=>A({media:"download"}),mediaZip:()=>A({media:"zip"}),mediaSkip:()=>A({media:"skip"}),removeZip:()=>Ct(),pickSeg:e=>{e.seg==="kind"?D.value=e.id:X.value=e.id}}});y.add(()=>It.dispose()),y.add(Fe(n,{position:"bottom"}));const W=n.querySelector(".wpx-file"),Ie=()=>{const e=W.files?.[0];W.value="",Ae(e)};W.addEventListener("change",Ie);const U=n.querySelector(".wpx-drop"),Oe=e=>{e.preventDefault(),ee.value=!0},Ue=()=>{ee.value=!1},He=e=>{e.preventDefault(),ee.value=!1,Ae(e.dataTransfer?.files?.[0])};U.addEventListener("dragover",Oe),U.addEventListener("dragleave",Ue),U.addEventListener("drop",He);const ce=n.querySelector(".wpx-zip-file"),Ne=()=>{const e=ce.files?.[0];ce.value="",Rt(e)};ce.addEventListener("change",Ne),y.add(()=>{W.removeEventListener("change",Ie),U.removeEventListener("dragover",Oe),U.removeEventListener("dragleave",Ue),U.removeEventListener("drop",He),ce.removeEventListener("change",Ne)}),fe(n.querySelector(".wpx-staged"),".wpx-row",e=>[{header:d.peek().find(t=>t.id===e.dataset.id)?.siteTitle||"Upload"},{label:"Preview the import",icon:"eye",action:()=>ie(e.dataset.id)},{label:"Discard the upload",icon:"trash-2",action:()=>De(e.dataset.id)}],e=>ie(e.dataset.id));const Ot=(e,t)=>{h.value={...h.peek(),choices:Xt(h.peek().choices,e,t)}};fe(n.querySelector(".wpx-items"),".wpx-row",e=>{const t=B.peek().find(s=>s.key===e.dataset.key);if(!t)return[];const a=[{header:t.title}];for(const s of Yt(t,t.choice))a.push({label:s.label,icon:s.icon,action:()=>Ot(t.key,s.id)});return t.oldUrl&&/^https?:\/\//i.test(t.oldUrl)&&a.push({label:"Open the old address",icon:"link",action:()=>window.open(t.oldUrl,"_blank","noopener,noreferrer")}),t.target?.startsWith("/")&&(t.action==="update"||t.action==="keep")&&a.push({label:"Open it on this site",icon:"external-link",action:()=>window.open(t.target,"_blank","noopener")}),a.length===1&&a.push({label:"Nothing to change here",icon:"info",disabled:!0,action:()=>{}}),a},e=>J(e.querySelector(".wpx-kebab")||e)),fe(n.querySelector(".wpx-redirects"),".wpx-row",e=>[{header:e.dataset.from},{label:"Copy the old address",icon:"copy",action:()=>lt(e.dataset.from)}],null),fe(n.querySelector(".wpx-history"),".wpx-row",e=>[{header:P.peek().find(t=>t.id===e.dataset.id)?.siteTitle||"Import"},{label:"Open the report",icon:"file-text",action:()=>le(e.dataset.id)},{label:"Remove from the history",icon:"trash-2",action:()=>Dt(e.dataset.id)}],e=>le(e.dataset.id)),Zt(l,{id:"wpx-upload",icon:"upload",title:"Upload an export",onClick:()=>{u.peek()&&F(),setTimeout(()=>W.click(),0)}}),I.scan(n);const Te=Je(c(),d.peek().map(e=>e.id),w);Te.pruned&&g&&L({owner:w,items:Te.items});const ze=Object.entries(Te.items).sort((e,t)=>String(t[1].at).localeCompare(String(e[1].at)))[0];ze&&!b.peek()&&await ie(ze[0])}};
|
package/package.json
CHANGED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import from WordPress - the API (mounted at /api/import/wordpress behind
|
|
3
|
+
* toolGate('wordpress-import'); every route needs `import.wordpress`). The
|
|
4
|
+
* work is services/wpImport.
|
|
5
|
+
*
|
|
6
|
+
* GET /status staged uploads, the running job, recent runs, what posts can become
|
|
7
|
+
* GET /count the sidebar badge
|
|
8
|
+
* POST /upload a WXR export (.xml, or a .zip holding one) - multipart, field "file"
|
|
9
|
+
* POST /staged/:id/uploads the wp-content/uploads folder as a .zip - multipart, field "file"
|
|
10
|
+
* DELETE /staged/:id/uploads drop that zip
|
|
11
|
+
* POST /staged/:id/preview {options} -> the plan; nothing is written
|
|
12
|
+
* POST /staged/:id/run {options} -> a background job
|
|
13
|
+
* DELETE /staged/:id discard an upload
|
|
14
|
+
* GET /jobs/:id a job's progress
|
|
15
|
+
* GET /history · GET|DELETE /history/:id
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import {pipeline} from 'node:stream/promises';
|
|
19
|
+
import {Transform} from 'node:stream';
|
|
20
|
+
import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
|
|
21
|
+
import {
|
|
22
|
+
blogTarget, cleanOptions, createStaging, discardStaged, getJob, inspectStaged, previewImport, runningJob, stagingMeta,
|
|
23
|
+
stagingPath, startRun, updateStagingMeta, UPLOADS_ZIP_MAX_BYTES, WXR_MAX_BYTES, zipRel
|
|
24
|
+
} from '../../services/wpImport/index.js';
|
|
25
|
+
import {getReport, isId, listHistory, listStaging, removeHistory} from '../../services/wpImport/store.js';
|
|
26
|
+
import {openZip} from '../../services/wpImport/zip.js';
|
|
27
|
+
import {seoEngine} from '../../services/seo/index.js';
|
|
28
|
+
import {allowInGitignore} from '../../services/siteGitignore.js';
|
|
29
|
+
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
30
|
+
|
|
31
|
+
// Uploads, the mapping, the history and reports (names of who ran it, commenters) all stay out of the site's repo.
|
|
32
|
+
const IMPORT_RULE = '/content/imports/wordpress/';
|
|
33
|
+
|
|
34
|
+
/** Stream a multipart file to disk, stopping at `max` bytes. */
|
|
35
|
+
async function saveUpload(file, dest, max) {
|
|
36
|
+
let size = 0;
|
|
37
|
+
const counter = new Transform({
|
|
38
|
+
transform(chunk, _enc, cb) {
|
|
39
|
+
size += chunk.length;
|
|
40
|
+
if (size > max) return cb(Object.assign(new Error('too big'), {code: 'ETOOBIG'}));
|
|
41
|
+
cb(null, chunk);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
await pipeline(file, counter, fs.createWriteStream(dest, {mode: 0o600}));
|
|
45
|
+
if (file.truncated) throw Object.assign(new Error('too big'), {code: 'ETOOBIG'});
|
|
46
|
+
return size;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mb = (n) => `${Math.round(n / 1048576)} MB`;
|
|
50
|
+
|
|
51
|
+
/** What the screen shows of a plan - the plan minus internals. */
|
|
52
|
+
export function planForScreen(plan, blog) {
|
|
53
|
+
return {
|
|
54
|
+
source: plan.source,
|
|
55
|
+
site: plan.site,
|
|
56
|
+
blogBase: plan.blogBase,
|
|
57
|
+
blog: {available: blog.available, basePath: blog.basePath, comments: Boolean(blog.comments), reason: blog.reason || ''},
|
|
58
|
+
options: plan.options,
|
|
59
|
+
summary: plan.summary,
|
|
60
|
+
notes: plan.notes,
|
|
61
|
+
items: plan.items.map(r => ({
|
|
62
|
+
key: r.key, kind: r.kind, wpId: r.wpId, title: r.title, action: r.action, reason: r.reason || '',
|
|
63
|
+
target: r.target || '', oldUrl: r.oldUrl || '', status: r.status || '', wpStatus: r.wpStatus || '',
|
|
64
|
+
shortcodes: r.shortcodes || [], builder: r.builder || '', comments: r.comments || 0, count: r.count || 0,
|
|
65
|
+
replaces: Boolean(r.replaces), wanted: r.wanted || ''
|
|
66
|
+
})),
|
|
67
|
+
redirects: plan.redirects.map(r => ({from: r.from, to: r.to, action: r.action, reason: r.reason || ''}))
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function wordpressImportRoutes(fastify, opts = {}) {
|
|
72
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
73
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
74
|
+
const may = {preHandler: [authenticate, requirePermission('import', 'wordpress')]};
|
|
75
|
+
const fail = (reply, err, fallback = 400) => reply.code(err.statusCode || fallback).send({error: err.message || String(err)});
|
|
76
|
+
|
|
77
|
+
fastify.get('/status', may, async () => {
|
|
78
|
+
const blog = await blogTarget();
|
|
79
|
+
const job = runningJob();
|
|
80
|
+
return {
|
|
81
|
+
staged: await listStaging(),
|
|
82
|
+
running: job ? {...job} : null,
|
|
83
|
+
history: (await listHistory()).slice(0, 50),
|
|
84
|
+
blog: {available: blog.available, basePath: blog.basePath, comments: Boolean(blog.comments), reason: blog.reason || ''},
|
|
85
|
+
redirects: Boolean(seoEngine()),
|
|
86
|
+
limits: {wxr: WXR_MAX_BYTES, uploads: UPLOADS_ZIP_MAX_BYTES}
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
fastify.get('/count', may, async () => {
|
|
91
|
+
const job = runningJob();
|
|
92
|
+
const runs = await listHistory();
|
|
93
|
+
const staged = await listStaging();
|
|
94
|
+
if (job) {
|
|
95
|
+
return {count: 1, label: `Importing - ${job.phase}`, tone: 'live',
|
|
96
|
+
details: {title: 'Import from WordPress', rows: [{label: 'Progress', value: `${job.done} of ${job.total}`}], items: [], empty: ''}};
|
|
97
|
+
}
|
|
98
|
+
const last = runs[0];
|
|
99
|
+
return {
|
|
100
|
+
count: runs.length,
|
|
101
|
+
label: runs.length ? `${plural(runs.length, 'import')} so far` : 'Nothing imported yet',
|
|
102
|
+
tone: 'info',
|
|
103
|
+
details: {
|
|
104
|
+
title: 'Import from WordPress',
|
|
105
|
+
rows: [
|
|
106
|
+
{label: 'Imports', value: String(runs.length)},
|
|
107
|
+
{label: 'Waiting to run', value: String(staged.length)},
|
|
108
|
+
...(last ? [{label: 'Last', value: ago(last.at)}] : [])
|
|
109
|
+
],
|
|
110
|
+
items: runs.slice(0, 5).map(r => ({text: clip(r.siteTitle || r.source || 'WordPress', 48), meta: `${ago(r.at)}${r.status === 'partial' ? ' · some failed' : ''}`, ...(r.status === 'partial' && {tone: 'danger'})})),
|
|
111
|
+
empty: 'Upload a WordPress export to start.'
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
fastify.post('/upload', may, async (request, reply) => {
|
|
117
|
+
const file = await request.file({limits: {fileSize: WXR_MAX_BYTES + 1, files: 1}}).catch(() => null);
|
|
118
|
+
if (!file) return reply.code(400).send({error: 'Choose the export file to upload.'});
|
|
119
|
+
const name = String(file.filename || '').slice(0, 200);
|
|
120
|
+
const kind = /\.zip$/i.test(name) ? 'zip' : /\.xml$/i.test(name) ? 'xml' : '';
|
|
121
|
+
if (!kind) {
|
|
122
|
+
file.file.resume();
|
|
123
|
+
return reply.code(400).send({error: 'Upload the .xml file WordPress exports (Tools › Export), or a .zip holding it.'});
|
|
124
|
+
}
|
|
125
|
+
try { allowInGitignore(process.cwd(), [IMPORT_RULE]); } catch { /* not a managed site repo */ }
|
|
126
|
+
const id = await createStaging({fileName: name.replace(/[^\w.() -]/g, '_'), kind, by: request.user?.email || ''});
|
|
127
|
+
try {
|
|
128
|
+
const size = await saveUpload(file.file, stagingPath(id, kind === 'zip' ? 'upload.zip' : 'export.xml'), WXR_MAX_BYTES);
|
|
129
|
+
await updateStagingMeta(id, {size});
|
|
130
|
+
return await inspectStaged(id);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
await discardStaged(id);
|
|
133
|
+
if (err.code === 'ETOOBIG') return reply.code(413).send({error: `The export is over ${mb(WXR_MAX_BYTES)}. In WordPress, export the posts and the pages separately and import each.`});
|
|
134
|
+
return fail(reply, err);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
fastify.post('/staged/:id/uploads', may, async (request, reply) => {
|
|
139
|
+
const {id} = request.params;
|
|
140
|
+
const meta = isId(id) ? await stagingMeta(id) : null;
|
|
141
|
+
if (!meta) return reply.code(404).send({error: 'That upload has gone - upload the export again.'});
|
|
142
|
+
if (runningJob()?.staged === id) return reply.code(409).send({error: 'That import is running.'});
|
|
143
|
+
const file = await request.file({limits: {fileSize: UPLOADS_ZIP_MAX_BYTES + 1, files: 1}}).catch(() => null);
|
|
144
|
+
if (!file) return reply.code(400).send({error: 'Choose the uploads zip.'});
|
|
145
|
+
if (!/\.zip$/i.test(file.filename || '')) {
|
|
146
|
+
file.file.resume();
|
|
147
|
+
return reply.code(400).send({error: 'Zip the wp-content/uploads folder and upload the .zip.'});
|
|
148
|
+
}
|
|
149
|
+
const dest = stagingPath(id, 'uploads.zip');
|
|
150
|
+
try {
|
|
151
|
+
const size = await saveUpload(file.file, dest, UPLOADS_ZIP_MAX_BYTES);
|
|
152
|
+
const zip = await openZip(dest);
|
|
153
|
+
const files = zip.entries.length;
|
|
154
|
+
const unsafe = zip.unsafe.length;
|
|
155
|
+
const sample = zip.entries.slice(0, 3).map(e => zipRel(e.name));
|
|
156
|
+
await zip.close();
|
|
157
|
+
if (!files) throw new Error('That zip has no files in it.');
|
|
158
|
+
return await updateStagingMeta(id, {uploads: {fileName: String(file.filename).slice(0, 200).replace(/[^\w.() -]/g, '_'), size, files, unsafe, sample}});
|
|
159
|
+
} catch (err) {
|
|
160
|
+
fs.rmSync(dest, {force: true});
|
|
161
|
+
await updateStagingMeta(id, {uploads: null});
|
|
162
|
+
if (err.code === 'ETOOBIG') return reply.code(413).send({error: `The zip is over ${mb(UPLOADS_ZIP_MAX_BYTES)}. Zip the uploads a few years at a time and import with each.`});
|
|
163
|
+
return fail(reply, err);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
fastify.delete('/staged/:id/uploads', may, async (request, reply) => {
|
|
168
|
+
const {id} = request.params;
|
|
169
|
+
if (!isId(id) || !(await stagingMeta(id))) return reply.code(404).send({error: 'That upload has gone.'});
|
|
170
|
+
fs.rmSync(stagingPath(id, 'uploads.zip'), {force: true});
|
|
171
|
+
return updateStagingMeta(id, {uploads: null});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
fastify.post('/staged/:id/preview', may, async (request, reply) => {
|
|
175
|
+
const {id} = request.params;
|
|
176
|
+
if (!isId(id)) return reply.code(404).send({error: 'That upload has gone.'});
|
|
177
|
+
try {
|
|
178
|
+
const {plan, state, meta} = await previewImport(id, cleanOptions(request.body?.options));
|
|
179
|
+
return {meta, plan: planForScreen(plan, state.blog), redirectsAvailable: state.redirectsAvailable};
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return fail(reply, err);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
fastify.post('/staged/:id/run', may, async (request, reply) => {
|
|
186
|
+
const {id} = request.params;
|
|
187
|
+
if (!isId(id)) return reply.code(404).send({error: 'That upload has gone.'});
|
|
188
|
+
try {
|
|
189
|
+
const job = await startRun(id, cleanOptions(request.body?.options), request.user, fastify.log);
|
|
190
|
+
return reply.code(202).send({job: {...job}});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return fail(reply, err);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
fastify.delete('/staged/:id', may, async (request, reply) => {
|
|
197
|
+
const {id} = request.params;
|
|
198
|
+
if (!isId(id)) return reply.code(404).send({error: 'That upload has gone.'});
|
|
199
|
+
if (runningJob()?.staged === id) return reply.code(409).send({error: 'That import is running.'});
|
|
200
|
+
await discardStaged(id);
|
|
201
|
+
return {ok: true};
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
fastify.get('/jobs/:id', may, async (request, reply) => {
|
|
205
|
+
const job = getJob(String(request.params.id));
|
|
206
|
+
if (!job) return reply.code(404).send({error: 'No such import.'});
|
|
207
|
+
return {job: {...job}};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
fastify.get('/history', may, async () => ({runs: await listHistory()}));
|
|
211
|
+
fastify.get('/history/:id', may, async (request, reply) => {
|
|
212
|
+
const report = await getReport(String(request.params.id));
|
|
213
|
+
if (!report) return reply.code(404).send({error: 'No such import.'});
|
|
214
|
+
return report;
|
|
215
|
+
});
|
|
216
|
+
fastify.delete('/history/:id', may, async (request, reply) => {
|
|
217
|
+
if (!(await removeHistory(String(request.params.id)))) return reply.code(404).send({error: 'No such import.'});
|
|
218
|
+
return {ok: true};
|
|
219
|
+
});
|
|
220
|
+
}
|
package/server/server.js
CHANGED
|
@@ -600,6 +600,15 @@ await app.register(toolGate('contacts', contactsRoutes), {prefix: '/api/contacts
|
|
|
600
600
|
await app.register(seoPlugin, {enabled: isToolEnabled('seo'), getBaseUrl, rolesService});
|
|
601
601
|
}
|
|
602
602
|
await app.register(toolGate('seo', seoRoutes), {prefix: '/api/seo'});
|
|
603
|
+
// Import from WordPress (services/wpImport): its API behind the Tool switch, and - always,
|
|
604
|
+
// whatever the switch - the hook that answers WordPress's query addresses (/?p=12) from what
|
|
605
|
+
// an import made: those redirects are part of the migrated site, not of the Tool.
|
|
606
|
+
{
|
|
607
|
+
const {wordpressImportPlugin} = await import('./services/wpImport/index.js');
|
|
608
|
+
const {wordpressImportRoutes} = await import('./routes/api/wordpress-import.js');
|
|
609
|
+
await app.register(wordpressImportPlugin);
|
|
610
|
+
await app.register(toolGate('wordpress-import', wordpressImportRoutes), {prefix: '/api/import/wordpress'});
|
|
611
|
+
}
|
|
603
612
|
await app.register(toolsRoutes, {prefix: '/api'});
|
|
604
613
|
// What plugins show beside a contact or a note (Calendar's Events) - 0.79.
|
|
605
614
|
await app.register(relatedRoutes, {prefix: '/api/related'});
|
|
@@ -643,6 +652,9 @@ const coreToolItems = [
|
|
|
643
652
|
for (const item of coreToolItems) {
|
|
644
653
|
if (isToolEnabled(item.permission)) registerSidebarItem({item: {source: 'Built in', ...item}});
|
|
645
654
|
}
|
|
655
|
+
if (isToolEnabled('wordpress-import')) {
|
|
656
|
+
registerSidebarItem({item: {source: 'Built in', text: 'Import from WordPress', url: '#/wordpress-import', icon: 'download', permission: 'import', countUrl: '/api/import/wordpress/count', inventory: true}});
|
|
657
|
+
}
|
|
646
658
|
// Todo's to-dos on the calendar (registered when routes/api/todo.js loads).
|
|
647
659
|
if (!isToolEnabled('todo')) {
|
|
648
660
|
const {unregisterCalendarSource} = await import('./services/hooks.js');
|
package/server/services/hooks.js
CHANGED
|
@@ -80,7 +80,7 @@ const TOOL_GROUPS = new Map(Object.entries({
|
|
|
80
80
|
Marketing: ['#/analytics', '#/seo', '#/plugins/analytics-pro/dashboard', '#/plugins/social', '#/plugins/feedback', '#/plugins/surveys'],
|
|
81
81
|
Publishing: ['#/plugins/blog', '#/plugins/blog-pro', '#/plugins/docs', '#/plugins/theme-roller'],
|
|
82
82
|
Business: ['#/plugins/shopping-cart', '#/plugins/booking', '#/plugins/invoice', '#/job-board', '#/plugins/garage'],
|
|
83
|
-
Maintenance: ['#/plugins/security', '#/plugins/security-pro', '#/plugins/security-pro/my-2fa', '#/plugins/backup-pro', '#/plugins/data-transfer']
|
|
83
|
+
Maintenance: ['#/plugins/security', '#/plugins/security-pro', '#/plugins/security-pro/my-2fa', '#/plugins/backup-pro', '#/plugins/data-transfer', '#/wordpress-import']
|
|
84
84
|
}).flatMap(([group, urls]) => urls.map(url => [url, group])));
|
|
85
85
|
|
|
86
86
|
/**
|
|
@@ -251,6 +251,16 @@ export const REGISTRY = [
|
|
|
251
251
|
{key: 'manage', label: 'Manage', description: 'Change page titles and descriptions, redirects and settings'}
|
|
252
252
|
]
|
|
253
253
|
},
|
|
254
|
+
{
|
|
255
|
+
key: 'import',
|
|
256
|
+
label: 'Import',
|
|
257
|
+
description: 'Bring content in from other systems.',
|
|
258
|
+
icon: 'download',
|
|
259
|
+
group: 'Tools',
|
|
260
|
+
actions: [
|
|
261
|
+
{key: 'wordpress', label: 'From WordPress', description: 'Upload a WordPress export, preview it and import pages, posts, media, menus and redirects'}
|
|
262
|
+
]
|
|
263
|
+
},
|
|
254
264
|
{
|
|
255
265
|
key: 'notes',
|
|
256
266
|
label: 'Notes',
|
package/server/services/roles.js
CHANGED
|
@@ -477,7 +477,7 @@ export async function grantPermissionOnce(roleName, permission, source) {
|
|
|
477
477
|
* a later removal in System > Roles sticks. `contacts.manageGroups` is granted
|
|
478
478
|
* the same way, separately (server.js).
|
|
479
479
|
*/
|
|
480
|
-
export const ADMIN_TOOL_GRANTS = ['notes', 'todo', 'contacts', 'analytics', 'seo', 'calculator'];
|
|
480
|
+
export const ADMIN_TOOL_GRANTS = ['notes', 'todo', 'contacts', 'analytics', 'seo', 'calculator', 'import.wordpress'];
|
|
481
481
|
|
|
482
482
|
/**
|
|
483
483
|
* Grant the seeded `admin` role the built-in Tool permissions - once each.
|
package/server/services/tools.js
CHANGED
|
@@ -50,7 +50,8 @@ export const CORE_TOOLS = Object.freeze([
|
|
|
50
50
|
{name: 'todo', displayName: 'Todo', icon: 'check-square', description: 'To-do lists with due dates and reminders.'},
|
|
51
51
|
{name: 'calculator', displayName: 'Calculator', icon: 'calculator', description: 'Basic and scientific calculations, on a page or in a floating panel.'},
|
|
52
52
|
{name: 'analytics', displayName: 'Analytics', icon: 'chart-bar', description: 'Page views and visitors, counted on this server.'},
|
|
53
|
-
{name: 'seo', displayName: 'SEO', icon: 'search', description: 'How the site looks to search engines: an audit of every page, redirects, the sitemap and robots.txt.'}
|
|
53
|
+
{name: 'seo', displayName: 'SEO', icon: 'search', description: 'How the site looks to search engines: an audit of every page, redirects, the sitemap and robots.txt.'},
|
|
54
|
+
{name: 'wordpress-import', displayName: 'Import from WordPress', icon: 'download', description: 'Bring a WordPress site across: pages, posts, media, menus, comments and redirects from the old addresses.'}
|
|
54
55
|
]);
|
|
55
56
|
|
|
56
57
|
const CORE_NAMES = new Set(CORE_TOOLS.map(t => t.name));
|