domma-cms 1.7.0 → 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.
Files changed (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/CLAUDE.md +30 -0
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/mail-compose.js +1 -0
  5. package/admin/js/lib/wordpress-import-arrange.js +1 -0
  6. package/admin/js/templates/call-new.html +1 -1
  7. package/admin/js/templates/docs/usage-tools.html +13 -1
  8. package/admin/js/templates/wordpress-import.html +223 -0
  9. package/admin/js/views/call-new.js +1 -1
  10. package/admin/js/views/contacts.js +15 -15
  11. package/admin/js/views/form-submissions.js +2 -2
  12. package/admin/js/views/index.js +1 -1
  13. package/admin/js/views/wordpress-import.js +38 -0
  14. package/package.json +1 -1
  15. package/plugins/_lib/admin/mail/compose-window.js +3 -1
  16. package/plugins/_lib/admin/mail/identity.js +18 -3
  17. package/plugins/_lib/admin/mail/reader-view.js +32 -3
  18. package/plugins/blog/CLAUDE.md +4 -0
  19. package/plugins/blog/admin/lib/email.js +191 -0
  20. package/plugins/blog/admin/views/comments.js +3 -1
  21. package/plugins/blog/plugin.json +2 -2
  22. package/plugins/blog/plugin.public.js +1 -1
  23. package/plugins/blog/tests/email.test.js +69 -0
  24. package/plugins/booking/CLAUDE.md +4 -0
  25. package/plugins/booking/admin/lib/email.js +191 -0
  26. package/plugins/booking/admin/views/panels.js +7 -3
  27. package/plugins/booking/plugin.json +1 -1
  28. package/plugins/booking/tests/email.test.js +69 -0
  29. package/plugins/free-tier.lock.json +27 -19
  30. package/plugins/shopping-cart/CLAUDE.md +4 -0
  31. package/plugins/shopping-cart/admin/lib/email.js +191 -0
  32. package/plugins/shopping-cart/admin/views/orders.js +11 -3
  33. package/plugins/shopping-cart/plugin.json +2 -2
  34. package/plugins/shopping-cart/tests/email.test.js +69 -0
  35. package/plugins/support/CLAUDE.md +4 -0
  36. package/plugins/support/admin/lib/email.js +191 -0
  37. package/plugins/support/admin/views/desk.js +4 -1
  38. package/plugins/support/admin/views/inbox.js +6 -2
  39. package/plugins/support/plugin.json +2 -2
  40. package/plugins/support/tests/email.test.js +69 -0
  41. package/server/routes/api/wordpress-import.js +220 -0
  42. package/server/server.js +12 -0
  43. package/server/services/hooks.js +1 -1
  44. package/server/services/permissionRegistry.js +10 -0
  45. package/server/services/roles.js +1 -1
  46. package/server/services/tools.js +2 -1
  47. package/server/services/wpImport/convert.js +491 -0
  48. package/server/services/wpImport/fetch.js +264 -0
  49. package/server/services/wpImport/index.js +832 -0
  50. package/server/services/wpImport/plan.js +549 -0
  51. package/server/services/wpImport/store.js +242 -0
  52. package/server/services/wpImport/wxr.js +167 -0
  53. package/server/services/wpImport/xml.js +261 -0
  54. package/server/services/wpImport/zip.js +142 -0
@@ -1,4 +1,4 @@
1
- import{protectToolEditor as bt,mountToolTrash as yt,mountGroupBatch as gt,orphanToolDrafts as kt}from"../lib/tool-recovery.js";import{SHAPES as wt,whileLoading as Et}from"../lib/loading.js";import{apiRequest as St}from"../api.js";import{addBannerAction as Mt}from"../lib/plugin-chrome.js";import{mountRelated as $t,relatedMenuItems as Ct,relatedProviders as Tt}from"../lib/related.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as ye,openMenuAt as Q,promptWith as xt,settingsStore as At,sidebarCount as Nt,viewLifetime as Lt}from"../lib/tool-kit.js";import{arrangeContacts as Ot,countContacts as Bt,DEFAULT_SETTINGS as Ue,duplicateBody as Gt,initials as Pt,normaliseSettings as He,primaryEmail as Ke,primaryPhone as Ye,withGroup as We}from"../lib/contacts-arrange.js";const ze=At("contacts.settings",He),w=(G,d="GET",q)=>St(G,{method:d,...q!==void 0?{body:JSON.stringify(q)}:{}}),ge={company:"Company",jobTitle:"Job title",website:"Website",billingEmail:"Billing email",vatNumber:"VAT number",companyNumber:"Company number",reference:"Their reference",paymentTerms:"Payment terms",currency:"Preferred currency",accountName:"Account name",bankName:"Bank",sortCode:"Sort code",accountNumber:"Account number",iban:"IBAN",bic:"BIC / SWIFT"},Dt=["accountName","bankName","sortCode","accountNumber","iban","bic"],N={addresses:{title:"Addresses",add:"Add address",empty:"No address yet.",help:'Tick "Use for invoices" on the one an invoice should be addressed to.',columns:[{key:"label",placeholder:"Label (Office, Home\u2026)"},{key:"line1",placeholder:"Address line 1",wide:!0},{key:"line2",placeholder:"Address line 2",wide:!0},{key:"city",placeholder:"City"},{key:"region",placeholder:"County / Region"},{key:"postcode",placeholder:"Postcode"},{key:"country",placeholder:"Country"},{key:"isBilling",label:"Use for invoices",check:!0}]},emails:{title:"More email addresses",add:"Add email",empty:"None.",help:"Besides the main email above. Search finds a contact by these too.",columns:[{key:"label",placeholder:"Label (Accounts, Personal\u2026)"},{key:"address",placeholder:"name@example.com",wide:!0}]},phones:{title:"More phone numbers",add:"Add phone",empty:"None.",help:"Besides the main phone above. Call in the menu uses the main one, or the first of these.",columns:[{key:"label",placeholder:"Label (Mobile, Office\u2026)"},{key:"number",placeholder:"+44 \u2026",wide:!0}]}},It=`
1
+ import{protectToolEditor as yt,mountToolTrash as gt,mountGroupBatch as kt,orphanToolDrafts as wt}from"../lib/tool-recovery.js";import{SHAPES as Et,whileLoading as St}from"../lib/loading.js";import{apiRequest as Mt}from"../api.js";import{addBannerAction as $t}from"../lib/plugin-chrome.js";import{mountRelated as Ct,relatedMenuItems as Tt,relatedProviders as xt}from"../lib/related.js";import{helpPopovers as de}from"../lib/help-popover.js";import{onceClosed as ke,openMenuAt as Q,promptWith as At,settingsStore as Nt,sidebarCount as Lt,viewLifetime as Ot}from"../lib/tool-kit.js";import{arrangeContacts as Bt,countContacts as Gt,DEFAULT_SETTINGS as Ke,duplicateBody as Pt,initials as Dt,normaliseSettings as Ye,primaryEmail as ne,primaryPhone as We,withGroup as ze}from"../lib/contacts-arrange.js";import{openEmail as It}from"../lib/mail-compose.js";const _e=Nt("contacts.settings",Ye),$=(G,d="GET",U)=>Mt(G,{method:d,...U!==void 0?{body:JSON.stringify(U)}:{}}),we={company:"Company",jobTitle:"Job title",website:"Website",billingEmail:"Billing email",vatNumber:"VAT number",companyNumber:"Company number",reference:"Their reference",paymentTerms:"Payment terms",currency:"Preferred currency",accountName:"Account name",bankName:"Bank",sortCode:"Sort code",accountNumber:"Account number",iban:"IBAN",bic:"BIC / SWIFT"},Rt=["accountName","bankName","sortCode","accountNumber","iban","bic"],N={addresses:{title:"Addresses",add:"Add address",empty:"No address yet.",help:'Tick "Use for invoices" on the one an invoice should be addressed to.',columns:[{key:"label",placeholder:"Label (Office, Home\u2026)"},{key:"line1",placeholder:"Address line 1",wide:!0},{key:"line2",placeholder:"Address line 2",wide:!0},{key:"city",placeholder:"City"},{key:"region",placeholder:"County / Region"},{key:"postcode",placeholder:"Postcode"},{key:"country",placeholder:"Country"},{key:"isBilling",label:"Use for invoices",check:!0}]},emails:{title:"More email addresses",add:"Add email",empty:"None.",help:"Besides the main email above. Search finds a contact by these too.",columns:[{key:"label",placeholder:"Label (Accounts, Personal\u2026)"},{key:"address",placeholder:"name@example.com",wide:!0}]},phones:{title:"More phone numbers",add:"Add phone",empty:"None.",help:"Besides the main phone above. Call in the menu uses the main one, or the first of these.",columns:[{key:"label",placeholder:"Label (Mobile, Office\u2026)"},{key:"number",placeholder:"+44 \u2026",wide:!0}]}},jt=`
2
2
  <div class="ct-row{{selClass}}" data-id="{{id}}" tabindex="0">
3
3
  <input type="checkbox" class="ct-select" aria-label="Select {{name}}" data-bind-checked="selected">
4
4
  <button type="button" class="ct-fav{{favClass}}" title="{{favTitle}}" aria-label="{{favTitle}}">\u2605</button>
@@ -11,17 +11,17 @@ import{protectToolEditor as bt,mountToolTrash as yt,mountGroupBatch as gt,orphan
11
11
  <span class="ct-col ct-phone">{{phone}}</span>
12
12
  <span class="ct-col ct-groups"><span class="ct-groups-text" title="{{groupsText}}">{{groupsText}}</span></span>
13
13
  <button type="button" class="ct-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
14
- </div>`,Rt='<button type="button" class="ct-chip{{cls}}" data-chip="{{key}}" title="{{title}}">{{label}} <b>{{count}}</b></button>',jt='<button type="button" class="ct-chip{{cls}}" data-group="{{name}}" aria-pressed="{{on}}">{{name}}</button>',qt=`
14
+ </div>`,qt='<button type="button" class="ct-chip{{cls}}" data-chip="{{key}}" title="{{title}}">{{label}} <b>{{count}}</b></button>',Ft='<button type="button" class="ct-chip{{cls}}" data-group="{{name}}" aria-pressed="{{on}}">{{name}}</button>',Ut=`
15
15
  <div class="ct-group-row" data-group="{{name}}" tabindex="0">
16
16
  <span>{{name}}</span><small>{{countText}}</small>
17
17
  <button type="button" class="btn btn-sm btn-ghost" data-act="show">Show</button>
18
18
  <button type="button" class="btn btn-sm btn-ghost" data-act="rename" data-bind-disabled="locked" title="{{lockTitle}}">Rename</button>
19
19
  <button type="button" class="btn btn-sm btn-ghost text-danger" data-act="delete" data-bind-disabled="locked" title="{{lockTitle}}">Delete</button>
20
- </div>`,Ft="Groups are shared by everyone who uses Contacts, so renaming or deleting one changes their contacts too. Only the person who created a group can do that, or someone whose role has Manage groups in Contacts (admins do, unless it was taken away). Groups made before creators were recorded belong to the Manage groups holders. You can still file your own contacts in any group.";function ke(G){const d=document.createElement("template");return d.innerHTML=G.trim(),d.content.firstElementChild}const B=(G,d,q=`${d}s`)=>`${G} ${G===1?d:q}`,h=Lt(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(G){h.reset();const d=G.get(0);let q=!1;h.add(()=>{q=!0});const u=()=>!q,_e=d.querySelector(".ct-loading"),X={editor:null,settings:null,groups:null};let we=[];Tt("contact").then(e=>{u()&&(we=e)});const L=M.observable(ze.read()),p=M.observable([]),g=M.observable([]),ne=M.observable(!0),F=M.observable(!1),se=M.observable(""),Z=M.observable([]),ce=M.observable(null),Ee=M.observable(""),x=M.observable({kind:"all"}),m=M.observable(new Set),Se=M.observable(!1),Me=M.computed(()=>Bt(p.value,g.value)),Je=M.computed(()=>Ot(p.value,{query:Ee.value,filter:x.value,sort:L.value.sort,favouritesFirst:L.value.favouritesFirst})),O=M.observable([]);h.add(M.effect(()=>{const e=m.value;O.value=Je.value.map(t=>({id:t.id,name:t.name||"Unnamed",sub:[t.jobTitle,t.company&&!L.value.showCompany?t.company:""].filter(Boolean).join(" \xB7 "),initials:Pt(t.name),company:t.company||"",email:Ke(t),phone:Ye(t),groupsText:(t.groups||[]).join(", "),favClass:t.favourite===!0?" is-on":"",favTitle:t.favourite===!0?"Starred - click to unstar":"Star this contact",selected:e.has(t.id),selClass:e.has(t.id)?" is-selected":""}))}));const $e=M.observable([]);h.add(M.effect(()=>{const e=Me.value,t=x.value;$e.value=[{key:"all",label:"All",count:e.all,title:"Everyone",cls:t.kind==="all"?" is-active":""},{key:"fav",label:"\u2605 Starred",count:e.fav,title:"Only starred contacts",cls:t.kind==="fav"?" is-active":""},...g.value.map(a=>({key:`group:${a}`,label:a,count:e.groups[a]??0,title:`Only the ${a} group`,cls:t.kind==="group"&&t.value===a?" is-active":""}))]})),h.add(M.effect(()=>{const e=x.value;e.kind==="group"&&!g.value.includes(e.value)&&(x.value={kind:"all"})}));const Ve={loadError:se,rows:O,chips:$e,loading:ne,query:Ee,searchFocus:Se,firstLoad:M.computed(()=>ne.value&&!F.value),hasOrphans:M.computed(()=>Z.value.length>0),orphanText:M.computed(()=>{const e=Z.value;return e.length?e.length===1?`You left unsaved changes to "${e[0].name}", which has since been deleted.`:`You left unsaved changes to ${B(e.length,"contact")} that have since been deleted.`:""}),hasRows:M.computed(()=>O.value.length>0),isEmpty:M.computed(()=>F.value&&!se.value&&O.value.length===0),emptyText:M.computed(()=>p.value.length?"No contacts match. Clear the search or pick All.":"No contacts yet. New contact adds one, or \u22EE > Import brings a file in."),countText:M.computed(()=>{if(!F.value)return"";const e=O.value.length,t=p.value.length;return e===t?B(t,"contact"):`${e} of ${t}`}),hasSelection:M.computed(()=>m.value.size>0),selectionText:M.computed(()=>`${B(m.value.size,"contact")} selected`),allSelected:M.computed(()=>O.value.length>0&&O.value.every(e=>m.value.has(e.id)))};h.add(M.effect(()=>ze.write(L.value)));const Qe=(e,t)=>{L.value=He({...L.peek(),[e]:t})},U=e=>p.peek().find(t=>t.id===e),Xe=e=>{p.value=p.peek().map(t=>t.id===e.id?e:t)};let le=0;async function W(){const e=++le;ne.value=!0;const t=Promise.all([w("/contacts/contacts"),w("/contacts/groups"),et()]);de();try{const[a,n,c]=F.peek()?await t:await Et(_e,t,wt.iconRows);if(!u()||e!==le)return;p.value=Array.isArray(a)?a:a?.data??[],g.value=Array.isArray(n)?n:[],ee=c,se.value="",F.value=!0,Te()}catch{if(!u()||e!==le)return;se.value="Could not load this list. Your items have not been removed.",E.toast("Failed to load contacts.",{type:"error"})}finally{u()&&e===le&&(ne.value=!1)}}async function Ze(){try{const e=await w("/contacts/groups");u()&&(g.value=Array.isArray(e)?e:g.peek())}catch{}}async function de(){try{const e=await w("/contacts/groups/access");u()&&(ce.value=new Map((e?.groups||[]).map(t=>[t.name,t.canManage===!0])))}catch{u()&&(ce.value=new Map)}}const ue=e=>ce.peek()?.get(e)===!0;let ee=null;async function et(){try{const e=await w("/contacts/trash");return new Set((Array.isArray(e)?e:[]).map(t=>t.id))}catch{return null}}const Ce=e=>{if(ee)for(const t of e)ee.add(t)};function Te(){if(F.peek()){if(!ee){Z.value=[];return}Z.value=kt("contacts",[...p.peek().map(e=>e.id),...ee]).map(({id:e,draft:t})=>({id:e,name:String(t.values?.fields?.name||"").trim()||"Unnamed"}))}}h.add(M.effect(()=>{p.value,g.value,F.peek()&&Nt("#/contacts")}));async function te(e,t,a){try{const n=await w(`/contacts/contacts/${e}`,"PUT",t);return u()&&Xe(n),a&&E.toast(a,{type:"success"}),n||null}catch(n){return E.toast(n?.message||"Failed to update the contact.",{type:"error"}),null}}async function xe(e){const t=U(e);if(L.peek().confirmDelete&&!await E.confirm("Move this contact to Trash? You can restore it later."))return!1;try{if(await w(`/contacts/contacts/${e}`,"DELETE"),Ce([e]),u()){p.value=p.peek().filter(n=>n.id!==e);const a=new Set(m.peek());a.delete(e)&&(m.value=a)}return E.toast(`${t?.name?"Contact":"It"} deleted.`,{type:"success"}),!0}catch{return E.toast("Failed to delete the contact.",{type:"error"}),!1}}async function tt(e){const t=U(e);if(t)try{const a=await w("/contacts/contacts","POST",Gt(t));if(!u())return;if(p.value=[...p.peek(),a],X.editor){E.toast("Copied.",{type:"success"});return}E.toast("Copied - the copy is open to edit.",{type:"success"}),H(a.id)}catch{E.toast("Failed to duplicate the contact.",{type:"error"})}}async function ae(e){if(g.peek().includes(e))return!0;try{return await w("/contacts/groups","POST",{name:e}),await Ze(),!0}catch(t){return E.toast(t?.message||"Could not create the group.",{type:"error"}),!1}}async function at(e,t,a){const n=U(e);n&&(a&&!await ae(t)||await te(e,{groups:We(n.groups,t,a)},a?`Added to ${t}.`:`Removed from ${t}.`))}async function Ae(e){const t=String(await E.prompt("New group name:")??"").trim();t&&await Ie.run(e,t)}async function Ne(e,t){try{await navigator.clipboard.writeText(e),E.toast(`${t} copied.`,{type:"success"})}catch{E.toast("The browser would not allow copying.",{type:"error"})}}async function ot(){const e=[...m.peek()];if(e.length&&await E.confirm(`Delete ${B(e.length,"contact")}? You can restore it from Trash.`))try{const t=await w("/contacts/contacts/bulk","DELETE",{ids:e});if(!u())return;const a=t?.deletedIds??[];Ce(a),p.value=p.peek().filter(n=>!a.includes(n.id)),m.value=new Set(t?.failedIds??[]),E.toast(`${B(t?.deleted??e.length,"contact")} moved to Trash${t?.failedIds?.length?` \xB7 ${t.failedIds.length} failed (still selected)`:""}.`,{type:t?.failedIds?.length?"warning":"success"})}catch{E.toast("Bulk delete failed.",{type:"error"})}}async function nt(e){await Ie.run([...m.peek()],e)}const Le=e=>{const t=new Set(m.peek());t.delete(e)||t.add(e),m.value=t};async function st(){try{const e=await w("/contacts/export"),t=new Blob([JSON.stringify(e,null,2)],{type:"application/json"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download="contacts.json",document.body.appendChild(n),n.click(),n.remove(),setTimeout(()=>URL.revokeObjectURL(a),1e3),E.toast("Contacts exported.",{type:"success"})}catch{E.toast("Export failed.",{type:"error"})}}const re=d.querySelector(".ct-import-file");re.addEventListener("change",async()=>{const e=re.files?.[0];if(re.value="",!!e)try{const t=JSON.parse(await e.text()),a=Array.isArray(t)?t:t.contacts;if(!Array.isArray(a))throw new Error("Invalid format");const n=await w("/contacts/import","POST",{contacts:a});E.toast(`Imported ${B(n?.imported??0,"contact")}.`,{type:"success"}),u()&&await W()}catch{E.toast("Import failed. The file must be a contacts JSON export.",{type:"error"})}});function H(e,{draftId:t=null}={}){const a=e?U(e):null;e&&!a||pe("editor",n=>ct(a,t,n))}function pe(e,t){if(X[e]||!u())return;const a={};X[e]=a;const n=()=>{X[e]===a&&(X[e]=null)};try{t(n)}catch(c){n(),E.toast("Could not open that panel.",{type:"error"}),console.error(c)}}function ct(e,t,a){const n=E.slideover({title:e?"Edit contact":t?"New contact (from unsaved changes)":"New contact",size:"lg",position:"right"}),c=(o,l,{type:y="text",mono:f=!1,placeholder:$="",wide:A=!1}={})=>`
20
+ </div>`,Ht="Groups are shared by everyone who uses Contacts, so renaming or deleting one changes their contacts too. Only the person who created a group can do that, or someone whose role has Manage groups in Contacts (admins do, unless it was taken away). Groups made before creators were recorded belong to the Manage groups holders. You can still file your own contacts in any group.";function Ee(G){const d=document.createElement("template");return d.innerHTML=G.trim(),d.content.firstElementChild}const B=(G,d,U=`${d}s`)=>`${G} ${G===1?d:U}`,v=Ot(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(G){v.reset();const d=G.get(0);let U=!1;v.add(()=>{U=!0});const f=()=>!U,Je=d.querySelector(".ct-loading"),X={editor:null,settings:null,groups:null};let Se=[];xt("contact").then(e=>{f()&&(Se=e)});const L=M.observable(_e.read()),h=M.observable([]),w=M.observable([]),se=M.observable(!0),H=M.observable(!1),ce=M.observable(""),Z=M.observable([]),le=M.observable(null),Me=M.observable(""),x=M.observable({kind:"all"}),p=M.observable(new Set),$e=M.observable(!1),Ce=M.computed(()=>Gt(h.value,w.value)),Ve=M.computed(()=>Bt(h.value,{query:Me.value,filter:x.value,sort:L.value.sort,favouritesFirst:L.value.favouritesFirst})),O=M.observable([]);v.add(M.effect(()=>{const e=p.value;O.value=Ve.value.map(t=>({id:t.id,name:t.name||"Unnamed",sub:[t.jobTitle,t.company&&!L.value.showCompany?t.company:""].filter(Boolean).join(" \xB7 "),initials:Dt(t.name),company:t.company||"",email:ne(t),phone:We(t),groupsText:(t.groups||[]).join(", "),favClass:t.favourite===!0?" is-on":"",favTitle:t.favourite===!0?"Starred - click to unstar":"Star this contact",selected:e.has(t.id),selClass:e.has(t.id)?" is-selected":""}))}));const Te=M.observable([]);v.add(M.effect(()=>{const e=Ce.value,t=x.value;Te.value=[{key:"all",label:"All",count:e.all,title:"Everyone",cls:t.kind==="all"?" is-active":""},{key:"fav",label:"\u2605 Starred",count:e.fav,title:"Only starred contacts",cls:t.kind==="fav"?" is-active":""},...w.value.map(a=>({key:`group:${a}`,label:a,count:e.groups[a]??0,title:`Only the ${a} group`,cls:t.kind==="group"&&t.value===a?" is-active":""}))]})),v.add(M.effect(()=>{const e=x.value;e.kind==="group"&&!w.value.includes(e.value)&&(x.value={kind:"all"})}));const Qe={loadError:ce,rows:O,chips:Te,loading:se,query:Me,searchFocus:$e,firstLoad:M.computed(()=>se.value&&!H.value),hasOrphans:M.computed(()=>Z.value.length>0),orphanText:M.computed(()=>{const e=Z.value;return e.length?e.length===1?`You left unsaved changes to "${e[0].name}", which has since been deleted.`:`You left unsaved changes to ${B(e.length,"contact")} that have since been deleted.`:""}),hasRows:M.computed(()=>O.value.length>0),isEmpty:M.computed(()=>H.value&&!ce.value&&O.value.length===0),emptyText:M.computed(()=>h.value.length?"No contacts match. Clear the search or pick All.":"No contacts yet. New contact adds one, or \u22EE > Import brings a file in."),countText:M.computed(()=>{if(!H.value)return"";const e=O.value.length,t=h.value.length;return e===t?B(t,"contact"):`${e} of ${t}`}),hasSelection:M.computed(()=>p.value.size>0),selectionText:M.computed(()=>`${B(p.value.size,"contact")} selected`),allSelected:M.computed(()=>O.value.length>0&&O.value.every(e=>p.value.has(e.id)))};v.add(M.effect(()=>_e.write(L.value)));const Xe=(e,t)=>{L.value=Ye({...L.peek(),[e]:t})},P=e=>h.peek().find(t=>t.id===e),Ze=e=>{h.value=h.peek().map(t=>t.id===e.id?e:t)};let re=0;async function z(){const e=++re;se.value=!0;const t=Promise.all([$("/contacts/contacts"),$("/contacts/groups"),tt()]);ue();try{const[a,n,c]=H.peek()?await t:await St(Je,t,Et.iconRows);if(!f()||e!==re)return;h.value=Array.isArray(a)?a:a?.data??[],w.value=Array.isArray(n)?n:[],ee=c,ce.value="",H.value=!0,Ae()}catch{if(!f()||e!==re)return;ce.value="Could not load this list. Your items have not been removed.",E.toast("Failed to load contacts.",{type:"error"})}finally{f()&&e===re&&(se.value=!1)}}async function et(){try{const e=await $("/contacts/groups");f()&&(w.value=Array.isArray(e)?e:w.peek())}catch{}}async function ue(){try{const e=await $("/contacts/groups/access");f()&&(le.value=new Map((e?.groups||[]).map(t=>[t.name,t.canManage===!0])))}catch{f()&&(le.value=new Map)}}const pe=e=>le.peek()?.get(e)===!0;let ee=null;async function tt(){try{const e=await $("/contacts/trash");return new Set((Array.isArray(e)?e:[]).map(t=>t.id))}catch{return null}}const xe=e=>{if(ee)for(const t of e)ee.add(t)};function Ae(){if(H.peek()){if(!ee){Z.value=[];return}Z.value=wt("contacts",[...h.peek().map(e=>e.id),...ee]).map(({id:e,draft:t})=>({id:e,name:String(t.values?.fields?.name||"").trim()||"Unnamed"}))}}v.add(M.effect(()=>{h.value,w.value,H.peek()&&Lt("#/contacts")}));async function te(e,t,a){try{const n=await $(`/contacts/contacts/${e}`,"PUT",t);return f()&&Ze(n),a&&E.toast(a,{type:"success"}),n||null}catch(n){return E.toast(n?.message||"Failed to update the contact.",{type:"error"}),null}}async function Ne(e){const t=P(e);if(L.peek().confirmDelete&&!await E.confirm("Move this contact to Trash? You can restore it later."))return!1;try{if(await $(`/contacts/contacts/${e}`,"DELETE"),xe([e]),f()){h.value=h.peek().filter(n=>n.id!==e);const a=new Set(p.peek());a.delete(e)&&(p.value=a)}return E.toast(`${t?.name?"Contact":"It"} deleted.`,{type:"success"}),!0}catch{return E.toast("Failed to delete the contact.",{type:"error"}),!1}}async function at(e){const t=P(e);if(t)try{const a=await $("/contacts/contacts","POST",Pt(t));if(!f())return;if(h.value=[...h.peek(),a],X.editor){E.toast("Copied.",{type:"success"});return}E.toast("Copied - the copy is open to edit.",{type:"success"}),K(a.id)}catch{E.toast("Failed to duplicate the contact.",{type:"error"})}}async function ae(e){if(w.peek().includes(e))return!0;try{return await $("/contacts/groups","POST",{name:e}),await et(),!0}catch(t){return E.toast(t?.message||"Could not create the group.",{type:"error"}),!1}}async function ot(e,t,a){const n=P(e);n&&(a&&!await ae(t)||await te(e,{groups:ze(n.groups,t,a)},a?`Added to ${t}.`:`Removed from ${t}.`))}async function Le(e){const t=String(await E.prompt("New group name:")??"").trim();t&&await je.run(e,t)}async function Oe(e,t){try{await navigator.clipboard.writeText(e),E.toast(`${t} copied.`,{type:"success"})}catch{E.toast("The browser would not allow copying.",{type:"error"})}}async function nt(){const e=[...p.peek()];if(e.length&&await E.confirm(`Delete ${B(e.length,"contact")}? You can restore it from Trash.`))try{const t=await $("/contacts/contacts/bulk","DELETE",{ids:e});if(!f())return;const a=t?.deletedIds??[];xe(a),h.value=h.peek().filter(n=>!a.includes(n.id)),p.value=new Set(t?.failedIds??[]),E.toast(`${B(t?.deleted??e.length,"contact")} moved to Trash${t?.failedIds?.length?` \xB7 ${t.failedIds.length} failed (still selected)`:""}.`,{type:t?.failedIds?.length?"warning":"success"})}catch{E.toast("Bulk delete failed.",{type:"error"})}}async function st(e){await je.run([...p.peek()],e)}const Be=e=>{const t=new Set(p.peek());t.delete(e)||t.add(e),p.value=t};async function ct(){try{const e=await $("/contacts/export"),t=new Blob([JSON.stringify(e,null,2)],{type:"application/json"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download="contacts.json",document.body.appendChild(n),n.click(),n.remove(),setTimeout(()=>URL.revokeObjectURL(a),1e3),E.toast("Contacts exported.",{type:"success"})}catch{E.toast("Export failed.",{type:"error"})}}const ie=d.querySelector(".ct-import-file");ie.addEventListener("change",async()=>{const e=ie.files?.[0];if(ie.value="",!!e)try{const t=JSON.parse(await e.text()),a=Array.isArray(t)?t:t.contacts;if(!Array.isArray(a))throw new Error("Invalid format");const n=await $("/contacts/import","POST",{contacts:a});E.toast(`Imported ${B(n?.imported??0,"contact")}.`,{type:"success"}),f()&&await z()}catch{E.toast("Import failed. The file must be a contacts JSON export.",{type:"error"})}});function K(e,{draftId:t=null}={}){const a=e?P(e):null;e&&!a||me("editor",n=>lt(a,t,n))}function me(e,t){if(X[e]||!f())return;const a={};X[e]=a;const n=()=>{X[e]===a&&(X[e]=null)};try{t(n)}catch(c){n(),E.toast("Could not open that panel.",{type:"error"}),console.error(c)}}function lt(e,t,a){const n=E.slideover({title:e?"Edit contact":t?"New contact (from unsaved changes)":"New contact",size:"lg",position:"right"}),c=(o,l,{type:k="text",mono:b=!1,placeholder:C="",wide:A=!1}={})=>`
21
21
  <div${A?' class="ct-wide"':""}>
22
22
  <label class="form-label" for="ct-f-${o}">${l}</label>
23
- <input id="ct-f-${o}" type="${y}" class="form-input${f?" font-mono":""}" placeholder="${$}" data-model="${o}.value">
24
- </div>`,k=o=>`
23
+ <input id="ct-f-${o}" type="${k}" class="form-input${b?" font-mono":""}" placeholder="${C}" data-model="${o}.value">
24
+ </div>`,S=o=>`
25
25
  <div class="ct-repeater" data-repeater="${o}">
26
26
  <div class="ct-subhead">
27
27
  <strong>${N[o].title} <span data-help="${N[o].help}"></span></strong>
@@ -30,7 +30,7 @@ import{protectToolEditor as bt,mountToolTrash as yt,mountGroupBatch as gt,orphan
30
30
  </div>
31
31
  <div class="ct-repeater-list"></div>
32
32
  <p class="text-muted text-sm contacts-empty-row">${N[o].empty}</p>
33
- </div>`,i=ke(`
33
+ </div>`,r=Ee(`
34
34
  <div class="ct-panel">
35
35
  <div class="ct-grid">
36
36
  <div class="ct-wide">
@@ -98,15 +98,15 @@ import{protectToolEditor as bt,mountToolTrash as yt,mountGroupBatch as gt,orphan
98
98
  ${c("bic","BIC / SWIFT",{mono:!0,placeholder:"NWBKGB2L"})}
99
99
  </div>
100
100
  </div>
101
- ${k("addresses")}
101
+ ${S("addresses")}
102
102
  </div>
103
103
  </details>
104
104
 
105
105
  <details class="ct-section">
106
106
  <summary>More emails &amp; phones <span class="ct-section-sum" data-bind-text="extraSummary.value"></span></summary>
107
107
  <div class="ct-section-body">
108
- ${k("emails")}
109
- ${k("phones")}
108
+ ${S("emails")}
109
+ ${S("phones")}
110
110
  </div>
111
111
  </details>
112
112
 
@@ -121,11 +121,11 @@ import{protectToolEditor as bt,mountToolTrash as yt,mountGroupBatch as gt,orphan
121
121
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannotSave.value">
122
122
  <span data-icon="save" data-icon-size="13"></span> Save</button>
123
123
  </div>
124
- </div>`),S=o=>o==null?"":String(o),s={name:M.observable(S(e?.name)),email:M.observable(S(e?.email)),phone:M.observable(S(e?.phone)),notes:M.observable(S(e?.notes)),favourite:M.observable(e?.favourite===!0),chosen:M.observable(Array.isArray(e?.groups)?[...e.groups]:[]),isEdit:!!e,meta:e?`Added ${e.createdAt?D(e.createdAt).format("D MMM YYYY"):"earlier"}${e.updatedAt?`, last changed ${D(e.updatedAt).format("D MMM YYYY HH:mm")}`:""}. Ctrl+Enter saves.`:"Ctrl+Enter saves."};for(const o of Object.keys(ge))s[o]=M.observable(S(e?.[o]));const C=Object.fromEntries(Object.keys(N).map(o=>[o,M.observable(0)]));s.pick=M.observable([]);const z=M.effect(()=>{const o=s.chosen.value;s.pick.value=[...new Set([...g.value,...o])].map(l=>({name:l,on:String(o.includes(l)),cls:o.includes(l)?" is-active":""}))});s.noGroups=M.computed(()=>s.pick.value.length===0),s.cannotSave=M.computed(()=>!s.name.value.trim()),s.billingSummary=M.computed(()=>{const o=[];s.company.value.trim()&&o.push(s.company.value.trim()),s.vatNumber.value.trim()&&o.push("VAT"),(s.sortCode.value.trim()||s.iban.value.trim())&&o.push("bank details");const l=C.addresses.value;return l&&o.push(B(l,"address","addresses")),o.join(" \xB7 ")}),s.extraSummary=M.computed(()=>{const o=C.emails.value+C.phones.value;return o?B(o,"more"):""});function _(o,l){const y=N[o],f=i.querySelector(`[data-repeater="${o}"]`),$=f.querySelector(".ct-repeater-list");$.replaceChildren(),f.querySelector(".contacts-empty-row").style.display=l.length?"none":"",l.forEach((A,P)=>{const R=document.createElement("div");R.className="contacts-row";for(const T of y.columns){if(T.check){const ve=document.createElement("label");ve.className="form-check contacts-row-check";const j=document.createElement("input");j.type="checkbox",j.className="form-check-input",j.dataset.key=T.key,j.checked=A[T.key]===!0,j.addEventListener("change",()=>{j.checked&&$.querySelectorAll(`input[data-key="${T.key}"]`).forEach(Fe=>{Fe!==j&&(Fe.checked=!1)})});const be=document.createElement("span");be.className="form-check-label",be.textContent=T.label,ve.append(j,be),R.appendChild(ve);continue}const V=document.createElement("input");V.type="text",V.className="form-input"+(T.wide?" contacts-row-wide":""),V.dataset.key=T.key,V.placeholder=T.placeholder??"",V.value=A[T.key]??"",R.appendChild(V)}const J=document.createElement("button");J.type="button",J.className="btn btn-sm btn-ghost contacts-row-remove",J.title="Remove",J.textContent="\xD7",J.addEventListener("click",()=>{const T=r(o,{keepBlank:!0});T.splice(P,1),_(o,T)}),R.appendChild(J),$.appendChild(R)}),C[o].value=l.length}function r(o,{keepBlank:l=!1}={}){const y=N[o],f=[...i.querySelectorAll(`[data-repeater="${o}"] .contacts-row`)].map($=>{const A={};for(const P of y.columns){const R=$.querySelector(`[data-key="${P.key}"]`);A[P.key]=P.check?R?.checked===!0:(R?.value??"").trim()}return A});return l?f:f.filter($=>Object.entries($).some(([A,P])=>A!=="label"&&P!==""&&P!==!1))}i.addEventListener("click",o=>{const l=o.target.closest("[data-add]");if(l){const f=l.dataset.add;_(f,[...r(f,{keepBlank:!0}),{}]),i.querySelector(`[data-repeater="${f}"] .contacts-row:last-child input`)?.focus();return}const y=o.target.closest(".ct-pick [data-group]");if(y){const f=y.dataset.group,$=s.chosen.peek();s.chosen.value=$.includes(f)?$.filter(A=>A!==f):[...$,f]}});const v=()=>{const o={name:s.name.peek().trim(),email:s.email.peek().trim(),phone:s.phone.peek().trim(),notes:s.notes.peek().trim(),favourite:s.favourite.peek()===!0,groups:[...s.chosen.peek()]};for(const l of Object.keys(ge))o[l]=String(s[l].peek()??"").trim();for(const l of Object.keys(N))o[l]=r(l);return o};let Y=!1,b=e?.id??null;const oe=o=>{i.inert=o,i.classList.toggle("is-saving",o)},je=async()=>{if(Y||s.cannotSave.peek())return;Y=!0,oe(!0);let o=!1;try{const l=v();for(const f of l.groups)g.peek().includes(f)||await ae(f);let y;if(b){if(y=await te(b,l,"Contact saved."),!y)return}else y=await w("/contacts/contacts","POST",l),b=y.id,u()&&(p.value=[...p.peek(),y]),E.toast("Contact added.",{type:"success"});he.saved({id:y.id??b,was:String(y.updatedAt??"")}),o=await n.close()!==!1}catch(l){E.toast(l?.message||"Failed to save the contact.",{type:"error"})}finally{Y=!1,o||oe(!1)}};n.element.appendChild(i),e&&$t(i.querySelector(".related-box"),"contact",e.id,{onOpen:()=>n.close(),name:e.name});const ht=ie(i);for(const o of Object.keys(N))_(o,Array.isArray(e?.[o])?e[o]:[]);i.querySelector(".ct-pick").innerHTML=jt;const vt=M.applyBindings(s,i,{methods:{save:je,cancel:()=>n.close(),del:async()=>{e&&await xe(e.id)&&(he?.discard(),n.close())},async newGroup(){const o=String(await E.prompt("New group name:")??"").trim();!o||!await ae(o)||s.chosen.peek().includes(o)||(s.chosen.value=[...s.chosen.peek(),o])}}});s.billingSummary.peek()&&(i.querySelector(".ct-section").open=!0),i.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),je())}),I.scan(i),ye(n,()=>{z?.(),vt.dispose(),ht(),a(),u()&&setTimeout(()=>{u()&&Te()},0)});const qe=["name","email","phone","notes","favourite","chosen",...Object.keys(ge)],he=bt({so:n,panel:i,tool:"contacts",id:e?.id??t,lifetime:h,was:e?String(e.updatedAt??""):void 0,gone:!!t,noun:"contact",persist:o=>{for(const l of Dt)delete o.fields?.[l];return o},read:()=>({fields:Object.fromEntries(qe.map(o=>[o,s[o].peek()])),lists:Object.fromEntries(Object.keys(N).map(o=>[o,r(o,{keepBlank:!0})]))}),write:o=>{for(const l of qe)l in(o.fields||{})&&(s[l].value=o.fields[l]);for(const l of Object.keys(N))_(l,Array.isArray(o.lists?.[l])?o.lists[l]:[]);s.billingSummary.peek()&&(i.querySelector(".ct-section").open=!0)}});t&&he.asked.then(o=>{o==="thrown"&&n.close()}),n.open(),setTimeout(()=>i.querySelector("#ct-f-name")?.focus(),60)}function lt(){pe("settings",rt)}function rt(e){const t=E.slideover({title:"Contacts settings",size:"sm",position:"right"}),a=(s,C,z)=>`
124
+ </div>`),m=o=>o==null?"":String(o),s={name:M.observable(m(e?.name)),email:M.observable(m(e?.email)),phone:M.observable(m(e?.phone)),notes:M.observable(m(e?.notes)),favourite:M.observable(e?.favourite===!0),chosen:M.observable(Array.isArray(e?.groups)?[...e.groups]:[]),isEdit:!!e,meta:e?`Added ${e.createdAt?D(e.createdAt).format("D MMM YYYY"):"earlier"}${e.updatedAt?`, last changed ${D(e.updatedAt).format("D MMM YYYY HH:mm")}`:""}. Ctrl+Enter saves.`:"Ctrl+Enter saves."};for(const o of Object.keys(we))s[o]=M.observable(m(e?.[o]));const u=Object.fromEntries(Object.keys(N).map(o=>[o,M.observable(0)]));s.pick=M.observable([]);const R=M.effect(()=>{const o=s.chosen.value;s.pick.value=[...new Set([...w.value,...o])].map(l=>({name:l,on:String(o.includes(l)),cls:o.includes(l)?" is-active":""}))});s.noGroups=M.computed(()=>s.pick.value.length===0),s.cannotSave=M.computed(()=>!s.name.value.trim()),s.billingSummary=M.computed(()=>{const o=[];s.company.value.trim()&&o.push(s.company.value.trim()),s.vatNumber.value.trim()&&o.push("VAT"),(s.sortCode.value.trim()||s.iban.value.trim())&&o.push("bank details");const l=u.addresses.value;return l&&o.push(B(l,"address","addresses")),o.join(" \xB7 ")}),s.extraSummary=M.computed(()=>{const o=u.emails.value+u.phones.value;return o?B(o,"more"):""});function _(o,l){const k=N[o],b=r.querySelector(`[data-repeater="${o}"]`),C=b.querySelector(".ct-repeater-list");C.replaceChildren(),b.querySelector(".contacts-empty-row").style.display=l.length?"none":"",l.forEach((A,j)=>{const q=document.createElement("div");q.className="contacts-row";for(const T of k.columns){if(T.check){const ye=document.createElement("label");ye.className="form-check contacts-row-check";const F=document.createElement("input");F.type="checkbox",F.className="form-check-input",F.dataset.key=T.key,F.checked=A[T.key]===!0,F.addEventListener("change",()=>{F.checked&&C.querySelectorAll(`input[data-key="${T.key}"]`).forEach(He=>{He!==F&&(He.checked=!1)})});const ge=document.createElement("span");ge.className="form-check-label",ge.textContent=T.label,ye.append(F,ge),q.appendChild(ye);continue}const V=document.createElement("input");V.type="text",V.className="form-input"+(T.wide?" contacts-row-wide":""),V.dataset.key=T.key,V.placeholder=T.placeholder??"",V.value=A[T.key]??"",q.appendChild(V)}const J=document.createElement("button");J.type="button",J.className="btn btn-sm btn-ghost contacts-row-remove",J.title="Remove",J.textContent="\xD7",J.addEventListener("click",()=>{const T=i(o,{keepBlank:!0});T.splice(j,1),_(o,T)}),q.appendChild(J),C.appendChild(q)}),u[o].value=l.length}function i(o,{keepBlank:l=!1}={}){const k=N[o],b=[...r.querySelectorAll(`[data-repeater="${o}"] .contacts-row`)].map(C=>{const A={};for(const j of k.columns){const q=C.querySelector(`[data-key="${j.key}"]`);A[j.key]=j.check?q?.checked===!0:(q?.value??"").trim()}return A});return l?b:b.filter(C=>Object.entries(C).some(([A,j])=>A!=="label"&&j!==""&&j!==!1))}r.addEventListener("click",o=>{const l=o.target.closest("[data-add]");if(l){const b=l.dataset.add;_(b,[...i(b,{keepBlank:!0}),{}]),r.querySelector(`[data-repeater="${b}"] .contacts-row:last-child input`)?.focus();return}const k=o.target.closest(".ct-pick [data-group]");if(k){const b=k.dataset.group,C=s.chosen.peek();s.chosen.value=C.includes(b)?C.filter(A=>A!==b):[...C,b]}});const y=()=>{const o={name:s.name.peek().trim(),email:s.email.peek().trim(),phone:s.phone.peek().trim(),notes:s.notes.peek().trim(),favourite:s.favourite.peek()===!0,groups:[...s.chosen.peek()]};for(const l of Object.keys(we))o[l]=String(s[l].peek()??"").trim();for(const l of Object.keys(N))o[l]=i(l);return o};let W=!1,g=e?.id??null;const oe=o=>{r.inert=o,r.classList.toggle("is-saving",o)},Fe=async()=>{if(W||s.cannotSave.peek())return;W=!0,oe(!0);let o=!1;try{const l=y();for(const b of l.groups)w.peek().includes(b)||await ae(b);let k;if(g){if(k=await te(g,l,"Contact saved."),!k)return}else k=await $("/contacts/contacts","POST",l),g=k.id,f()&&(h.value=[...h.peek(),k]),E.toast("Contact added.",{type:"success"});ve.saved({id:k.id??g,was:String(k.updatedAt??"")}),o=await n.close()!==!1}catch(l){E.toast(l?.message||"Failed to save the contact.",{type:"error"})}finally{W=!1,o||oe(!1)}};n.element.appendChild(r),e&&Ct(r.querySelector(".related-box"),"contact",e.id,{onOpen:()=>n.close(),name:e.name});const bt=de(r);for(const o of Object.keys(N))_(o,Array.isArray(e?.[o])?e[o]:[]);r.querySelector(".ct-pick").innerHTML=Ft;const vt=M.applyBindings(s,r,{methods:{save:Fe,cancel:()=>n.close(),del:async()=>{e&&await Ne(e.id)&&(ve?.discard(),n.close())},async newGroup(){const o=String(await E.prompt("New group name:")??"").trim();!o||!await ae(o)||s.chosen.peek().includes(o)||(s.chosen.value=[...s.chosen.peek(),o])}}});s.billingSummary.peek()&&(r.querySelector(".ct-section").open=!0),r.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),Fe())}),I.scan(r),ke(n,()=>{R?.(),vt.dispose(),bt(),a(),f()&&setTimeout(()=>{f()&&Ae()},0)});const Ue=["name","email","phone","notes","favourite","chosen",...Object.keys(we)],ve=yt({so:n,panel:r,tool:"contacts",id:e?.id??t,lifetime:v,was:e?String(e.updatedAt??""):void 0,gone:!!t,noun:"contact",persist:o=>{for(const l of Rt)delete o.fields?.[l];return o},read:()=>({fields:Object.fromEntries(Ue.map(o=>[o,s[o].peek()])),lists:Object.fromEntries(Object.keys(N).map(o=>[o,i(o,{keepBlank:!0})]))}),write:o=>{for(const l of Ue)l in(o.fields||{})&&(s[l].value=o.fields[l]);for(const l of Object.keys(N))_(l,Array.isArray(o.lists?.[l])?o.lists[l]:[]);s.billingSummary.peek()&&(r.querySelector(".ct-section").open=!0)}});t&&ve.asked.then(o=>{o==="thrown"&&n.close()}),n.open(),setTimeout(()=>r.querySelector("#ct-f-name")?.focus(),60)}function rt(){me("settings",it)}function it(e){const t=E.slideover({title:"Contacts settings",size:"sm",position:"right"}),a=(s,u,R)=>`
125
125
  <div class="ct-setting-row">
126
- <label class="form-label" for="ct-set-${s}" style="margin:0">${C}${z?` <span data-help="${z}"></span>`:""}</label>
126
+ <label class="form-label" for="ct-set-${s}" style="margin:0">${u}${R?` <span data-help="${R}"></span>`:""}</label>
127
127
  <input id="ct-set-${s}" type="checkbox" class="form-switch" data-model="${s}.value">
128
- </div>`,n=ke(`
128
+ </div>`,n=Ee(`
129
129
  <div class="ct-panel">
130
130
  <div>
131
131
  <label class="form-label" for="ct-set-sort">Sort by</label>
@@ -160,7 +160,7 @@ import{protectToolEditor as bt,mountToolTrash as yt,mountGroupBatch as gt,orphan
160
160
  <span class="spacer"></span>
161
161
  <button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
162
162
  </div>
163
- </div>`),c=s=>M.computed({read:()=>L.value[s],write:C=>Qe(s,C)}),k=Object.fromEntries(Object.keys(Ue).map(s=>[s,c(s)]));t.element.appendChild(n);const i=ie(n),S=M.applyBindings(k,n,{methods:{done:()=>t.close(),reset:()=>{L.value={...Ue}}}});ye(t,()=>{S.dispose(),i(),e()}),t.open()}async function Oe(e){if(!ue(e))return;const t=String(await xt("Rename the group to:",{},e)??"").trim();if(!(!t||t===e||!u()))try{await w(`/contacts/groups/${encodeURIComponent(e)}`,"PUT",{newName:t}),u()&&await W(),E.toast("Group renamed.",{type:"success"})}catch(a){E.toast(a?.message||"Failed to rename the group.",{type:"error"})}}async function Be(e){if(ue(e)&&!(!await E.confirm("Delete this group? Its contacts stay - they are just no longer in it.",{title:"Delete group",confirmText:"Delete group"})||!u()))try{await w(`/contacts/groups/${encodeURIComponent(e)}`,"DELETE"),u()&&await W(),E.toast("Group deleted.",{type:"success"})}catch(t){E.toast(t?.message||"Failed to delete the group.",{type:"error"})}}function Ge(e,{panel:t=null}={}){const a=!ue(e),n=a?" (not yours to change)":"",c=p.peek().filter(k=>(k.groups||[]).includes(e)).map(k=>k.id);return[{label:"Show only this group",icon:"filter",action:()=>{x.value={kind:"group",value:e},t?.close()}},{label:`Select its ${B(c.length,"contact")}`,icon:"check-square",disabled:!c.length,action:()=>{x.value={kind:"group",value:e},m.value=new Set(c),t?.close()}},{type:"divider"},{label:`Rename\u2026${n}`,icon:"edit",disabled:a,action:()=>Oe(e)},{label:`Delete\u2026${n}`,icon:"trash",danger:!0,disabled:a,action:()=>Be(e)},...t?[]:[{type:"divider"},{label:"Manage groups\u2026",icon:"folder",action:me}]]}function me(){pe("groups",it)}function it(e){const t=E.slideover({title:"Groups",size:"sm",position:"right"}),a=ke(`
163
+ </div>`),c=s=>M.computed({read:()=>L.value[s],write:u=>Xe(s,u)}),S=Object.fromEntries(Object.keys(Ke).map(s=>[s,c(s)]));t.element.appendChild(n);const r=de(n),m=M.applyBindings(S,n,{methods:{done:()=>t.close(),reset:()=>{L.value={...Ke}}}});ke(t,()=>{m.dispose(),r(),e()}),t.open()}async function Ge(e){if(!pe(e))return;const t=String(await At("Rename the group to:",{},e)??"").trim();if(!(!t||t===e||!f()))try{await $(`/contacts/groups/${encodeURIComponent(e)}`,"PUT",{newName:t}),f()&&await z(),E.toast("Group renamed.",{type:"success"})}catch(a){E.toast(a?.message||"Failed to rename the group.",{type:"error"})}}async function Pe(e){if(pe(e)&&!(!await E.confirm("Delete this group? Its contacts stay - they are just no longer in it.",{title:"Delete group",confirmText:"Delete group"})||!f()))try{await $(`/contacts/groups/${encodeURIComponent(e)}`,"DELETE"),f()&&await z(),E.toast("Group deleted.",{type:"success"})}catch(t){E.toast(t?.message||"Failed to delete the group.",{type:"error"})}}function fe(e,t={}){const a=e.map(n=>({name:n.name,address:ne(n)})).filter(n=>n.address);a.length&&It(a.length===1?{to:a,...t}:{bcc:a,...t})}function De(e,{panel:t=null}={}){const a=!pe(e),n=a?" (not yours to change)":"",c=h.peek().filter(m=>(m.groups||[]).includes(e)),S=c.map(m=>m.id),r=c.filter(m=>ne(m));return[{label:"Show only this group",icon:"filter",action:()=>{x.value={kind:"group",value:e},t?.close()}},{label:`Select its ${B(S.length,"contact")}`,icon:"check-square",disabled:!S.length,action:()=>{x.value={kind:"group",value:e},p.value=new Set(S),t?.close()}},{label:"Email this group\u2026",icon:"mail",disabled:!r.length,action:()=>{t?.close(),fe(r)}},{type:"divider"},{label:`Rename\u2026${n}`,icon:"edit",disabled:a,action:()=>Ge(e)},{label:`Delete\u2026${n}`,icon:"trash",danger:!0,disabled:a,action:()=>Pe(e)},...t?[]:[{type:"divider"},{label:"Manage groups\u2026",icon:"folder",action:he}]]}function he(){me("groups",dt)}function dt(e){const t=E.slideover({title:"Groups",size:"sm",position:"right"}),a=Ee(`
164
164
  <div class="ct-panel">
165
165
  <form class="ct-add-group" data-on-submit="add">
166
166
  <input type="text" class="form-input" placeholder="New group name\u2026" aria-label="New group name" data-model="draft.value">
@@ -169,9 +169,9 @@ import{protectToolEditor as bt,mountToolTrash as yt,mountGroupBatch as gt,orphan
169
169
  <p class="ct-panel-meta" style="margin:0">Renaming a group renames it on every contact in it - everyone's, since groups are shared; deleting one takes it off them.
170
170
  <span data-help="Contacts themselves are never deleted from here. Right-click a group (or Shift+F10 on it) for the same actions."></span></p>
171
171
  <p class="ct-panel-meta" style="margin:0" data-bind-hidden="!someLocked.value">Some groups cannot be renamed or deleted by you.
172
- <span data-help-title="Why is Rename greyed out?" data-help="${Ft}"></span></p>
172
+ <span data-help-title="Why is Rename greyed out?" data-help="${Ht}"></span></p>
173
173
  <div class="ct-groups-list" data-if="hasGroups.value">
174
174
  <div class="ct-groups-rows" data-each="list.value key=name"></div>
175
175
  </div>
176
176
  <p class="text-muted" data-if="noGroups.value">No groups yet. Add one above.</p>
177
- </div>`),n=M.observable(""),c={draft:n,list:M.observable([]),someLocked:M.observable(!1)};let k=!1;de();const i=M.effect(()=>{const r=Me.value.groups,v=ce.value,Y=g.value.map(b=>{const oe=!(v&&v.get(b)===!0);return{name:b,countText:B(r[b]??0,"contact"),locked:oe,lockTitle:oe?"Only its creator, or someone allowed to manage every group, can change this group":""}});c.list.value=Y,c.someLocked.value=v!==null&&Y.some(b=>b.locked)});c.hasGroups=M.computed(()=>c.list.value.length>0),c.noGroups=M.computed(()=>c.list.value.length===0),c.cannotAdd=M.computed(()=>!n.value.trim());let S=!1;async function s(){const r=n.peek().trim();if(!(!r||S)){S=!0;try{await ae(r)&&(await de(),!k&&n.peek().trim()===r&&(n.value=""),E.toast("Group added.",{type:"success"}))}finally{S=!1}}}a.addEventListener("click",r=>{const v=r.target.closest("[data-act]"),b=r.target.closest(".ct-group-row")?.dataset.group;if(b){if(!v){x.value={kind:"group",value:b},t.close();return}v.disabled||(v.dataset.act==="show"?(x.value={kind:"group",value:b},t.close()):v.dataset.act==="rename"?Oe(b):v.dataset.act==="delete"&&Be(b))}}),a.addEventListener("keydown",r=>{const v=r.target.closest?.(".ct-group-row");!v||r.target!==v||(r.key==="F10"&&r.shiftKey?(r.preventDefault(),Q(v)):r.key==="Enter"&&(r.preventDefault(),x.value={kind:"group",value:v.dataset.group},t.close()))});const C=E.contextMenu(a,{cursor:"glyph",match:".ct-group-row",inherit:!1,density:"compact",items:r=>r?.dataset.group?Ge(r.dataset.group,{panel:t}):[]});t.element.appendChild(a);const z=ie(a);a.querySelector(".ct-groups-rows").innerHTML=qt;const _=M.applyBindings(c,a,{methods:{add:()=>(s(),!1)}});ye(t,()=>{k=!0,i?.(),_.dispose(),z(),C?.destroy?.(),e()}),t.open()}Mt(G,{id:"contacts-settings",icon:"settings",title:"Contacts settings",onClick:lt}),d.querySelector(".ct-rows").innerHTML=It,d.querySelector(".ct-chips").innerHTML=Rt;const K=d.querySelector(".ct-list"),Pe=d.querySelector(".ct-bar"),fe=d.querySelector(".ct-chips-row"),De=d.querySelector(".ct-bulk");h.add(ie(d,{position:"bottom"}));const dt=M.applyBindings(Ve,d,{methods:{create:()=>H(null),retry:W,reviewOrphan:()=>{const e=Z.peek()[0];e&&H(null,{draftId:e.id})},bulkDelete:ot,clearSelection:()=>{m.value=new Set}}});h.add(()=>dt.dispose()),yt({root:d,toolbar:d.querySelector(".ct-bar"),tool:"contacts",endpoint:"/contacts",itemPath:"/contacts",label:"Contacts",titleOf:e=>e.name,reload:W,lifetime:h});const Ie=gt({root:d,ensureGroup:ae,byId:U,patch:te,withGroup:We,lifetime:h});h.add(M.effect(()=>{const e=L.value;K.classList.toggle("is-compact",e.density==="compact");const t={company:!e.showCompany,email:!e.showEmail,phone:!e.showPhone,groups:!e.showGroups};for(const[n,c]of Object.entries(t))K.classList.toggle(`hide-${n}`,c);const a=["1.1rem","1.5rem","minmax(10rem, 1.4fr)"];t.company||a.push("minmax(0, 1fr)"),t.email||a.push("minmax(0, 1.2fr)"),t.phone||a.push("minmax(0, .9fr)"),t.groups||a.push("minmax(0, 1fr)"),a.push("1.75rem"),K.style.setProperty("--ct-cols",a.join(" "))}));const ut=E.contextMenu(K,{cursor:"glyph",match:".ct-row",inherit:!1,density:"compact",items:e=>{const t=e.dataset.id,a=U(t);if(!a)return[];const n=Ke(a),c=Ye(a),k=a.groups||[],i=s=>s.filter(C=>C.visible!==!1),S=Ct(we,t,s=>s.label.replace("{name}",a.name||"them"));return i([{label:"Edit\u2026",icon:"edit",action:()=>H(t)},...S,{type:"divider"},{label:`Email ${n}`,icon:"mail",visible:!!n,action:()=>{location.href=`mailto:${encodeURIComponent(n).replace(/%40/g,"@")}`}},{label:`Call ${c}`,icon:"phone",visible:!!c,action:()=>{location.href=`tel:${c.replace(/[^\d+]/g,"")}`}},{label:"Copy email",icon:"copy",visible:!!n,action:()=>Ne(n,"Email")},{label:"Copy phone",icon:"copy",visible:!!c,action:()=>Ne(c,"Phone")},{type:"divider",visible:!!(n||c)},{label:a.favourite===!0?"Unstar":"Star",icon:"star",action:()=>te(t,{favourite:a.favourite!==!0})},{label:"Groups",icon:"folder",submenu:[...g.peek().map(s=>({label:`${k.includes(s)?"\u2713 ":""}${s}`,action:()=>at(t,s,!k.includes(s))})),...g.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>Ae([t])}]},{label:"Duplicate",icon:"copy",action:()=>tt(t)},{label:m.peek().has(t)?"Deselect":"Select",icon:"check-square",action:()=>Le(t)},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>xe(t)}])}});h.add(()=>ut?.destroy?.());const pt=E.contextMenu(fe,{cursor:"glyph",match:".ct-chip",inherit:!1,density:"compact",items:e=>{const t=e?.dataset.chip||"";return t.startsWith("group:")?Ge(t.slice(6)):[{label:t==="fav"?"Show only starred":"Show everyone",icon:"filter",action:()=>{x.value=t==="fav"?{kind:"fav"}:{kind:"all"}}},{type:"divider"},{label:"Manage groups\u2026",icon:"folder",action:me}]}});h.add(()=>pt?.destroy?.()),fe.addEventListener("keydown",e=>{const t=e.target.closest?.(".ct-chip");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Q(t))});const mt=E.contextMenu(Pe,{cursor:"glyph",match:".ct-more",inherit:!1,density:"compact",items:()=>[{label:"Manage groups\u2026",icon:"folder",action:me},{type:"divider"},{label:"Export all (JSON)",icon:"download",action:st},{label:"Import from file\u2026",icon:"upload",action:()=>re.click()},{type:"divider"},{label:"Select all shown",icon:"check-square",disabled:!O.peek().length,action:()=>{m.value=new Set(O.peek().map(e=>e.id))}},{label:"Clear selection",icon:"x",disabled:!m.peek().size,action:()=>{m.value=new Set}}]});h.add(()=>mt?.destroy?.());const ft=E.contextMenu(De,{cursor:"glyph",match:".ct-bulk-group",inherit:!1,density:"compact",items:()=>[...g.peek().map(e=>({label:e,action:()=>nt(e)})),...g.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>Ae([...m.peek()])}]});h.add(()=>ft?.destroy?.()),Pe.addEventListener("click",e=>{const t=e.target.closest(".ct-more");t&&Q(t)}),De.addEventListener("click",e=>{const t=e.target.closest(".ct-bulk-group");t&&Q(t)}),fe.addEventListener("click",e=>{const t=e.target.closest(".ct-chip");if(!t)return;const a=t.dataset.chip;x.value=a==="fav"?{kind:"fav"}:a.startsWith("group:")?{kind:"group",value:a.slice(6)}:{kind:"all"}}),K.addEventListener("click",e=>{if(e.target.closest(".ct-select-all"))return;const t=e.target.closest(".ct-row");if(!t)return;const a=t.dataset.id;if(e.target.closest(".ct-kebab"))return Q(e.target.closest(".ct-kebab"));if(!e.target.closest(".ct-select")){if(e.target.closest(".ct-fav")){const n=U(a);n&&te(a,{favourite:n.favourite!==!0});return}H(a)}}),K.addEventListener("change",e=>{if(e.target.matches(".ct-select")&&Le(e.target.closest(".ct-row").dataset.id),e.target.matches(".ct-select-all")){const t=O.peek().map(a=>a.id);m.value=e.target.checked?new Set(t):new Set}}),K.addEventListener("keydown",e=>{const t=e.target.closest?.(".ct-row");!t||e.target!==t||(e.key==="Enter"?(e.preventDefault(),H(t.dataset.id)):e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Q(t.querySelector(".ct-kebab")||t)))});const Re=e=>{e.ctrlKey||e.metaKey||e.altKey||e.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(e.key==="/"?(e.preventDefault(),Se.value=!0):(e.key==="n"||e.key==="N")&&(e.preventDefault(),H(null)))};document.addEventListener("keydown",Re),h.add(()=>document.removeEventListener("keydown",Re)),I.scan(d),await W()}};
177
+ </div>`),n=M.observable(""),c={draft:n,list:M.observable([]),someLocked:M.observable(!1)};let S=!1;ue();const r=M.effect(()=>{const i=Ce.value.groups,y=le.value,W=w.value.map(g=>{const oe=!(y&&y.get(g)===!0);return{name:g,countText:B(i[g]??0,"contact"),locked:oe,lockTitle:oe?"Only its creator, or someone allowed to manage every group, can change this group":""}});c.list.value=W,c.someLocked.value=y!==null&&W.some(g=>g.locked)});c.hasGroups=M.computed(()=>c.list.value.length>0),c.noGroups=M.computed(()=>c.list.value.length===0),c.cannotAdd=M.computed(()=>!n.value.trim());let m=!1;async function s(){const i=n.peek().trim();if(!(!i||m)){m=!0;try{await ae(i)&&(await ue(),!S&&n.peek().trim()===i&&(n.value=""),E.toast("Group added.",{type:"success"}))}finally{m=!1}}}a.addEventListener("click",i=>{const y=i.target.closest("[data-act]"),g=i.target.closest(".ct-group-row")?.dataset.group;if(g){if(!y){x.value={kind:"group",value:g},t.close();return}y.disabled||(y.dataset.act==="show"?(x.value={kind:"group",value:g},t.close()):y.dataset.act==="rename"?Ge(g):y.dataset.act==="delete"&&Pe(g))}}),a.addEventListener("keydown",i=>{const y=i.target.closest?.(".ct-group-row");!y||i.target!==y||(i.key==="F10"&&i.shiftKey?(i.preventDefault(),Q(y)):i.key==="Enter"&&(i.preventDefault(),x.value={kind:"group",value:y.dataset.group},t.close()))});const u=E.contextMenu(a,{cursor:"glyph",match:".ct-group-row",inherit:!1,density:"compact",items:i=>i?.dataset.group?De(i.dataset.group,{panel:t}):[]});t.element.appendChild(a);const R=de(a);a.querySelector(".ct-groups-rows").innerHTML=Ut;const _=M.applyBindings(c,a,{methods:{add:()=>(s(),!1)}});ke(t,()=>{S=!0,r?.(),_.dispose(),R(),u?.destroy?.(),e()}),t.open()}$t(G,{id:"contacts-settings",icon:"settings",title:"Contacts settings",onClick:rt}),d.querySelector(".ct-rows").innerHTML=jt,d.querySelector(".ct-chips").innerHTML=qt;const Y=d.querySelector(".ct-list"),Ie=d.querySelector(".ct-bar"),be=d.querySelector(".ct-chips-row"),Re=d.querySelector(".ct-bulk");v.add(de(d,{position:"bottom"}));const ut=M.applyBindings(Qe,d,{methods:{create:()=>K(null),retry:z,reviewOrphan:()=>{const e=Z.peek()[0];e&&K(null,{draftId:e.id})},bulkDelete:nt,clearSelection:()=>{p.value=new Set}}});v.add(()=>ut.dispose()),gt({root:d,toolbar:d.querySelector(".ct-bar"),tool:"contacts",endpoint:"/contacts",itemPath:"/contacts",label:"Contacts",titleOf:e=>e.name,reload:z,lifetime:v});const je=kt({root:d,ensureGroup:ae,byId:P,patch:te,withGroup:ze,lifetime:v});v.add(M.effect(()=>{const e=L.value;Y.classList.toggle("is-compact",e.density==="compact");const t={company:!e.showCompany,email:!e.showEmail,phone:!e.showPhone,groups:!e.showGroups};for(const[n,c]of Object.entries(t))Y.classList.toggle(`hide-${n}`,c);const a=["1.1rem","1.5rem","minmax(10rem, 1.4fr)"];t.company||a.push("minmax(0, 1fr)"),t.email||a.push("minmax(0, 1.2fr)"),t.phone||a.push("minmax(0, .9fr)"),t.groups||a.push("minmax(0, 1fr)"),a.push("1.75rem"),Y.style.setProperty("--ct-cols",a.join(" "))}));const pt=E.contextMenu(Y,{cursor:"glyph",match:".ct-row",inherit:!1,density:"compact",items:e=>{const t=e.dataset.id,a=P(t);if(!a)return[];const n=ne(a),c=We(a),S=a.groups||[],r=u=>u.filter(R=>R.visible!==!1),m=p.peek().has(t)?[...p.peek()].map(P).filter(u=>u&&ne(u)):[],s=Tt(Se,t,u=>u.label.replace("{name}",a.name||"them"));return r([{label:"Edit\u2026",icon:"edit",action:()=>K(t)},...s,{type:"divider"},{label:`Email ${n}`,icon:"mail",visible:!!n,action:()=>fe([a])},{label:`Email the ${m.length} selected\u2026`,icon:"mail",visible:m.length>1,action:()=>fe(m)},{label:`Call ${c}`,icon:"phone",visible:!!c,action:()=>{location.href=`tel:${c.replace(/[^\d+]/g,"")}`}},{label:"Copy email",icon:"copy",visible:!!n,action:()=>Oe(n,"Email")},{label:"Copy phone",icon:"copy",visible:!!c,action:()=>Oe(c,"Phone")},{type:"divider",visible:!!(n||c)},{label:a.favourite===!0?"Unstar":"Star",icon:"star",action:()=>te(t,{favourite:a.favourite!==!0})},{label:"Groups",icon:"folder",submenu:[...w.peek().map(u=>({label:`${S.includes(u)?"\u2713 ":""}${u}`,action:()=>ot(t,u,!S.includes(u))})),...w.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>Le([t])}]},{label:"Duplicate",icon:"copy",action:()=>at(t)},{label:p.peek().has(t)?"Deselect":"Select",icon:"check-square",action:()=>Be(t)},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>Ne(t)}])}});v.add(()=>pt?.destroy?.());const mt=E.contextMenu(be,{cursor:"glyph",match:".ct-chip",inherit:!1,density:"compact",items:e=>{const t=e?.dataset.chip||"";return t.startsWith("group:")?De(t.slice(6)):[{label:t==="fav"?"Show only starred":"Show everyone",icon:"filter",action:()=>{x.value=t==="fav"?{kind:"fav"}:{kind:"all"}}},{type:"divider"},{label:"Manage groups\u2026",icon:"folder",action:he}]}});v.add(()=>mt?.destroy?.()),be.addEventListener("keydown",e=>{const t=e.target.closest?.(".ct-chip");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Q(t))});const ft=E.contextMenu(Ie,{cursor:"glyph",match:".ct-more",inherit:!1,density:"compact",items:()=>[{label:"Manage groups\u2026",icon:"folder",action:he},{type:"divider"},{label:"Export all (JSON)",icon:"download",action:ct},{label:"Import from file\u2026",icon:"upload",action:()=>ie.click()},{type:"divider"},{label:"Select all shown",icon:"check-square",disabled:!O.peek().length,action:()=>{p.value=new Set(O.peek().map(e=>e.id))}},{label:"Clear selection",icon:"x",disabled:!p.peek().size,action:()=>{p.value=new Set}}]});v.add(()=>ft?.destroy?.());const ht=E.contextMenu(Re,{cursor:"glyph",match:".ct-bulk-group",inherit:!1,density:"compact",items:()=>[...w.peek().map(e=>({label:e,action:()=>st(e)})),...w.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>Le([...p.peek()])}]});v.add(()=>ht?.destroy?.()),Ie.addEventListener("click",e=>{const t=e.target.closest(".ct-more");t&&Q(t)}),Re.addEventListener("click",e=>{const t=e.target.closest(".ct-bulk-group");t&&Q(t)}),be.addEventListener("click",e=>{const t=e.target.closest(".ct-chip");if(!t)return;const a=t.dataset.chip;x.value=a==="fav"?{kind:"fav"}:a.startsWith("group:")?{kind:"group",value:a.slice(6)}:{kind:"all"}}),Y.addEventListener("click",e=>{if(e.target.closest(".ct-select-all"))return;const t=e.target.closest(".ct-row");if(!t)return;const a=t.dataset.id;if(e.target.closest(".ct-kebab"))return Q(e.target.closest(".ct-kebab"));if(!e.target.closest(".ct-select")){if(e.target.closest(".ct-fav")){const n=P(a);n&&te(a,{favourite:n.favourite!==!0});return}K(a)}}),Y.addEventListener("change",e=>{if(e.target.matches(".ct-select")&&Be(e.target.closest(".ct-row").dataset.id),e.target.matches(".ct-select-all")){const t=O.peek().map(a=>a.id);p.value=e.target.checked?new Set(t):new Set}}),Y.addEventListener("keydown",e=>{const t=e.target.closest?.(".ct-row");!t||e.target!==t||(e.key==="Enter"?(e.preventDefault(),K(t.dataset.id)):e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Q(t.querySelector(".ct-kebab")||t)))});const qe=e=>{e.ctrlKey||e.metaKey||e.altKey||e.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(e.key==="/"?(e.preventDefault(),$e.value=!0):(e.key==="n"||e.key==="N")&&(e.preventDefault(),K(null)))};document.addEventListener("keydown",qe),v.add(()=>document.removeEventListener("keydown",qe)),I.scan(d),await z()}};
@@ -1,4 +1,4 @@
1
- import{apiRequest as A}from"/admin/js/api.js";import{addBannerAction as Y}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as De,whileLoading as Le}from"/admin/js/lib/loading.js";import{helpPopovers as me}from"/admin/js/lib/help-popover.js";import{onceClosed as Re,openMenuAt as pe,sidebarCount as Q,viewLifetime as Ie}from"/admin/js/lib/tool-kit.js";import{FILTERS as Oe,answerFields as Ue,answerText as U,arrangeSubmissions as qe,submissionCounts as je,submissionRow as L,submissionText as Fe}from"/admin/js/lib/form-submissions-arrange.js";import{fmtDateTime as Pe}from"../lib/dates.js";const ue=/^#\/forms\/[^/?#]+\/submissions\/?(\?.*)?$/,Z="form-submissions.filter",Be=`
1
+ import{apiRequest as A}from"/admin/js/api.js";import{addBannerAction as Y}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as De,whileLoading as Le}from"/admin/js/lib/loading.js";import{helpPopovers as me}from"/admin/js/lib/help-popover.js";import{onceClosed as Re,openMenuAt as pe,sidebarCount as Q,viewLifetime as Ie}from"/admin/js/lib/tool-kit.js";import{FILTERS as Oe,answerFields as qe,answerText as q,arrangeSubmissions as Ue,submissionCounts as Fe,submissionRow as L,submissionText as je}from"/admin/js/lib/form-submissions-arrange.js";import{fmtDateTime as Pe}from"../lib/dates.js";import{openEmail as Be}from"../lib/mail-compose.js";const ue=/^#\/forms\/[^/?#]+\/submissions\/?(\?.*)?$/,Z="form-submissions.filter",Ke=`
2
2
  <div class="fsx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="fsx-dot" aria-hidden="true"></span>
