domma-cms 1.10.19 → 1.10.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.21] - 2026-10-25
4
+
5
+ - **A form in a footer block keeps cached pages up to date.** What a footer block draws (a `[form]`, its file cap) now tags every cached page that shows it, so saving the form or changing the cap reaches those pages at once instead of after the cache expires. Producers can add tags to a plain cached value with `{_cacheValue, _cacheTags}`.
6
+ - **A visitor's file named just `json`, `md` or the like is taken** - it is stored with a prefix and served as a download, so refusing it was wrong; the page and the server agree.
7
+ - **A plugin's media listener that throws no longer fails an upload** (or leaves a visitor's file behind with an error): `content:mediaUploaded` / `content:mediaDeleted` listener errors are logged instead.
8
+
9
+ ## [1.10.20] - 2026-10-25
10
+
11
+ - Forms support versioned JSON definition import and export from the list banner and item actions. Imports create a fresh private submissions collection, keep existing forms and collections, and assign a new slug on conflicts.
12
+ - Shared transfer services support Surveys and Campaigns Pro definition bundles without copying responses, recipients, invitations, delivery history or claims.
13
+
3
14
  ## [1.10.19] - 2026-10-25
4
15
 
5
16
  - **Built against domma-js 0.58.2** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v0.58.2).
@@ -0,0 +1,2 @@
1
+ import{apiRequest as c}from"/admin/js/api.js";const d=n=>String(n??"").replace(/[&<>"']/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[t]);export async function exportDefinition(n){try{const t=await c(n),i=URL.createObjectURL(new Blob([JSON.stringify(t,null,4)+`
2
+ `],{type:"application/json"})),o=document.createElement("a");o.href=i,o.download=`${t.kind}-${t.definition.slug}.json`,document.body.appendChild(o),o.click(),o.remove(),setTimeout(()=>URL.revokeObjectURL(i),1e3)}catch(t){E.toast(`Export failed: ${t.message||t}`,{type:"error"})}}export function importDefinition(n,t,i,o){if(n.querySelector("[data-definition-import]"))return;const e=document.createElement("input");e.type="file",e.accept=".json,application/json",e.hidden=!0,e.dataset.definitionImport="true",n.appendChild(e),e.addEventListener("cancel",()=>e.remove(),{once:!0}),e.addEventListener("change",async()=>{try{const r=e.files?.[0];if(!r)return;if(r.size>1e6)throw new Error("The export must be smaller than 1 MB.");const a=JSON.parse(await r.text());if(a.format!=="domma-definition"||a.version!==1||a.kind!==i||!a.definition?.title)throw new Error(`Choose a Domma ${i} JSON export.`);const l=i==="form"?"The form settings and actions will be copied. It gets a new, empty submissions collection.":i==="survey"?"Its backing form is included. The survey starts as a draft with no audience or schedule.":"The campaign starts as a draft with no audience or sending schedule.";if(!await E.confirm(`Import <strong>${d(a.definition.title)}</strong>?<br>${l}<br>Existing items are kept. Naming conflicts get a new slug.`,{confirmText:"Import"})||!n.isConnected)return;const s=await c(t,{method:"POST",body:JSON.stringify(a)});E.toast(`Imported "${s.title}" as ${s.slug}.`,{type:"success"}),await o(s)}catch(r){E.toast(`Import failed: ${r.message||r}`,{type:"error"})}finally{e.remove()}},{once:!0}),e.click()}
@@ -1,4 +1,4 @@
1
- import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as ve,whileLoading as he}from"/admin/js/lib/loading.js";import{helpPopovers as Q}from"/admin/js/lib/help-popover.js";import{onceClosed as ge,openMenuAt as G,sidebarCount as P,viewLifetime as ye}from"/admin/js/lib/tool-kit.js";import{filterByProject as we,getProjectFromHash as xe,renderProjectFilterNotice as ke}from"/admin/js/lib/project-context.js";import{FILTERS as Ee,arrangeForms as Se,duplicatePayload as Te,formRow as Me,newFormDraftQuestion as Ce,shortcodeFor as $e,slugify as X}from"/admin/js/lib/forms-arrange.js";const Z=/^#\/(projects\/[^/?#]+\/)?forms\/?(\?.*)?$/,q="#/forms",x="forms.draft",j="forms.filter",De=`
1
+ import{exportDefinition as ve,importDefinition as he}from"/admin/js/lib/definition-transfer.js";import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as U}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as ge,whileLoading as ye}from"/admin/js/lib/loading.js";import{helpPopovers as Q}from"/admin/js/lib/help-popover.js";import{onceClosed as we,openMenuAt as G,sidebarCount as x,viewLifetime as xe}from"/admin/js/lib/tool-kit.js";import{filterByProject as ke,getProjectFromHash as Ee,renderProjectFilterNotice as Se}from"/admin/js/lib/project-context.js";import{FILTERS as Te,arrangeForms as Me,duplicatePayload as Ce,formRow as $e,newFormDraftQuestion as De,shortcodeFor as Fe,slugify as X}from"/admin/js/lib/forms-arrange.js";const Z=/^#\/(projects\/[^/?#]+\/)?forms\/?(\?.*)?$/,k="#/forms",T="forms.draft",j="forms.filter",Le=`
2
2
  <div class="fmx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="fmx-icon" aria-hidden="true"><span data-icon="layout" data-icon-size="16"></span></span>
4
4
  <span class="fmx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
@@ -10,7 +10,7 @@ import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/
10
10
  </span>
11
11
  <span class="fmx-count" title="Submissions">{{totalText}}</span>
12
12
  <button type="button" class="fmx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
