domma-cms 0.77.0 → 0.77.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.
@@ -1 +1 @@
1
- export const OP_LABELS={eq:"=",ne:"\u2260",contains:"contains",gt:">",lt:"<"},MAX_COLUMNS=5,PAGE_SIZE=500,SHOW_STEP=100;export function matchesFilter(e,t){const r=e?.data?.[t.field],n=String(r??"");switch(t.op){case"eq":return n===String(t.value);case"ne":return n!==String(t.value);case"contains":return n.toLowerCase().includes(String(t.value).toLowerCase());case"gt":case"lt":{const i=o=>String(o).trim()!==""&&Number.isFinite(Number(o))?Number(o):NaN,s=i(n),a=i(t.value);if(!Number.isNaN(s)&&!Number.isNaN(a))return t.op==="gt"?s>a:s<a;const c=Date.parse(n),u=Date.parse(t.value);return!Number.isNaN(c)&&!Number.isNaN(u)?t.op==="gt"?c>u:c<u:t.op==="gt"?n>String(t.value):n<String(t.value)}default:return!0}}export function valueText(e){return e==null?"":typeof e=="boolean"?e?"Yes":"No":Array.isArray(e)?e.map(t=>t&&typeof t=="object"?JSON.stringify(t):String(t)).join(", "):typeof e=="object"?JSON.stringify(e):String(e)}export function cellText(e,t=60){const r=valueText(e).replace(/\s+/g," ").trim();return r.length>t?`${r.slice(0,t-1)}\u2026`:r}export function arrangeEntries(e,{filters:t=[],query:r="",sort:n={key:"created",dir:"desc"}}={}){const i=String(r||"").toLowerCase().trim();let s=(e||[]).filter(o=>t.every(l=>matchesFilter(o,l)));i&&(s=s.filter(o=>Object.values(o?.data||{}).some(l=>valueText(l).toLowerCase().includes(i))));const a=n?.dir==="asc"?1:-1,c=n?.key||"created",u=o=>c==="created"?String(o?.meta?.createdAt||""):o?.data?.[c];return[...s].sort((o,l)=>{const p=u(o),g=u(l),m=typeof p=="number"?p:parseFloat(p),b=typeof g=="number"?g:parseFloat(g);return c!=="created"&&!Number.isNaN(m)&&!Number.isNaN(b)&&String(m)===String(p??"").trim()&&String(b)===String(g??"").trim()?(m-b)*a:valueText(p).localeCompare(valueText(g),void 0,{numeric:!0,sensitivity:"base"})*a})}export function filterChoices(e,t){const r=valueText(t);if(r==="")return[];const n=r.length>28?`${r.slice(0,27)}\u2026`:r,i=[{op:"eq",value:r,label:`= \u201C${n}\u201D`},{op:"ne",value:r,label:`\u2260 \u201C${n}\u201D`}];return["number","date","time","datetime"].includes(e?.type)&&i.push({op:"gt",value:r,label:`> \u201C${n}\u201D`},{op:"lt",value:r,label:`< \u201C${n}\u201D`}),i}export function filterText(e){const t=String(e.value);return`${e.label} ${OP_LABELS[e.op]||e.op} \u201C${t.length>24?`${t.slice(0,23)}\u2026`:t}\u201D`}export function isSecretField(e){return e?.type==="password"||/pass(word)?|secret|token|api[-_]?key|private|credential/i.test(String(e?.name||""))}export function draftable(e,t=[]){const r=new Set(t.filter(isSecretField).map(n=>n.name));return Object.fromEntries(Object.entries(e||{}).filter(([n])=>!r.has(n)))}export function snapshot(e){const t=Object.keys(e||{}).sort();return JSON.stringify(t.map(r=>[r,typeof e[r]=="boolean"?e[r]:valueText(e[r])]))}export const hasForm=e=>!["roles","user-profiles"].includes(e),canClear=e=>e!=="roles";export function countText(e,t,r){const n=a=>a.toLocaleString("en-GB"),i=a=>`entr${a===1?"y":"ies"}`,s=e===t?`${n(t)} ${i(t)}`:`Showing ${n(e)} of ${n(t)}`;return r>t?`${s} \xB7 ${n(t)} of ${n(r)} loaded`:s}
1
+ export const OP_LABELS={eq:"=",ne:"\u2260",contains:"contains",gt:">",lt:"<"},MAX_COLUMNS=5,PAGE_SIZE=500,SHOW_STEP=100;export function matchesFilter(t,e){const r=t?.data?.[e.field],n=String(r??"");switch(e.op){case"eq":return n===String(e.value);case"ne":return n!==String(e.value);case"contains":return n.toLowerCase().includes(String(e.value).toLowerCase());case"gt":case"lt":{const i=o=>String(o).trim()!==""&&Number.isFinite(Number(o))?Number(o):NaN,s=i(n),c=i(e.value);if(!Number.isNaN(s)&&!Number.isNaN(c))return e.op==="gt"?s>c:s<c;const a=Date.parse(n),u=Date.parse(e.value);return!Number.isNaN(a)&&!Number.isNaN(u)?e.op==="gt"?a>u:a<u:e.op==="gt"?n>String(e.value):n<String(e.value)}default:return!0}}export function valueText(t){return t==null?"":typeof t=="boolean"?t?"Yes":"No":Array.isArray(t)?t.map(e=>e&&typeof e=="object"?JSON.stringify(e):String(e)).join(", "):typeof t=="object"?JSON.stringify(t):String(t)}export function cellText(t,e=60){const r=valueText(t).replace(/\s+/g," ").trim();return r.length>e?`${r.slice(0,e-1)}\u2026`:r}export function arrangeEntries(t,{filters:e=[],query:r="",sort:n={key:"created",dir:"desc"}}={}){const i=String(r||"").toLowerCase().trim();let s=(t||[]).filter(o=>e.every(p=>matchesFilter(o,p)));i&&(s=s.filter(o=>Object.values(o?.data||{}).some(p=>valueText(p).toLowerCase().includes(i))));const c=n?.dir==="asc"?1:-1,a=n?.key||"created",u=o=>a==="created"?String(o?.meta?.createdAt||""):o?.data?.[a];return[...s].sort((o,p)=>{const l=u(o),m=u(p),g=typeof l=="number"?l:parseFloat(l),b=typeof m=="number"?m:parseFloat(m);return a!=="created"&&!Number.isNaN(g)&&!Number.isNaN(b)&&String(g)===String(l??"").trim()&&String(b)===String(m??"").trim()?(g-b)*c:valueText(l).localeCompare(valueText(m),void 0,{numeric:!0,sensitivity:"base"})*c})}export function filterChoices(t,e){const r=valueText(e);if(r==="")return[];const n=r.length>28?`${r.slice(0,27)}\u2026`:r,i=[{op:"eq",value:r,label:`= \u201C${n}\u201D`},{op:"ne",value:r,label:`\u2260 \u201C${n}\u201D`}];return["number","date","time","datetime"].includes(t?.type)&&i.push({op:"gt",value:r,label:`> \u201C${n}\u201D`},{op:"lt",value:r,label:`< \u201C${n}\u201D`}),i}export function filterText(t){const e=String(t.value);return`${t.label} ${OP_LABELS[t.op]||t.op} \u201C${e.length>24?`${e.slice(0,23)}\u2026`:e}\u201D`}export function typedValue(t,e){if(t?.type==="number"&&typeof e=="string"&&e.trim()!==""){const r=Number(e);if(Number.isFinite(r))return r}return e}export function isSecretField(t){return t?.type==="password"||/pass(word)?|secret|token|api[-_]?key|private|credential/i.test(String(t?.name||""))}export function draftable(t,e=[]){const r=new Set(e.filter(isSecretField).map(n=>n.name));return Object.fromEntries(Object.entries(t||{}).filter(([n])=>!r.has(n)))}export function snapshot(t){const e=Object.keys(t||{}).sort();return JSON.stringify(e.map(r=>[r,typeof t[r]=="boolean"?t[r]:valueText(t[r])]))}export const hasForm=t=>!["roles","user-profiles"].includes(t),canClear=t=>t!=="roles";export function countText(t,e,r){const n=c=>c.toLocaleString("en-GB"),i=c=>`entr${c===1?"y":"ies"}`,s=t===e?`${n(e)} ${i(e)}`:`Showing ${n(t)} of ${n(e)}`;return r>e?`${s} \xB7 ${n(e)} of ${n(r)} loaded`:s}
@@ -1,10 +1,10 @@
1
- import{api as g,apiRequest as lt}from"../api.js";import{addBannerAction as U}from"../lib/plugin-chrome.js";import{helpPopovers as Ne}from"../lib/help-popover.js";import{onceClosed as rt,openMenuAt as Ie,sidebarCount as z,viewLifetime as it}from"../lib/tool-kit.js";import{MAX_COLUMNS as dt,PAGE_SIZE as se,SHOW_STEP as W,arrangeEntries as ut,canClear as pt,cellText as He,countText as mt,draftable as ft,filterChoices as ht,filterText as yt,hasForm as vt,snapshot as Oe,valueText as Re}from"../lib/collection-entries-arrange.js";const Ue=/^#\/collections\/[^/?#]+\/entries\/?(\?.*)?$/,j=m=>`collection-entries.draft.${m}`,N=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function oe(m){const h=document.createElement("template");return h.innerHTML=m.trim(),h.content.firstElementChild}const bt='<span class="cex-chip">{{text}}<button type="button" data-idx="{{idx}}" title="Remove this filter" aria-label="Remove filter {{text}}">\xD7</button></span>';function gt(m){return`
1
+ import{api as g,apiRequest as lt}from"../api.js";import{addBannerAction as U}from"../lib/plugin-chrome.js";import{helpPopovers as Ne}from"../lib/help-popover.js";import{onceClosed as rt,openMenuAt as Ie,sidebarCount as z,viewLifetime as it}from"../lib/tool-kit.js";import{MAX_COLUMNS as dt,PAGE_SIZE as se,SHOW_STEP as W,arrangeEntries as ut,canClear as pt,cellText as He,countText as mt,draftable as ft,filterChoices as ht,filterText as yt,hasForm as vt,snapshot as Oe,typedValue as bt,valueText as Re}from"../lib/collection-entries-arrange.js";const Ue=/^#\/collections\/[^/?#]+\/entries\/?(\?.*)?$/,j=m=>`collection-entries.draft.${m}`,N=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function oe(m){const h=document.createElement("template");return h.innerHTML=m.trim(),h.content.firstElementChild}const gt='<span class="cex-chip">{{text}}<button type="button" data-idx="{{idx}}" title="Remove this filter" aria-label="Remove filter {{text}}">\xD7</button></span>';function Et(m){return`
2
2
  <tr class="cex-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