4
4
  <span class="fsx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
@@ -8,4 +8,4 @@ import{apiRequest as A}from"/admin/js/api.js";import{addBannerAction as Y}from"/
8
8
  </span>
9
9
  <span class="fsx-when" title="{{whenFull}}">{{when}}</span>
10
10
  <button type="button" class="fsx-kebab" aria-label="Actions for this submission" title="Actions">\u22EE</button>
11
- </div>`,ee=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;"),q=(i,r,y=`${r}s`)=>`${i} ${i===1?r:y}`,te=i=>i?Pe(i):"";async function fe(i){try{return await navigator.clipboard.writeText(i),!0}catch{}try{const r=document.createElement("textarea");r.value=i,r.style.cssText="position:fixed;opacity:0;",document.body.appendChild(r),r.select();const y=document.execCommand("copy");return r.remove(),y}catch{return!1}}const b=Ie(ue);export const formSubmissionsView={templateUrl:"/admin/js/templates/form-submissions.html",async onMount(i){b.reset();const r=i.get(0).querySelector(".fsx")||i.get(0),y=()=>ue.test(location.hash)&&r.isConnected,u=decodeURIComponent((location.hash.match(/^#\/forms\/([^/?#]+)\/submissions/)||[])[1]||"");if(!u){E.toast("No form selected.",{type:"error"});return}const k=encodeURIComponent(u),w=M.observable(null),l=M.observable([]),ne=M.observable(!0),j=M.observable("");let se="inbox";try{se=Oe.includes(S.get(Z))?S.get(Z):"inbox"}catch{}const v=M.observable(se),oe=M.observable(""),F=M.observable(""),P=M.observable("");b.add(M.effect(()=>{const e=v.value;try{S.set(Z,e)}catch{}}));const $=M.computed(()=>Ue(w.value||{})),B=M.observable([]);b.add(M.effect(()=>{const e=Date.now(),t=qe(l.value,{filter:v.value,query:oe.value,from:F.value,to:P.value,now:e}),n=$.value;B.value=t.map(a=>{const s=L(a,n,e);return{...s,when:s.at?D(s.at).fromNow():"",whenFull:te(s.at)}})}));const he=M.computed(()=>je(l.value)),be=M.computed(()=>{const e=B.value.length,t=l.value.length;return t?e===t?q(t,"submission"):`${e} of ${q(t,"submission")}`:""}),ye=M.computed(()=>l.value.length?v.value==="spam"?"Nothing marked as spam.":"No submissions match.":"No submissions yet - they appear here as people send the form."),we=M.computed(()=>w.value?`What people sent through "${w.value.title||u}".`:"What people sent through this form."),R=e=>l.peek().find(t=>String(t.id)===String(e));async function Ee(){try{const[e,t]=await Le(r.querySelector(".dm-loading-slot"),Promise.all([A(`/forms/${k}`),A(`/forms/${k}/submissions`)]),De.rows);if(!y())return;w.value=e||null,l.value=Array.isArray(t)?t:[],j.value="",ve(e?.title||u)}catch(e){if(!y())return;j.value=`Could not load the submissions: ${e.message||e}`}finally{ne.value=!1}}function ve(e){const t=i.get(0).querySelector(".dm-plugin-banner-name"),n=t?.lastChild;n&&n.nodeType===Node.TEXT_NODE&&t.querySelector(".dm-plugin-banner-sep")&&(n.textContent=`${e} submissions`),document.title=`${e} - Submissions - Domma CMS`}const K=(e,t)=>ce(e.id,()=>xe(e,t)),W=e=>ce(e.id,()=>ge(e));async function xe(e,t){try{const n=await A(`/forms/${k}/submissions/${encodeURIComponent(e.id)}/spam`,{method:"POST",body:JSON.stringify({spam:t})});return l.value=l.peek().map(a=>String(a.id)===String(e.id)?{...a,data:n?.entry?.data||{...a.data,spam:t}}:a),E.toast(t?"Marked as spam - it is under Spam now.":"Back in the inbox.",{type:"success"}),Q("#/forms"),!0}catch(n){return E.toast(`Not changed: ${n.message||n}`,{type:"error"}),!1}}async function ge(e){const t=L(e,$.peek()).title;if(!await E.confirm(`Delete the submission from "${ee(t)}"? This cannot be undone.`,{confirmText:"Delete"}))return!1;try{return await A(`/forms/${k}/submissions/${encodeURIComponent(e.id)}`,{method:"DELETE"}),l.value=l.peek().filter(n=>String(n.id)!==String(e.id)),E.toast("Submission deleted.",{type:"success"}),Q("#/forms"),!0}catch(n){return E.toast(`Not deleted: ${n.message||n}`,{type:"error"}),!1}}let _=!1;async function Ce(){if(!_){_=!0;try{await ke()}finally{_=!1}}}async function ke(){const e=l.peek().length;if(!e){E.toast("There are no submissions to clear.",{type:"info"});return}const t=w.peek()?.title||u;if(await E.confirm(`Delete all ${q(e,"submission")} to "${ee(t)}", spam included? This cannot be undone - Export them first if you may need them.`))try{await A(`/forms/${k}/submissions`,{method:"DELETE"}),l.value=[],E.toast("All submissions cleared.",{type:"success"}),Q("#/forms")}catch(n){E.toast(`Not cleared: ${n.message||n}`,{type:"error"})}}const ae=async e=>{const t=Fe(e,$.peek(),{formTitle:w.peek()?.title||u,when:te(e.meta?.createdAt)});E.toast(await fe(t)?"Copied.":"Copy failed.",{type:"info"})},Se=async e=>{E.toast(await fe(e)?`Copied ${e}.`:"Copy failed.",{type:"info"})},ie=e=>{const t=L(e,$.peek()).email;if(!t)return;const n=`Re: ${w.peek()?.title||u}`;window.location.href=`mailto:${encodeURIComponent(t)}?subject=${encodeURIComponent(n)}`};async function $e(e,t){try{const n=await fetch(`/api/forms/${k}/submissions/${e}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!n.ok)throw new Error(`${n.status} ${n.statusText}`);const a=await n.blob(),s=document.createElement("a");return s.href=URL.createObjectURL(a),s.download=t,document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(s.href),1e3),!0}catch(n){return E.toast(`Export failed: ${n.message||n}`,{type:"error"}),!1}}const H=new Set,O=new Set;function re(e,t,n,a="md"){if(O.has(e))return null;O.add(e);let s;try{s=E.slideover({title:ee(t),size:a,position:"right"}),s.element.appendChild(n);const m=me(n);return H.add(s),Re(s,()=>{O.delete(e),H.delete(s),m()}),I.scan(n),s.open(),s}catch(m){O.delete(e);try{s?.destroy?.(),s?.element?.remove()}catch{}return E.toast(`Could not open it: ${m.message||m}`,{type:"error"}),null}}const J=new Set;async function ce(e,t){const n=String(e);if(J.has(n))return!1;J.add(n);try{return await t()}finally{J.delete(n)}}function Ne(){const e=l.peek().length,t=document.createElement("div");t.className="fsx-export";const n=document.createElement("p");n.className="fsx-note",n.textContent=e?`All ${q(e,"submission")}, spam included.`:"There are no submissions yet - the file will be empty.",t.appendChild(n);let a;const s=(m,x,z,G,N)=>{const c=document.createElement("button");c.type="button",c.className="btn btn-ghost";const g=document.createElement("span");g.setAttribute("data-icon",x),c.append(g,document.createTextNode(` ${m}`));const T=document.createElement("span");T.setAttribute("data-help",N),c.addEventListener("click",async()=>{if(!c.disabled){c.disabled=!0;try{await $e(z,`${u}-submissions.${G}`)&&a?.close()}finally{c.disabled=!1}}});const o=document.createElement("div");o.append(c,T),t.appendChild(o)};s("Export as CSV","file-text","export","csv","One row per submission, one column per question - opens in Excel, Numbers or Google Sheets."),s("Export as JSON","code","export/json","json","Every submission exactly as stored, with its id and dates - for importing elsewhere or keeping a full copy."),a=re("export","Export submissions",t,"sm")}function V(e){if(!e)return;const t=$.peek(),n=L(e,t),a=document.createElement("div");a.className="fsx-detail";const s=document.createElement("div");s.className="fsx-facts";const m=o=>{const d=document.createElement("span");d.textContent=o,s.appendChild(d)};e.meta?.createdAt&&m(`Sent ${te(e.meta.createdAt)} (${D(e.meta.createdAt).fromNow()})`),e.meta?.ip&&m(`IP ${e.meta.ip}`),n.spam&&m("Marked as spam"),a.appendChild(s);const x=document.createElement("dl");x.className="fsx-answers";const z=t.filter(o=>U(e.data?.[o.name])!=="");for(const o of z){const d=document.createElement("div");d.className="fsx-answer";const h=document.createElement("dt");h.textContent=o.label;const C=document.createElement("dd");C.textContent=U(e.data[o.name]),d.append(h,C),x.appendChild(d)}const G=new Set(t.map(o=>o.name));for(const[o,d]of Object.entries(e.data||{})){if(G.has(o)||o==="spam"||U(d)==="")continue;const h=document.createElement("div");h.className="fsx-answer";const C=document.createElement("dt");C.textContent=`${o} (no longer on the form)`;const p=document.createElement("dd");p.textContent=U(d),h.append(C,p),x.appendChild(h)}if(x.children.length)a.appendChild(x);else{const o=document.createElement("p");o.className="fsx-note",o.textContent="Nothing was filled in.",a.appendChild(o)}const N=document.createElement("div");N.className="fsx-actions";const c=(o,d,h,C)=>{const p=document.createElement("button");p.type="button",p.className=`btn btn-sm ${h}`;const X=document.createElement("span");return X.setAttribute("data-icon",d),X.setAttribute("data-icon-size","13"),p.append(X,document.createTextNode(` ${o}`)),p.addEventListener("click",C),N.appendChild(p),p};let g;c("Copy","copy","btn-ghost",()=>ae(e)),n.email&&c("Email them","mail","btn-ghost",()=>ie(e)),c(n.spam?"Not spam":"Mark as spam",n.spam?"inbox":"alert-triangle","btn-ghost",async()=>{await K(e,!n.spam)&&g?.close()});const T=document.createElement("span");T.className="fsx-spacer",N.appendChild(T),c("Delete","trash","btn-ghost text-danger",async()=>{await W(e)&&g?.close()}),c("Close","x","btn-ghost",()=>g?.close()),a.appendChild(N),g=re(`sub:${e.id}`,n.title,a)}function Te(e){const t=R(e);if(!t)return[];const n=L(t,$.peek());return[{header:n.title},{label:"Open\u2026",icon:"eye",action:()=>V(t)},{label:"Copy submission",icon:"copy",action:()=>ae(t)},{type:"divider"},{label:n.email?`Copy ${n.email}`:"Copy email",icon:"at-sign",disabled:!n.email,action:()=>Se(n.email)},{label:"Email them\u2026",icon:"mail",disabled:!n.email,action:()=>ie(t)},{type:"divider"},n.spam?{label:"Not spam - back to the inbox",icon:"inbox",action:()=>K(t,!1)}:{label:"Mark as spam",icon:"alert-triangle",action:()=>K(t,!0)},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>W(t)}]}const f=r.querySelector(".fsx-list");f.innerHTML=Be;const Me=M.applyBindings({loading:ne,loadError:j,filter:v,query:oe,from:F,to:P,rows:B,counts:he,summary:be,emptyText:ye,intro:we},r,{methods:{showInbox:()=>{v.value="inbox"},showNew:()=>{v.value="new"},showSpam:()=>{v.value="spam"},clearDates:()=>{F.value="",P.value=""}}});b.add(()=>Me.dispose()),b.add(me(r,{position:"bottom"}));const Ae=E.contextMenu(f,{cursor:"glyph",match:".fsx-row",inherit:!1,density:"compact",items:e=>Te(e.dataset.id)});b.add(()=>Ae?.destroy?.());const le=e=>{const t=e.target.closest(".fsx-row");if(!t)return;const n=e.target.closest(".fsx-kebab");if(n){pe(n);return}V(R(t.dataset.id))},de=e=>{const t=e.target.closest(".fsx-row");if(!(!t||e.target!==t)){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),pe(t);else if(e.key==="Enter")V(R(t.dataset.id));else if(e.key==="Delete"){e.preventDefault();const n=R(t.dataset.id);n&&W(n)}}};f.addEventListener("click",le),f.addEventListener("keydown",de),b.add(()=>{f.removeEventListener("click",le),f.removeEventListener("keydown",de)}),Y(i,{id:"form-submissions-back",icon:"arrow-left",title:"All forms",onClick:()=>{location.hash="#/forms"}}),Y(i,{id:"form-submissions-export",icon:"download",title:"Export (CSV or JSON)",onClick:Ne}),Y(i,{id:"form-submissions-clear",icon:"trash",title:"Clear all submissions",onClick:Ce}),b.add(()=>{[...H].forEach(e=>{try{e.close()}catch{}})}),I.scan(r),await Ee(),y()&&requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}};
11
+ </div>`,ee=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;"),U=(i,r,y=`${r}s`)=>`${i} ${i===1?r:y}`,te=i=>i?Pe(i):"";async function fe(i){try{return await navigator.clipboard.writeText(i),!0}catch{}try{const r=document.createElement("textarea");r.value=i,r.style.cssText="position:fixed;opacity:0;",document.body.appendChild(r),r.select();const y=document.execCommand("copy");return r.remove(),y}catch{return!1}}const b=Ie(ue);export const formSubmissionsView={templateUrl:"/admin/js/templates/form-submissions.html",async onMount(i){b.reset();const r=i.get(0).querySelector(".fsx")||i.get(0),y=()=>ue.test(location.hash)&&r.isConnected,u=decodeURIComponent((location.hash.match(/^#\/forms\/([^/?#]+)\/submissions/)||[])[1]||"");if(!u){E.toast("No form selected.",{type:"error"});return}const k=encodeURIComponent(u),w=M.observable(null),l=M.observable([]),ne=M.observable(!0),F=M.observable("");let se="inbox";try{se=Oe.includes(S.get(Z))?S.get(Z):"inbox"}catch{}const v=M.observable(se),oe=M.observable(""),j=M.observable(""),P=M.observable("");b.add(M.effect(()=>{const e=v.value;try{S.set(Z,e)}catch{}}));const $=M.computed(()=>qe(w.value||{})),B=M.observable([]);b.add(M.effect(()=>{const e=Date.now(),t=Ue(l.value,{filter:v.value,query:oe.value,from:j.value,to:P.value,now:e}),n=$.value;B.value=t.map(a=>{const s=L(a,n,e);return{...s,when:s.at?D(s.at).fromNow():"",whenFull:te(s.at)}})}));const he=M.computed(()=>Fe(l.value)),be=M.computed(()=>{const e=B.value.length,t=l.value.length;return t?e===t?U(t,"submission"):`${e} of ${U(t,"submission")}`:""}),ye=M.computed(()=>l.value.length?v.value==="spam"?"Nothing marked as spam.":"No submissions match.":"No submissions yet - they appear here as people send the form."),we=M.computed(()=>w.value?`What people sent through "${w.value.title||u}".`:"What people sent through this form."),R=e=>l.peek().find(t=>String(t.id)===String(e));async function Ee(){try{const[e,t]=await Le(r.querySelector(".dm-loading-slot"),Promise.all([A(`/forms/${k}`),A(`/forms/${k}/submissions`)]),De.rows);if(!y())return;w.value=e||null,l.value=Array.isArray(t)?t:[],F.value="",ve(e?.title||u)}catch(e){if(!y())return;F.value=`Could not load the submissions: ${e.message||e}`}finally{ne.value=!1}}function ve(e){const t=i.get(0).querySelector(".dm-plugin-banner-name"),n=t?.lastChild;n&&n.nodeType===Node.TEXT_NODE&&t.querySelector(".dm-plugin-banner-sep")&&(n.textContent=`${e} submissions`),document.title=`${e} - Submissions - Domma CMS`}const K=(e,t)=>ce(e.id,()=>xe(e,t)),W=e=>ce(e.id,()=>ge(e));async function xe(e,t){try{const n=await A(`/forms/${k}/submissions/${encodeURIComponent(e.id)}/spam`,{method:"POST",body:JSON.stringify({spam:t})});return l.value=l.peek().map(a=>String(a.id)===String(e.id)?{...a,data:n?.entry?.data||{...a.data,spam:t}}:a),E.toast(t?"Marked as spam - it is under Spam now.":"Back in the inbox.",{type:"success"}),Q("#/forms"),!0}catch(n){return E.toast(`Not changed: ${n.message||n}`,{type:"error"}),!1}}async function ge(e){const t=L(e,$.peek()).title;if(!await E.confirm(`Delete the submission from "${ee(t)}"? This cannot be undone.`,{confirmText:"Delete"}))return!1;try{return await A(`/forms/${k}/submissions/${encodeURIComponent(e.id)}`,{method:"DELETE"}),l.value=l.peek().filter(n=>String(n.id)!==String(e.id)),E.toast("Submission deleted.",{type:"success"}),Q("#/forms"),!0}catch(n){return E.toast(`Not deleted: ${n.message||n}`,{type:"error"}),!1}}let _=!1;async function Ce(){if(!_){_=!0;try{await ke()}finally{_=!1}}}async function ke(){const e=l.peek().length;if(!e){E.toast("There are no submissions to clear.",{type:"info"});return}const t=w.peek()?.title||u;if(await E.confirm(`Delete all ${U(e,"submission")} to "${ee(t)}", spam included? This cannot be undone - Export them first if you may need them.`))try{await A(`/forms/${k}/submissions`,{method:"DELETE"}),l.value=[],E.toast("All submissions cleared.",{type:"success"}),Q("#/forms")}catch(n){E.toast(`Not cleared: ${n.message||n}`,{type:"error"})}}const ae=async e=>{const t=je(e,$.peek(),{formTitle:w.peek()?.title||u,when:te(e.meta?.createdAt)});E.toast(await fe(t)?"Copied.":"Copy failed.",{type:"info"})},Se=async e=>{E.toast(await fe(e)?`Copied ${e}.`:"Copy failed.",{type:"info"})},ie=e=>{const t=L(e,$.peek()).email;t&&Be({to:[t],subject:`Re: ${w.peek()?.title||u}`})};async function $e(e,t){try{const n=await fetch(`/api/forms/${k}/submissions/${e}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!n.ok)throw new Error(`${n.status} ${n.statusText}`);const a=await n.blob(),s=document.createElement("a");return s.href=URL.createObjectURL(a),s.download=t,document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(s.href),1e3),!0}catch(n){return E.toast(`Export failed: ${n.message||n}`,{type:"error"}),!1}}const H=new Set,O=new Set;function re(e,t,n,a="md"){if(O.has(e))return null;O.add(e);let s;try{s=E.slideover({title:ee(t),size:a,position:"right"}),s.element.appendChild(n);const m=me(n);return H.add(s),Re(s,()=>{O.delete(e),H.delete(s),m()}),I.scan(n),s.open(),s}catch(m){O.delete(e);try{s?.destroy?.(),s?.element?.remove()}catch{}return E.toast(`Could not open it: ${m.message||m}`,{type:"error"}),null}}const J=new Set;async function ce(e,t){const n=String(e);if(J.has(n))return!1;J.add(n);try{return await t()}finally{J.delete(n)}}function Ne(){const e=l.peek().length,t=document.createElement("div");t.className="fsx-export";const n=document.createElement("p");n.className="fsx-note",n.textContent=e?`All ${U(e,"submission")}, spam included.`:"There are no submissions yet - the file will be empty.",t.appendChild(n);let a;const s=(m,x,z,G,N)=>{const c=document.createElement("button");c.type="button",c.className="btn btn-ghost";const g=document.createElement("span");g.setAttribute("data-icon",x),c.append(g,document.createTextNode(` ${m}`));const T=document.createElement("span");T.setAttribute("data-help",N),c.addEventListener("click",async()=>{if(!c.disabled){c.disabled=!0;try{await $e(z,`${u}-submissions.${G}`)&&a?.close()}finally{c.disabled=!1}}});const o=document.createElement("div");o.append(c,T),t.appendChild(o)};s("Export as CSV","file-text","export","csv","One row per submission, one column per question - opens in Excel, Numbers or Google Sheets."),s("Export as JSON","code","export/json","json","Every submission exactly as stored, with its id and dates - for importing elsewhere or keeping a full copy."),a=re("export","Export submissions",t,"sm")}function V(e){if(!e)return;const t=$.peek(),n=L(e,t),a=document.createElement("div");a.className="fsx-detail";const s=document.createElement("div");s.className="fsx-facts";const m=o=>{const d=document.createElement("span");d.textContent=o,s.appendChild(d)};e.meta?.createdAt&&m(`Sent ${te(e.meta.createdAt)} (${D(e.meta.createdAt).fromNow()})`),e.meta?.ip&&m(`IP ${e.meta.ip}`),n.spam&&m("Marked as spam"),a.appendChild(s);const x=document.createElement("dl");x.className="fsx-answers";const z=t.filter(o=>q(e.data?.[o.name])!=="");for(const o of z){const d=document.createElement("div");d.className="fsx-answer";const h=document.createElement("dt");h.textContent=o.label;const C=document.createElement("dd");C.textContent=q(e.data[o.name]),d.append(h,C),x.appendChild(d)}const G=new Set(t.map(o=>o.name));for(const[o,d]of Object.entries(e.data||{})){if(G.has(o)||o==="spam"||q(d)==="")continue;const h=document.createElement("div");h.className="fsx-answer";const C=document.createElement("dt");C.textContent=`${o} (no longer on the form)`;const p=document.createElement("dd");p.textContent=q(d),h.append(C,p),x.appendChild(h)}if(x.children.length)a.appendChild(x);else{const o=document.createElement("p");o.className="fsx-note",o.textContent="Nothing was filled in.",a.appendChild(o)}const N=document.createElement("div");N.className="fsx-actions";const c=(o,d,h,C)=>{const p=document.createElement("button");p.type="button",p.className=`btn btn-sm ${h}`;const X=document.createElement("span");return X.setAttribute("data-icon",d),X.setAttribute("data-icon-size","13"),p.append(X,document.createTextNode(` ${o}`)),p.addEventListener("click",C),N.appendChild(p),p};let g;c("Copy","copy","btn-ghost",()=>ae(e)),n.email&&c("Email them","mail","btn-ghost",()=>ie(e)),c(n.spam?"Not spam":"Mark as spam",n.spam?"inbox":"alert-triangle","btn-ghost",async()=>{await K(e,!n.spam)&&g?.close()});const T=document.createElement("span");T.className="fsx-spacer",N.appendChild(T),c("Delete","trash","btn-ghost text-danger",async()=>{await W(e)&&g?.close()}),c("Close","x","btn-ghost",()=>g?.close()),a.appendChild(N),g=re(`sub:${e.id}`,n.title,a)}function Te(e){const t=R(e);if(!t)return[];const n=L(t,$.peek());return[{header:n.title},{label:"Open\u2026",icon:"eye",action:()=>V(t)},{label:"Copy submission",icon:"copy",action:()=>ae(t)},{type:"divider"},{label:n.email?`Copy ${n.email}`:"Copy email",icon:"at-sign",disabled:!n.email,action:()=>Se(n.email)},{label:"Email them\u2026",icon:"mail",disabled:!n.email,action:()=>ie(t)},{type:"divider"},n.spam?{label:"Not spam - back to the inbox",icon:"inbox",action:()=>K(t,!1)}:{label:"Mark as spam",icon:"alert-triangle",action:()=>K(t,!0)},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>W(t)}]}const f=r.querySelector(".fsx-list");f.innerHTML=Ke;const Me=M.applyBindings({loading:ne,loadError:F,filter:v,query:oe,from:j,to:P,rows:B,counts:he,summary:be,emptyText:ye,intro:we},r,{methods:{showInbox:()=>{v.value="inbox"},showNew:()=>{v.value="new"},showSpam:()=>{v.value="spam"},clearDates:()=>{j.value="",P.value=""}}});b.add(()=>Me.dispose()),b.add(me(r,{position:"bottom"}));const Ae=E.contextMenu(f,{cursor:"glyph",match:".fsx-row",inherit:!1,density:"compact",items:e=>Te(e.dataset.id)});b.add(()=>Ae?.destroy?.());const le=e=>{const t=e.target.closest(".fsx-row");if(!t)return;const n=e.target.closest(".fsx-kebab");if(n){pe(n);return}V(R(t.dataset.id))},de=e=>{const t=e.target.closest(".fsx-row");if(!(!t||e.target!==t)){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),pe(t);else if(e.key==="Enter")V(R(t.dataset.id));else if(e.key==="Delete"){e.preventDefault();const n=R(t.dataset.id);n&&W(n)}}};f.addEventListener("click",le),f.addEventListener("keydown",de),b.add(()=>{f.removeEventListener("click",le),f.removeEventListener("keydown",de)}),Y(i,{id:"form-submissions-back",icon:"arrow-left",title:"All forms",onClick:()=>{location.hash="#/forms"}}),Y(i,{id:"form-submissions-export",icon:"download",title:"Export (CSV or JSON)",onClick:Ne}),Y(i,{id:"form-submissions-clear",icon:"trash",title:"Clear all submissions",onClick:Ce}),b.add(()=>{[...H].forEach(e=>{try{e.close()}catch{}})}),I.scan(r),await Ee(),y()&&requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}};
@@ -1 +1 @@
1
- import{dashboardView as o}from"./dashboard.js";import{dashboardsView as i}from"./dashboards.js";import{pagesView as r}from"./pages.js";import{pageEditorView as t}from"./page-editor.js";import{settingsView as e}from"./settings.js";import{themeView as m}from"./theme.js";import{notificationsView as p}from"./notifications.js";import{layoutsView as n}from"./layouts.js";import{mediaView as s}from"./media.js";import{loginView as f}from"./login.js";import{usersView as w}from"./users.js";import{userEditorView as c}from"./user-editor.js";import{pluginsView as V}from"./plugins.js";import{pluginCodeView as a}from"./plugin-code.js";import{documentationView as d}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as u}from"./doc-pages.js";import{pluginGuidesView as E,pluginGuideView as g}from"./plugin-guides.js";import{apiReferenceView as b}from"./api-reference.js";import{collectionsView as h}from"./collections.js";import{collectionEditorView as v}from"./collection-editor.js";import{collectionEntriesView as y}from"./collection-entries.js";import{formsView as j}from"./forms.js";import{formEditorView as k}from"./form-editor.js";import{formSubmissionsView as L}from"./form-submissions.js";import{viewsListView as x}from"./views-list.js";import{viewEditorView as M}from"./view-editor.js";import{viewPreviewView as D}from"./view-preview.js";import{actionsListView as G}from"./actions-list.js";import{actionEditorView as P}from"./action-editor.js";import{proDocsView as R}from"./pro-docs.js";import{blocksView as S}from"./blocks.js";import{blockEditorView as A}from"./block-editor.js";import{componentsView as C}from"./components.js";import{componentEditorView as N}from"./component-editor.js";import{myProfileView as T}from"./my-profile.js";import{rolesView as U}from"./roles.js";import{roleEditorView as q}from"./role-editor.js";import{effectsView as z}from"./effects.js";import{searchView as B}from"./search.js";import{contentApiView as F}from"./content-api.js";import{notesView as H}from"./notes.js";import{todoView as I}from"./todo.js";import{calculatorView as J}from"../lib/calculator.js";import{analyticsView as K}from"./analytics.js";import{seoView as O}from"./seo.js";import{contactsView as Q}from"./contacts.js";import{callNewView as W}from"./call-new.js";import{menusView as X}from"./menus.js";import{contextMenusView as Y}from"./context-menus.js";import{contextMenuEditorView as Z}from"./context-menu-editor.js";import{menuEditorView as _}from"./menu-editor.js";import{menuLocationsView as $}from"./menu-locations.js";import{projectsView as oo}from"./projects.js";import{projectDetailView as io}from"./project-detail.js";import{projectSettingsView as ro}from"./project-settings.js";import{apiTokensView as to}from"./api-tokens.js";import{apiEndpointsView as eo}from"./api-endpoints.js";import{apiEndpointEditorView as mo}from"./api-endpoint-editor.js";const po={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:oo,projectDetail:io,projectSettings:ro,apiTokens:to,apiEndpoints:eo,apiEndpointEditor:mo,dashboard:o,dashboards:i,pages:r,pageEditor:t,settings:e,theme:m,layouts:n,media:s,login:f,users:w,userEditor:c,plugins:V,pluginCode:a,documentation:d,tutorials:l,apiReference:b,collections:h,collectionEditor:v,collectionEntries:y,forms:j,formEditor:k,formSubmissions:L,viewsList:x,viewEditor:M,viewPreview:D,actionsList:G,actionEditor:P,proDocs:R,blocks:S,blockEditor:A,components:C,componentEditor:N,myProfile:T,roles:U,roleEditor:q,effects:z,search:B,contentApi:F,notes:H,todo:I,calculator:J,analytics:K,seo:O,contacts:Q,callNew:W,notifications:p,menus:X,contextMenus:Y,contextMenuEditor:Z,menuEditor:_,menuLocations:$,menusRedirect:po,pluginGuides:E,pluginGuide:g,...u};
1
+ import{dashboardView as o}from"./dashboard.js";import{dashboardsView as i}from"./dashboards.js";import{pagesView as r}from"./pages.js";import{pageEditorView as t}from"./page-editor.js";import{settingsView as e}from"./settings.js";import{themeView as m}from"./theme.js";import{notificationsView as p}from"./notifications.js";import{layoutsView as s}from"./layouts.js";import{mediaView as n}from"./media.js";import{loginView as f}from"./login.js";import{usersView as w}from"./users.js";import{userEditorView as c}from"./user-editor.js";import{pluginsView as V}from"./plugins.js";import{pluginCodeView as a}from"./plugin-code.js";import{documentationView as d}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as u}from"./doc-pages.js";import{pluginGuidesView as E,pluginGuideView as g}from"./plugin-guides.js";import{apiReferenceView as b}from"./api-reference.js";import{collectionsView as h}from"./collections.js";import{collectionEditorView as v}from"./collection-editor.js";import{collectionEntriesView as y}from"./collection-entries.js";import{formsView as j}from"./forms.js";import{formEditorView as k}from"./form-editor.js";import{formSubmissionsView as L}from"./form-submissions.js";import{viewsListView as x}from"./views-list.js";import{viewEditorView as M}from"./view-editor.js";import{viewPreviewView as D}from"./view-preview.js";import{actionsListView as G}from"./actions-list.js";import{actionEditorView as P}from"./action-editor.js";import{proDocsView as R}from"./pro-docs.js";import{blocksView as S}from"./blocks.js";import{blockEditorView as A}from"./block-editor.js";import{componentsView as C}from"./components.js";import{componentEditorView as I}from"./component-editor.js";import{myProfileView as N}from"./my-profile.js";import{rolesView as T}from"./roles.js";import{roleEditorView as U}from"./role-editor.js";import{effectsView as q}from"./effects.js";import{searchView as z}from"./search.js";import{contentApiView as B}from"./content-api.js";import{notesView as F}from"./notes.js";import{todoView as H}from"./todo.js";import{calculatorView as J}from"../lib/calculator.js";import{analyticsView as K}from"./analytics.js";import{seoView as O}from"./seo.js";import{wordpressImportView as Q}from"./wordpress-import.js";import{contactsView as W}from"./contacts.js";import{callNewView as X}from"./call-new.js";import{menusView as Y}from"./menus.js";import{contextMenusView as Z}from"./context-menus.js";import{contextMenuEditorView as _}from"./context-menu-editor.js";import{menuEditorView as $}from"./menu-editor.js";import{menuLocationsView as oo}from"./menu-locations.js";import{projectsView as io}from"./projects.js";import{projectDetailView as ro}from"./project-detail.js";import{projectSettingsView as to}from"./project-settings.js";import{apiTokensView as eo}from"./api-tokens.js";import{apiEndpointsView as mo}from"./api-endpoints.js";import{apiEndpointEditorView as po}from"./api-endpoint-editor.js";const so={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:io,projectDetail:ro,projectSettings:to,apiTokens:eo,apiEndpoints:mo,apiEndpointEditor:po,dashboard:o,dashboards:i,pages:r,pageEditor:t,settings:e,theme:m,layouts:s,media:n,login:f,users:w,userEditor:c,plugins:V,pluginCode:a,documentation:d,tutorials:l,apiReference:b,collections:h,collectionEditor:v,collectionEntries:y,forms:j,formEditor:k,formSubmissions:L,viewsList:x,viewEditor:M,viewPreview:D,actionsList:G,actionEditor:P,proDocs:R,blocks:S,blockEditor:A,components:C,componentEditor:I,myProfile:N,roles:T,roleEditor:U,effects:q,search:z,contentApi:B,notes:F,todo:H,calculator:J,analytics:K,seo:O,wordpressImport:Q,contacts:W,callNew:X,notifications:p,menus:Y,contextMenus:Z,contextMenuEditor:_,menuEditor:$,menuLocations:oo,menusRedirect:so,pluginGuides:E,pluginGuide:g,...u};
@@ -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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -337,7 +337,9 @@ export function openCompose({
337
337
  const slideover = E.slideover({
338
338
  title: draft.savedUid
339
339
  ? 'Draft'
340
- : (draft.inReplyTo ? 'Reply' : (draft.subject ? 'Forward' : 'New message')),
340
+ // From the mode, not the subject: a new message can arrive with
341
+ // a subject already in it (Contacts' "Send birthday wishes").
342
+ : (draft.inReplyTo ? 'Reply' : (openedAs === 'forward' ? 'Forward' : 'New message')),
341
343
  size: 'lg',
342
344
  position: 'right'
343
345
  });
@@ -241,7 +241,21 @@ export function signsOn(identity, mode) {
241
241
  * @returns {object}
242
242
  */
243
243
  export function applyIdentity(draft, identity, mode = 'new') {
244
- if (!signsOn(identity, mode)) return draft;
244
+ // `lead`: text another screen asked to start the message with (Contacts'
245
+ // Email…, a call link). It goes ABOVE the signature, where the cursor
246
+ // would be - put in `body`/`html` it would land under it, or be lost to
247
+ // the HTML part entirely.
248
+ const leadText = String(draft?.lead ?? '').trim();
249
+ const {lead: _unused, ...rest} = draft ?? {};
250
+ draft = rest;
251
+ if (!signsOn(identity, mode)) {
252
+ if (!leadText) return draft;
253
+ return {
254
+ ...draft,
255
+ html: textToHtml(leadText) + String(draft.html ?? ''),
256
+ body: draft.body ? `${leadText}\n\n${draft.body}` : leadText
257
+ };
258
+ }
245
259
 
246
260
  const id = normaliseIdentity(identity);
247
261
  const sigHtml = id.signatureHtml || textToHtml(id.signatureText);
@@ -254,7 +268,7 @@ export function applyIdentity(draft, identity, mode = 'new') {
254
268
  const block = `<div class="dm-mail-signature">${sigHtml}</div>`;
255
269
  // An empty paragraph above everything, which is where the cursor lands
256
270
  // and where the message gets written.
257
- const lead = '<p><br></p>';
271
+ const lead = leadText ? textToHtml(leadText) : '<p><br></p>';
258
272
  const quoted = String(draft.html ?? '');
259
273
  const quotedText = String(draft.body ?? '');
260
274
  const below = id.placement === 'below-quote' && quoted;
@@ -262,7 +276,8 @@ export function applyIdentity(draft, identity, mode = 'new') {
262
276
  return {
263
277
  ...draft,
264
278
  html: below ? lead + quoted + block : lead + block + quoted,
265
- body: below ? `${quotedText}\n\n${sigText}` : `\n\n${sigText}${quotedText}`
279
+ body: below ? `${leadText}${leadText ? '\n\n' : ''}${quotedText}\n\n${sigText}`
280
+ : `${leadText}\n\n${sigText}${quotedText}`
266
281
  };
267
282
  }
268
283
 
@@ -6089,15 +6089,43 @@ return {
6089
6089
  });
6090
6090
  }
6091
6091
 
6092
- /** A new, empty message - the banner's Compose and the pane's. */
6093
- async function startNewMessage() {
6092
+ /**
6093
+ * A new message - the banner's Compose and the pane's, empty; or one
6094
+ * another screen asked for (Contacts' Email…), already addressed.
6095
+ *
6096
+ * @param {object} [draft] - `{to, cc, bcc, subject, body}`
6097
+ */
6098
+ async function startNewMessage(draft) {
6094
6099
  try {
6095
- openCompose(composeOptions(await identitySoon(), {mode: 'new'}));
6100
+ openCompose(composeOptions(await identitySoon(), {mode: 'new', ...(draft ? {draft} : {})}));
6096
6101
  } catch (err) {
6097
6102
  E.toast(err.message || 'Compose could not be opened.', {type: 'error'});
6098
6103
  }
6099
6104
  }
6100
6105
 
6106
+ /**
6107
+ * Another screen sent someone here to write to a person:
6108
+ * `#/plugins/<name>?compose=1&to=…` (admin/js/lib/mail-compose.js).
6109
+ * Opened once, then taken off the address so a reload or Back does
6110
+ * not open it again. A core that predates the helper has no
6111
+ * requests to read, and nothing happens.
6112
+ */
6113
+ async function takeComposeRequest() {
6114
+ const mail = await import('/admin/js/lib/mail-compose.js').catch(() => null);
6115
+ const request = mail?.readComposeRequest(location.hash);
6116
+ if (!request) return;
6117
+ history.replaceState(history.state, '', `${location.pathname}${location.search}#/plugins/${pluginName}`);
6118
+ if (!features.send) return;
6119
+ if (!currentAccountId) {
6120
+ E.toast('Add a mailbox first, then write your message.', {type: 'warning'});
6121
+ return;
6122
+ }
6123
+ const draft = {to: request.to, cc: request.cc, bcc: request.bcc, subject: request.subject};
6124
+ // Above the signature, in both parts - applyIdentity places it.
6125
+ if (request.body) draft.lead = request.body;
6126
+ await startNewMessage(draft);
6127
+ }
6128
+
6101
6129
  if (features.send) {
6102
6130
  const compose = $container.find('#mail-compose-btn').get(0);
6103
6131
  if (compose && bannerCompose) compose.remove();
@@ -6149,6 +6177,7 @@ return {
6149
6177
 
6150
6178
  await start();
6151
6179
  if (!here()) return;
6180
+ await takeComposeRequest();
6152
6181
  // The sidebar updates independently; keep an open inbox in step with
6153
6182
  // the mirror without interrupting selection, search or older pages.
6154
6183
  refreshTimer = setInterval(async () => {
@@ -353,3 +353,7 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
353
353
  - `menuKeys(list, rowSel)`: Shift+F10 on a focused row opens its menu. `pokeSidebar()` after any change that moves
354
354
  the badge. `withSkeleton(slot, promise, shape)` wraps core's loading.js (slot must be a block element).
355
355
  - Settings: leaving always keeps the draft (the old `unloading` flag is gone).
356
+
357
+ ## 1.10.1
358
+
359
+ - **Email (openEmail).** Email actions go through `admin/lib/email.js`: `emailTo()` for menu actions (several people always in Bcc), `routeMailLinks(root)` for visible `mailto:` links. Core's `/admin/js/lib/mail-compose.js` (CMS 1.6.61+) opens Email Pro's compose window when the user has a mailbox there; older cores keep a cleaned, capped `mailto:` (over 1800 chars the addresses are copied instead). Stored addresses are encoded into links, or shown as text when invalid. Never set `location.href = 'mailto:…'` directly.