13
- </div>`,k=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");function Fe(m){const i=document.createElement("template");return i.innerHTML=m.trim(),i.content.firstElementChild}const c=ye(Z);export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(m){c.reset();const i=m.get(0).querySelector(".fmx")||m.get(0),p=()=>Z.test(location.hash)&&i.isConnected,T=xe(),d=M.observable([]),B=M.observable(!0),C=M.observable("");let O="all";try{O=Ee.includes(S.get(j))?S.get(j):"all"}catch{}const b=M.observable(O),U=M.observable("");c.add(M.effect(()=>{const e=b.value;try{S.set(j,e)}catch{}}));const $=M.observable([]);c.add(M.effect(()=>{const e=Date.now();$.value=Se(d.value,{filter:b.value,query:U.value}).map(t=>Me(t,e))}));const ee=M.computed(()=>{const e=d.value;return{all:e.length,recent:e.filter(t=>Number(t.submissionsThisWeek)>0).length,empty:e.filter(t=>!Number(t.submissionCount)).length}}),te=M.computed(()=>{const e=d.value.reduce((t,s)=>t+(Number(s.submissionCount)||0),0);return d.value.length?`${e} submission${e===1?"":"s"} in all`:""}),se=M.computed(()=>d.value.length?"No forms match.":"No forms yet - New form in the banner makes one."),D=e=>d.peek().find(t=>t.slug===e);async function _(){try{let e=await he(i.querySelector(".dm-loading-slot"),h("/forms"),ve.rows);if(!p())return;if(e=Array.isArray(e)?e:[],T){const t=e.length;e=we(e,T),ke(i,{total:t,shown:e.length,slug:T,allHref:"#/forms",noun:"forms"})}d.value=e,C.value=""}catch(e){C.value=`Could not load the forms: ${e.message||e}`}finally{B.value=!1}}const g=e=>{location.hash=`#/forms/edit/${encodeURIComponent(e)}`},ae=e=>{location.hash=`#/forms/${encodeURIComponent(e)}/submissions`};async function ne(e){const t=$e(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function oe(e){const t=e.plugin?`This form is managed by the <strong>${k(e.plugin)}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete the form "${k(e.title||e.slug)}" and all its submissions? This cannot be undone.`;if(await E.confirm(t))try{await h(`/forms/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),P(q),p()&&await _()}catch(s){E.toast(`Failed to delete the form: ${s.message||s}`,{type:"error"})}}const F=new Set,y=new Set;let v=null,z=!1;function L({key:e,title:t,html:s,model:r,methods:n,dirty:o=()=>!1,cleanup:l=()=>{}}){if(y.has(e))return null;y.add(e);let a;try{a=E.slideover({title:t,size:"sm",position:"right"});const u=Fe(s);a.element.appendChild(u);const ue=Q(u),me=M.applyBindings(r,u,{methods:n});F.add(a),ge(a,()=>{y.delete(e),l(),me.dispose(),ue(),F.delete(a)});const fe=a.close.bind(a);let V=!1,R=!1;a.close=async N=>{if(!V&&N!==!0&&!z&&o()){if(R)return;R=!0;const be=await E.confirm("Throw away what you have typed?",{confirmText:"Throw away",cancelText:"Keep editing"});if(R=!1,!be)return}return V=!0,fe()};const pe=N=>{u.inert=!!N};return I.scan(u),a.open(),{so:a,body:u,lock:pe}}catch(u){y.delete(e);try{a?.destroy?.(),a?.element?.remove()}catch{}return E.toast(`Could not open the panel: ${u.message||u}`,{type:"error"}),null}}function K(e=null){const t={title:M.observable(e?.title??""),saving:M.observable(!1)};t.slug=M.computed(()=>X(t.title.value)),t.err=M.computed(()=>t.title.value.trim()?t.slug.value?D(t.slug.value)?`There is already a form "${t.slug.value}" - choose another title.`:"":"The title needs at least one letter or number.":""),t.hint=M.computed(()=>t.slug.value&&!t.err.value?`Its address will be "${t.slug.value}" - [form name="${t.slug.value}" /].`:""),t.cannot=M.computed(()=>t.saving.value||!t.title.value.trim()||!!t.err.value);const s=()=>!!t.title.peek().trim(),r={kind:"create",dirty:s,values:()=>({title:t.title.peek()})};async function n(){if(!t.cannot.peek()){t.saving.value=!0,o.lock(!0);try{const a=await h("/forms",{method:"POST",body:JSON.stringify({title:t.title.peek().trim()})});P(q),o.so.close(!0),A(),p()?g(a.slug):E.toast(`Form "${a.title||a.slug}" created.`,{type:"success"})}catch(a){o.lock(!1),E.toast(a.message||"Failed to create the form.",{type:"error"})}finally{t.saving.value=!1}}}const o=L({key:"create",title:"New form",model:t,dirty:s,cleanup:()=>{v===r&&(v=null)},methods:{save:n,cancel:()=>o.so.close()},html:`
13
+ </div>`,C=d=>String(d??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");function Ie(d){const l=document.createElement("template");return l.innerHTML=d.trim(),l.content.firstElementChild}const c=xe(Z);export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(d){c.reset();const l=d.get(0).querySelector(".fmx")||d.get(0),p=()=>Z.test(location.hash)&&l.isConnected,$=Ee(),u=M.observable([]),B=M.observable(!0),D=M.observable("");let _="all";try{_=Te.includes(S.get(j))?S.get(j):"all"}catch{}const b=M.observable(_),z=M.observable("");c.add(M.effect(()=>{const e=b.value;try{S.set(j,e)}catch{}}));const F=M.observable([]);c.add(M.effect(()=>{const e=Date.now();F.value=Me(u.value,{filter:b.value,query:z.value}).map(t=>$e(t,e))}));const ee=M.computed(()=>{const e=u.value;return{all:e.length,recent:e.filter(t=>Number(t.submissionsThisWeek)>0).length,empty:e.filter(t=>!Number(t.submissionCount)).length}}),te=M.computed(()=>{const e=u.value.reduce((t,s)=>t+(Number(s.submissionCount)||0),0);return u.value.length?`${e} submission${e===1?"":"s"} in all`:""}),se=M.computed(()=>u.value.length?"No forms match.":"No forms yet - New form in the banner makes one."),L=e=>u.peek().find(t=>t.slug===e);async function A(){try{let e=await ye(l.querySelector(".dm-loading-slot"),h("/forms"),ge.rows);if(!p())return;if(e=Array.isArray(e)?e:[],$){const t=e.length;e=ke(e,$),Se(l,{total:t,shown:e.length,slug:$,allHref:"#/forms",noun:"forms"})}u.value=e,D.value=""}catch(e){D.value=`Could not load the forms: ${e.message||e}`}finally{B.value=!1}}const g=e=>{location.hash=`#/forms/edit/${encodeURIComponent(e)}`},ae=e=>{location.hash=`#/forms/${encodeURIComponent(e)}/submissions`};async function ne(e){const t=Fe(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function oe(e){const t=e.plugin?`This form is managed by the <strong>${C(e.plugin)}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete the form "${C(e.title||e.slug)}" and all its submissions? This cannot be undone.`;if(await E.confirm(t))try{await h(`/forms/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),x(k),p()&&await A()}catch(s){E.toast(`Failed to delete the form: ${s.message||s}`,{type:"error"})}}const R=new Set,y=new Set;let v=null,K=!1;function N({key:e,title:t,html:s,model:r,methods:n,dirty:o=()=>!1,cleanup:i=()=>{}}){if(y.has(e))return null;y.add(e);let a;try{a=E.slideover({title:t,size:"sm",position:"right"});const m=Ie(s);a.element.appendChild(m);const ue=Q(m),me=M.applyBindings(r,m,{methods:n});R.add(a),we(a,()=>{y.delete(e),i(),me.dispose(),ue(),R.delete(a)});const fe=a.close.bind(a);let Y=!1,q=!1;a.close=async O=>{if(!Y&&O!==!0&&!K&&o()){if(q)return;q=!0;const be=await E.confirm("Throw away what you have typed?",{confirmText:"Throw away",cancelText:"Keep editing"});if(q=!1,!be)return}return Y=!0,fe()};const pe=O=>{m.inert=!!O};return I.scan(m),a.open(),{so:a,body:m,lock:pe}}catch(m){y.delete(e);try{a?.destroy?.(),a?.element?.remove()}catch{}return E.toast(`Could not open the panel: ${m.message||m}`,{type:"error"}),null}}function H(e=null){const t={title:M.observable(e?.title??""),saving:M.observable(!1)};t.slug=M.computed(()=>X(t.title.value)),t.err=M.computed(()=>t.title.value.trim()?t.slug.value?L(t.slug.value)?`There is already a form "${t.slug.value}" - choose another title.`:"":"The title needs at least one letter or number.":""),t.hint=M.computed(()=>t.slug.value&&!t.err.value?`Its address will be "${t.slug.value}" - [form name="${t.slug.value}" /].`:""),t.cannot=M.computed(()=>t.saving.value||!t.title.value.trim()||!!t.err.value);const s=()=>!!t.title.peek().trim(),r={kind:"create",dirty:s,values:()=>({title:t.title.peek()})};async function n(){if(!t.cannot.peek()){t.saving.value=!0,o.lock(!0);try{const a=await h("/forms",{method:"POST",body:JSON.stringify({title:t.title.peek().trim()})});x(k),o.so.close(!0),P(),p()?g(a.slug):E.toast(`Form "${a.title||a.slug}" created.`,{type:"success"})}catch(a){o.lock(!1),E.toast(a.message||"Failed to create the form.",{type:"error"})}finally{t.saving.value=!1}}}const o=N({key:"create",title:"New form",model:t,dirty:s,cleanup:()=>{v===r&&(v=null)},methods:{save:n,cancel:()=>o.so.close()},html:`
14
14
  <div class="fmx-form">
15
15
  <div>
16
16
  <label class="form-label" for="fmx-new-title">Title
@@ -26,7 +26,7 @@ import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/
26
26
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
27
27
  <span data-icon="plus" data-icon-size="13"></span> Create</button>
28
28
  </div>
29
- </div>`});if(!o)return!1;v=r;const l=o.body.querySelector("#fmx-new-title");return l.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),n())}),setTimeout(()=>l?.focus(),50),!0}function le(e){const t=`${e.title||e.slug} (Copy)`,s={title:M.observable(t),saving:M.observable(!1)};s.slug=M.computed(()=>X(s.title.value)),s.err=M.computed(()=>s.slug.value?D(s.slug.value)?`There is already a form "${s.slug.value}".`:"":"The title needs at least one letter or number."),s.cannot=M.computed(()=>s.saving.value||!!s.err.value);async function r(){if(!s.cannot.peek()){s.saving.value=!0,n.lock(!0);try{const l=await h(`/forms/${encodeURIComponent(e.slug)}`),a=await h("/forms",{method:"POST",body:JSON.stringify(Te(l,s.title.peek()))});E.toast(`Duplicated as "${a.title}".`,{type:"success"}),P(q),n.so.close(!0),p()&&g(a.slug)}catch(l){n.lock(!1),E.toast(l.message||"Failed to duplicate the form.",{type:"error"})}finally{s.saving.value=!1}}}const n=L({key:`duplicate:${e.slug}`,title:`Duplicate ${k(e.title||e.slug)}`,model:s,dirty:()=>s.title.peek().trim()!==t,methods:{save:r,cancel:()=>n.so.close()},html:`
29
+ </div>`});if(!o)return!1;v=r;const i=o.body.querySelector("#fmx-new-title");return i.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),n())}),setTimeout(()=>i?.focus(),50),!0}function le(e){const t=`${e.title||e.slug} (Copy)`,s={title:M.observable(t),saving:M.observable(!1)};s.slug=M.computed(()=>X(s.title.value)),s.err=M.computed(()=>s.slug.value?L(s.slug.value)?`There is already a form "${s.slug.value}".`:"":"The title needs at least one letter or number."),s.cannot=M.computed(()=>s.saving.value||!!s.err.value);async function r(){if(!s.cannot.peek()){s.saving.value=!0,n.lock(!0);try{const i=await h(`/forms/${encodeURIComponent(e.slug)}`),a=await h("/forms",{method:"POST",body:JSON.stringify(Ce(i,s.title.peek()))});E.toast(`Duplicated as "${a.title}".`,{type:"success"}),x(k),n.so.close(!0),p()&&g(a.slug)}catch(i){n.lock(!1),E.toast(i.message||"Failed to duplicate the form.",{type:"error"})}finally{s.saving.value=!1}}}const n=N({key:`duplicate:${e.slug}`,title:`Duplicate ${C(e.title||e.slug)}`,model:s,dirty:()=>s.title.peek().trim()!==t,methods:{save:r,cancel:()=>n.so.close()},html:`
30
30
  <div class="fmx-form">
31
31
  <div>
32
32
  <label class="form-label" for="fmx-dup-title">Title of the copy</label>
@@ -40,7 +40,7 @@ import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/
40
40
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
41
41
  <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
42
42
  </div>
43
- </div>`});if(!n)return;const o=n.body.querySelector("#fmx-dup-title");o.addEventListener("keydown",l=>{l.key==="Enter"&&(l.preventDefault(),r())}),setTimeout(()=>{o?.focus(),o?.select()},50)}function ie(){const e={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};e.cannot=M.computed(()=>e.sending.value||!/^[^\s@]+@[^\s@]+$/.test(e.to.value.trim()));async function t(){if(!e.cannot.peek()){e.sending.value=!0,s.lock(!0);try{await h("/forms/test-email",{method:"POST",body:JSON.stringify({to:e.to.peek().trim()})}),s.so.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){s.lock(!1),E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{e.sending.value=!1}}}const s=L({key:"test-email",title:"Send test email",model:e,methods:{send:t,cancel:()=>s.so.close()},html:`
43
+ </div>`});if(!n)return;const o=n.body.querySelector("#fmx-dup-title");o.addEventListener("keydown",i=>{i.key==="Enter"&&(i.preventDefault(),r())}),setTimeout(()=>{o?.focus(),o?.select()},50)}function ie(){const e={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};e.cannot=M.computed(()=>e.sending.value||!/^[^\s@]+@[^\s@]+$/.test(e.to.value.trim()));async function t(){if(!e.cannot.peek()){e.sending.value=!0,s.lock(!0);try{await h("/forms/test-email",{method:"POST",body:JSON.stringify({to:e.to.peek().trim()})}),s.so.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){s.lock(!1),E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{e.sending.value=!1}}}const s=N({key:"test-email",title:"Send test email",model:e,methods:{send:t,cancel:()=>s.so.close()},html:`
44
44
  <div class="fmx-form">