3
  <td class="cex-check"><input type="checkbox" class="cex-sel" aria-label="Select this entry" data-bind-checked="sel"></td>
4
4
  ${Array.from({length:m},(v,a)=>`<td data-col="${a}" title="{{t${a}}}">{{c${a}}}</td>`).join("")}
5
5
  <td class="cex-when" title="{{whenFull}}">{{when}}</td>
6
6
  <td class="cex-tools"><button type="button" class="cex-run" data-bind-hidden="!runs" title="Run one of this collection's actions">\u26A1 {{runs}}</button><button type="button" class="cex-kebab" aria-label="Actions for this entry" title="Actions">\u22EE</button></td>
7
- </tr>`}function Et(m,h){const v={};return m.forEach(a=>{const u={};a.fullWidth?u.span=h:a.span>1&&(u.span=a.span),a.placeholder&&(u.placeholder=a.placeholder),a.helper&&(u.hint=a.helper);const i=a.type==="checkbox"?"boolean":a.type==="date"?"string":a.type;v[a.name]={type:i,label:a.label,required:a.required,options:a.options,...Object.keys(u).length?{formConfig:u}:{}}}),v}function ce(m,h){const v={};for(const a of h){const u=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!u.length)continue;const i=u[0];i.type==="radio"?v[a.name]=u.find(p=>p.checked)?.value??"":i.type==="checkbox"&&u.length>1?v[a.name]=u.filter(p=>p.checked).map(p=>p.value):i.type==="checkbox"?v[a.name]=i.checked:i.tagName==="SELECT"&&i.multiple?v[a.name]=[...i.selectedOptions].map(p=>p.value):v[a.name]=i.value}return v}function ze(m,h,v){for(const a of h){if(!(a.name in v))continue;const u=v[a.name],i=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!i.length)continue;const p=i[0];p.type==="radio"?i.forEach(c=>{c.checked=c.value===String(u)}):p.type==="checkbox"&&i.length>1?i.forEach(c=>{c.checked=Array.isArray(u)&&u.includes(c.value)}):p.type==="checkbox"?p.checked=u===!0:p.tagName==="SELECT"&&p.multiple?[...p.options].forEach(c=>{c.selected=Array.isArray(u)&&u.includes(c.value)}):p.value=u??"",i.forEach(c=>c.dispatchEvent(new Event("input",{bubbles:!0})))}}const je=it(Ue);export const collectionEntriesView={templateUrl:"/admin/js/templates/collection-entries.html",async onMount(m){je.reset();const h=m.get(0).querySelector(".cex")||m.get(0),v=location.hash.match(/^#\/collections\/([^/?#]+)\/entries/),a=v?decodeURIComponent(v[1]):null,u=()=>Ue.test(location.hash)&&h.isConnected&&location.hash.includes(`/collections/${encodeURIComponent(a)}/entries`),i=e=>(je.add(e),e);if(!a){E.toast("No collection selected.",{type:"error"});return}const p=M.observable(null),c=M.observable([]),x=M.observable(0),X=M.observable(!0),B=M.observable(""),Fe=M.computed(()=>p.value!==null),le=M.observable(""),w=M.observable([]),Z=M.observable({key:"created",dir:"desc"}),y=M.observable([]),q=M.observable(W),P=M.observable([]),Q=M.observable({}),A=M.observable(!1);let ee=1;const re=()=>p.peek()?.fields||[],ie=M.computed(()=>(p.value?.fields||[]).slice(0,dt)),_=e=>c.peek().find(t=>t.id===e),he=e=>P.peek().filter(t=>{const n=Q.peek()[t.slug];return n==null||n.has(e)}),T=M.observable([]),ye=M.observable([]);i(M.effect(()=>{const e=ut(c.value,{filters:w.value,query:le.value,sort:Z.value});T.value=e})),i(M.effect(()=>{const e=ie.value,t=new Set(y.value);Q.value,P.value,ye.value=T.value.slice(0,q.value).map(n=>{const s={id:n.id,sel:t.has(n.id),rowCls:t.has(n.id)?"is-selected":""};e.forEach((r,d)=>{s[`c${d}`]=He(n.data?.[r.name]),s[`t${d}`]=Re(n.data?.[r.name])});const o=n.meta?.createdAt;s.when=o?D(o).format("D MMM YYYY"):"",s.whenFull=o?`Created ${D(o).format("D MMM YYYY HH:mm")}${n.meta?.updatedAt?` \xB7 changed ${D(n.meta.updatedAt).format("D MMM YYYY HH:mm")}`:""}`:"";const l=he(n.id).length;return s.runs=l?String(l):"",s})}));const Be=M.computed(()=>w.value.map((e,t)=>({key:`${t}:${e.field}:${e.op}:${e.value}`,idx:t,text:yt(e)}))),Pe=M.computed(()=>y.value.length),_e={schema:p,loaded:c,loading:X,loadError:B,ready:Fe,query:le,rows:ye,chips:Be,busy:A,selectedCount:Pe,countLine:M.computed(()=>mt(T.value.length,c.value.length,x.value)),selectedText:M.computed(()=>`${y.value.length} selected`),canClear:M.computed(()=>pt(a)),introText:M.computed(()=>{const e=p.value;return e?`${e.description?`${e.description} - `:""}${(e.fields||[]).length} field${(e.fields||[]).length===1?"":"s"}${e.storage?.adapter==="mongodb"?", on MongoDB":""}.`:""}),emptyText:M.computed(()=>c.value.length?"No entries match the search or filters.":"No entries yet - Add entry in the banner makes one, or Import brings many."),moreText:M.computed(()=>{const e=T.value.length-q.value;if(e>0)return`Show ${Math.min(W,e)} more (${e} not shown)`;const t=x.value-c.value.length;return t>0?`Load the next ${Math.min(se,t)} from the server`:""})};async function J({reset:e=!0}={}){X.value=!0;try{const t=e?Math.max(se,c.peek().length):se;e&&(ee=1);const n=await g.collections.listEntries(a,{limit:t,page:ee});if(!u())return;const s=n?.entries||(Array.isArray(n)?n:[]);c.value=e?s:[...c.peek(),...s.filter(l=>!_(l.id))],x.value=Number(n?.total)||c.peek().length,e&&(ee=Math.max(1,Math.ceil(c.peek().length/se)));const o=new Set(c.peek().map(l=>l.id));y.value=y.peek().filter(l=>o.has(l)),B.value="",await Ve()}catch(t){B.value=`Could not load the entries: ${t.message||t}`}finally{X.value=!1}}async function Je(){ee+=1,await J({reset:!1})}async function Ke(){try{if((await lt("/actions/_count",{method:"GET"}).catch(()=>null))?.available===!1)return;const t=await g.actions.forCollection(a);P.value=Array.isArray(t)?t:[]}catch{P.value=[]}}async function Ve(){const e=P.peek().filter(s=>s.access?.rowLevel);if(!e.length||!c.peek().length)return;const t=c.peek().map(s=>s.id),n={...Q.peek()};await Promise.all(e.map(async s=>{try{n[s.slug]=new Set((await g.actions.checkAccess(s.slug,t)).allowed)}catch{n[s.slug]=null}})),Q.value=n}const K=e=>{const t=re()[0];return(t?He(e?.data?.[t.name],40):"")||`Entry ${String(e?.id||"").slice(0,8)}`};async function ve(e){if(await E.confirm(`Delete \u201C${N(K(e))}\u201D? This cannot be undone.`))try{await g.collections.deleteEntry(a,e.id),c.value=c.peek().filter(t=>t.id!==e.id),x.value=Math.max(0,x.peek()-1),y.value=y.peek().filter(t=>t!==e.id),z("#/collections"),E.toast("Entry deleted.",{type:"success"})}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function Ge(){const e=[...y.peek()];if(!e.length||!await E.confirm(`Delete ${e.length} entr${e.length===1?"y":"ies"}? This cannot be undone.`))return;A.value=!0;let t=0;const n=[];for(const s of e)try{await g.collections.deleteEntry(a,s),t++}catch(o){n.push(o.message||String(o))}A.value=!1,y.value=[],z("#/collections"),E.toast(n.length?`Deleted ${t}; ${n.length} refused (${n[0]}).`:`Deleted ${t} entr${t===1?"y":"ies"}.`,{type:n.length?"warning":"success",duration:n.length?7e3:4e3}),u()&&await J()}async function We(){const e=p.peek();if(await E.confirm(`Delete ALL ${x.peek()} entries in \u201C${N(e?.title||a)}\u201D? This cannot be undone.`)){A.value=!0;try{await g.collections.clearEntries(a),c.value=[],x.value=0,y.value=[],z("#/collections"),E.toast("All entries deleted.",{type:"success"})}catch(t){E.toast(`Not cleared: ${t.message||t}`,{type:"error"})}finally{A.value=!1}}}async function Xe(e){try{const t=await g.collections.createEntry(a,{...e.data||{}});c.value=[t,...c.peek()],x.value=x.peek()+1,z("#/collections"),E.toast("Duplicated - the copy is at the top.",{type:"success"})}catch(t){E.toast(`Not duplicated: ${t.message||t}`,{type:"error"})}}async function Ze(e,t){const n=e.trigger?.confirmMessage;if(!(n&&!await E.confirm(N(n)))){A.value=!0;try{const s=await g.actions.execute(e.slug,t.id);if(s?.success){const o=s.stepsCompleted??0;E.toast(`${e.trigger?.label||e.title||"Action"} done (${o} step${o===1?"":"s"}).`,{type:"success"}),u()&&await J()}else E.toast(s?.results?.find(o=>!o.success)?.error||"The action failed.",{type:"error"})}catch(s){E.toast(s.message||"The action failed.",{type:"error"})}finally{A.value=!1}}}async function be(e){try{await navigator.clipboard.writeText(e),E.toast("Copied.",{type:"success",duration:1500})}catch{E.toast("Copy failed - your browser refused the clipboard.",{type:"error"})}}function de(e){const t=y.peek();y.value=t.includes(e)?t.filter(n=>n!==e):[...t,e]}function ge(e,t,n){w.value=[...w.peek(),{field:e.name,label:e.label||e.name,op:t,value:n}],q.value=W}const ue=new Set;let Y=null,Ee=!1;function ke(e,t,n){const s=e.close.bind(e);let o=!1,l=!1;e.close=async r=>{if(!o&&r!==!0&&!Ee&&t()){if(l)return;l=!0;const d=await E.confirm(n);if(l=!1,!d)return}return o=!0,s()}}function te(e,t=()=>{}){ue.add(e),rt(e,()=>{ue.delete(e),t()})}function H(e,t=null){const n=p.peek(),s=re(),o=!!e,l=E.slideover({title:o?`Edit ${N(K(e))}`:"New entry",size:"md",position:"right"}),r=oe(`
7
+ </tr>`}function kt(m,h){const v={};return m.forEach(a=>{const u={};a.fullWidth?u.span=h:a.span>1&&(u.span=a.span),a.placeholder&&(u.placeholder=a.placeholder),a.helper&&(u.hint=a.helper);const i=a.type==="checkbox"?"boolean":a.type==="date"?"string":a.type;v[a.name]={type:i,label:a.label,required:a.required,options:a.options,...Object.keys(u).length?{formConfig:u}:{}}}),v}function ce(m,h){const v={};for(const a of h){const u=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!u.length)continue;const i=u[0];i.type==="radio"?v[a.name]=u.find(p=>p.checked)?.value??"":i.type==="checkbox"&&u.length>1?v[a.name]=u.filter(p=>p.checked).map(p=>p.value):i.type==="checkbox"?v[a.name]=i.checked:i.tagName==="SELECT"&&i.multiple?v[a.name]=[...i.selectedOptions].map(p=>p.value):v[a.name]=bt(a,i.value)}return v}function ze(m,h,v){for(const a of h){if(!(a.name in v))continue;const u=v[a.name],i=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!i.length)continue;const p=i[0];p.type==="radio"?i.forEach(c=>{c.checked=c.value===String(u)}):p.type==="checkbox"&&i.length>1?i.forEach(c=>{c.checked=Array.isArray(u)&&u.includes(c.value)}):p.type==="checkbox"?p.checked=u===!0:p.tagName==="SELECT"&&p.multiple?[...p.options].forEach(c=>{c.selected=Array.isArray(u)&&u.includes(c.value)}):p.value=u??"",i.forEach(c=>c.dispatchEvent(new Event("input",{bubbles:!0})))}}const je=it(Ue);export const collectionEntriesView={templateUrl:"/admin/js/templates/collection-entries.html",async onMount(m){je.reset();const h=m.get(0).querySelector(".cex")||m.get(0),v=location.hash.match(/^#\/collections\/([^/?#]+)\/entries/),a=v?decodeURIComponent(v[1]):null,u=()=>Ue.test(location.hash)&&h.isConnected&&location.hash.includes(`/collections/${encodeURIComponent(a)}/entries`),i=e=>(je.add(e),e);if(!a){E.toast("No collection selected.",{type:"error"});return}const p=M.observable(null),c=M.observable([]),x=M.observable(0),X=M.observable(!0),B=M.observable(""),Fe=M.computed(()=>p.value!==null),le=M.observable(""),w=M.observable([]),Z=M.observable({key:"created",dir:"desc"}),y=M.observable([]),q=M.observable(W),P=M.observable([]),Q=M.observable({}),A=M.observable(!1);let ee=1;const re=()=>p.peek()?.fields||[],ie=M.computed(()=>(p.value?.fields||[]).slice(0,dt)),_=e=>c.peek().find(t=>t.id===e),he=e=>P.peek().filter(t=>{const n=Q.peek()[t.slug];return n==null||n.has(e)}),T=M.observable([]),ye=M.observable([]);i(M.effect(()=>{const e=ut(c.value,{filters:w.value,query:le.value,sort:Z.value});T.value=e})),i(M.effect(()=>{const e=ie.value,t=new Set(y.value);Q.value,P.value,ye.value=T.value.slice(0,q.value).map(n=>{const s={id:n.id,sel:t.has(n.id),rowCls:t.has(n.id)?"is-selected":""};e.forEach((r,d)=>{s[`c${d}`]=He(n.data?.[r.name]),s[`t${d}`]=Re(n.data?.[r.name])});const o=n.meta?.createdAt;s.when=o?D(o).format("D MMM YYYY"):"",s.whenFull=o?`Created ${D(o).format("D MMM YYYY HH:mm")}${n.meta?.updatedAt?` \xB7 changed ${D(n.meta.updatedAt).format("D MMM YYYY HH:mm")}`:""}`:"";const l=he(n.id).length;return s.runs=l?String(l):"",s})}));const Be=M.computed(()=>w.value.map((e,t)=>({key:`${t}:${e.field}:${e.op}:${e.value}`,idx:t,text:yt(e)}))),Pe=M.computed(()=>y.value.length),_e={schema:p,loaded:c,loading:X,loadError:B,ready:Fe,query:le,rows:ye,chips:Be,busy:A,selectedCount:Pe,countLine:M.computed(()=>mt(T.value.length,c.value.length,x.value)),selectedText:M.computed(()=>`${y.value.length} selected`),canClear:M.computed(()=>pt(a)),introText:M.computed(()=>{const e=p.value;return e?`${e.description?`${e.description} - `:""}${(e.fields||[]).length} field${(e.fields||[]).length===1?"":"s"}${e.storage?.adapter==="mongodb"?", on MongoDB":""}.`:""}),emptyText:M.computed(()=>c.value.length?"No entries match the search or filters.":"No entries yet - Add entry in the banner makes one, or Import brings many."),moreText:M.computed(()=>{const e=T.value.length-q.value;if(e>0)return`Show ${Math.min(W,e)} more (${e} not shown)`;const t=x.value-c.value.length;return t>0?`Load the next ${Math.min(se,t)} from the server`:""})};async function J({reset:e=!0}={}){X.value=!0;try{const t=e?Math.max(se,c.peek().length):se;e&&(ee=1);const n=await g.collections.listEntries(a,{limit:t,page:ee});if(!u())return;const s=n?.entries||(Array.isArray(n)?n:[]);c.value=e?s:[...c.peek(),...s.filter(l=>!_(l.id))],x.value=Number(n?.total)||c.peek().length,e&&(ee=Math.max(1,Math.ceil(c.peek().length/se)));const o=new Set(c.peek().map(l=>l.id));y.value=y.peek().filter(l=>o.has(l)),B.value="",await Ve()}catch(t){B.value=`Could not load the entries: ${t.message||t}`}finally{X.value=!1}}async function Je(){ee+=1,await J({reset:!1})}async function Ke(){try{if((await lt("/actions/_count",{method:"GET"}).catch(()=>null))?.available===!1)return;const t=await g.actions.forCollection(a);P.value=Array.isArray(t)?t:[]}catch{P.value=[]}}async function Ve(){const e=P.peek().filter(s=>s.access?.rowLevel);if(!e.length||!c.peek().length)return;const t=c.peek().map(s=>s.id),n={...Q.peek()};await Promise.all(e.map(async s=>{try{n[s.slug]=new Set((await g.actions.checkAccess(s.slug,t)).allowed)}catch{n[s.slug]=null}})),Q.value=n}const K=e=>{const t=re()[0];return(t?He(e?.data?.[t.name],40):"")||`Entry ${String(e?.id||"").slice(0,8)}`};async function ve(e){if(await E.confirm(`Delete \u201C${N(K(e))}\u201D? This cannot be undone.`))try{await g.collections.deleteEntry(a,e.id),c.value=c.peek().filter(t=>t.id!==e.id),x.value=Math.max(0,x.peek()-1),y.value=y.peek().filter(t=>t!==e.id),z("#/collections"),E.toast("Entry deleted.",{type:"success"})}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function Ge(){const e=[...y.peek()];if(!e.length||!await E.confirm(`Delete ${e.length} entr${e.length===1?"y":"ies"}? This cannot be undone.`))return;A.value=!0;let t=0;const n=[];for(const s of e)try{await g.collections.deleteEntry(a,s),t++}catch(o){n.push(o.message||String(o))}A.value=!1,y.value=[],z("#/collections"),E.toast(n.length?`Deleted ${t}; ${n.length} refused (${n[0]}).`:`Deleted ${t} entr${t===1?"y":"ies"}.`,{type:n.length?"warning":"success",duration:n.length?7e3:4e3}),u()&&await J()}async function We(){const e=p.peek();if(await E.confirm(`Delete ALL ${x.peek()} entries in \u201C${N(e?.title||a)}\u201D? This cannot be undone.`)){A.value=!0;try{await g.collections.clearEntries(a),c.value=[],x.value=0,y.value=[],z("#/collections"),E.toast("All entries deleted.",{type:"success"})}catch(t){E.toast(`Not cleared: ${t.message||t}`,{type:"error"})}finally{A.value=!1}}}async function Xe(e){try{const t=await g.collections.createEntry(a,{...e.data||{}});c.value=[t,...c.peek()],x.value=x.peek()+1,z("#/collections"),E.toast("Duplicated - the copy is at the top.",{type:"success"})}catch(t){E.toast(`Not duplicated: ${t.message||t}`,{type:"error"})}}async function Ze(e,t){const n=e.trigger?.confirmMessage;if(!(n&&!await E.confirm(N(n)))){A.value=!0;try{const s=await g.actions.execute(e.slug,t.id);if(s?.success){const o=s.stepsCompleted??0;E.toast(`${e.trigger?.label||e.title||"Action"} done (${o} step${o===1?"":"s"}).`,{type:"success"}),u()&&await J()}else E.toast(s?.results?.find(o=>!o.success)?.error||"The action failed.",{type:"error"})}catch(s){E.toast(s.message||"The action failed.",{type:"error"})}finally{A.value=!1}}}async function be(e){try{await navigator.clipboard.writeText(e),E.toast("Copied.",{type:"success",duration:1500})}catch{E.toast("Copy failed - your browser refused the clipboard.",{type:"error"})}}function de(e){const t=y.peek();y.value=t.includes(e)?t.filter(n=>n!==e):[...t,e]}function ge(e,t,n){w.value=[...w.peek(),{field:e.name,label:e.label||e.name,op:t,value:n}],q.value=W}const ue=new Set;let Y=null,Ee=!1;function ke(e,t,n){const s=e.close.bind(e);let o=!1,l=!1;e.close=async r=>{if(!o&&r!==!0&&!Ee&&t()){if(l)return;l=!0;const d=await E.confirm(n);if(l=!1,!d)return}return o=!0,s()}}function te(e,t=()=>{}){ue.add(e),rt(e,()=>{ue.delete(e),t()})}function H(e,t=null){const n=p.peek(),s=re(),o=!!e,l=E.slideover({title:o?`Edit ${N(K(e))}`:"New entry",size:"md",position:"right"}),r=oe(`
8
8
  <div class="cex-form">
9
9
  <div class="cex-fields"></div>
10
10
  <small class="cex-hint cex-when-line"></small>
@@ -15,7 +15,7 @@ import{api as g,apiRequest as lt}from"../api.js";import{addBannerAction as U}fro
15
15
  <button type="button" class="btn btn-sm btn-ghost" data-act="cancel">Cancel</button>
16
16
  <button type="button" class="btn btn-sm btn-primary" data-act="save"><span data-icon="check" data-icon-size="13"></span> <span class="cex-save-text"></span></button>
17
17
  </div>
18
- </div>`);r.querySelector(".cex-save-text").textContent=o?"Save":"Add entry";const d=r.querySelector(".cex-fields");F.create(Et(s,n?.columns||2),e?.data||{},{layout:n?.layout||"stacked",columns:n?.columns||2,showSubmitButton:!1}).renderTo(d),s.forEach(k=>{if(k.type!=="date"||!k.name)return;const O=d.querySelector(`[name="${CSS.escape(k.name)}"]`);O&&O.type!=="date"&&(O.type="date")}),e?.data&&ze(d,s,e.data),o&&e.meta?.createdAt&&(r.querySelector(".cex-when-line").textContent=`Created ${D(e.meta.createdAt).format("D MMM YYYY HH:mm")}${e.meta.updatedAt?`, last changed ${D(e.meta.updatedAt).format("D MMM YYYY HH:mm")}`:""}${e.meta.source?` \xB7 via ${e.meta.source}`:""}.`),l.element.appendChild(r);const f=Oe(ce(d,s));t&&ze(d,s,t);const b=()=>Oe(ce(d,s))!==f,ct=r.querySelector(".cex-dirty"),me=()=>{ct.hidden=!b()};d.addEventListener("input",me),d.addEventListener("change",me),me();let fe=!1;async function De(){if(fe)return;document.activeElement?.dispatchEvent?.(new Event("change",{bubbles:!0}));const k=ce(d,s);fe=!0;const O=r.querySelector('[data-act="save"]');O.disabled=!0;try{if(o){const G=await g.collections.updateEntry(a,e.id,k);c.value=c.peek().map(Ye=>Ye.id===e.id?G:Ye),E.toast("Entry saved.",{type:"success"})}else{const G=await g.collections.createEntry(a,k);c.value=[G,...c.peek()],x.value=x.peek()+1,z("#/collections"),E.toast("Entry added.",{type:"success"})}try{S.remove?S.remove(j(a)):S.set(j(a),null)}catch{}l.close(!0)}catch(G){E.toast(G.message||"Not saved.",{type:"error"})}finally{fe=!1,O.disabled=!1}}r.querySelector('[data-act="save"]').addEventListener("click",De),r.querySelector('[data-act="cancel"]').addEventListener("click",()=>l.close()),r.addEventListener("keydown",k=>{(k.ctrlKey||k.metaKey)&&(k.key==="Enter"||k.key==="s")&&(k.preventDefault(),De())});const Te={id:e?.id??null,dirty:b,values:()=>ft(ce(d,s),s)};Y=Te,te(l,()=>{Y===Te&&(Y=null)}),ke(l,b,"Discard the changes you have not saved?"),I.scan(r),l.open(),d.querySelector("input, select, textarea")?.focus()}function Qe(e){const t=E.slideover({title:N(K(e)),size:"md",position:"right"}),n=oe(`
18
+ </div>`);r.querySelector(".cex-save-text").textContent=o?"Save":"Add entry";const d=r.querySelector(".cex-fields");F.create(kt(s,n?.columns||2),e?.data||{},{layout:n?.layout||"stacked",columns:n?.columns||2,showSubmitButton:!1}).renderTo(d),s.forEach(k=>{if(k.type!=="date"||!k.name)return;const O=d.querySelector(`[name="${CSS.escape(k.name)}"]`);O&&O.type!=="date"&&(O.type="date")}),e?.data&&ze(d,s,e.data),o&&e.meta?.createdAt&&(r.querySelector(".cex-when-line").textContent=`Created ${D(e.meta.createdAt).format("D MMM YYYY HH:mm")}${e.meta.updatedAt?`, last changed ${D(e.meta.updatedAt).format("D MMM YYYY HH:mm")}`:""}${e.meta.source?` \xB7 via ${e.meta.source}`:""}.`),l.element.appendChild(r);const f=Oe(ce(d,s));t&&ze(d,s,t);const b=()=>Oe(ce(d,s))!==f,ct=r.querySelector(".cex-dirty"),me=()=>{ct.hidden=!b()};d.addEventListener("input",me),d.addEventListener("change",me),me();let fe=!1;async function De(){if(fe)return;document.activeElement?.dispatchEvent?.(new Event("change",{bubbles:!0}));const k=ce(d,s);fe=!0;const O=r.querySelector('[data-act="save"]');O.disabled=!0;try{if(o){const G=await g.collections.updateEntry(a,e.id,k);c.value=c.peek().map(Ye=>Ye.id===e.id?G:Ye),E.toast("Entry saved.",{type:"success"})}else{const G=await g.collections.createEntry(a,k);c.value=[G,...c.peek()],x.value=x.peek()+1,z("#/collections"),E.toast("Entry added.",{type:"success"})}try{S.remove?S.remove(j(a)):S.set(j(a),null)}catch{}l.close(!0)}catch(G){E.toast(G.message||"Not saved.",{type:"error"})}finally{fe=!1,O.disabled=!1}}r.querySelector('[data-act="save"]').addEventListener("click",De),r.querySelector('[data-act="cancel"]').addEventListener("click",()=>l.close()),r.addEventListener("keydown",k=>{(k.ctrlKey||k.metaKey)&&(k.key==="Enter"||k.key==="s")&&(k.preventDefault(),De())});const Te={id:e?.id??null,dirty:b,values:()=>ft(ce(d,s),s)};Y=Te,te(l,()=>{Y===Te&&(Y=null)}),ke(l,b,"Discard the changes you have not saved?"),I.scan(r),l.open(),d.querySelector("input, select, textarea")?.focus()}function Qe(e){const t=E.slideover({title:N(K(e)),size:"md",position:"right"}),n=oe(`
19
19
  <div class="cex-form">
20
20
  <dl class="cex-detail"></dl>
21
21
  <div class="cex-meta"></div>
@@ -47,4 +47,4 @@ import{api as g,apiRequest as lt}from"../api.js";import{addBannerAction as U}fro
47
47
  <button type="button" class="btn btn-ghost" data-fmt="json"><span data-icon="download" data-icon-size="14"></span> Export as JSON</button>
48
48
  <button type="button" class="btn btn-ghost" data-fmt="csv"><span data-icon="download" data-icon-size="14"></span> Export as CSV</button>
49
49
  </div>
50
- </div>`);t.querySelectorAll("[data-fmt]").forEach(n=>n.addEventListener("click",async()=>{const s=n.dataset.fmt;try{const o=await fetch(`/api/collections/${encodeURIComponent(a)}/export?format=${s}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!o.ok)throw new Error(`${o.status}`);const l=await o.blob(),r=document.createElement("a");r.href=URL.createObjectURL(l),r.download=`${a}-entries.${s}`,document.body.appendChild(r),r.click(),r.remove(),setTimeout(()=>URL.revokeObjectURL(r.href),1e3),e.close(!0)}catch(o){E.toast(`Export failed (${o.message}).`,{type:"error"})}})),e.element.appendChild(t),te(e),I.scan(t),e.open()}function nt(e,t){const n=_(e.dataset.id);if(!n)return[];const s=t?.event?.target?.closest?.("td[data-col]"),o=s?ie.peek()[Number(s.dataset.col)]:null,l=he(n.id),r=y.peek().includes(n.id),d=o?ht(o,n.data?.[o.name]):[];return[{header:K(n)},{label:"Edit\u2026",icon:"edit",action:()=>H(n)},{label:"See all of it\u2026",icon:"eye",action:()=>Qe(n)},{label:"Duplicate",icon:"copy",action:()=>Xe(n)},{label:"Copy id",icon:"hash",action:()=>be(n.id)},{label:r?"Deselect":"Select",icon:"check",action:()=>de(n.id)},...l.length?[{type:"divider"},...l.map(f=>({label:`${f.trigger?.label||f.title||f.slug}\u2026`,icon:f.trigger?.icon||"zap",disabled:A.peek(),action:()=>Ze(f,n)}))]:[],...o?[{type:"divider"},{label:`Filter by ${o.label||o.name}`,icon:"filter",submenu:[...d.map(f=>({label:f.label,action:()=>ge(o,f.op,f.value)})),{label:`${o.label||o.name} contains\u2026`,action:async()=>{const f=await E.prompt(`Show entries whose ${N(o.label||o.name)} contains`,{inputPlaceholder:"text"});f&&String(f).trim()&&ge(o,"contains",String(f).trim())}}]}]:[],...w.peek().length?[{label:`Clear filters (${w.peek().length})`,icon:"x",action:()=>{w.value=[]}}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ve(n)}]}const L=h.querySelector(".cex-rows"),V=h.querySelector(".cex-head"),pe=h.querySelector(".cex-chips-list");pe.innerHTML=bt;const at=E.loader(h,{type:"dots"});try{p.value=await g.collections.get(a)}catch(e){B.value=`Could not load the collection: ${e.message||e}`}finally{at.destroy()}if(!u())return;if(!p.peek()&&!B.peek()){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const xe=ie.peek();L.innerHTML=gt(xe.length),V.replaceChildren();const ne=(e,t)=>{const n=document.createElement("th");return e&&(n.className=e),t&&n.append(t),V.appendChild(n),n},$=document.createElement("input");$.type="checkbox",$.title="Select every entry shown",$.setAttribute("aria-label","Select every entry shown"),ne("cex-check",$);const we=(e,t)=>{const n=document.createElement("button");return n.type="button",n.dataset.sort=e,n.append(document.createTextNode(t),Object.assign(document.createElement("span"),{className:"cex-sort"})),n};xe.forEach(e=>ne("",we(e.name,e.label||e.name))),ne("cex-when",we("created","Created")),ne("cex-tools"),i(M.effect(()=>{const e=Z.value;V.querySelectorAll("[data-sort]").forEach(t=>{t.querySelector(".cex-sort").textContent=t.dataset.sort===e.key?e.dir==="asc"?"\u25B2":"\u25BC":"",t.parentElement.setAttribute("aria-sort",t.dataset.sort===e.key?e.dir==="asc"?"ascending":"descending":"none")})})),i(M.effect(()=>{const e=T.value.map(n=>n.id),t=new Set(y.value);$.checked=e.length>0&&e.every(n=>t.has(n)),$.indeterminate=!$.checked&&e.some(n=>t.has(n))}));const Se=e=>{const t=e.target.closest("[data-sort]");if(!t)return;const n=Z.peek();Z.value={key:t.dataset.sort,dir:n.key===t.dataset.sort&&n.dir==="asc"?"desc":"asc"}};V.addEventListener("click",Se),$.addEventListener("change",()=>{const e=T.peek().map(t=>t.id);y.value=$.checked?[...new Set([...y.peek(),...e])]:y.peek().filter(t=>!e.includes(t))}),i(()=>V.removeEventListener("click",Se));const st=M.applyBindings(_e,h,{methods:{clearSelection:()=>{y.value=[]},deleteSelected:()=>Ge(),clearAll:()=>We(),clearFilters:()=>{w.value=[]},showMore:()=>{T.peek().length>q.peek()?q.value=q.peek()+W:Je()}}});i(()=>st.dispose()),i(Ne(h,{position:"bottom"})),i(M.effect(()=>{le.value,w.value,q.value=W}));const Me=e=>{const t=e.target.closest("button[data-idx]");if(!t)return;const n=Number(t.dataset.idx);w.value=w.peek().filter((s,o)=>o!==n)};pe.addEventListener("click",Me),i(()=>pe.removeEventListener("click",Me));const ot=E.contextMenu(L,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:(e,t)=>nt(e,t)});i(()=>ot?.destroy?.());const Ce=e=>{const t=e.target.closest(".cex-row");if(!t)return;if(e.target.closest(".cex-kebab, .cex-run")){Ie(e.target.closest(".cex-kebab, .cex-run"));return}if(e.target.closest(".cex-check"))return;const n=_(t.dataset.id);n&&H(n)},$e=e=>{if(!e.target.matches(".cex-sel"))return;const t=e.target.closest(".cex-row");t&&de(t.dataset.id)},Ae=e=>{const t=e.target.closest(".cex-row");if(!t||e.target!==t)return;const n=_(t.dataset.id);n&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ie(t)):e.key==="Enter"?(e.preventDefault(),H(n)):e.key===" "?(e.preventDefault(),de(n.id)):e.key==="Delete"&&(e.preventDefault(),ve(n)))};L.addEventListener("click",Ce),L.addEventListener("change",$e),L.addEventListener("keydown",Ae),i(()=>{L.removeEventListener("click",Ce),L.removeEventListener("change",$e),L.removeEventListener("keydown",Ae)});const Le=p.peek(),ae=m.get(0).querySelector(".dm-plugin-banner-name")?.querySelector(".dm-plugin-banner-sep");if(ae&&Le?.title){for(;ae.nextSibling;)ae.nextSibling.remove();ae.after(document.createTextNode(Le.title))}U(m,{id:"entries-back",icon:"arrow-left",title:"All collections",onClick:()=>{location.hash="#/collections"}}),U(m,{id:"entries-fields",icon:"edit-3",title:"Edit its fields and settings",onClick:()=>{location.hash=`#/collections/edit/${encodeURIComponent(a)}`}}),vt(a)&&U(m,{id:"entries-form",icon:"layout",title:"Open its form",onClick:()=>{location.hash=`#/forms/edit/${encodeURIComponent(a)}`}}),U(m,{id:"entries-export",icon:"download",title:"Export",onClick:()=>tt()}),U(m,{id:"entries-import",icon:"upload",title:"Import",onClick:()=>et()}),U(m,{id:"entries-add",icon:"plus",title:"Add entry",onClick:()=>H(null)});const qe=e=>{Y?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",qe),i(()=>window.removeEventListener("beforeunload",qe)),i(()=>{if(Y?.dirty())try{S.set(j(a),{id:Y.id,values:Y.values(),at:new Date().toISOString()})}catch{}Ee=!0,[...ue].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(h),!p.peek()){X.value=!1;return}if(await Ke(),!u()||(await J(),!u()))return;let C=null;try{C=S.get(j(a))}catch{}if(C?.values){try{S.remove?S.remove(j(a)):S.set(j(a),null)}catch{}let e=C.id?_(C.id):null;if(C.id&&!e&&(e=await g.collections.getEntry(a,C.id).catch(()=>null)),!u()||C.id&&!e)return;const t=e?`your changes to \u201C${N(K(e))}\u201D`:"a new entry",n=e?.meta?.updatedAt&&C.at&&e.meta.updatedAt>C.at?" It has been changed since - check before saving.":"";await E.confirm(`You left ${t} unsaved.${n} Pick up where you left off?`)&&u()&&H(e,C.values)}}};
50
+ </div>`);t.querySelectorAll("[data-fmt]").forEach(n=>n.addEventListener("click",async()=>{const s=n.dataset.fmt;try{const o=await fetch(`/api/collections/${encodeURIComponent(a)}/export?format=${s}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!o.ok)throw new Error(`${o.status}`);const l=await o.blob(),r=document.createElement("a");r.href=URL.createObjectURL(l),r.download=`${a}-entries.${s}`,document.body.appendChild(r),r.click(),r.remove(),setTimeout(()=>URL.revokeObjectURL(r.href),1e3),e.close(!0)}catch(o){E.toast(`Export failed (${o.message}).`,{type:"error"})}})),e.element.appendChild(t),te(e),I.scan(t),e.open()}function nt(e,t){const n=_(e.dataset.id);if(!n)return[];const s=t?.event?.target?.closest?.("td[data-col]"),o=s?ie.peek()[Number(s.dataset.col)]:null,l=he(n.id),r=y.peek().includes(n.id),d=o?ht(o,n.data?.[o.name]):[];return[{header:K(n)},{label:"Edit\u2026",icon:"edit",action:()=>H(n)},{label:"See all of it\u2026",icon:"eye",action:()=>Qe(n)},{label:"Duplicate",icon:"copy",action:()=>Xe(n)},{label:"Copy id",icon:"hash",action:()=>be(n.id)},{label:r?"Deselect":"Select",icon:"check",action:()=>de(n.id)},...l.length?[{type:"divider"},...l.map(f=>({label:`${f.trigger?.label||f.title||f.slug}\u2026`,icon:f.trigger?.icon||"zap",disabled:A.peek(),action:()=>Ze(f,n)}))]:[],...o?[{type:"divider"},{label:`Filter by ${o.label||o.name}`,icon:"filter",submenu:[...d.map(f=>({label:f.label,action:()=>ge(o,f.op,f.value)})),{label:`${o.label||o.name} contains\u2026`,action:async()=>{const f=await E.prompt(`Show entries whose ${N(o.label||o.name)} contains`,{inputPlaceholder:"text"});f&&String(f).trim()&&ge(o,"contains",String(f).trim())}}]}]:[],...w.peek().length?[{label:`Clear filters (${w.peek().length})`,icon:"x",action:()=>{w.value=[]}}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ve(n)}]}const L=h.querySelector(".cex-rows"),V=h.querySelector(".cex-head"),pe=h.querySelector(".cex-chips-list");pe.innerHTML=gt;const at=E.loader(h,{type:"dots"});try{p.value=await g.collections.get(a)}catch(e){B.value=`Could not load the collection: ${e.message||e}`}finally{at.destroy()}if(!u())return;if(!p.peek()&&!B.peek()){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const xe=ie.peek();L.innerHTML=Et(xe.length),V.replaceChildren();const ne=(e,t)=>{const n=document.createElement("th");return e&&(n.className=e),t&&n.append(t),V.appendChild(n),n},$=document.createElement("input");$.type="checkbox",$.title="Select every entry shown",$.setAttribute("aria-label","Select every entry shown"),ne("cex-check",$);const we=(e,t)=>{const n=document.createElement("button");return n.type="button",n.dataset.sort=e,n.append(document.createTextNode(t),Object.assign(document.createElement("span"),{className:"cex-sort"})),n};xe.forEach(e=>ne("",we(e.name,e.label||e.name))),ne("cex-when",we("created","Created")),ne("cex-tools"),i(M.effect(()=>{const e=Z.value;V.querySelectorAll("[data-sort]").forEach(t=>{t.querySelector(".cex-sort").textContent=t.dataset.sort===e.key?e.dir==="asc"?"\u25B2":"\u25BC":"",t.parentElement.setAttribute("aria-sort",t.dataset.sort===e.key?e.dir==="asc"?"ascending":"descending":"none")})})),i(M.effect(()=>{const e=T.value.map(n=>n.id),t=new Set(y.value);$.checked=e.length>0&&e.every(n=>t.has(n)),$.indeterminate=!$.checked&&e.some(n=>t.has(n))}));const Se=e=>{const t=e.target.closest("[data-sort]");if(!t)return;const n=Z.peek();Z.value={key:t.dataset.sort,dir:n.key===t.dataset.sort&&n.dir==="asc"?"desc":"asc"}};V.addEventListener("click",Se),$.addEventListener("change",()=>{const e=T.peek().map(t=>t.id);y.value=$.checked?[...new Set([...y.peek(),...e])]:y.peek().filter(t=>!e.includes(t))}),i(()=>V.removeEventListener("click",Se));const st=M.applyBindings(_e,h,{methods:{clearSelection:()=>{y.value=[]},deleteSelected:()=>Ge(),clearAll:()=>We(),clearFilters:()=>{w.value=[]},showMore:()=>{T.peek().length>q.peek()?q.value=q.peek()+W:Je()}}});i(()=>st.dispose()),i(Ne(h,{position:"bottom"})),i(M.effect(()=>{le.value,w.value,q.value=W}));const Me=e=>{const t=e.target.closest("button[data-idx]");if(!t)return;const n=Number(t.dataset.idx);w.value=w.peek().filter((s,o)=>o!==n)};pe.addEventListener("click",Me),i(()=>pe.removeEventListener("click",Me));const ot=E.contextMenu(L,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:(e,t)=>nt(e,t)});i(()=>ot?.destroy?.());const Ce=e=>{const t=e.target.closest(".cex-row");if(!t)return;if(e.target.closest(".cex-kebab, .cex-run")){Ie(e.target.closest(".cex-kebab, .cex-run"));return}if(e.target.closest(".cex-check"))return;const n=_(t.dataset.id);n&&H(n)},$e=e=>{if(!e.target.matches(".cex-sel"))return;const t=e.target.closest(".cex-row");t&&de(t.dataset.id)},Ae=e=>{const t=e.target.closest(".cex-row");if(!t||e.target!==t)return;const n=_(t.dataset.id);n&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ie(t)):e.key==="Enter"?(e.preventDefault(),H(n)):e.key===" "?(e.preventDefault(),de(n.id)):e.key==="Delete"&&(e.preventDefault(),ve(n)))};L.addEventListener("click",Ce),L.addEventListener("change",$e),L.addEventListener("keydown",Ae),i(()=>{L.removeEventListener("click",Ce),L.removeEventListener("change",$e),L.removeEventListener("keydown",Ae)});const Le=p.peek(),ae=m.get(0).querySelector(".dm-plugin-banner-name")?.querySelector(".dm-plugin-banner-sep");if(ae&&Le?.title){for(;ae.nextSibling;)ae.nextSibling.remove();ae.after(document.createTextNode(Le.title))}U(m,{id:"entries-back",icon:"arrow-left",title:"All collections",onClick:()=>{location.hash="#/collections"}}),U(m,{id:"entries-fields",icon:"edit-3",title:"Edit its fields and settings",onClick:()=>{location.hash=`#/collections/edit/${encodeURIComponent(a)}`}}),vt(a)&&U(m,{id:"entries-form",icon:"layout",title:"Open its form",onClick:()=>{location.hash=`#/forms/edit/${encodeURIComponent(a)}`}}),U(m,{id:"entries-export",icon:"download",title:"Export",onClick:()=>tt()}),U(m,{id:"entries-import",icon:"upload",title:"Import",onClick:()=>et()}),U(m,{id:"entries-add",icon:"plus",title:"Add entry",onClick:()=>H(null)});const qe=e=>{Y?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",qe),i(()=>window.removeEventListener("beforeunload",qe)),i(()=>{if(Y?.dirty())try{S.set(j(a),{id:Y.id,values:Y.values(),at:new Date().toISOString()})}catch{}Ee=!0,[...ue].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(h),!p.peek()){X.value=!1;return}if(await Ke(),!u()||(await J(),!u()))return;let C=null;try{C=S.get(j(a))}catch{}if(C?.values){try{S.remove?S.remove(j(a)):S.set(j(a),null)}catch{}let e=C.id?_(C.id):null;if(C.id&&!e&&(e=await g.collections.getEntry(a,C.id).catch(()=>null)),!u()||C.id&&!e)return;const t=e?`your changes to \u201C${N(K(e))}\u201D`:"a new entry",n=e?.meta?.updatedAt&&C.at&&e.meta.updatedAt>C.at?" It has been changed since - check before saving.":"";await E.confirm(`You left ${t} unsaved.${n} Pick up where you left off?`)&&u()&&H(e,C.values)}}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.77.0",
3
+ "version": "0.77.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",
package/server/server.js CHANGED
@@ -314,6 +314,18 @@ try {
314
314
  // Initialise response cache (Memory/None/Redis driver per config.cache).
315
315
  await cache.initCache(config.cache);
316
316
 
317
+ // Number fields saved as text by the pre-0.77.1 entry form - one-shot repair.
318
+ // After the cache: it invalidates the collections it changes.
319
+ try {
320
+ const {migrateNumberFields} = await import('./services/number-fields-migration.js');
321
+ const {ran, converted, pending} = await migrateNumberFields();
322
+ const done = Object.entries(converted).map(([slug, n]) => `${slug} (${n})`);
323
+ if (ran && done.length) app.log.info(`[collections] Number fields stored as text converted: ${done.join(', ')}`);
324
+ if (pending.length) app.log.warn(`[collections] Number-field repair will retry next start for: ${pending.join(', ')}`);
325
+ } catch (err) {
326
+ app.log.warn(`[collections] Number-field repair skipped: ${err.message}`);
327
+ }
328
+
317
329
  /*
318
330
  * An admin change clears the response cache.
319
331
  *
@@ -15,6 +15,7 @@
15
15
  * all(slug) → entry[]
16
16
  * insertMany(slug, entries) → { imported, skipped, errors }
17
17
  * count(slug) → number
18
+ * replaceAll(slug, entries) → void (file only - one write for a whole-collection rewrite)
18
19
  */
19
20
  import fs from 'fs/promises';
20
21
  import path from 'path';
@@ -207,6 +208,18 @@ export class FileAdapter {
207
208
  const entries = await read(slug);
208
209
  return entries.length;
209
210
  }
211
+
212
+ /**
213
+ * Replace every entry in one write - for a data migration that touches many
214
+ * entries, where update() would rewrite the whole file once per entry.
215
+ *
216
+ * @param {string} slug
217
+ * @param {object[]} entries
218
+ * @returns {Promise<void>}
219
+ */
220
+ async replaceAll(slug, entries) {
221
+ await write(slug, entries);
222
+ }
210
223
  }
211
224
 
212
225
  export default new FileAdapter();
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Number-fields migration - one-shot repair of number fields stored as text.
3
+ *
4
+ * Before 0.77.1 the admin's entry form read every input as text, so a `number`
5
+ * field saved from it was stored as "42" rather than 42 - and a stored string
6
+ * sorts, filters and sums as text in the API, [collection] displays and views.
7
+ * The form is fixed; this converts what it left behind, in every collection and
8
+ * through the storage adapter, so MongoDB-backed collections are included.
9
+ *
10
+ * Only top-level `number` fields, and only text that is a plain decimal number
11
+ * (whitespace trimmed): "", "abc", "0x10" and "Infinity" are left exactly as
12
+ * they are. An entry's `meta.updatedAt` is not touched - this is a repair, not
13
+ * an edit.
14
+ *
15
+ * Runs ONCE per install: the marker (content/.migrations.json) is written only
16
+ * when every collection was done. It is deliberately not a standing coercion -
17
+ * what an API client writes after this is the client's business.
18
+ */
19
+ import fs from 'fs/promises';
20
+ import path from 'path';
21
+ import {config} from '../config.js';
22
+
23
+ export const MIGRATION_ID = 'number-field-types';
24
+
25
+ const DECIMAL = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
26
+
27
+ /** "42" -> 42; anything that is not plain decimal text is returned unchanged. */
28
+ export function numberFromText(value) {
29
+ if (typeof value !== 'string') return value;
30
+ const t = value.trim();
31
+ if (!DECIMAL.test(t)) return value;
32
+ const n = Number(t);
33
+ return Number.isFinite(n) ? n : value;
34
+ }
35
+
36
+ /**
37
+ * An entry with its number fields converted, or null when nothing changed.
38
+ *
39
+ * @param {object} entry
40
+ * @param {string[]} names - the collection's number field names
41
+ */
42
+ export function convertEntry(entry, names) {
43
+ const data = entry?.data;
44
+ if (!data || typeof data !== 'object') return null;
45
+ let changed = null;
46
+ for (const name of names) {
47
+ if (!(name in data)) continue;
48
+ const next = numberFromText(data[name]);
49
+ if (next !== data[name]) (changed ||= {...data})[name] = next;
50
+ }
51
+ return changed ? {...entry, data: changed} : null;
52
+ }
53
+
54
+ function defaultMarkerPath() {
55
+ return path.join(path.dirname(path.resolve(config.content.collectionsDir)), '.migrations.json');
56
+ }
57
+
58
+ async function readMarker(file) {
59
+ try { return JSON.parse(await fs.readFile(file, 'utf8')) || {}; } catch { return {}; }
60
+ }
61
+
62
+ /**
63
+ * Run the migration.
64
+ *
65
+ * @param {{markerPath?: string, force?: boolean}} [opts]
66
+ * @returns {Promise<{ran: boolean, converted: object, pending: string[]}>}
67
+ * converted: slug -> entries changed; pending: collections that could not be done
68
+ * (they are retried on the next start, because the marker is not written)
69
+ */
70
+ export async function migrateNumberFields({markerPath = defaultMarkerPath(), force = false} = {}) {
71
+ const marker = await readMarker(markerPath);
72
+ if (marker[MIGRATION_ID] && !force) return {ran: false, converted: {}, pending: []};
73
+
74
+ const {listCollections} = await import('./collections.js');
75
+ const {getAdapter} = await import('./adapterRegistry.js');
76
+ const {default: fileAdapter} = await import('./adapters/FileAdapter.js');
77
+ const cache = await import('./cache/index.js');
78
+
79
+ const converted = {};
80
+ const pending = [];
81
+ for (const schema of await listCollections()) {
82
+ const names = (schema.fields || []).filter(f => f?.type === 'number' && f.name).map(f => f.name);
83
+ if (!names.length) continue;
84
+ try {
85
+ const adapter = await getAdapter(schema.slug);
86
+ // A MongoDB collection whose connection is down resolves to the file adapter,
87
+ // which would find nothing to do - leave it for a start where Mongo is up.
88
+ if (schema.storage?.adapter === 'mongodb' && adapter === fileAdapter) { pending.push(schema.slug); continue; }
89
+ const entries = await adapter.all(schema.slug);
90
+ const next = entries.map(e => convertEntry(e, names));
91
+ const count = next.filter(Boolean).length;
92
+ if (!count) continue;
93
+ if (typeof adapter.replaceAll === 'function') {
94
+ await adapter.replaceAll(schema.slug, entries.map((e, i) => next[i] || e));
95
+ } else {
96
+ for (const e of next) if (e) await adapter.update(schema.slug, e.id, e);
97
+ }
98
+ converted[schema.slug] = count;
99
+ await cache.invalidateTags([`collection:${schema.slug}`]).catch(() => {});
100
+ } catch {
101
+ pending.push(schema.slug);
102
+ }
103
+ }
104
+
105
+ if (!pending.length) {
106
+ await fs.writeFile(markerPath, JSON.stringify({...marker, [MIGRATION_ID]: new Date().toISOString()}, null, 2) + '\n');
107
+ }
108
+ return {ran: true, converted, pending};
109
+ }