45
45
  <div>
46
46
  <label class="form-label" for="fmx-test-to">Send to
@@ -54,4 +54,4 @@ import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as Y}from"/
54
54
  <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="cannot.value">
55
55
  <span data-icon="mail" data-icon-size="13"></span> Send</button>
56
56
  </div>
57
- </div>`});if(!s)return;const r=s.body.querySelector("#fmx-test-to");r.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),t())}),setTimeout(()=>r?.focus(),50)}function re(e){const t=D(e);if(!t)return[];const s=Number(t.submissionCount)||0;return[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>g(t.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ae(t.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>ne(t)},{label:"Duplicate\u2026",icon:"copy",action:()=>le(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>oe(t)}]}const f=i.querySelector(".fmx-list");f.innerHTML=De;const ce=M.applyBindings({forms:d,loading:B,loadError:C,filter:b,query:U,rows:$,counts:ee,summary:te,emptyText:se},i,{methods:{showAll:()=>{b.value="all"},showRecent:()=>{b.value="recent"},showEmpty:()=>{b.value="empty"}}});c.add(()=>ce.dispose()),c.add(Q(i,{position:"bottom"})),c.add(M.effect(()=>{$.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const de=E.contextMenu(f,{cursor:"glyph",match:".fmx-row",inherit:!1,density:"compact",items:e=>re(e.dataset.slug)});c.add(()=>de?.destroy?.());const H=e=>{const t=e.target.closest(".fmx-row");if(!t)return;const s=e.target.closest(".fmx-kebab");if(s){G(s);return}g(t.dataset.slug)},W=e=>{const t=e.target.closest(".fmx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),G(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.slug))};f.addEventListener("click",H),f.addEventListener("keydown",W),c.add(()=>{f.removeEventListener("click",H),f.removeEventListener("keydown",W)}),Y(m,{id:"forms-new",icon:"plus",title:"New form",onClick:()=>K()}),Y(m,{id:"forms-test-email",icon:"mail",title:"Send a test email",onClick:()=>ie()});const A=()=>{try{S.remove?S.remove(x):S.set(x,null)}catch{}},J=e=>{v?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",J),c.add(()=>window.removeEventListener("beforeunload",J)),c.add(()=>{if(v?.dirty())try{S.set(x,{values:v.values(),at:new Date().toISOString()})}catch{}z=!0,[...F].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(i),await _(),!p())return;let w=null;try{w=S.get(x)}catch{}if(w?.values?.title){const e=Ce(w,d.peek());if(await E.confirm(k(e),{title:"Unsaved new form",confirmText:"Throw away",cancelText:"Keep it"})){A();return}if(!p())return;K(w.values)&&A()}}};
57
+ </div>`});if(!s)return;const r=s.body.querySelector("#fmx-test-to");r.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),t())}),setTimeout(()=>r?.focus(),50)}function re(e){const t=L(e);if(!t)return[];const s=Number(t.submissionCount)||0;return[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>g(t.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ae(t.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>ne(t)},{label:"Export JSON",icon:"download",action:()=>ve(`/forms/${encodeURIComponent(t.slug)}/export`)},{label:"Duplicate\u2026",icon:"copy",action:()=>le(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>oe(t)}]}const f=l.querySelector(".fmx-list");f.innerHTML=Le;const ce=M.applyBindings({forms:u,loading:B,loadError:D,filter:b,query:z,rows:F,counts:ee,summary:te,emptyText:se},l,{methods:{showAll:()=>{b.value="all"},showRecent:()=>{b.value="recent"},showEmpty:()=>{b.value="empty"}}});c.add(()=>ce.dispose()),c.add(Q(l,{position:"bottom"})),c.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const de=E.contextMenu(f,{cursor:"glyph",match:".fmx-row",inherit:!1,density:"compact",items:e=>re(e.dataset.slug)});c.add(()=>de?.destroy?.());const J=e=>{const t=e.target.closest(".fmx-row");if(!t)return;const s=e.target.closest(".fmx-kebab");if(s){G(s);return}g(t.dataset.slug)},W=e=>{const t=e.target.closest(".fmx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),G(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.slug))};f.addEventListener("click",J),f.addEventListener("keydown",W),c.add(()=>{f.removeEventListener("click",J),f.removeEventListener("keydown",W)}),U(d,{id:"forms-import",icon:"upload",title:"Import",onClick:()=>he(l,"/forms/import","form",async()=>{x(k),p()&&await A()})}),U(d,{id:"forms-new",icon:"plus",title:"New form",onClick:()=>H()}),U(d,{id:"forms-test-email",icon:"mail",title:"Send a test email",onClick:()=>ie()});const P=()=>{try{S.remove?S.remove(T):S.set(T,null)}catch{}},V=e=>{v?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",V),c.add(()=>window.removeEventListener("beforeunload",V)),c.add(()=>{if(v?.dirty())try{S.set(T,{values:v.values(),at:new Date().toISOString()})}catch{}K=!0,[...R].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await A(),!p())return;let w=null;try{w=S.get(T)}catch{}if(w?.values?.title){const e=De(w,u.peek());if(await E.confirm(C(e),{title:"Unsaved new form",confirmText:"Throw away",cancelText:"Keep it"})){P();return}if(!p())return;H(w.values)&&P()}}};
@@ -0,0 +1,11 @@
1
+ # Import and export definitions
2
+
3
+ Forms, Surveys Pro and Campaigns Pro support portable JSON definitions.
4
+
5
+ Open an item's actions menu and choose **Export JSON**. In the destination site's list, choose **Import**, select that file and review the confirmation. Imports create new items. Existing slugs receive a numeric suffix instead of being overwritten.
6
+
7
+ - Forms include fields, page breaks, conditional rules, settings and actions. An imported form gets a fresh, private submissions collection. Review email, webhook and CMS action settings for the destination site.
8
+ - Surveys include their backing form, including wizard steps. The imported survey points to its imported form and starts as a draft. Select its audience and dates before opening it.
9
+ - Campaigns include their message, links and offer settings. The imported campaign starts as a draft. Select recipients and a sending schedule before sending it.
10
+
11
+ Definitions do not include submissions, responses, audience contacts, invitations, delivery history, claims or sending credentials. Existing response exports remain available separately. Imports accept version 1 Domma JSON exports smaller than 1 MB.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.10.19",
3
+ "version": "1.10.21",
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",
@@ -1 +1 @@
1
- export const DEFAULT_MAX_SIZE=5242880,DEFAULT_MAX_FILES=5,MAX_FILES_LIMIT=20;export function fileRules(e){const t=e&&e.file||{},s=!!(e&&e.multiple),n=Math.floor(Number(t.maxFiles));return{accept:String(t.accept||"").split(",").map(l=>l.trim()).filter(Boolean),maxSize:Number(t.maxSize)||5242880,multiple:s,maxFiles:s?Math.min(n>=1?n:5,20):1}}function f(e){return e===1?"one file":`${e} files`}export function countRefusal(e,t){const{maxFiles:s,multiple:n}=fileRules(e);if(Number(t)<=s)return null;const l=e&&(e.label||e.name)||"File";return n?`"${l}" takes at most ${f(s)}.`:`"${l}" takes one file.`}export function fileRefs(e){return(Array.isArray(e)?e:[e]).filter(s=>s&&typeof s=="object"&&!Array.isArray(s)&&typeof s.url=="string"&&(typeof s.name=="string"||s.name===void 0))}export function isFileValue(e){const t=fileRefs(e);return t.length>0&&t.length===(Array.isArray(e)?e.length:1)}export function fileValueText(e,{base:t="",urls:s=!0}={}){return fileRefs(e).map(n=>{const l=n.name||n.url.split("/").pop()||"file";return!s||!n.url?l:`${l} (${fileUrl(n,t)})`}).join("; ")}export function fileUrl(e,t=""){const s=String(e&&e.url||""),n=String(t||"").replace(/\/+$/,"");return n&&s.startsWith("/")?`${n}${s}`:s}export function mimeAccepted(e,t){if(!e||!e.length)return!0;const s=String(t||"");return e.some(n=>n.endsWith("/*")?s.startsWith(n.slice(0,-1)):n===s)}export function cleanUploadName(e){const t=s=>s.replace(/^[-.]+|[-.]+$/g,"");return t(t(String(e||"upload").toLowerCase().replace(/[^\w.-]+/g,"-").replace(/-+/g,"-")).slice(0,200))||"upload"}const z=/^(text\/html|text\/xml|text\/xsl|text\/javascript|text\/ecmascript|application\/(x-)?javascript|application\/ecmascript|application\/xml|application\/[\w.-]+\+xml|image\/svg\+xml)\b/i,y=/\.(html?|xht(ml)?|shtml?|svgz?|xml|xsd|xslt?|xul|rdf|owl|atom|rss|wsdl|smil?|kml|opf|xlf|gpx|ttml|mathml|mml|xop|m?js|mht(ml)?)$/i,w=new Set("123 1km 3dml 3mf aab aam aas abw ac acc ace acu acutc aep afm afp ahead ai air ait ami apk appcache application apr arc arj asc asm aso atc atom atomcat atomdeleted atomsvc atx aw azf azs azw bat bcpio bdf bdm bdoc bed bh2 blb blorb bmi bmml book box boz bsp bz bz2 c c11amc c11amz c4d c4f c4g c4p c4u cap car cb7 cba cbr cbt cbz cc cco cct ccxml cdbcmsg cdf cdfx cdkey cdmia cdmic cdmid cdmio cdmiq cdx cdxml cdy cer cfs chat chrt cif cii cjs cla class clkk clkp clkt clkw clkx clp cmc cmdf cml cmp cod coffee com cpio cpp cpt crd crl crt crx cryptonote csh csl csml csp css cst cu curl cww cxt cxx dae daf dart dataless davmount dbf dbk dcr dcurl dd2 ddd ddf der dfac dgc dic dir dis disposition-notification dmp dna dp dpg dsc dssc dtb dtd dvi dwd dwf dxp dxr ear ecma edm edx efif ei6 eml emma emotionml emz eps es es3 esa esf et3 etx eva evy exi exp ext ez ez2 ez3 f f77 f90 fcdt fcs fdf fdt fe_launch fg5 fgd fig flo flw flx fly fm fnc fo for frame fsc ftc fti fxp fxpl fzs g2w g3w gac gam gbr gca gdl gdoc geo geojson gex ggb ggt ghf gim glb gltf gml gmx gnumeric gph gpx gqf gqs gram gramps gre grv grxml gsf gsheet gslides gtar gtm gtw gv gxf gxt h hal hbci hbs hdd hdf held hh hjson hlp hpgl hpid hps hqx htc htke htm html hvd hvp hvs i2g icc ice icm ics ifb ifm iges igl igm igs igx iif imp ink inkml install iota ipfix ipk irm irp itp its ivp ivu jad jade jam jar jardiff java jisp jlt jnlp joda js json json5 jsonld jsonml jsx karbon kdbx key kfo kia kml kmz kne knp kon kpr kpt kpxx ksp ktr ktz kwd kwt lasxml latex lbd lbe les less lgr lha link66 list3820 listafp litcoffee lnk lostxml ltf lua luac lwp lzh m13 m14 m21 m3u8 m4p ma mads maei mag maker man manifest map markdown mathml mb mbk mbox mc1 mcd mcurl md mdb mdx me mesh meta4 metalink mets mfm mft mgp mgz mie mif mime mjs mkd mlp mmd mmf mml mny mobi mods mp21 mp4s mpc mpd mpkg mpm mpn mpy mqy mrc mrcx ms mscml mseed mseq msf msh msl msty mtl mts mus musd musicxml mvb mvt mwf mxf mxl mxml mxs n-gage n3 nb nbp nc ncx nfo ngdat nitf nlu nml nnd nns nnw nq nsc nsf nt ntf numbers nzb oa2 oa3 oas obd obgx obj oda ogex ogx omdoc onepkg onetmp onetoc onetoc2 opf opml oprc org osf osfpvg osm otf ova ovf owl oxps oxt p p10 p12 p7b p7c p7m p7r p7s p8 pac pages pas paw pbd pcap pcf pcl pclxl pcurl pdb pde pem pfa pfb pfm pfr pfx pgn pgp php pki pkipath pkpass pl plb plc plf pls pm pml portpkg ppd pqa prc pre prf provx ps psb psf pskcxml ptid pub pvb pwn qam qbo qfx qps qwd qwt qxb qxd qxl qxt raml rapd rcprofile rdf rdz relo rep res rif ris rl rld rm rms rmvb rnc rng roa roff rp9 rpm rpss rpst rq rs rsat rsd rsheet rss rtx run rusd s saf sass sbml sc scd scm scq scs scss scurl sda sdc sdd sdkd sdkm sdp sdw sea see seed sema semd semf senmlx sensmlx ser setpay setreg sfd-hdstx sfs sfv sgl sgm sgml sh shar shex shf shtml sieve sig silo sis sisx sit sitx siv skd skm skp skt slim slm sls slt sm smf smi smil smzip snf spc spdx spf spl spot spp spq sql src srt sru srx ssdl sse ssf ssml st stc std stf sti stk stl stpx stpxz stpz str stw styl stylus sub sus susp sv4cpio sv4crc svc svd svg svgz swa swf swi swidtag sxc sxd sxg sxi sxm sxw t t3 taglet tao tcap tcl td teacher tei teicorpus tex texi texinfo tfi tfm tk tmo toml torrent tpl tpt tr tra trig trm tsd tsv ttc ttf ttl ttml twd twds txd txf u32 u8dsn u8hdr u8mdn u8msg ubj udeb ufd ufdl ulx umj unityweb uoml uri uris urls usdz ustar utz uu uvd uvf uvt uvvd uvvf uvvt uvvx uvvz uvx uvz vbox vbox-extpack vcard vcd vcf vcg vcs vcx vdi vds vhd vis vmdk vor vox vrml vsd vsf vss vst vsw vtt vtu vxml w3d wad wadl war wasm wbs wbxml webapp webmanifest wg wgt wmd wml wmlc wmls wmlsc wmz woff woff2 wpd wqd wri wrl wsc wsdl wspolicy wtb x32 x3d x3db x3dbz x3dv x3dvz x3dz x_b x_t xaml xap xar xav xbap xbd xca xcs xdf xdm xdp xdssc xdw xel xenc xer xfdf xfdl xht xhtml xhvml xlf xml xns xo xop xpi xpl xpr xpw xpx xsd xsl xslt xsm xspf xul xvm xvml xyz xz yaml yang yin yml ymp z1 z2 z3 z4 z5 z6 z7 z8 zaz zir zirz zmm ".trim().split(" "));export function isRefusedKind(e){const t=cleanUploadName(e&&(e.filename??e.name)||"");return w.has(t.replace(/^.*\./,""))}function h(e){const t=/\.[^.]+$/.exec(cleanUploadName(e&&(e.filename??e.name)||""));return t?t[0]:""}export function isActiveFile(e){const t=String(e&&(e.mimetype??e.type)||""),s=e&&(e.filename??e.name)||"";return z.test(t)||y.test(cleanUploadName(s))}export function fileRefusal(e,t){const s=fileRules(e),n=e&&(e.label||e.name)||"File";if(Number(t&&t.size)>s.maxSize)return`"${n}" file is too large (max ${Math.round(s.maxSize/1024)} KB).`;const l=t&&(t.mimetype??t.type)||"";return isActiveFile(t)?`"${n}" file type "${l||h(t)}" not allowed (it could run as a page on this site).`:isRefusedKind(t)?`"${n}" file type "${h(t)||l}" cannot be uploaded here.`:s.accept.length&&!mimeAccepted(s.accept,l)?`"${n}" file type "${l}" not allowed (expected: ${String(e.file.accept).trim()}).`:null}const v=[[/wordprocessingml|^msword$/,"Word"],[/spreadsheetml|ms-excel/,"Excel"],[/presentationml|ms-powerpoint/,"PowerPoint"],[/^plain$/,"text"]];export function describeAccept(e){const t=[];return(e||[]).forEach(s=>{const[n,l]=String(s).split("/");let o;l==="*"?o={image:"images",audio:"audio",video:"video",text:"text files"}[n]||`${n} files`:l?o=v.find(([i])=>i.test(l))?.[1]||l.replace(/^(x-|vnd\.)/,"").split(/[.+]/)[0].toUpperCase():o=String(s),o&&!t.includes(o)&&t.push(o)}),t.length<2?t[0]||"":`${t.slice(0,-1).join(", ")} or ${t[t.length-1]}`}export function formatSize(e){const t=Number(e)||0;if(t<1024)return`${t} B`;const s=["KB","MB","GB"];let n=t,l=-1;do n/=1024,l++;while(n>=1024&&l<s.length-1);return`${n>=10?Math.round(n):Math.round(n*10)/10} ${s[l]}`}export function fileHint(e){const t=fileRules(e),s=describeAccept(t.accept),n=t.multiple?`up to ${f(t.maxFiles)}, ${formatSize(t.maxSize)} each`:`up to ${formatSize(t.maxSize)}`;return s?`${s[0].toUpperCase()}${s.slice(1)} - ${n}`:`Any file - ${n}`}export function dropzoneOptions(e){const t=fileRules(e);return{accept:t.accept.length?t.accept.join(","):null,maxSize:t.maxSize,multiple:t.multiple,maxFiles:t.multiple?t.maxFiles:null,labels:{prompt:t.multiple?"Drop files here, paste, or click to browse":"Drop a file here, paste, or click to browse",hint:fileHint(e),required:"Please add a file",type:refusalMessage(e,{name:"{name}"},"type"),size:refusalMessage(e,{name:"{name}"},"size"),count:refusalMessage(e,{name:"{name}"},"count")}}}export function fileBlueprint(e,t){return{...t,type:"dropzone",formConfig:{...t.formConfig||{},...dropzoneOptions(e)}}}export function refusalMessage(e,t,s,n){const l=fileRules(e),o=t&&t.name||"That file";if(s==="total")return`${o} was not added: the files together would be more than ${formatSize(n)}.`;if(s==="size")return`${o} was not added: it is larger than ${formatSize(l.maxSize)}.`;if(s==="type"){const i=describeAccept(l.accept);return`${o} was not added: ${i?`only ${i} can be uploaded here`:"that type of file is not accepted"}.`}return s==="active"?`${o} was not added: web pages, SVG images and scripts cannot be uploaded.`:s==="kind"?`${o} was not added: that kind of file cannot be uploaded here.`:`${o} was not added: only ${f(l.maxFiles)} can be added here.`}function k(e){return Array.isArray(e)&&e.length>0&&typeof File<"u"&&e.every(t=>t instanceof File)}export function splitFiles(e,t,s){const n={...e||{}},l=new Map,o=new Set((t||[]).filter(i=>i&&i.type==="file"&&i.name).map(i=>i.name));for(const[i,r]of Object.entries(n))k(r)?(l.set(i,r),delete n[i]):o.has(i)&&(Array.isArray(r)||r==null)&&(n[i]="");for(const[i,r]of s||[])l.set(i,r||[]);for(const[i,r]of l)r.length?delete n[i]:(l.delete(i),(o.has(i)||i in n)&&(n[i]=""));return{values:n,files:l}}export function submitBody(e,t){if(![...(t||new Map).values()].some(l=>l&&l.length))return{body:JSON.stringify(e),headers:{"Content-Type":"application/json"},multipart:!1};const n=new FormData;for(const[l,o]of Object.entries(e))o==null||t.has(l)||n.append(l,typeof o=="object"?JSON.stringify(o):String(o));for(const[l,o]of t)for(const i of o||[])n.append(l,i,i.name);return{body:n,headers:{},multipart:!0}}export function captureFileFields(e,t){!e||!t||e.querySelectorAll('input[type="file"]').forEach(s=>{s.name&&t.set(s.name,Array.from(s.files||[]))})}export function restoreFileFields(e,t){!e||!t||!t.size||e.querySelectorAll('input[type="file"]').forEach(s=>{const n=t.get(s.name);if(!n||!n.length||s.files&&s.files.length)return;const l=b(s);if(l){l.addFiles(n);return}try{const o=new DataTransfer;n.forEach(i=>o.items.add(i)),s.files=o.files}catch{}})}function b(e){const t=globalThis.E;if(!t||typeof t.get!="function")return null;const s=e.closest("[data-dropzone-field]"),n=s&&t.get(s,"dropzone")||t.get(e,"dropzone");return n&&typeof n.addFiles=="function"?n:null}function p(e,t){const s=String(t&&t.name||"").replace(/\[\]$/,"");return(e||[]).find(n=>n&&n.name===s)||{name:s}}function S(e){let t=e.nextElementSibling;return(!t||!t.classList.contains("fb-file-refusal"))&&(t=document.createElement("p"),t.className="fb-file-refusal",t.hidden=!0,e.after(t)),t}export function submissionTooLarge(e){const t=e/1048576;return`The files sent are too large together (at most ${t>=1?`${Math.round(t*10)/10} MB`:`${Math.round(e/1024)} KB`} in one submission). Send fewer or smaller files.`}export function setFileLimits(e,t,s){e&&(e._fbFileLimits=t||null,s&&(e._fbRetained=s))}function u(e){return Number(e&&e._fbFileLimits&&e._fbFileLimits.maxTotalBytes)||0}function m(e){let t=0;for(const s of e||[])t+=Number(s&&s.size)||0;return t}export function totalTooLarge(e,t){const s=u(e);if(!s||!t)return null;let n=0;for(const l of t.values())n+=m(l);return n>s?submissionTooLarge(s):null}function $(e,t){const s=globalThis.E,n=a=>s&&typeof s.get=="function"?s.get(a,"dropzone"):null,l=new Set;e.querySelectorAll('.dm-dropzone, [data-dropzone-field], input[type="file"]').forEach(a=>{const c=n(a);c&&l.add(c)});const o=new Set,i=new Set;let r=0;for(const a of l)a.input&&(o.add(a.input),a.input.name&&i.add(a.input.name)),a.zone&&a.zone.querySelectorAll('input[type="file"]').forEach(c=>o.add(c)),a!==t&&(r+=m(a.getFiles()));e.querySelectorAll('input[type="file"]').forEach(a=>{o.has(a)||(a.name&&i.add(a.name),r+=m(a.files))});for(const[a,c]of e._fbRetained||[])i.has(a)||(r+=m(c));return r}export function wireFileFields(e,t){if(!e||e._fbFilesWired)return;e._fbFilesWired=!0;const s=new WeakMap,n=(o,i,r)=>{const a=S(o.zone),c=s.get(o.zone)||[];c.push(refusalMessage(p(t,o.input),i,r,u(e))),s.set(o.zone,c),a.textContent=c.join(" "),a.hidden=!1,o.zone.classList.add("is-invalid"),queueMicrotask(()=>s.delete(o.zone))};e.addEventListener("dm:dropzone:reject",o=>{const i=o.detail&&o.detail.instance,[r,a]=o.detail&&o.detail.args||[];i&&i.zone&&n(i,r,a)});const l=new Map;e.addEventListener("dm:dropzone:beforeadd",o=>{const i=o.detail&&o.detail.instance,[r]=o.detail&&o.detail.args||[];if(!i||!i.zone||!r)return;const a=r.file||r;if(isActiveFile(a)){o.preventDefault(),n(i,r,"active");return}if(isRefusedKind(a)){o.preventDefault(),n(i,r,"kind");return}const c=u(e);if(!c)return;const d=i.options&&i.options.multiple===!1,x=Number(r.size??a.size)||0,g=d?0:l.get(i)||0;if($(e,d?i:null)+g+x>c){o.preventDefault(),n(i,r,"total");return}d||(l.has(i)||queueMicrotask(()=>l.delete(i)),l.set(i,g+x))}),e.addEventListener("dm:dropzone:change",o=>{const i=o.detail&&o.detail.instance;i&&l.delete(i)}),e.addEventListener("dm:dropzone:add",o=>{const i=o.detail&&o.detail.instance;if(!i||!i.zone||s.get(i.zone))return;const r=i.zone.nextElementSibling;r&&r.classList.contains("fb-file-refusal")&&(r.hidden=!0,r.textContent="")}),e.addEventListener("dm:dropzone:change",o=>{const i=o.detail&&o.detail.instance;if(!i||!i.input)return;i.input.dispatchEvent(new Event("input",{bubbles:!0}));const r=i.input.closest(".fb-field-invalid");r&&i.getFiles().length&&r.classList.remove("fb-field-invalid")})}export function enhanceFileInputs(e,t){const s=globalThis.E;!e||!s||typeof s.dropzone!="function"||(e.querySelectorAll('input[type="file"]').forEach(n=>{b(n)||s.dropzone(n,dropzoneOptions(p(t,n)))}),wireFileFields(e,t))}export function checkFileInputs(e,t){const s=[];return e&&e.querySelectorAll('input[type="file"]').forEach(n=>{const l=p(t,n),o=Array.from(n.files||[]),i=countRefusal(l,o.length);i&&s.push(i),o.forEach(r=>{const a=fileRefusal(l,r);a&&s.push(a)})}),s}export function destroyDropzones(e){const t=globalThis.E;!e||!t||typeof t.get!="function"||e.querySelectorAll('.dm-dropzone, input[type="file"]').forEach(s=>{const n=t.get(s,"dropzone");n&&typeof n.destroy=="function"&&n.destroy()})}
1
+ export const DEFAULT_MAX_SIZE=5242880,DEFAULT_MAX_FILES=5,MAX_FILES_LIMIT=20;export function fileRules(e){const t=e&&e.file||{},s=!!(e&&e.multiple),n=Math.floor(Number(t.maxFiles));return{accept:String(t.accept||"").split(",").map(l=>l.trim()).filter(Boolean),maxSize:Number(t.maxSize)||5242880,multiple:s,maxFiles:s?Math.min(n>=1?n:5,20):1}}function f(e){return e===1?"one file":`${e} files`}export function countRefusal(e,t){const{maxFiles:s,multiple:n}=fileRules(e);if(Number(t)<=s)return null;const l=e&&(e.label||e.name)||"File";return n?`"${l}" takes at most ${f(s)}.`:`"${l}" takes one file.`}export function fileRefs(e){return(Array.isArray(e)?e:[e]).filter(s=>s&&typeof s=="object"&&!Array.isArray(s)&&typeof s.url=="string"&&(typeof s.name=="string"||s.name===void 0))}export function isFileValue(e){const t=fileRefs(e);return t.length>0&&t.length===(Array.isArray(e)?e.length:1)}export function fileValueText(e,{base:t="",urls:s=!0}={}){return fileRefs(e).map(n=>{const l=n.name||n.url.split("/").pop()||"file";return!s||!n.url?l:`${l} (${fileUrl(n,t)})`}).join("; ")}export function fileUrl(e,t=""){const s=String(e&&e.url||""),n=String(t||"").replace(/\/+$/,"");return n&&s.startsWith("/")?`${n}${s}`:s}export function mimeAccepted(e,t){if(!e||!e.length)return!0;const s=String(t||"");return e.some(n=>n.endsWith("/*")?s.startsWith(n.slice(0,-1)):n===s)}export function cleanUploadName(e){const t=s=>s.replace(/^[-.]+|[-.]+$/g,"");return t(t(String(e||"upload").toLowerCase().replace(/[^\w.-]+/g,"-").replace(/-+/g,"-")).slice(0,200))||"upload"}const z=/^(text\/html|text\/xml|text\/xsl|text\/javascript|text\/ecmascript|application\/(x-)?javascript|application\/ecmascript|application\/xml|application\/[\w.-]+\+xml|image\/svg\+xml)\b/i,y=/\.(html?|xht(ml)?|shtml?|svgz?|xml|xsd|xslt?|xul|rdf|owl|atom|rss|wsdl|smil?|kml|opf|xlf|gpx|ttml|mathml|mml|xop|m?js|mht(ml)?)$/i,w=new Set("123 1km 3dml 3mf aab aam aas abw ac acc ace acu acutc aep afm afp ahead ai air ait ami apk appcache application apr arc arj asc asm aso atc atom atomcat atomdeleted atomsvc atx aw azf azs azw bat bcpio bdf bdm bdoc bed bh2 blb blorb bmi bmml book box boz bsp bz bz2 c c11amc c11amz c4d c4f c4g c4p c4u cap car cb7 cba cbr cbt cbz cc cco cct ccxml cdbcmsg cdf cdfx cdkey cdmia cdmic cdmid cdmio cdmiq cdx cdxml cdy cer cfs chat chrt cif cii cjs cla class clkk clkp clkt clkw clkx clp cmc cmdf cml cmp cod coffee com cpio cpp cpt crd crl crt crx cryptonote csh csl csml csp css cst cu curl cww cxt cxx dae daf dart dataless davmount dbf dbk dcr dcurl dd2 ddd ddf der dfac dgc dic dir dis disposition-notification dmp dna dp dpg dsc dssc dtb dtd dvi dwd dwf dxp dxr ear ecma edm edx efif ei6 eml emma emotionml emz eps es es3 esa esf et3 etx eva evy exi exp ext ez ez2 ez3 f f77 f90 fcdt fcs fdf fdt fe_launch fg5 fgd fig flo flw flx fly fm fnc fo for frame fsc ftc fti fxp fxpl fzs g2w g3w gac gam gbr gca gdl gdoc geo geojson gex ggb ggt ghf gim glb gltf gml gmx gnumeric gph gpx gqf gqs gram gramps gre grv grxml gsf gsheet gslides gtar gtm gtw gv gxf gxt h hal hbci hbs hdd hdf held hh hjson hlp hpgl hpid hps hqx htc htke htm html hvd hvp hvs i2g icc ice icm ics ifb ifm iges igl igm igs igx iif imp ink inkml install iota ipfix ipk irm irp itp its ivp ivu jad jade jam jar jardiff java jisp jlt jnlp joda js json json5 jsonld jsonml jsx karbon kdbx key kfo kia kml kmz kne knp kon kpr kpt kpxx ksp ktr ktz kwd kwt lasxml latex lbd lbe les less lgr lha link66 list3820 listafp litcoffee lnk lostxml ltf lua luac lwp lzh m13 m14 m21 m3u8 m4p ma mads maei mag maker man manifest map markdown mathml mb mbk mbox mc1 mcd mcurl md mdb mdx me mesh meta4 metalink mets mfm mft mgp mgz mie mif mime mjs mkd mlp mmd mmf mml mny mobi mods mp21 mp4s mpc mpd mpkg mpm mpn mpy mqy mrc mrcx ms mscml mseed mseq msf msh msl msty mtl mts mus musd musicxml mvb mvt mwf mxf mxl mxml mxs n-gage n3 nb nbp nc ncx nfo ngdat nitf nlu nml nnd nns nnw nq nsc nsf nt ntf numbers nzb oa2 oa3 oas obd obgx obj oda ogex ogx omdoc onepkg onetmp onetoc onetoc2 opf opml oprc org osf osfpvg osm otf ova ovf owl oxps oxt p p10 p12 p7b p7c p7m p7r p7s p8 pac pages pas paw pbd pcap pcf pcl pclxl pcurl pdb pde pem pfa pfb pfm pfr pfx pgn pgp php pki pkipath pkpass pl plb plc plf pls pm pml portpkg ppd pqa prc pre prf provx ps psb psf pskcxml ptid pub pvb pwn qam qbo qfx qps qwd qwt qxb qxd qxl qxt raml rapd rcprofile rdf rdz relo rep res rif ris rl rld rm rms rmvb rnc rng roa roff rp9 rpm rpss rpst rq rs rsat rsd rsheet rss rtx run rusd s saf sass sbml sc scd scm scq scs scss scurl sda sdc sdd sdkd sdkm sdp sdw sea see seed sema semd semf senmlx sensmlx ser setpay setreg sfd-hdstx sfs sfv sgl sgm sgml sh shar shex shf shtml sieve sig silo sis sisx sit sitx siv skd skm skp skt slim slm sls slt sm smf smi smil smzip snf spc spdx spf spl spot spp spq sql src srt sru srx ssdl sse ssf ssml st stc std stf sti stk stl stpx stpxz stpz str stw styl stylus sub sus susp sv4cpio sv4crc svc svd svg svgz swa swf swi swidtag sxc sxd sxg sxi sxm sxw t t3 taglet tao tcap tcl td teacher tei teicorpus tex texi texinfo tfi tfm tk tmo toml torrent tpl tpt tr tra trig trm tsd tsv ttc ttf ttl ttml twd twds txd txf u32 u8dsn u8hdr u8mdn u8msg ubj udeb ufd ufdl ulx umj unityweb uoml uri uris urls usdz ustar utz uu uvd uvf uvt uvvd uvvf uvvt uvvx uvvz uvx uvz vbox vbox-extpack vcard vcd vcf vcg vcs vcx vdi vds vhd vis vmdk vor vox vrml vsd vsf vss vst vsw vtt vtu vxml w3d wad wadl war wasm wbs wbxml webapp webmanifest wg wgt wmd wml wmlc wmls wmlsc wmz woff woff2 wpd wqd wri wrl wsc wsdl wspolicy wtb x32 x3d x3db x3dbz x3dv x3dvz x3dz x_b x_t xaml xap xar xav xbap xbd xca xcs xdf xdm xdp xdssc xdw xel xenc xer xfdf xfdl xht xhtml xhvml xlf xml xns xo xop xpi xpl xpr xpw xpx xsd xsl xslt xsm xspf xul xvm xvml xyz xz yaml yang yin yml ymp z1 z2 z3 z4 z5 z6 z7 z8 zaz zir zirz zmm ".trim().split(" "));export function isRefusedKind(e){const t=cleanUploadName(e&&(e.filename??e.name)||""),s=t.lastIndexOf(".");return s>=0&&w.has(t.slice(s+1))}function h(e){const t=/\.[^.]+$/.exec(cleanUploadName(e&&(e.filename??e.name)||""));return t?t[0]:""}export function isActiveFile(e){const t=String(e&&(e.mimetype??e.type)||""),s=e&&(e.filename??e.name)||"";return z.test(t)||y.test(cleanUploadName(s))}export function fileRefusal(e,t){const s=fileRules(e),n=e&&(e.label||e.name)||"File";if(Number(t&&t.size)>s.maxSize)return`"${n}" file is too large (max ${Math.round(s.maxSize/1024)} KB).`;const l=t&&(t.mimetype??t.type)||"";return isActiveFile(t)?`"${n}" file type "${l||h(t)}" not allowed (it could run as a page on this site).`:isRefusedKind(t)?`"${n}" file type "${h(t)||l}" cannot be uploaded here.`:s.accept.length&&!mimeAccepted(s.accept,l)?`"${n}" file type "${l}" not allowed (expected: ${String(e.file.accept).trim()}).`:null}const v=[[/wordprocessingml|^msword$/,"Word"],[/spreadsheetml|ms-excel/,"Excel"],[/presentationml|ms-powerpoint/,"PowerPoint"],[/^plain$/,"text"]];export function describeAccept(e){const t=[];return(e||[]).forEach(s=>{const[n,l]=String(s).split("/");let o;l==="*"?o={image:"images",audio:"audio",video:"video",text:"text files"}[n]||`${n} files`:l?o=v.find(([i])=>i.test(l))?.[1]||l.replace(/^(x-|vnd\.)/,"").split(/[.+]/)[0].toUpperCase():o=String(s),o&&!t.includes(o)&&t.push(o)}),t.length<2?t[0]||"":`${t.slice(0,-1).join(", ")} or ${t[t.length-1]}`}export function formatSize(e){const t=Number(e)||0;if(t<1024)return`${t} B`;const s=["KB","MB","GB"];let n=t,l=-1;do n/=1024,l++;while(n>=1024&&l<s.length-1);return`${n>=10?Math.round(n):Math.round(n*10)/10} ${s[l]}`}export function fileHint(e){const t=fileRules(e),s=describeAccept(t.accept),n=t.multiple?`up to ${f(t.maxFiles)}, ${formatSize(t.maxSize)} each`:`up to ${formatSize(t.maxSize)}`;return s?`${s[0].toUpperCase()}${s.slice(1)} - ${n}`:`Any file - ${n}`}export function dropzoneOptions(e){const t=fileRules(e);return{accept:t.accept.length?t.accept.join(","):null,maxSize:t.maxSize,multiple:t.multiple,maxFiles:t.multiple?t.maxFiles:null,labels:{prompt:t.multiple?"Drop files here, paste, or click to browse":"Drop a file here, paste, or click to browse",hint:fileHint(e),required:"Please add a file",type:refusalMessage(e,{name:"{name}"},"type"),size:refusalMessage(e,{name:"{name}"},"size"),count:refusalMessage(e,{name:"{name}"},"count")}}}export function fileBlueprint(e,t){return{...t,type:"dropzone",formConfig:{...t.formConfig||{},...dropzoneOptions(e)}}}export function refusalMessage(e,t,s,n){const l=fileRules(e),o=t&&t.name||"That file";if(s==="total")return`${o} was not added: the files together would be more than ${formatSize(n)}.`;if(s==="size")return`${o} was not added: it is larger than ${formatSize(l.maxSize)}.`;if(s==="type"){const i=describeAccept(l.accept);return`${o} was not added: ${i?`only ${i} can be uploaded here`:"that type of file is not accepted"}.`}return s==="active"?`${o} was not added: web pages, SVG images and scripts cannot be uploaded.`:s==="kind"?`${o} was not added: that kind of file cannot be uploaded here.`:`${o} was not added: only ${f(l.maxFiles)} can be added here.`}function k(e){return Array.isArray(e)&&e.length>0&&typeof File<"u"&&e.every(t=>t instanceof File)}export function splitFiles(e,t,s){const n={...e||{}},l=new Map,o=new Set((t||[]).filter(i=>i&&i.type==="file"&&i.name).map(i=>i.name));for(const[i,r]of Object.entries(n))k(r)?(l.set(i,r),delete n[i]):o.has(i)&&(Array.isArray(r)||r==null)&&(n[i]="");for(const[i,r]of s||[])l.set(i,r||[]);for(const[i,r]of l)r.length?delete n[i]:(l.delete(i),(o.has(i)||i in n)&&(n[i]=""));return{values:n,files:l}}export function submitBody(e,t){if(![...(t||new Map).values()].some(l=>l&&l.length))return{body:JSON.stringify(e),headers:{"Content-Type":"application/json"},multipart:!1};const n=new FormData;for(const[l,o]of Object.entries(e))o==null||t.has(l)||n.append(l,typeof o=="object"?JSON.stringify(o):String(o));for(const[l,o]of t)for(const i of o||[])n.append(l,i,i.name);return{body:n,headers:{},multipart:!0}}export function captureFileFields(e,t){!e||!t||e.querySelectorAll('input[type="file"]').forEach(s=>{s.name&&t.set(s.name,Array.from(s.files||[]))})}export function restoreFileFields(e,t){!e||!t||!t.size||e.querySelectorAll('input[type="file"]').forEach(s=>{const n=t.get(s.name);if(!n||!n.length||s.files&&s.files.length)return;const l=b(s);if(l){l.addFiles(n);return}try{const o=new DataTransfer;n.forEach(i=>o.items.add(i)),s.files=o.files}catch{}})}function b(e){const t=globalThis.E;if(!t||typeof t.get!="function")return null;const s=e.closest("[data-dropzone-field]"),n=s&&t.get(s,"dropzone")||t.get(e,"dropzone");return n&&typeof n.addFiles=="function"?n:null}function p(e,t){const s=String(t&&t.name||"").replace(/\[\]$/,"");return(e||[]).find(n=>n&&n.name===s)||{name:s}}function S(e){let t=e.nextElementSibling;return(!t||!t.classList.contains("fb-file-refusal"))&&(t=document.createElement("p"),t.className="fb-file-refusal",t.hidden=!0,e.after(t)),t}export function submissionTooLarge(e){const t=e/1048576;return`The files sent are too large together (at most ${t>=1?`${Math.round(t*10)/10} MB`:`${Math.round(e/1024)} KB`} in one submission). Send fewer or smaller files.`}export function setFileLimits(e,t,s){e&&(e._fbFileLimits=t||null,s&&(e._fbRetained=s))}function u(e){return Number(e&&e._fbFileLimits&&e._fbFileLimits.maxTotalBytes)||0}function m(e){let t=0;for(const s of e||[])t+=Number(s&&s.size)||0;return t}export function totalTooLarge(e,t){const s=u(e);if(!s||!t)return null;let n=0;for(const l of t.values())n+=m(l);return n>s?submissionTooLarge(s):null}function $(e,t){const s=globalThis.E,n=a=>s&&typeof s.get=="function"?s.get(a,"dropzone"):null,l=new Set;e.querySelectorAll('.dm-dropzone, [data-dropzone-field], input[type="file"]').forEach(a=>{const c=n(a);c&&l.add(c)});const o=new Set,i=new Set;let r=0;for(const a of l)a.input&&(o.add(a.input),a.input.name&&i.add(a.input.name)),a.zone&&a.zone.querySelectorAll('input[type="file"]').forEach(c=>o.add(c)),a!==t&&(r+=m(a.getFiles()));e.querySelectorAll('input[type="file"]').forEach(a=>{o.has(a)||(a.name&&i.add(a.name),r+=m(a.files))});for(const[a,c]of e._fbRetained||[])i.has(a)||(r+=m(c));return r}export function wireFileFields(e,t){if(!e||e._fbFilesWired)return;e._fbFilesWired=!0;const s=new WeakMap,n=(o,i,r)=>{const a=S(o.zone),c=s.get(o.zone)||[];c.push(refusalMessage(p(t,o.input),i,r,u(e))),s.set(o.zone,c),a.textContent=c.join(" "),a.hidden=!1,o.zone.classList.add("is-invalid"),queueMicrotask(()=>s.delete(o.zone))};e.addEventListener("dm:dropzone:reject",o=>{const i=o.detail&&o.detail.instance,[r,a]=o.detail&&o.detail.args||[];i&&i.zone&&n(i,r,a)});const l=new Map;e.addEventListener("dm:dropzone:beforeadd",o=>{const i=o.detail&&o.detail.instance,[r]=o.detail&&o.detail.args||[];if(!i||!i.zone||!r)return;const a=r.file||r;if(isActiveFile(a)){o.preventDefault(),n(i,r,"active");return}if(isRefusedKind(a)){o.preventDefault(),n(i,r,"kind");return}const c=u(e);if(!c)return;const d=i.options&&i.options.multiple===!1,x=Number(r.size??a.size)||0,g=d?0:l.get(i)||0;if($(e,d?i:null)+g+x>c){o.preventDefault(),n(i,r,"total");return}d||(l.has(i)||queueMicrotask(()=>l.delete(i)),l.set(i,g+x))}),e.addEventListener("dm:dropzone:change",o=>{const i=o.detail&&o.detail.instance;i&&l.delete(i)}),e.addEventListener("dm:dropzone:add",o=>{const i=o.detail&&o.detail.instance;if(!i||!i.zone||s.get(i.zone))return;const r=i.zone.nextElementSibling;r&&r.classList.contains("fb-file-refusal")&&(r.hidden=!0,r.textContent="")}),e.addEventListener("dm:dropzone:change",o=>{const i=o.detail&&o.detail.instance;if(!i||!i.input)return;i.input.dispatchEvent(new Event("input",{bubbles:!0}));const r=i.input.closest(".fb-field-invalid");r&&i.getFiles().length&&r.classList.remove("fb-field-invalid")})}export function enhanceFileInputs(e,t){const s=globalThis.E;!e||!s||typeof s.dropzone!="function"||(e.querySelectorAll('input[type="file"]').forEach(n=>{b(n)||s.dropzone(n,dropzoneOptions(p(t,n)))}),wireFileFields(e,t))}export function checkFileInputs(e,t){const s=[];return e&&e.querySelectorAll('input[type="file"]').forEach(n=>{const l=p(t,n),o=Array.from(n.files||[]),i=countRefusal(l,o.length);i&&s.push(i),o.forEach(r=>{const a=fileRefusal(l,r);a&&s.push(a)})}),s}export function destroyDropzones(e){const t=globalThis.E;!e||!t||typeof t.get!="function"||e.querySelectorAll('.dm-dropzone, input[type="file"]').forEach(s=>{const n=t.get(s,"dropzone");n&&typeof n.destroy=="function"&&n.destroy()})}
@@ -1,3 +1,5 @@
1
+ import {packDefinition, unpackDefinition, sendDefinition} from '../../services/definition-transfer.js';
2
+ import {importFormDefinition} from '../../services/form-transfer.js';
1
3
  /**
2
4
  * Core Forms API Routes
3
5
  * REST endpoints for form CRUD, public rendering, and submission handling.
@@ -119,7 +121,9 @@ function safeFilename(raw) {
119
121
 
120
122
  /** Why a stored name would be served as something a form may not collect, or null. */
121
123
  function servedTypeRefusal(fieldCfg, filename) {
122
- const served = servedType(cleanUploadName(filename));
124
+ // As stored: safeFilename puts a prefix in front, so a name with no dot ("json") has
125
+ // no type and is served as a download, like any other name without an extension.
126
+ const served = servedType(`00000000-${cleanUploadName(filename)}`);
123
127
  if (!served || formMayCollect(served)) return null;
124
128
  const label = fieldCfg.label || fieldCfg.name;
125
129
  return `"${label}" file type "${served}" cannot be uploaded here.`;
@@ -499,6 +503,21 @@ export async function formsRoutes(fastify) {
499
503
  const canUpdate = { preHandler: [authenticate, requirePermission('collections', 'update')] };
500
504
  const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
501
505
 
506
+ fastify.get('/forms/:slug/export', canRead, async (request, reply) => {
507
+ let form;
508
+ try { form = await readForm(request.params.slug); }
509
+ catch { return reply.code(404).send({error: 'Form not found.'}); }
510
+ const {canSeeArtefact} = await import('../../services/projects.js');
511
+ if (!canSeeArtefact(request.user, form)) return reply.code(403).send({error: 'Access denied for this project'});
512
+ return sendDefinition(reply, packDefinition('form', form));
513
+ });
514
+ fastify.post('/forms/import', canCreate, async (request, reply) => {
515
+ try {
516
+ const form = await importFormDefinition(unpackDefinition('form', request.body));
517
+ return reply.code(201).send(form);
518
+ } catch (err) { return reply.code(400).send({error: err.message}); }
519
+ });
520
+
502
521
  // -----------------------------------------------------------------------
503
522
  // GET /forms - list all form definitions with submission counts
504
523
  // -----------------------------------------------------------------------
@@ -208,7 +208,7 @@ export async function publicRoutes(fastify) {
208
208
  // Re-parse with user context so the body's [menu] shortcode sees the
209
209
  // visitor's role for visibility filtering. The first getPage() above
210
210
  // was anonymous - it only needed the page's metadata.
211
- const render = async () => {
211
+ const render = async (extra = {}) => {
212
212
  let expiry = null;
213
213
  if (!isDraft && page.hasExpiry) {
214
214
  const rawCookies = Object.fromEntries(String(request.headers.cookie || '').split(';').map(part => {
@@ -225,6 +225,7 @@ export async function publicRoutes(fastify) {
225
225
  }
226
226
  const pageForRole = await getPage(page.urlPath, {user: userObj, ...(expiry && {expiry})}) || page;
227
227
  return renderPage(pageForRole, {
228
+ ...extra,
228
229
  baseUrl,
229
230
  user: userObj,
230
231
  ...(isDraft && {preview: {status: page.status || 'draft', urlPath: page.urlPath || urlPath, user: userObj}})
@@ -271,7 +272,10 @@ export async function publicRoutes(fastify) {
271
272
  // x-dm-cache says which way this response went - hit, miss, off (the
272
273
  // cache is disabled) or bypass (a draft, an expiring or a per-viewer
273
274
  // page, never cached) - so a slow page can be read from the browser.
274
- const {value: html, status} = await cache.wrapStatus(cacheKey, render, {tags: cacheTags});
275
+ // The footer blocks' own tags (a [form] in one) are known only once it is drawn.
276
+ const drawnTags = new Set();
277
+ const renderTagged = async () => ({_cacheValue: await render({cacheTags: drawnTags}), _cacheTags: [...drawnTags]});
278
+ const {value: html, status} = await cache.wrapStatus(cacheKey, renderTagged, {tags: cacheTags});
275
279
  reply.header('x-dm-cache', status);
276
280
  hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
277
281
  // cached: this HTML is shared, so its compressed body is kept too (htmlResponse.js).
@@ -239,6 +239,9 @@ function _vk(key) {
239
239
 
240
240
  function _extractCacheTags(raw) {
241
241
  if (raw && typeof raw === 'object' && Array.isArray(raw._cacheTags)) {
242
+ // {_cacheValue, _cacheTags}: a value that is not an object (a page's HTML) with tags
243
+ // only its production could know.
244
+ if ('_cacheValue' in raw) return {value: raw._cacheValue, extraTags: raw._cacheTags};
242
245
  const {_cacheTags, ...value} = raw;
243
246
  return {value, extraTags: _cacheTags};
244
247
  }
@@ -247,6 +250,7 @@ function _extractCacheTags(raw) {
247
250
 
248
251
  function _stripCacheTags(raw) {
249
252
  if (raw && typeof raw === 'object' && Array.isArray(raw._cacheTags)) {
253
+ if ('_cacheValue' in raw) return raw._cacheValue;
250
254
  const {_cacheTags, ...value} = raw;
251
255
  return value;
252
256
  }
@@ -409,6 +409,19 @@ export async function listMedia() {
409
409
  return stats.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
410
410
  }
411
411
 
412
+ /**
413
+ * Emit a media event without letting a listener undo the work: the hooks are a plain
414
+ * EventEmitter, so a plugin's listener that throws would throw out of saveMedia after the
415
+ * file was written - the caller would report a failure and never clean the file up.
416
+ */
417
+ function tellListeners(event, data) {
418
+ try {
419
+ hooks.emit(event, data);
420
+ } catch (err) {
421
+ console.warn(`[media] A ${event} listener failed: ${err?.message || err}`);
422
+ }
423
+ }
424
+
412
425
  /**
413
426
  * Save an uploaded file to the media directory.
414
427
  *
@@ -431,7 +444,7 @@ export async function saveMedia(filename, buffer, {exclusive = false} = {}) {
431
444
  } else {
432
445
  await fs.writeFile(filePath, buffer);
433
446
  }
434
- hooks.emit('content:mediaUploaded', {filename});
447
+ tellListeners('content:mediaUploaded', {filename});
435
448
  return { name: filename, url: `/media/${filename}` };
436
449
  }
437
450
 
@@ -444,7 +457,7 @@ export async function saveMedia(filename, buffer, {exclusive = false} = {}) {
444
457
  export async function deleteMedia(filename) {
445
458
  const filePath = path.join(MEDIA_DIR, filename);
446
459
  await fs.unlink(filePath);
447
- hooks.emit('content:mediaDeleted', {filename});
460
+ tellListeners('content:mediaDeleted', {filename});
448
461
  }
449
462
 
450
463
  /**
@@ -0,0 +1,32 @@
1
+ /** Versioned, definition-only transfers. Runtime entries and audience IDs stay on their site. */
2
+ const KEYS = {
3
+ form: ['slug', 'title', 'description', 'fields', 'settings', 'actions'],
4
+ survey: ['slug', 'title', 'description', 'formSlug', 'mode', 'reminderAfterDays', 'intro', 'footer', 'thankYou', 'inviteEmail'],
5
+ campaign: ['slug', 'title', 'description', 'message', 'linkUrl', 'endsAt', 'offer']
6
+ };
7
+ export function definition(kind, value) {
8
+ if (!KEYS[kind] || !value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid definition.');
9
+ if (typeof value.title !== 'string' || !value.title.trim()) throw new Error('A definition needs a title.');
10
+ if (typeof value.slug !== 'string' || value.slug.length > 80 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.slug)) throw new Error('Invalid definition slug.');
11
+ if (kind === 'form') {
12
+ if (!Array.isArray(value.fields) || value.fields.some(f => !f || typeof f !== 'object' || Array.isArray(f) || typeof f.type !== 'string')) throw new Error('Invalid form fields.');
13
+ for (const key of ['settings', 'actions']) if (value[key] != null && (typeof value[key] !== 'object' || Array.isArray(value[key]))) throw new Error(`Invalid form ${key}.`);
14
+ }
15
+ return structuredClone(Object.fromEntries(KEYS[kind].filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]])));
16
+ }
17
+ export function packDefinition(kind, value, extra = {}) {
18
+ return {format: 'domma-definition', version: 1, kind, definition: definition(kind, value), ...extra};
19
+ }
20
+ export function unpackDefinition(kind, bundle) {
21
+ if (bundle?.format !== 'domma-definition' || bundle.version !== 1 || bundle.kind !== kind) throw new Error(`Choose a Domma ${kind} export (version 1).`);
22
+ return definition(kind, bundle.definition);
23
+ }
24
+ export function unusedSlug(slug, existing) {
25
+ const used = new Set(existing);
26
+ let next = slug;
27
+ for (let i = 2; used.has(next); i++) next = `${slug.slice(0, 70).replace(/-$/, '')}-${i}`;
28
+ return next;
29
+ }
30
+ export function sendDefinition(reply, bundle) {
31
+ return reply.header('Content-Disposition', `attachment; filename="${bundle.kind}-${bundle.definition.slug}.json"`).send(bundle);
32
+ }
@@ -360,7 +360,8 @@ export async function renderDocPage(docPath, opts = {}) {
360
360
  sidebar: false,
361
361
  tags: ['docs']
362
362
  };
363
- return renderPage(page, {baseUrl, user});
363
+ const drawn = new Set();
364
+ return {_cacheValue: await renderPage(page, {baseUrl, user, cacheTags: drawn}), _cacheTags: [...drawn]};
364
365
  },
365
366
  // The shell's menus and footer blocks too, so editing them reaches these pages.
366
367
  {tags: ['docs', 'site', 'nav', ...footerBlockCacheTags(getConfig('site'))]}
@@ -0,0 +1,23 @@
1
+ import {createForm, deleteForm, ensureCollectionForForm, listForms} from './forms.js';
2
+ import {getCollection} from './collections.js';
3
+ import {definition, unusedSlug} from './definition-transfer.js';
4
+
5
+ /** Imported forms always get their own private submission collection. */
6
+ export async function importFormDefinition(input) {
7
+ const data = definition('form', input);
8
+ const used = (await listForms()).map(f => f.slug);
9
+ data.slug = unusedSlug(data.slug, used);
10
+ while (await getCollection(data.slug)) {
11
+ used.push(data.slug);
12
+ data.slug = unusedSlug(input.slug, used);
13
+ }
14
+ data.actions = {...data.actions, collection: {enabled: true, slug: data.slug}};
15
+ const form = await createForm(data);
16
+ try {
17
+ await ensureCollectionForForm(form);
18
+ } catch (err) {
19
+ await deleteForm(form.slug);
20
+ throw err;
21
+ }
22
+ return form;
23
+ }
@@ -8,7 +8,6 @@ import {fileURLToPath} from 'url';
8
8
  import {getConfig} from '../config.js';
9
9
  import {getInjectionSnippets} from './plugins.js';
10
10
  import {applyTransforms} from './hooks.js';
11
- import {FILE_LIMITS_TAG} from './forms.js';
12
11
  import {contentsPaint, readContentsOverride, resolveContents} from './pageContents.js';
13
12
  import {applyMenuOverride, hasThemeSwitcherItem, resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from './menus.js';
14
13
  import {buildMenuNav} from './menuRender.js';
@@ -173,13 +172,9 @@ export function footerBlockNames(site) {
173
172
  * and expiring shortcodes are never run - those stages come before [block] - so
174
173
  * the block names are the whole of what a footer depends on beyond `site`.)
175
174
  */
176
- // A footer block's own shortcode tags are not carried out of its render, and a [form] in
177
- // one draws the file cap: every page with footer blocks also carries the cap's tag (it is
178
- // invalidated only when server.json changes the cap).
179
- export const footerBlockCacheTags = (site) => {
180
- const names = footerBlockNames(site);
181
- return names.length ? [...names.map(n => `block:${n}`), FILE_LIMITS_TAG] : [];
182
- };
175
+ // What a block's own shortcodes depend on (a [form]'s `form:<slug>`, its file cap) is
176
+ // only known once it is drawn: renderPage hands those to `opts.cacheTags`.
177
+ export const footerBlockCacheTags = (site) => footerBlockNames(site).map(n => `block:${n}`);
183
178
 
184
179
  /**
185
180
  * The blocks in the footer (site.json footer.blocks - Site Settings > Footer,
@@ -192,7 +187,8 @@ export const footerBlockCacheTags = (site) => {
192
187
  * Both render paths need this - the standing trap in this file.
193
188
  *
194
189
  * @param {object} site - site.json
195
- * @param {{user?: object|null, urlPath?: string, project?: string|null}} [ctx]
190
+ * @param {{user?: object|null, urlPath?: string, project?: string|null, tags?: Set<string>}} [ctx]
191
+ * tags: receives the cache tags of what the blocks drew (a [form] in one, its file cap)
196
192
  * @returns {Promise<string>} '' when there are none
197
193
  */
198
194
  export async function buildFooterBlocks(site, ctx = {}) {
@@ -203,6 +199,7 @@ export async function buildFooterBlocks(site, ctx = {}) {
203
199
  try {
204
200
  const out = await parseMarkdown(`[block template="${name}" /]`, {user: ctx.user || null, urlPath: ctx.urlPath || '/', project: ctx.project ?? null});
205
201
  const html = String(out.html || '').trim();
202
+ for (const t of out.tags || []) ctx.tags?.add(t);
206
203
  // An unknown block renders nothing at all: no empty column for it.
207
204
  if (html) cols.push(`<div class="cms-footer-block">${html}</div>`);
208
205
  } catch (err) {
@@ -331,6 +328,8 @@ async function buildOverlayMenus(menuCtx, user) {
331
328
  * @param {boolean} [opts.noTrack] - Mark the render as a preview for client-side
332
329
  * analytics without drawing a banner. Implied by `opts.preview`; set directly
333
330
  * by the editor's full-page preview, which has no banner.
331
+ * @param {Set<string>} [opts.cacheTags] - Receives the cache tags of what the shell
332
+ * drew beyond the page body (the footer blocks' shortcodes), for the caller's cache entry.
334
333
  * @returns {Promise<string>}
335
334
  */
336
335
  export async function renderPage(page, opts = {}) {
@@ -449,7 +448,7 @@ export async function renderPage(page, opts = {}) {
449
448
 
450
449
  const themeView = await buildThemeView(page);
451
450
  const searchAssets = buildSearchAssets();
452
- const footerBlocksHtml = preset.footer !== false ? await buildFooterBlocks(site, {user: opts.user || null, urlPath: menuCtx.urlPath, project: menuCtx.project}) : '';
451
+ const footerBlocksHtml = preset.footer !== false ? await buildFooterBlocks(site, {user: opts.user || null, urlPath: menuCtx.urlPath, project: menuCtx.project, tags: opts.cacheTags}) : '';
453
452
  const switcherAssets = buildThemeSwitcherAssets(themeView.cfg, {
454
453
  html: `${page.html || ''}${overlayMenus}${footerBlocksHtml}`,
455
454
  menus: [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated]