domma-cms 1.6.3 → 1.6.5

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 FREQUENT_KEY="frequent",FREQUENT_MAX=12;export function findLeaf(t,e){for(const r of t||[])if(!(!r||r.type||r.hidden)){if(Array.isArray(r.items)&&r.items.length){const n=findLeaf(r.items,e);if(n)return n}else if(r.url===e)return r}return null}export function frequentFolder(t,e,{hidden:r=!1}={}){const n=[];for(const i of Array.isArray(e)?e:[]){const o=typeof i=="string"&&i?findLeaf(t,i):null;o&&n.push({text:o.text,url:o.url,...o.icon&&{icon:o.icon},...o.colour&&{colour:o.colour}})}return n.length?{text:"Frequent",key:FREQUENT_KEY,icon:"star",items:n,frequent:!0}:r?null:{text:"Frequent",key:FREQUENT_KEY,icon:"star",items:[],frequent:!0,empty:!0}}export function addFrequent(t,e){const r=(Array.isArray(t)?t:[]).filter(n=>typeof n=="string"&&n);return!e||r.includes(e)||r.length>=12?r:[...r,e]}export function removeFrequent(t,e){return(Array.isArray(t)?t:[]).filter(r=>r!==e)}export function moveFrequent(t,e,r){const n=Array.isArray(t)?[...t]:[],i=n.indexOf(e),o=i+r;return i<0||o<0||o>=n.length||([n[i],n[o]]=[n[o],n[i]]),n}export function storageKey(t){const e=t&&(t.id||t._id||t.email);return`sidebar.frequent.${e?String(e):"anon"}`}export function readFrequent(t){try{const e=S.get(storageKey(t));return Array.isArray(e)?e.filter(r=>typeof r=="string"&&r):[]}catch{return[]}}export function readFrequentHidden(t){try{return S.get(`${storageKey(t)}.hidden`)===!0}catch{return!1}}export function writeFrequentHidden(t,e){try{return S.set(`${storageKey(t)}.hidden`,!!e),!0}catch{return!1}}export function writeFrequent(t,e){try{return S.set(storageKey(t),Array.isArray(e)?e:[]),!0}catch{return!1}}
1
+ export const FREQUENT_KEY="frequent",FREQUENT_MAX=12;export function findLeaf(e,t){for(const r of e||[])if(!(!r||r.type||r.hidden)){if(Array.isArray(r.items)&&r.items.length){const n=findLeaf(r.items,t);if(n)return n}else if(r.url===t)return r}return null}export function frequentFolder(e,t,{hidden:r=!1}={}){const n=[];for(const o of Array.isArray(t)?t:[]){const i=typeof o=="string"&&o?findLeaf(e,o):null;i&&n.push({text:i.text,url:i.url,...i.icon&&{icon:i.icon},...i.colour&&{colour:i.colour}})}return n.length?{text:"Frequent",key:FREQUENT_KEY,icon:"star",items:n,frequent:!0}:r?null:{text:"Frequent",key:FREQUENT_KEY,icon:"star",items:[],frequent:!0,empty:!0}}export function addFrequent(e,t){const r=(Array.isArray(e)?e:[]).filter(n=>typeof n=="string"&&n);return!t||r.includes(t)||r.length>=12?r:[...r,t]}export function removeFrequent(e,t){return(Array.isArray(e)?e:[]).filter(r=>r!==t)}export function moveFrequent(e,t,r){const n=Array.isArray(e)?[...e]:[],o=n.indexOf(t),i=o+r;return o<0||i<0||i>=n.length||([n[o],n[i]]=[n[i],n[o]]),n}export function storageKey(e){const t=e&&(e.id||e._id||e.email);return`sidebar.frequent.${t?String(t):"anon"}`}export function readFrequent(e){try{const t=S.get(storageKey(e));return Array.isArray(t)?t.filter(r=>typeof r=="string"&&r):[]}catch{return[]}}export function readFrequentHidden(e){try{return S.get(`${storageKey(e)}.hidden`)===!0}catch{return!1}}export function writeFrequentHidden(e,t){try{return S.set(`${storageKey(e)}.hidden`,!!t),!0}catch{return!1}}export function writeFrequent(e,t){try{return S.set(storageKey(e),Array.isArray(t)?t:[]),!0}catch{return!1}}let u=null;export function resetFrequentSync(){u=null}export function saveFrequent(e,t){return e.put("/sidebar/frequent",{urls:readFrequent(t),hidden:readFrequentHidden(t)})}export async function syncFrequent(e,t){const r=t&&(t.id||t._id||t.email);if(!r||u===String(r))return;const n=await e.get("/sidebar/frequent");if(!(!n||typeof n!="object")){if(u=String(r),!n.saved){(readFrequent(t).length||readFrequentHidden(t))&&await saveFrequent(e,t);return}writeFrequent(t,Array.isArray(n.urls)?n.urls:[]),writeFrequentHidden(t,n.hidden===!0)}}
@@ -1,7 +1,7 @@
1
- import{api as g,getUser as P}from"../api.js";import{isPathAllowed as ce}from"./admin-scope.js";import{colourToCss as K,applyFloat as de,applyHighlight as ue,itemHighlightAttrs as me}from"/public/js/menu-decor.mjs?v=20261009-hover-darken";import{placeTools as fe,ensureLockedTools as pe,managePluginsItem as he,stripItemByUrl as ge,insertFoldersBeforeSystem as be,pruneEmptyToolsFolder as ye,sidebarStateKey as V,canAny as O,filterByPermission as ve,MANAGE_PLUGINS_URL as X}from"./sidebar-grouping.js";import{arrangeButtonMarkup as xe,canArrange as $e,startArrange as we}from"./sidebar-arrange.js";import{collapseButtonMarkup as ke,wireSidebarSize as Se,COLLAPSED_WIDTH as Ce}from"./sidebar-size.js";import{FREQUENT_MAX as Z,addFrequent as qe,frequentFolder as Ae,moveFrequent as Y,readFrequent as U,readFrequentHidden as Fe,removeFrequent as Le,writeFrequent as Ee,writeFrequentHidden as J}from"./sidebar-frequent.js";import{hotkeyFor as Q,hotkeyTitle as Te,setHotkeyTree as Me}from"./hotkeys-ui.js";import{hotkeyLabel as He}from"./hotkeys.js";function h(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}const Ne=[{text:"Dashboard",url:"#/",icon:"home"},{text:"Menus",url:"#/menus",icon:"menu",permission:"menus"}];async function je(e){if(!O(e,"projects"))return[];let n=[];try{n=await g.projects.listWithCounts()}catch{return[]}if(!Array.isArray(n))return[];const o=[{key:"pages",text:"Pages",icon:"file-text",path:"/pages"},{key:"collections",text:"Collections",icon:"database",path:"/collections"},{key:"forms",text:"Forms",icon:"layout",path:"/forms"},{key:"actions",text:"Actions",icon:"zap",path:"/actions"},{key:"menus",text:"Menus",icon:"menu",path:"/menus"},{key:"blocks",text:"Blocks",icon:"box",path:"/blocks"},{key:"components",text:"Components",icon:"component",path:"/components"},{key:"views",text:"Views",icon:"eye",path:"/views"},{key:"roles",text:"Roles",icon:"shield",path:"/roles"},{key:"users",text:"Users",icon:"users",path:"/users"},{key:"apis",text:"APIs",icon:"code",path:"/apis"}];return n.map(t=>{const c="#/projects/"+encodeURIComponent(t.slug),a=t.counts||{},i=[{text:"Overview",url:c,icon:t.icon||"folder"}];for(const d of o){const r=Number(a[d.key])||0;r<=0||i.push({text:`${d.text} (${r})`,url:c+d.path,icon:d.icon})}i.push({text:"Settings",url:c+"/settings",icon:"settings"});const u=t.enabled===!1;return{text:t.name||t.slug,key:`project.${t.slug}`,icon:t.icon||"folder",items:i,...u&&{colour:"neutral",badge:{text:"off"}}}})}function Be(e,n){for(const o of e)if(!o.hidden&&(o.key==="projects"||!o.key&&(o.text||"").toLowerCase()==="projects")){o.items=Array.isArray(o.items)?o.items.slice():[],o.items.push(...n);return}}function ee(e,n){const o=ne.includes(n.tone)?n.tone:"info",t=n.label||String(n.count);n.details?N.set(e,n.details):N.delete(e);const s=!n.inventory&&F.some(c=>c.url===e&&!c.inventory)?" data-live":"";return`<span class="sidebar-badge sidebar-badge--${o}" data-count-for="${h(e)}"${s} data-badge-label="${h(t)}" aria-label="${h(t)}">${h(String(n.count))}</span>`}function te(e){for(const n of e.querySelectorAll("details")){const o=n.querySelector(":scope > summary");if(!o)continue;let t=0,s=!1;const c=[];for(const r of n.querySelectorAll(":scope > .sidebar-children .sidebar-badge[data-live]")){const m=Number(r.textContent)||0;m&&(t+=m,s||(s=r.classList.contains("sidebar-badge--danger")),c.push({text:r.closest("a")?.querySelector(".sidebar-text")?.textContent.trim()??"",meta:r.dataset.badgeLabel,tone:r.classList.contains("sidebar-badge--danger")?"danger":void 0}))}const a=`folder:${n.dataset.stateKey||o.textContent.trim()}`;let i=o.querySelector(":scope > .sidebar-badge--sum");if(!t){i?.remove(),N.delete(a);continue}i||(i=document.createElement("span"),o.querySelector(".sidebar-text")?.after(i));const u=o.querySelector(".sidebar-text")?.textContent.trim()||"This folder",d=c.map(r=>`${r.text}: ${r.meta}`).join(" \xB7 ");i.className=`sidebar-badge sidebar-badge--sum sidebar-badge--${s?"danger":"info"}`,i.dataset.countFor=a,i.dataset.badgeLabel=d,i.setAttribute("aria-label",d),i.textContent=String(t),N.set(a,{title:u,items:c})}}const N=new Map;function Pe(e,n){if(!e.url||!n)return"";const o=n[e.url];return!o||!(o.count>0)&&!o.showZero?"":ee(e.url,o)}const ne=["info","attention","danger","live"],Re={"#/pages":["page","pages"],"#/media":["file in the media library","files in the media library"],"#/menus":["menu","menus"],"#/context-menus":["context menu","context menus"],"#/theme":["element override in the theme","element overrides in the theme"],"#/collections":["collection","collections"],"#/forms":["form","forms"],"#/views":["view","views"],"#/actions":["action","actions"],"#/blocks":["block","blocks"],"#/components":["component","components"],"#/users":["user","users"],"#/plugins":["plugin installed","plugins installed"],"#/system/notifications":["unread notification","unread notifications"]};function z(e,{fallbackLabel:n="",tone:o="info"}={}){const t=Number(typeof e=="object"&&e!==null?e.count:e);if(!Number.isFinite(t)||t<0)return null;const s=typeof e?.label=="string"&&e.label||(n?`${t} - ${n}`:String(t)),c={count:Math.round(t),label:s,tone:ne.includes(e?.tone)?e.tone:o};return e?.details&&typeof e.details=="object"&&(c.details=e.details),e?.inventory===!0&&(c.inventory=!0),e?.showZero===!0&&(c.showZero=!0),c}function se(e,n,o=null,{inFrequent:t=!1}={}){if(e&&e.type==="separator")return'<hr class="sidebar-divider">';if(e&&e.type==="spacer"){const b=["xs","sm","md","lg","xl","flex"].includes(e.size)?e.size:"md";return`<div class="sidebar-spacer sidebar-spacer--${h(b)}" aria-hidden="true"></div>`}const s=String(e.text||"?").trim().charAt(0).toUpperCase()||"?",c=e.icon?`<span data-icon="${h(e.icon)}"></span>`:`<span class="sidebar-initial" aria-hidden="true">${h(s)}</span>`,a=h(e.text||""),i=e.url?h(e.url):"",u=Array.isArray(e.items)&&e.items.length>0,d=e.colour?K(e.colour):"",r=me(e),m=[r.hover&&` data-menu-item-hover="${h(r.hover)}"`,r.active&&` data-menu-item-active="${h(r.active)}"`].filter(Boolean).join(""),y=[d&&`color:${h(d)}`,r.accent&&`--dm-menu-highlight:${h(r.accent)}`].filter(Boolean).join(";"),v=y?` style="${y}"`:"",k=e.url?Te(e.url,e.text||"",{inFrequent:t}):"",C=k?` title="${h(k)}"`:"";let q="";if(e.badge&&e.badge.text!=null&&e.badge.text!==""){const b=e.badge.variant?K(e.badge.variant):"";q=`<span class="dm-menu-badge"${b?` style="background:${h(b)};color:var(--dm-text-inverse,#fff)"`:""}>${h(String(e.badge.text))}</span>`}if(e.frequent&&e.empty){const b=V(e,o),A=S.get(b)!==!1;return`<details data-state-key="${h(b)}"${A?" open":""} data-frequent data-frequent-empty>
2
- <summary${v}>${c} <span class="sidebar-text">${a}</span></summary>
1
+ import{api as g,getUser as L}from"../api.js";import{isPathAllowed as ue}from"./admin-scope.js";import{colourToCss as V,applyFloat as fe,applyHighlight as me,itemHighlightAttrs as pe}from"/public/js/menu-decor.mjs?v=20261009-hover-darken";import{placeTools as he,ensureLockedTools as ge,managePluginsItem as be,stripItemByUrl as ye,insertFoldersBeforeSystem as ve,pruneEmptyToolsFolder as xe,sidebarStateKey as X,canAny as O,filterByPermission as $e,MANAGE_PLUGINS_URL as Z}from"./sidebar-grouping.js";import{arrangeButtonMarkup as we,canArrange as ke,startArrange as Se}from"./sidebar-arrange.js";import{collapseButtonMarkup as qe,wireSidebarSize as Ce,COLLAPSED_WIDTH as Ae}from"./sidebar-size.js";import{FREQUENT_MAX as Y,addFrequent as Fe,frequentFolder as Le,moveFrequent as J,readFrequent as z,readFrequentHidden as Ee,removeFrequent as Te,saveFrequent as Me,syncFrequent as He,writeFrequent as Ne,writeFrequentHidden as Q}from"./sidebar-frequent.js";import{hotkeyFor as ee,hotkeyTitle as je,setHotkeyTree as Be}from"./hotkeys-ui.js";import{hotkeyLabel as Pe}from"./hotkeys.js";function m(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}const Re=[{text:"Dashboard",url:"#/",icon:"home"},{text:"Menus",url:"#/menus",icon:"menu",permission:"menus"}];async function _e(e){if(!O(e,"projects"))return[];let n=[];try{n=await g.projects.listWithCounts()}catch{return[]}if(!Array.isArray(n))return[];const s=[{key:"pages",text:"Pages",icon:"file-text",path:"/pages"},{key:"collections",text:"Collections",icon:"database",path:"/collections"},{key:"forms",text:"Forms",icon:"layout",path:"/forms"},{key:"actions",text:"Actions",icon:"zap",path:"/actions"},{key:"menus",text:"Menus",icon:"menu",path:"/menus"},{key:"blocks",text:"Blocks",icon:"box",path:"/blocks"},{key:"components",text:"Components",icon:"component",path:"/components"},{key:"views",text:"Views",icon:"eye",path:"/views"},{key:"roles",text:"Roles",icon:"shield",path:"/roles"},{key:"users",text:"Users",icon:"users",path:"/users"},{key:"apis",text:"APIs",icon:"code",path:"/apis"}];return n.map(t=>{const o="#/projects/"+encodeURIComponent(t.slug),a=t.counts||{},d=[{text:"Overview",url:o,icon:t.icon||"folder"}];for(const c of s){const r=Number(a[c.key])||0;r<=0||d.push({text:`${c.text} (${r})`,url:o+c.path,icon:c.icon})}d.push({text:"Settings",url:o+"/settings",icon:"settings"});const u=t.enabled===!1;return{text:t.name||t.slug,key:`project.${t.slug}`,icon:t.icon||"folder",items:d,...u&&{colour:"neutral",badge:{text:"off"}}}})}function Ie(e,n){for(const s of e)if(!s.hidden&&(s.key==="projects"||!s.key&&(s.text||"").toLowerCase()==="projects")){s.items=Array.isArray(s.items)?s.items.slice():[],s.items.push(...n);return}}function te(e,n){const s=se.includes(n.tone)?n.tone:"info",t=n.label||String(n.count);n.details?j.set(e,n.details):j.delete(e);const i=!n.inventory&&A.some(o=>o.url===e&&!o.inventory)?" data-live":"";return`<span class="sidebar-badge sidebar-badge--${s}" data-count-for="${m(e)}"${i} data-badge-label="${m(t)}" aria-label="${m(t)}">${m(String(n.count))}</span>`}function ne(e){for(const n of e.querySelectorAll("details")){const s=n.querySelector(":scope > summary");if(!s)continue;let t=0,i=!1;const o=[];for(const r of n.querySelectorAll(":scope > .sidebar-children .sidebar-badge[data-live]")){const f=Number(r.textContent)||0;f&&(t+=f,i||(i=r.classList.contains("sidebar-badge--danger")),o.push({text:r.closest("a")?.querySelector(".sidebar-text")?.textContent.trim()??"",meta:r.dataset.badgeLabel,tone:r.classList.contains("sidebar-badge--danger")?"danger":void 0}))}const a=`folder:${n.dataset.stateKey||s.textContent.trim()}`;let d=s.querySelector(":scope > .sidebar-badge--sum");if(!t){d?.remove(),j.delete(a);continue}d||(d=document.createElement("span"),s.querySelector(".sidebar-text")?.after(d));const u=s.querySelector(".sidebar-text")?.textContent.trim()||"This folder",c=o.map(r=>`${r.text}: ${r.meta}`).join(" \xB7 ");d.className=`sidebar-badge sidebar-badge--sum sidebar-badge--${i?"danger":"info"}`,d.dataset.countFor=a,d.dataset.badgeLabel=c,d.setAttribute("aria-label",c),d.textContent=String(t),j.set(a,{title:u,items:o})}}const j=new Map;function Oe(e,n){if(!e.url||!n)return"";const s=n[e.url];return!s||!(s.count>0)&&!s.showZero?"":te(e.url,s)}const se=["info","attention","danger","live"],Ue={"#/pages":["page","pages"],"#/media":["file in the media library","files in the media library"],"#/menus":["menu","menus"],"#/context-menus":["context menu","context menus"],"#/theme":["element override in the theme","element overrides in the theme"],"#/collections":["collection","collections"],"#/forms":["form","forms"],"#/views":["view","views"],"#/actions":["action","actions"],"#/blocks":["block","blocks"],"#/components":["component","components"],"#/users":["user","users"],"#/plugins":["plugin installed","plugins installed"],"#/system/notifications":["unread notification","unread notifications"]};function W(e,{fallbackLabel:n="",tone:s="info"}={}){const t=Number(typeof e=="object"&&e!==null?e.count:e);if(!Number.isFinite(t)||t<0)return null;const i=typeof e?.label=="string"&&e.label||(n?`${t} - ${n}`:String(t)),o={count:Math.round(t),label:i,tone:se.includes(e?.tone)?e.tone:s};return e?.details&&typeof e.details=="object"&&(o.details=e.details),e?.inventory===!0&&(o.inventory=!0),e?.showZero===!0&&(o.showZero=!0),o}function oe(e,n,s=null,{inFrequent:t=!1}={}){if(e&&e.type==="separator")return'<hr class="sidebar-divider">';if(e&&e.type==="spacer"){const y=["xs","sm","md","lg","xl","flex"].includes(e.size)?e.size:"md";return`<div class="sidebar-spacer sidebar-spacer--${m(y)}" aria-hidden="true"></div>`}const i=String(e.text||"?").trim().charAt(0).toUpperCase()||"?",o=e.icon?`<span data-icon="${m(e.icon)}"></span>`:`<span class="sidebar-initial" aria-hidden="true">${m(i)}</span>`,a=m(e.text||""),d=e.url?m(e.url):"",u=Array.isArray(e.items)&&e.items.length>0,c=e.colour?V(e.colour):"",r=pe(e),f=[r.hover&&` data-menu-item-hover="${m(r.hover)}"`,r.active&&` data-menu-item-active="${m(r.active)}"`].filter(Boolean).join(""),x=[c&&`color:${m(c)}`,r.accent&&`--dm-menu-highlight:${m(r.accent)}`].filter(Boolean).join(";"),w=x?` style="${x}"`:"",k=e.url?je(e.url,e.text||"",{inFrequent:t}):"",q=k?` title="${m(k)}"`:"";let F="";if(e.badge&&e.badge.text!=null&&e.badge.text!==""){const y=e.badge.variant?V(e.badge.variant):"";F=`<span class="dm-menu-badge"${y?` style="background:${m(y)};color:var(--dm-text-inverse,#fff)"`:""}>${m(String(e.badge.text))}</span>`}if(e.frequent&&e.empty){const y=X(e,s),C=S.get(y)!==!1;return`<details data-state-key="${m(y)}"${C?" open":""} data-frequent data-frequent-empty>
2
+ <summary${w}>${o} <span class="sidebar-text">${a}</span></summary>
3
3
  <div class="sidebar-children"><p class="sidebar-frequent-empty">Right-click any Tool and choose <strong>Add to Frequent</strong> to keep it here.</p></div>
4
- </details>`}if(u){const b=V(e,o),A=S.get(b)!==!1,j=e.items.map(L=>se(L,n,b,{inFrequent:!!e.frequent})).join(""),B=e.frequent?' data-frequent title="Your Frequent Tools - right-click any Tool to add or remove it"':"";return`<details data-state-key="${h(b)}"${A?" open":""}${B}>
5
- <summary${v}${C}>${c} <span class="sidebar-text">${a}</span>${q}</summary>
6
- <div class="sidebar-children">${j}</div>
7
- </details>`}return`<a href="${i}" class="sidebar-link" data-url="${i}"${v}${m}${C}>${c} <span class="sidebar-text">${a}</span>${Pe(e,n)}${q}</a>`}async function _e(e=[],n=[],o=null){const t=f=>f.then(x=>Array.isArray(x)?x.length:Array.isArray(x?.entries)?x.entries.length:0).catch(()=>0);F=Ue(e,o);const s=new Set(F.map(f=>f.url)),c={pages:"#/pages",media:"#/media",menus:"#/menus","context-menus":"#/context-menus",theme:"#/theme",collections:"#/collections",forms:"#/forms",views:"#/views",actions:"#/actions",blocks:"#/blocks",components:"#/components",users:"#/users",plugins:"#/plugins",notifications:"#/system/notifications"},a=(f,x)=>O(n,f)&&!s.has(c[f])?x():Promise.resolve(0),[i,u,d,r,m,y,v,k,C,q,b,A,j,B]=await Promise.all([a("pages",()=>t(g.pages.list())),a("media",()=>t(g.media.list())),a("menus",()=>t(g.menus.list())),a("context-menus",()=>t(g.contextMenus.list())),a("theme",()=>g.theme.get().then(f=>(f?.overrides||[]).length).catch(()=>0)),a("collections",()=>t(g.collections.list())),a("forms",()=>t(g.forms.list())),a("views",()=>t(g.views.list())),a("actions",()=>t(g.actions.list())),a("blocks",()=>t(g.blocks.list())),a("components",()=>t(g.components.list())),a("users",()=>t(g.users.list())),a("plugins",()=>t(g.plugins.list())),a("notifications",()=>g.system?.notifications?.unreadCount?.().then(f=>f?.count??0).catch(()=>0)??Promise.resolve(0))]),L={};await Promise.all(F.map(async f=>{const x=await oe(f);x&&(L[f.url]=x)}));const _={"#/pages":i,"#/media":u,"#/menus":d,"#/context-menus":r,"#/theme":m,"#/collections":y,"#/forms":v,"#/views":k,"#/actions":C,"#/blocks":q,"#/components":b,"#/users":A,"#/plugins":j,"#/system/notifications":B},I={};for(const[f,x]of Object.entries(_)){if(s.has(f))continue;const[l,w]=Re[f]??["item","items"],p=z(x);p&&(I[f]={...p,label:`${p.count} ${p.count===1?l:w}`})}return{...I,...L}}let F=[];const Ie="dm:sidebar-count",Oe=6e4;function Ue(e=[],n=null){const o=[],t=s=>{if(s&&!(n&&typeof s.url=="string"&&s.url.startsWith("#/")&&!ce(n,s.url.slice(1)))){s.url&&typeof s.countUrl=="string"&&s.countUrl&&o.push({url:s.url,countUrl:s.countUrl,text:s.text||"",countLabel:s.countLabel||"",inventory:s.inventory===!0});for(const c of[...s.children||[],...s.items||[]])t(c)}};for(const s of e??[])t(s?.item??s);return o}async function oe(e){try{const n=new Date,o=`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,"0")}-${String(n.getDate()).padStart(2,"0")}`,t=e.countUrl+(e.countUrl.includes("?")?"&":"?")+`today=${o}`;return z(await H.get(t),{fallbackLabel:e.countLabel||e.text})}catch{return null}}function ie(e,n,o){const t=[...e.querySelectorAll("a.sidebar-link")].filter(s=>s.dataset.url===n);if(t.length){for(const s of t){const c=s.querySelector(".sidebar-badge[data-count-for]");if(!o||!(o.count>0)&&!o.showZero)c?.remove();else{const a=document.createRange();a.selectNodeContents(s);const i=a.createContextualFragment(ee(n,o)).firstElementChild;c?c.replaceWith(i):s.querySelector(".sidebar-text")?.after(i)}}W.set(n,o),te(e)}}const W=new Map;async function D(e,n=null){const o=n?F.filter(t=>t.url===n):F;await Promise.all(o.map(async t=>{const s=await oe(t),c=W.get(t.url);JSON.stringify(c??null)!==JSON.stringify(s)&&ie(e,t.url,s)}))}function ze(e){e.dataset.liveCounts||(e.dataset.liveCounts="1",window.addEventListener(Ie,n=>{const o=n.detail||{};if(o.url)if(o.count!==void 0){const t=F.find(s=>s.url===o.url);ie(e,o.url,z(o,{fallbackLabel:t?.countLabel||t?.text||""}))}else D(e,o.url)}),setInterval(()=>{!document.hidden&&document.body.contains(e)&&D(e)},Oe),document.addEventListener("visibilitychange",()=>{document.hidden||D(e)}),We(e))}function ae(e,n,o){const t=(a,i,u)=>{const d=document.createElement(a);return i&&(d.className=i),u!=null&&(d.textContent=String(u)),d};if(!o){e.textContent=n;return}e.appendChild(t("strong","dm-badge-pop-title",o.title||n)),(o.rows?.length||!o.items?.length)&&e.appendChild(t("p","dm-badge-pop-sub",n));const s=Array.isArray(o.rows)?o.rows.slice(0,6):[];if(s.length){const a=t("dl","dm-badge-pop-rows");for(const i of s)a.appendChild(t("dt",null,i.label)),a.appendChild(t("dd",i.tone==="danger"?"is-danger":null,i.value));e.appendChild(a)}const c=Array.isArray(o.items)?o.items.slice(0,6):[];if(c.length){const a=t("ul","dm-badge-pop-items");for(const i of c){const u=t("li",i.tone==="danger"?"is-danger":null);u.appendChild(t("span","dm-badge-pop-text",i.text)),i.meta&&u.appendChild(t("span","dm-badge-pop-meta",i.meta)),a.appendChild(u)}e.appendChild(a)}else o.empty&&e.appendChild(t("p","dm-badge-pop-empty",o.empty))}function We(e){let n=null,o=null;const t=()=>e.classList.contains("is-collapsed"),s=()=>{clearTimeout(o),n?.remove(),n=null},c=(d,r)=>{s(),o=setTimeout(()=>{n=document.createElement("div"),n.className="dm-help-pop dm-badge-pop",n.setAttribute("role","tooltip"),r(n),document.body.appendChild(n);const m=d.getBoundingClientRect(),y=n.getBoundingClientRect(),v=Math.min(m.right+10,innerWidth-y.width-8);n.style.left=`${Math.max(8,v)}px`,n.style.top=`${Math.max(8,Math.min(m.top+m.height/2-y.height/2,innerHeight-y.height-8))}px`},150)},a=d=>{const r=d?.dataset.badgeLabel;if(!r)return s();c(d,m=>ae(m,r,N.get(d.dataset.countFor)))},i=d=>{const r=d.querySelector(":scope > .sidebar-text")?.textContent.trim()||"",m=d.querySelector(":scope > .sidebar-badge[data-badge-label]:not([hidden])");c(d,y=>{const v=m&&N.get(m.dataset.countFor);if(v)return ae(y,m.dataset.badgeLabel,{...v,title:v.title||r});if(!m){y.textContent=r;return}const k=document.createElement("strong");k.className="dm-badge-pop-title",k.textContent=r;const C=document.createElement("p");C.className="dm-badge-pop-sub",C.textContent=m.dataset.badgeLabel,y.append(k,C)})},u=d=>{const r=d.closest?.("a.sidebar-link, summary");return r&&e.contains(r)?r:null};e.addEventListener("mouseover",d=>{if(t()){const m=u(d.target);m&&!m.contains(d.relatedTarget)&&i(m);return}const r=d.target.closest?.(".sidebar-badge[data-badge-label]");r&&a(r)}),e.addEventListener("mouseout",d=>{if(t()){const r=u(d.target);r&&!r.contains(d.relatedTarget)&&s();return}d.target.closest?.(".sidebar-badge[data-badge-label]")&&s()}),e.addEventListener("focusin",d=>{const r=d.target.closest?.("a, summary");if(t()&&r&&e.contains(r)&&r.querySelector(":scope > .sidebar-text"))return i(r);const m=r?.querySelector(":scope > .sidebar-badge[data-badge-label]:not([hidden])");m?a(m):s()}),e.addEventListener("focusout",s),e.addEventListener("click",s),window.addEventListener("scroll",s,!0),window.addEventListener("hashchange",s)}async function De(e=[]){if(!O(e,"settings"))return"Admin";try{const n=await g.settings.get();return n?.adminBrand?.title||n?.title||"Admin"}catch{return"Admin"}}export async function renderAdminSidebar({mount:e,permissions:n,adminScope:o=null}){const t=$(e).get(0);if(!t)return;let s,c=null,a=null,i=null,u=null,d=null;try{const l=await g.get("/sidebar/menu");s=Array.isArray(l?.items)?l.items:null,c=l?.variant||null,a=l?.position||null,i=l?.style||null,u=l?.float||null,d=l?.highlight||null}catch{s=null}(!s||!s.some(l=>l&&(!l.hidden||l.restricted)))&&(console.warn("[admin-sidebar] No admin-sidebar menu found; using fallback tree"),s=Ne.slice());let r=[];try{r=await H.get("/api/sidebar/registered-items")||[]}catch{}s=ge(s,X),s=fe(s,r.filter(l=>(l?.item??l)?.url!==X)),s=pe(s),s=be(s,[he()]);const m=await je(n);m.length&&Be(s,m),s=ve(s,n,o),s=ye(s);const y=Ae(s,U(P()),{hidden:Fe(P())});y&&(s=[y,...s]),Me(s);const[v,k]=await Promise.all([_e(r.filter(l=>O(n,l?.item?.permission)),n,o).catch(()=>({})),De(n)]),C=c?` dm-admin-sidebar--${h(c)}`:"";function q(l,w="px"){if(l==null)return l;const p=String(l).trim();return/^\d+(\.\d+)?$/.test(p)?`${p}${w}`:p}let b="";if(i){const l=[];i.fontFamily&&l.push(`font-family: ${i.fontFamily}, sans-serif`),i.fontSize&&l.push(`font-size: ${q(i.fontSize)}`),i.fontWeight&&l.push(`font-weight: ${i.fontWeight}`),i.letterSpacing&&l.push(`letter-spacing: ${q(i.letterSpacing,"em")}`);const w=[];if(l.length&&w.push(`#admin-sidebar .dm-admin-sidebar, #admin-sidebar .dm-admin-sidebar .sidebar-link, #admin-sidebar .dm-admin-sidebar summary, #admin-sidebar .dm-admin-sidebar .sidebar-text { ${l.join("; ")} }`),i.iconSize){const p=q(i.iconSize);w.push(`#admin-sidebar .dm-admin-sidebar [data-icon], #admin-sidebar .dm-admin-sidebar [data-icon] svg { width: ${p} !important; height: ${p} !important; }`)}w.length&&(b=`<style data-admin-sidebar-style>${w.join(" ")}</style>`)}const A=$e(n)?xe():"",j=`<div class="dm-admin-sidebar-header"><span data-icon="layout"></span><span class="dm-admin-sidebar-title">${h(k)}</span>${A}${ke()}</div>`,B=s.some(l=>l&&l.type==="spacer"&&l.size==="flex")?" dm-admin-sidebar--has-flex-spacer":"",L=`${b}<nav class="dm-admin-sidebar${C}${B}">${j}${s.map(l=>se(l,v)).join("")}</nav>`,_=document.createRange();_.selectNodeContents(t);const I=_.createContextualFragment(L);t.replaceChildren(I),Domma.icons.scan(t),t.querySelector(".dm-admin-sidebar-arrange")?.addEventListener("click",()=>we(t));for(const l of F)W.set(l.url,v[l.url]??null);te(t),ze(t),t.removeAttribute("data-menu-hover"),t.removeAttribute("data-menu-active"),t.style.removeProperty("--dm-menu-highlight"),ue(t,{highlight:d});const f=document.querySelector(".dashboard-wrapper");["position","top","bottom","left","right","zIndex","maxHeight","transform"].forEach(l=>{t.style[l]=""}),t.classList.remove("dm-menu-floating"),f&&(f.style.paddingLeft="",f.style.paddingRight=""),t.__dmGutter=()=>{if(a!=="floating"||!f)return;const l=u&&u.anchor||"TL",w=Number.isFinite(Number(u?.offsetX))?Number(u.offsetX):16,p=t.classList.contains("is-collapsed")?Ce:parseInt(t.style.width,10)||t.getBoundingClientRect().width||260,T=Math.round(p+w*2)+"px";l==="R"||l==="TR"||l==="BR"?f.style.paddingRight=T:f.style.paddingLeft=T},a==="floating"&&de(t,{position:"floating",float:u||{}}),t.querySelectorAll("details").forEach(l=>{l.addEventListener("toggle",function(){const w=this.getAttribute("data-state-key");w&&S.set(w,this.open);const p=this.querySelector(":scope > .sidebar-children");if(p)if(this.open){const T=p.scrollHeight;p.style.maxHeight="0",requestAnimationFrame(()=>{p.style.maxHeight=T+"px",p.addEventListener("transitionend",function le(){p.style.maxHeight="none",p.removeEventListener("transitionend",le)})})}else{const T=p.scrollHeight;p.style.maxHeight=T+"px",requestAnimationFrame(()=>{p.style.maxHeight="0"})}})}),Se(t),Xe(t),t.dataset.routeWired||(t.dataset.routeWired="1",M.subscribe("router:afterChange",()=>t.__dmSetActive?.()));function x(){const l=/^#\/(dashboards|my-dashboard)\b/.test(location.hash)?"#/":location.hash||"#/";$(t).find(".sidebar-link").removeClass("active"),$(t).find(`.sidebar-link[data-url="${l}"]`).addClass("active")}x(),t.__dmSetActive=x}function re(){try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}function G(e){return`${location.origin}${location.pathname}${e}`}function R(e,n){const o=P(),t=e(U(o));if(!Ee(o,t)){E.toast("Frequent could not be saved - this browser is blocking storage.",{type:"error"});return}t.length&&J(o,!1),n&&E.toast(n,{type:"success",duration:2500}),re()}function Ge(e){if(!J(P(),e)){E.toast("Frequent could not be saved - this browser is blocking storage.",{type:"error"});return}E.toast(e?"Frequent hidden. Add a Tool to it from its right-click menu to bring it back.":"Frequent shown.",{type:"success",duration:3500}),re()}function Ke(e){const n=e.dataset.url;if(!n)return[];const o=e.querySelector(".sidebar-text")?.textContent.trim()||n,t=U(P()),s=t.includes(n),c=!!e.closest("details[data-frequent]"),a=t.indexOf(n),i=!s&&t.length>=Z;return[{header:o},{label:"Open",icon:"arrow-right",...Q(n)&&{shortcut:He(Q(n))},action:()=>{location.hash=n}},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(G(n),"_blank","noopener")},{type:"divider"},s?{label:"Remove from Frequent",icon:"star",action:()=>R(u=>Le(u,n),`${o} removed from Frequent.`)}:{label:i?`Frequent is full (${Z})`:"Add to Frequent",icon:"star",disabled:i,action:()=>R(u=>qe(u,n),`${o} added to Frequent.`)},...c?[{label:"Move up",icon:"arrow-up",disabled:a<=0,action:()=>R(u=>Y(u,n,-1))},{label:"Move down",icon:"arrow-down",disabled:a<0||a>=t.length-1,action:()=>R(u=>Y(u,n,1))}]:[],{type:"divider"},{label:"Copy link",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(G(n)),E.toast("Link copied.",{type:"success",duration:2e3})}catch{E.toast(G(n),{type:"info",duration:8e3})}}}]}function Ve(e,n){const o=e.closest("details"),t=e.querySelector(".sidebar-text")?.textContent.trim()||"Folder",s=c=>n.querySelectorAll("details").forEach(a=>{a.open!==c&&(a.open=c)});return[{header:t},{label:o?.open?"Close":"Open",icon:o?.open?"chevron-up":"chevron-down",action:()=>{o&&(o.open=!o.open)}},{label:"Open every folder",icon:"chevrons-down",action:()=>s(!0)},{label:"Close every folder",icon:"chevrons-up",action:()=>s(!1)},...o?.hasAttribute("data-frequent-empty")?[{type:"divider"},{label:"Hide Frequent until I add a Tool",icon:"eye-off",action:()=>Ge(!0)}]:o?.hasAttribute("data-frequent")?[{type:"divider"},{label:"Clear Frequent",icon:"x",action:()=>R(()=>[],"Frequent cleared.")}]:[]]}function Xe(e){e.dataset.ctxWired||typeof E>"u"||!E.contextMenu||(e.dataset.ctxWired="1",E.contextMenu(e,{match:"a.sidebar-link, details > summary",inherit:!1,density:"compact",items:n=>n.matches("summary")?Ve(n,e):Ke(n)}))}
4
+ </details>`}if(u){const y=X(e,s),C=S.get(y)!==!1,B=e.items.map(T=>oe(T,n,y,{inFrequent:!!e.frequent})).join(""),P=e.frequent?' data-frequent title="Your Frequent Tools - right-click any Tool to add or remove it"':"";return`<details data-state-key="${m(y)}"${C?" open":""}${P}>
5
+ <summary${w}${q}>${o} <span class="sidebar-text">${a}</span>${F}</summary>
6
+ <div class="sidebar-children">${B}</div>
7
+ </details>`}return`<a href="${d}" class="sidebar-link" data-url="${d}"${w}${f}${q}>${o} <span class="sidebar-text">${a}</span>${Oe(e,n)}${F}</a>`}async function ze(e=[],n=[],s=null){const t=p=>p.then(b=>Array.isArray(b)?b.length:Array.isArray(b?.entries)?b.entries.length:0).catch(()=>0);A=Ge(e,s);const i=new Set(A.map(p=>p.url)),o={pages:"#/pages",media:"#/media",menus:"#/menus","context-menus":"#/context-menus",theme:"#/theme",collections:"#/collections",forms:"#/forms",views:"#/views",actions:"#/actions",blocks:"#/blocks",components:"#/components",users:"#/users",plugins:"#/plugins",notifications:"#/system/notifications"},a=(p,b)=>O(n,p)&&!i.has(o[p])?b():Promise.resolve(0),[d,u,c,r,f,x,w,k,q,F,y,C,B,P]=await Promise.all([a("pages",()=>t(g.pages.list())),a("media",()=>t(g.media.list())),a("menus",()=>t(g.menus.list())),a("context-menus",()=>t(g.contextMenus.list())),a("theme",()=>g.theme.get().then(p=>(p?.overrides||[]).length).catch(()=>0)),a("collections",()=>t(g.collections.list())),a("forms",()=>t(g.forms.list())),a("views",()=>t(g.views.list())),a("actions",()=>t(g.actions.list())),a("blocks",()=>t(g.blocks.list())),a("components",()=>t(g.components.list())),a("users",()=>t(g.users.list())),a("plugins",()=>t(g.plugins.list())),a("notifications",()=>g.system?.notifications?.unreadCount?.().then(p=>p?.count??0).catch(()=>0)??Promise.resolve(0))]),T={};await Promise.all(A.map(async p=>{const b=await ie(p);b&&(T[p.url]=b)}));const U={"#/pages":d,"#/media":u,"#/menus":c,"#/context-menus":r,"#/theme":f,"#/collections":x,"#/forms":w,"#/views":k,"#/actions":q,"#/blocks":F,"#/components":y,"#/users":C,"#/plugins":B,"#/system/notifications":P},R={};for(const[p,b]of Object.entries(U)){if(i.has(p))continue;const[I,l]=Ue[p]??["item","items"],v=W(b);v&&(R[p]={...v,label:`${v.count} ${v.count===1?I:l}`})}return{...R,...T}}let A=[];const We="dm:sidebar-count",De=6e4;function Ge(e=[],n=null){const s=[],t=i=>{if(i&&!(n&&typeof i.url=="string"&&i.url.startsWith("#/")&&!ue(n,i.url.slice(1)))){i.url&&typeof i.countUrl=="string"&&i.countUrl&&s.push({url:i.url,countUrl:i.countUrl,text:i.text||"",countLabel:i.countLabel||"",inventory:i.inventory===!0});for(const o of[...i.children||[],...i.items||[]])t(o)}};for(const i of e??[])t(i?.item??i);return s}async function ie(e){try{const n=new Date,s=`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,"0")}-${String(n.getDate()).padStart(2,"0")}`,t=e.countUrl+(e.countUrl.includes("?")?"&":"?")+`today=${s}`;return W(await H.get(t),{fallbackLabel:e.countLabel||e.text})}catch{return null}}function ae(e,n,s){const t=[...e.querySelectorAll("a.sidebar-link")].filter(i=>i.dataset.url===n);if(t.length){for(const i of t){const o=i.querySelector(".sidebar-badge[data-count-for]");if(!s||!(s.count>0)&&!s.showZero)o?.remove();else{const a=document.createRange();a.selectNodeContents(i);const d=a.createContextualFragment(te(n,s)).firstElementChild;o?o.replaceWith(d):i.querySelector(".sidebar-text")?.after(d)}}D.set(n,s),ne(e)}}const D=new Map;async function G(e,n=null){const s=n?A.filter(t=>t.url===n):A;await Promise.all(s.map(async t=>{const i=await ie(t),o=D.get(t.url);JSON.stringify(o??null)!==JSON.stringify(i)&&ae(e,t.url,i)}))}function Ke(e){e.dataset.liveCounts||(e.dataset.liveCounts="1",window.addEventListener(We,n=>{const s=n.detail||{};if(s.url)if(s.count!==void 0){const t=A.find(i=>i.url===s.url);ae(e,s.url,W(s,{fallbackLabel:t?.countLabel||t?.text||""}))}else G(e,s.url)}),setInterval(()=>{!document.hidden&&document.body.contains(e)&&G(e)},De),document.addEventListener("visibilitychange",()=>{document.hidden||G(e)}),Ve(e))}function re(e,n,s){const t=(a,d,u)=>{const c=document.createElement(a);return d&&(c.className=d),u!=null&&(c.textContent=String(u)),c};if(!s){e.textContent=n;return}e.appendChild(t("strong","dm-badge-pop-title",s.title||n)),(s.rows?.length||!s.items?.length)&&e.appendChild(t("p","dm-badge-pop-sub",n));const i=Array.isArray(s.rows)?s.rows.slice(0,6):[];if(i.length){const a=t("dl","dm-badge-pop-rows");for(const d of i)a.appendChild(t("dt",null,d.label)),a.appendChild(t("dd",d.tone==="danger"?"is-danger":null,d.value));e.appendChild(a)}const o=Array.isArray(s.items)?s.items.slice(0,6):[];if(o.length){const a=t("ul","dm-badge-pop-items");for(const d of o){const u=t("li",d.tone==="danger"?"is-danger":null);u.appendChild(t("span","dm-badge-pop-text",d.text)),d.meta&&u.appendChild(t("span","dm-badge-pop-meta",d.meta)),a.appendChild(u)}e.appendChild(a)}else s.empty&&e.appendChild(t("p","dm-badge-pop-empty",s.empty))}function Ve(e){let n=null,s=null;const t=()=>e.classList.contains("is-collapsed"),i=()=>{clearTimeout(s),n?.remove(),n=null},o=(c,r)=>{i(),s=setTimeout(()=>{n=document.createElement("div"),n.className="dm-help-pop dm-badge-pop",n.setAttribute("role","tooltip"),r(n),document.body.appendChild(n);const f=c.getBoundingClientRect(),x=n.getBoundingClientRect(),w=Math.min(f.right+10,innerWidth-x.width-8);n.style.left=`${Math.max(8,w)}px`,n.style.top=`${Math.max(8,Math.min(f.top+f.height/2-x.height/2,innerHeight-x.height-8))}px`},150)},a=c=>{const r=c?.dataset.badgeLabel;if(!r)return i();o(c,f=>re(f,r,j.get(c.dataset.countFor)))},d=c=>{const r=c.querySelector(":scope > .sidebar-text")?.textContent.trim()||"",f=c.querySelector(":scope > .sidebar-badge[data-badge-label]:not([hidden])");o(c,x=>{const w=f&&j.get(f.dataset.countFor);if(w)return re(x,f.dataset.badgeLabel,{...w,title:w.title||r});if(!f){x.textContent=r;return}const k=document.createElement("strong");k.className="dm-badge-pop-title",k.textContent=r;const q=document.createElement("p");q.className="dm-badge-pop-sub",q.textContent=f.dataset.badgeLabel,x.append(k,q)})},u=c=>{const r=c.closest?.("a.sidebar-link, summary");return r&&e.contains(r)?r:null};e.addEventListener("mouseover",c=>{if(t()){const f=u(c.target);f&&!f.contains(c.relatedTarget)&&d(f);return}const r=c.target.closest?.(".sidebar-badge[data-badge-label]");r&&a(r)}),e.addEventListener("mouseout",c=>{if(t()){const r=u(c.target);r&&!r.contains(c.relatedTarget)&&i();return}c.target.closest?.(".sidebar-badge[data-badge-label]")&&i()}),e.addEventListener("focusin",c=>{const r=c.target.closest?.("a, summary");if(t()&&r&&e.contains(r)&&r.querySelector(":scope > .sidebar-text"))return d(r);const f=r?.querySelector(":scope > .sidebar-badge[data-badge-label]:not([hidden])");f?a(f):i()}),e.addEventListener("focusout",i),e.addEventListener("click",i),window.addEventListener("scroll",i,!0),window.addEventListener("hashchange",i)}async function Xe(e=[]){if(!O(e,"settings"))return"Admin";try{const n=await g.settings.get();return n?.adminBrand?.title||n?.title||"Admin"}catch{return"Admin"}}export async function renderAdminSidebar({mount:e,permissions:n,adminScope:s=null}){const t=$(e).get(0);if(!t)return;const i=He(g,L()).catch(()=>{});let o,a=null,d=null,u=null,c=null,r=null;try{const l=await g.get("/sidebar/menu");o=Array.isArray(l?.items)?l.items:null,a=l?.variant||null,d=l?.position||null,u=l?.style||null,c=l?.float||null,r=l?.highlight||null}catch{o=null}(!o||!o.some(l=>l&&(!l.hidden||l.restricted)))&&(console.warn("[admin-sidebar] No admin-sidebar menu found; using fallback tree"),o=Re.slice());let f=[];try{f=await H.get("/api/sidebar/registered-items")||[]}catch{}o=ye(o,Z),o=he(o,f.filter(l=>(l?.item??l)?.url!==Z)),o=ge(o),o=ve(o,[be()]);const x=await _e(n);x.length&&Ie(o,x),o=$e(o,n,s),o=xe(o),await i;const w=Le(o,z(L()),{hidden:Ee(L())});w&&(o=[w,...o]),Be(o);const[k,q]=await Promise.all([ze(f.filter(l=>O(n,l?.item?.permission)),n,s).catch(()=>({})),Xe(n)]),F=a?` dm-admin-sidebar--${m(a)}`:"";function y(l,v="px"){if(l==null)return l;const h=String(l).trim();return/^\d+(\.\d+)?$/.test(h)?`${h}${v}`:h}let C="";if(u){const l=[];u.fontFamily&&l.push(`font-family: ${u.fontFamily}, sans-serif`),u.fontSize&&l.push(`font-size: ${y(u.fontSize)}`),u.fontWeight&&l.push(`font-weight: ${u.fontWeight}`),u.letterSpacing&&l.push(`letter-spacing: ${y(u.letterSpacing,"em")}`);const v=[];if(l.length&&v.push(`#admin-sidebar .dm-admin-sidebar, #admin-sidebar .dm-admin-sidebar .sidebar-link, #admin-sidebar .dm-admin-sidebar summary, #admin-sidebar .dm-admin-sidebar .sidebar-text { ${l.join("; ")} }`),u.iconSize){const h=y(u.iconSize);v.push(`#admin-sidebar .dm-admin-sidebar [data-icon], #admin-sidebar .dm-admin-sidebar [data-icon] svg { width: ${h} !important; height: ${h} !important; }`)}v.length&&(C=`<style data-admin-sidebar-style>${v.join(" ")}</style>`)}const B=ke(n)?we():"",P=`<div class="dm-admin-sidebar-header"><span data-icon="layout"></span><span class="dm-admin-sidebar-title">${m(q)}</span>${B}${qe()}</div>`,T=o.some(l=>l&&l.type==="spacer"&&l.size==="flex")?" dm-admin-sidebar--has-flex-spacer":"",U=`${C}<nav class="dm-admin-sidebar${F}${T}">${P}${o.map(l=>oe(l,k)).join("")}</nav>`,R=document.createRange();R.selectNodeContents(t);const p=R.createContextualFragment(U);t.replaceChildren(p),Domma.icons.scan(t),t.querySelector(".dm-admin-sidebar-arrange")?.addEventListener("click",()=>Se(t));for(const l of A)D.set(l.url,k[l.url]??null);ne(t),Ke(t),t.removeAttribute("data-menu-hover"),t.removeAttribute("data-menu-active"),t.style.removeProperty("--dm-menu-highlight"),me(t,{highlight:r});const b=document.querySelector(".dashboard-wrapper");["position","top","bottom","left","right","zIndex","maxHeight","transform"].forEach(l=>{t.style[l]=""}),t.classList.remove("dm-menu-floating"),b&&(b.style.paddingLeft="",b.style.paddingRight=""),t.__dmGutter=()=>{if(d!=="floating"||!b)return;const l=c&&c.anchor||"TL",v=Number.isFinite(Number(c?.offsetX))?Number(c.offsetX):16,h=t.classList.contains("is-collapsed")?Ae:parseInt(t.style.width,10)||t.getBoundingClientRect().width||260,N=Math.round(h+v*2)+"px";l==="R"||l==="TR"||l==="BR"?b.style.paddingRight=N:b.style.paddingLeft=N},d==="floating"&&fe(t,{position:"floating",float:c||{}}),t.querySelectorAll("details").forEach(l=>{l.addEventListener("toggle",function(){const v=this.getAttribute("data-state-key");v&&S.set(v,this.open);const h=this.querySelector(":scope > .sidebar-children");if(h)if(this.open){const N=h.scrollHeight;h.style.maxHeight="0",requestAnimationFrame(()=>{h.style.maxHeight=N+"px",h.addEventListener("transitionend",function de(){h.style.maxHeight="none",h.removeEventListener("transitionend",de)})})}else{const N=h.scrollHeight;h.style.maxHeight=N+"px",requestAnimationFrame(()=>{h.style.maxHeight="0"})}})}),Ce(t),Qe(t),t.dataset.routeWired||(t.dataset.routeWired="1",M.subscribe("router:afterChange",()=>t.__dmSetActive?.()));function I(){const l=/^#\/(dashboards|my-dashboard)\b/.test(location.hash)?"#/":location.hash||"#/";$(t).find(".sidebar-link").removeClass("active"),$(t).find(`.sidebar-link[data-url="${l}"]`).addClass("active")}I(),t.__dmSetActive=I}function le(){try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}function K(e){return`${location.origin}${location.pathname}${e}`}function _(e,n){const s=L(),t=e(z(s));if(!Ne(s,t)){E.toast("Frequent could not be saved - this browser is blocking storage.",{type:"error"});return}t.length&&Q(s,!1),ce(s),n&&E.toast(n,{type:"success",duration:2500}),le()}function ce(e){Me(g,e).catch(()=>E.toast("Frequent is saved in this browser only - the server could not keep it.",{type:"warning"}))}function Ze(e){if(!Q(L(),e)){E.toast("Frequent could not be saved - this browser is blocking storage.",{type:"error"});return}ce(L()),E.toast(e?"Frequent hidden. Add a Tool to it from its right-click menu to bring it back.":"Frequent shown.",{type:"success",duration:3500}),le()}function Ye(e){const n=e.dataset.url;if(!n)return[];const s=e.querySelector(".sidebar-text")?.textContent.trim()||n,t=z(L()),i=t.includes(n),o=!!e.closest("details[data-frequent]"),a=t.indexOf(n),d=!i&&t.length>=Y;return[{header:s},{label:"Open",icon:"arrow-right",...ee(n)&&{shortcut:Pe(ee(n))},action:()=>{location.hash=n}},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(K(n),"_blank","noopener")},{type:"divider"},i?{label:"Remove from Frequent",icon:"star",action:()=>_(u=>Te(u,n),`${s} removed from Frequent.`)}:{label:d?`Frequent is full (${Y})`:"Add to Frequent",icon:"star",disabled:d,action:()=>_(u=>Fe(u,n),`${s} added to Frequent.`)},...o?[{label:"Move up",icon:"arrow-up",disabled:a<=0,action:()=>_(u=>J(u,n,-1))},{label:"Move down",icon:"arrow-down",disabled:a<0||a>=t.length-1,action:()=>_(u=>J(u,n,1))}]:[],{type:"divider"},{label:"Copy link",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(K(n)),E.toast("Link copied.",{type:"success",duration:2e3})}catch{E.toast(K(n),{type:"info",duration:8e3})}}}]}function Je(e,n){const s=e.closest("details"),t=e.querySelector(".sidebar-text")?.textContent.trim()||"Folder",i=o=>n.querySelectorAll("details").forEach(a=>{a.open!==o&&(a.open=o)});return[{header:t},{label:s?.open?"Close":"Open",icon:s?.open?"chevron-up":"chevron-down",action:()=>{s&&(s.open=!s.open)}},{label:"Open every folder",icon:"chevrons-down",action:()=>i(!0)},{label:"Close every folder",icon:"chevrons-up",action:()=>i(!1)},...s?.hasAttribute("data-frequent-empty")?[{type:"divider"},{label:"Hide Frequent until I add a Tool",icon:"eye-off",action:()=>Ze(!0)}]:s?.hasAttribute("data-frequent")?[{type:"divider"},{label:"Clear Frequent",icon:"x",action:()=>_(()=>[],"Frequent cleared.")}]:[]]}function Qe(e){e.dataset.ctxWired||typeof E>"u"||!E.contextMenu||(e.dataset.ctxWired="1",E.contextMenu(e,{match:"a.sidebar-link, details > summary",inherit:!1,density:"compact",items:n=>n.matches("summary")?Je(n,e):Ye(n)}))}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
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",
@@ -5,10 +5,17 @@
5
5
  */
6
6
  import {getPermissionsFor, getPermissionsForRole, getRoleHierarchy, getRoleLevel} from '../services/roles.js';
7
7
  import {getEffectiveLevel, getEffectiveRoles} from '../services/userRoles.js';
8
+ import {getUserById} from '../services/users.js';
8
9
 
9
10
  /**
10
11
  * Verify JWT Bearer token. Populates request.user on success.
11
12
  *
13
+ * The token does not carry the user's project access scope, so it is read from
14
+ * the user record on every request and set as `request.user.projects` - every
15
+ * `canSeeArtefact()` check depends on it, and without it a scoped user looked
16
+ * unrestricted. Reading it here, not signing it into the token, means a change
17
+ * to someone's access applies to their next request.
18
+ *
12
19
  * @param {FastifyRequest} request
13
20
  * @param {FastifyReply} reply
14
21
  * @returns {Promise<void>}
@@ -30,6 +37,8 @@ export async function authenticate(request, reply) {
30
37
  message: 'Invalid or missing authentication token'
31
38
  });
32
39
  }
40
+ const user = request.user?.id ? await getUserById(request.user.id) : null;
41
+ request.user.projects = Array.isArray(user?.projects) ? user.projects : [];
33
42
  }
34
43
 
35
44
  /**
@@ -23,6 +23,7 @@ import {
23
23
  } from '../../services/previewLinks.js';
24
24
  import {getVersionCount} from '../../services/versions.js';
25
25
  import {authenticate, requirePermission} from '../../middleware/auth.js';
26
+ import {canSeeArtefact, canSeePage} from '../../services/projects.js';
26
27
  import {getConfig, saveConfig} from '../../config.js';
27
28
  import {relinkItems, relinkMenus} from '../../services/menus.js';
28
29
  import {ago, clip, plural} from '../../services/sidebarBadges.js';
@@ -113,7 +114,8 @@ export async function pagesRoutes(fastify) {
113
114
 
114
115
  fastify.get('/pages/preview-links', canRead, async (request) => {
115
116
  const {urlPath} = request.query || {};
116
- return {links: await listPreviewLinks(urlPath || undefined)};
117
+ const links = await listPreviewLinks(urlPath || undefined);
118
+ return {links: await visibleTo(request.user, links, l => l.urlPath)};
117
119
  });
118
120
 
119
121
  fastify.post('/pages/preview-links', canUpdate, async (request, reply) => {
@@ -123,9 +125,11 @@ export async function pagesRoutes(fastify) {
123
125
  }
124
126
  // Refuse to mint for a page that does not exist - otherwise a typo
125
127
  // produces a link that 404s for the recipient and looks like a bug.
126
- if (!(await getPage(urlPath))) {
128
+ const target = await getPage(urlPath);
129
+ if (!target) {
127
130
  return reply.status(404).send({error: 'Page not found'});
128
131
  }
132
+ if (!(await canSeePage(request.user, urlPath, target.project))) return denied(reply);
129
133
  const link = await createPreviewLink({
130
134
  urlPath,
131
135
  jwt: fastify.jwt,
@@ -141,6 +145,8 @@ export async function pagesRoutes(fastify) {
141
145
  });
142
146
 
143
147
  fastify.delete('/pages/preview-links/:id', canUpdate, async (request, reply) => {
148
+ const link = (await listPreviewLinks()).find(l => l.id === request.params.id);
149
+ if (link && !(await canSeePage(request.user, link.urlPath))) return denied(reply);
144
150
  const ok = await revokePreviewLink(request.params.id);
145
151
  if (!ok) return reply.status(404).send({error: 'Link not found'});
146
152
  return {success: true};
@@ -148,7 +154,7 @@ export async function pagesRoutes(fastify) {
148
154
 
149
155
  // Aggregate unique tags from all pages - must be registered before the wildcard /pages/* route
150
156
  fastify.get('/pages/tags', canRead, async (request, reply) => {
151
- const pages = await listPages();
157
+ const pages = (await listPages()).filter(p => inScope(request.user, p));
152
158
  const tagSet = new Set();
153
159
  pages.forEach(p => {
154
160
  if (Array.isArray(p.tags)) p.tags.forEach(t => { if (t) tagSet.add(t); });
@@ -157,11 +163,12 @@ export async function pagesRoutes(fastify) {
157
163
  });
158
164
 
159
165
  // The sidebar badge. Static, so it is matched before the /pages/* wildcard below.
160
- fastify.get('/pages/_count', canRead, async () => pagesBadge(await listPages()));
166
+ fastify.get('/pages/_count', canRead, async (request) =>
167
+ pagesBadge((await listPages()).filter(p => inScope(request.user, p))));
161
168
 
162
169
  // List all pages
163
170
  fastify.get('/pages', canRead, async (request, reply) => {
164
- const pages = await listPages();
171
+ const pages = (await listPages()).filter(p => inScope(request.user, p));
165
172
  const counts = await Promise.all(pages.map(p => getVersionCount(p.urlPath)));
166
173
  return pages.map((p, i) => ({
167
174
  urlPath: p.urlPath,
@@ -191,6 +198,7 @@ export async function pagesRoutes(fastify) {
191
198
  const urlPath = '/' + request.params['*'];
192
199
  const page = await getPage(urlPath);
193
200
  if (!page) return reply.status(404).send({ error: 'Page not found' });
201
+ if (!(await canSeePage(request.user, urlPath, page.project))) return denied(reply);
194
202
  return page;
195
203
  });
196
204
 
@@ -214,6 +222,7 @@ export async function pagesRoutes(fastify) {
214
222
  });
215
223
  }
216
224
 
225
+ if (!(await canSeePage(request.user, urlPath, frontmatter?.project))) return denied(reply);
217
226
  const page = await createPage(urlPath, frontmatter || {}, body || '');
218
227
  return reply.status(201).send(page);
219
228
  });
@@ -225,6 +234,10 @@ export async function pagesRoutes(fastify) {
225
234
 
226
235
  const existing = await getPage(urlPath);
227
236
  if (!existing) return reply.status(404).send({ error: 'Page not found' });
237
+ if (!(await canSeePage(request.user, urlPath, existing.project))) return denied(reply);
238
+ // Nor may a save move the page into a project outside the user's scope.
239
+ const project = frontmatter && 'project' in frontmatter ? frontmatter.project : existing.project;
240
+ if (!(await canSeePage(request.user, newUrlPath || urlPath, project))) return denied(reply);
228
241
 
229
242
  if (newUrlPath && newUrlPath !== urlPath) {
230
243
  const conflict = await getPage(newUrlPath);
@@ -249,6 +262,7 @@ export async function pagesRoutes(fastify) {
249
262
  const urlPath = '/' + request.params['*'];
250
263
  const existing = await getPage(urlPath);
251
264
  if (!existing) return reply.status(404).send({ error: 'Page not found' });
265
+ if (!(await canSeePage(request.user, urlPath, existing.project))) return denied(reply);
252
266
 
253
267
  await deletePage(urlPath);
254
268
  // A share link must not outlive the page it pointed at.
@@ -257,6 +271,45 @@ export async function pagesRoutes(fastify) {
257
271
  });
258
272
  }
259
273
 
274
+ /**
275
+ * A listed page's project is already resolved (listPages sets resolvedProject).
276
+ *
277
+ * @param {object} user
278
+ * @param {object} page
279
+ * @returns {boolean}
280
+ */
281
+ function inScope(user, page) {
282
+ return canSeeArtefact(user, {meta: {project: page.resolvedProject}});
283
+ }
284
+
285
+ /**
286
+ * Keep the items whose page `user` may see.
287
+ *
288
+ * @param {object} user
289
+ * @param {object[]} items
290
+ * @param {function(object): string} urlOf
291
+ * @returns {Promise<object[]>}
292
+ */
293
+ async function visibleTo(user, items, urlOf) {
294
+ const out = [];
295
+ for (const item of items) {
296
+ const page = await getPage(urlOf(item));
297
+ if (await canSeePage(user, urlOf(item), page?.project)) out.push(item);
298
+ }
299
+ return out;
300
+ }
301
+
302
+ /**
303
+ * The refusal every page route gives for a page outside the user's projects -
304
+ * the same wording as the collection routes.
305
+ *
306
+ * @param {FastifyReply} reply
307
+ * @returns {FastifyReply}
308
+ */
309
+ function denied(reply) {
310
+ return reply.status(403).send({error: 'Access denied for this project'});
311
+ }
312
+
260
313
  /**
261
314
  * A page moved: re-aim the menu links that pointed at it. Menus replaced
262
315
  * config/navigation.json; a site that still has the legacy file gets it rewritten
@@ -50,6 +50,16 @@ export async function projectsRoutes(fastify, opts = {}) {
50
50
  const canUpdate = {preHandler: [authenticate, requirePermission('projects', 'update')]};
51
51
  const canDelete = {preHandler: [authenticate, requirePermission('projects', 'delete')]};
52
52
 
53
+ // A project outside the user's access scope is not there for them: the same
54
+ // 404 the list's absence implies, as remove-contents already answers.
55
+ const inScope = async (request, reply) => {
56
+ const visible = await listProjectsForUser(request.user);
57
+ if (!visible.some(p => p.slug === request.params.slug)) {
58
+ return reply.status(404).send({error: 'Project not found'});
59
+ }
60
+ };
61
+ const scoped = (guard) => ({preHandler: [...guard.preHandler, inScope]});
62
+
53
63
  fastify.get('/projects', canRead, async (request) => {
54
64
  const list = await listProjectsForUser(request.user);
55
65
  if (request.query?.counts !== '1') return list;
@@ -64,7 +74,7 @@ export async function projectsRoutes(fastify, opts = {}) {
64
74
  // Registered before /projects/:slug; `_` is outside the slug alphabet, so no project can shadow it.
65
75
  fastify.get('/projects/_count', canRead, async (request) => projectsBadge(await listProjectsForUser(request.user)));
66
76
 
67
- fastify.get('/projects/:slug', canRead, async (request, reply) => {
77
+ fastify.get('/projects/:slug', scoped(canRead), async (request, reply) => {
68
78
  const project = await getProject(request.params.slug);
69
79
  if (!project) return reply.status(404).send({error: 'Project not found'});
70
80
  return project;
@@ -82,7 +92,7 @@ export async function projectsRoutes(fastify, opts = {}) {
82
92
  }
83
93
  });
84
94
 
85
- fastify.put('/projects/:slug', canUpdate, async (request, reply) => {
95
+ fastify.put('/projects/:slug', scoped(canUpdate), async (request, reply) => {
86
96
  try {
87
97
  const project = await updateProject(request.params.slug, request.body || {});
88
98
  await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
@@ -93,7 +103,7 @@ export async function projectsRoutes(fastify, opts = {}) {
93
103
  }
94
104
  });
95
105
 
96
- fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
106
+ fastify.delete('/projects/:slug', scoped(canDelete), async (request, reply) => {
97
107
  if (request.params.slug === CORE_PROJECT_SLUG) {
98
108
  return reply.status(403).send({error: 'Cannot delete the core project'});
99
109
  }
@@ -140,14 +150,14 @@ export async function projectsRoutes(fastify, opts = {}) {
140
150
  }
141
151
  });
142
152
 
143
- fastify.get('/projects/:slug/artefacts', canRead, async (request, reply) => {
153
+ fastify.get('/projects/:slug/artefacts', scoped(canRead), async (request, reply) => {
144
154
  if (!(await getProject(request.params.slug))) {
145
155
  return reply.status(404).send({error: 'Project not found'});
146
156
  }
147
157
  return getArtefactsForProject(request.params.slug);
148
158
  });
149
159
 
150
- fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
160
+ fastify.post('/projects/:slug/untag-all', scoped(canDelete), async (request, reply) => {
151
161
  if (request.params.slug === CORE_PROJECT_SLUG) {
152
162
  return reply.status(403).send({error: 'Cannot untag the core project - artefacts would immediately resolve back to it'});
153
163
  }
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * GET /api/sidebar/menu - the admin-sidebar menu, filtered for the caller
5
5
  * GET /api/sidebar/registered-items - plugin-registered sidebar items, filtered for the caller
6
+ * GET /api/sidebar/frequent - the caller's own Frequent list
7
+ * PUT /api/sidebar/frequent - keep it ({urls, hidden})
6
8
  *
7
9
  * Both need only a signed-in user, NOT `menus.read`: every user who can reach
8
10
  * the admin needs a sidebar, and a role without menu-editing rights (a
@@ -19,6 +21,7 @@ import {getRegisteredSidebarItems} from '../../services/hooks.js';
19
21
  import {filterItemsForUser, getMenu} from '../../services/menus.js';
20
22
  import {isDashboardPro} from '../../services/widgets.js';
21
23
  import {boardName, readPrefs, readSite} from '../../services/dashboardSettings.js';
24
+ import {frequentProblems, readFrequent, writeFrequent} from '../../services/sidebarFrequent.js';
22
25
 
23
26
  export const ADMIN_SIDEBAR_SLUG = 'admin-sidebar';
24
27
 
@@ -61,4 +64,17 @@ export async function sidebarRoutes(fastify, opts = {}) {
61
64
  ? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
62
65
  : entry);
63
66
  });
67
+
68
+ // Each person's own, so it follows them from browser to browser (sidebarFrequent.js).
69
+ fastify.get('/sidebar/frequent', signedIn, async (request) => readFrequent(request.user));
70
+
71
+ fastify.put('/sidebar/frequent', signedIn, async (request, reply) => {
72
+ const problems = frequentProblems(request.body);
73
+ if (problems.length) return reply.code(400).send({error: problems.join(' ')});
74
+ try {
75
+ return await writeFrequent(request.user, request.body);
76
+ } catch (err) {
77
+ return reply.code(400).send({error: err.message});
78
+ }
79
+ });
64
80
  }
@@ -18,11 +18,28 @@ import {
18
18
  restoreVersion
19
19
  } from '../../services/versions.js';
20
20
  import {authenticate, requirePermission} from '../../middleware/auth.js';
21
+ import {getPage} from '../../services/content.js';
22
+ import {canSeePage} from '../../services/projects.js';
23
+
24
+ /**
25
+ * A page's versions belong to its project: refuse them to a user outside it,
26
+ * as the page routes do. Every route here ends in the page's path (`*`).
27
+ *
28
+ * @param {FastifyRequest} request
29
+ * @param {FastifyReply} reply
30
+ */
31
+ async function pageInScope(request, reply) {
32
+ const urlPath = '/' + request.params['*'];
33
+ const page = await getPage(urlPath);
34
+ if (!(await canSeePage(request.user, urlPath, page?.project))) {
35
+ return reply.status(403).send({error: 'Access denied for this project'});
36
+ }
37
+ }
21
38
 
22
39
  export async function versionsRoutes(fastify) {
23
- const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
24
- const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
25
- const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
40
+ const canRead = {preHandler: [authenticate, requirePermission('pages', 'read'), pageInScope]};
41
+ const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update'), pageInScope]};
42
+ const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete'), pageInScope]};
26
43
 
27
44
  // List versions for a page
28
45
  fastify.get('/versions/list/*', canRead, async (request, reply) => {
@@ -405,6 +405,21 @@ export async function getProjectForPage(urlPath, explicitProject) {
405
405
  return best ?? coreFallback;
406
406
  }
407
407
 
408
+ /**
409
+ * Can `user` see the page at `urlPath`? A page's project comes from its front
410
+ * matter or its address (getProjectForPage), then the same rule as every other
411
+ * artefact applies.
412
+ *
413
+ * @param {object} user
414
+ * @param {string} urlPath
415
+ * @param {string|null|undefined} explicitProject - the page's front matter `project`
416
+ * @returns {Promise<boolean>}
417
+ */
418
+ export async function canSeePage(user, urlPath, explicitProject) {
419
+ const project = await getProjectForPage(urlPath, explicitProject);
420
+ return canSeeArtefact(user, {meta: {project}});
421
+ }
422
+
408
423
  /** The per-type buckets every project's artefacts are grouped into. */
409
424
  export function emptyGroups() {
410
425
  return {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The sidebar's Frequent folder, kept per user on the server so it follows the
3
+ * person to every browser and device (admin/js/lib/sidebar-frequent.js keeps
4
+ * a copy in the browser to draw from). content/sidebar/frequent/<user id>.json
5
+ * - per user, so ignored in a site repo like Dashboard Pro's prefs.
6
+ *
7
+ * Only the list is kept here, not what it may open: the sidebar draws Frequent
8
+ * from the user's already-filtered tree, so a url they cannot open never shows.
9
+ *
10
+ * Tested in tests/services/sidebar-frequent.test.js.
11
+ */
12
+ import fs from 'fs/promises';
13
+ import path from 'path';
14
+
15
+ /** As FREQUENT_MAX in admin/js/lib/sidebar-frequent.js. */
16
+ export const FREQUENT_MAX = 12;
17
+ const URL_MAX = 500;
18
+ const USER_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
19
+
20
+ /** A list, cleaned: admin hash urls only, once each, at most FREQUENT_MAX. */
21
+ export function normaliseFrequent(input) {
22
+ const p = input && typeof input === 'object' ? input : {};
23
+ const urls = [];
24
+ for (const u of Array.isArray(p.urls) ? p.urls : []) {
25
+ if (typeof u !== 'string' || !u.startsWith('#/') || u.length > URL_MAX || urls.includes(u)) continue;
26
+ urls.push(u);
27
+ if (urls.length >= FREQUENT_MAX) break;
28
+ }
29
+ return {urls, hidden: p.hidden === true};
30
+ }
31
+
32
+ /** What is wrong with a body sent to PUT, or [] when nothing is. */
33
+ export function frequentProblems(input) {
34
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return ['Send {urls, hidden}.'];
35
+ const problems = [];
36
+ if (input.urls !== undefined && !Array.isArray(input.urls)) problems.push('urls must be a list.');
37
+ if (input.hidden !== undefined && typeof input.hidden !== 'boolean') problems.push('hidden must be true or false.');
38
+ return problems;
39
+ }
40
+
41
+ const DIR = () => path.resolve('content', 'sidebar', 'frequent');
42
+ const fileFor = (user) => (USER_ID_RE.test(String(user?.id || '')) ? path.join(DIR(), `${user.id}.json`) : null);
43
+
44
+ /**
45
+ * The user's list. `saved` is false until they have kept one here, so the
46
+ * browser knows to bring up a list it kept before Frequent moved to the server.
47
+ */
48
+ export async function readFrequent(user) {
49
+ const file = fileFor(user);
50
+ let data = null;
51
+ if (file) {
52
+ try { data = JSON.parse(await fs.readFile(file, 'utf8')); } catch { data = null; }
53
+ }
54
+ return {...normaliseFrequent(data), saved: !!data};
55
+ }
56
+
57
+ export async function writeFrequent(user, input) {
58
+ const file = fileFor(user);
59
+ if (!file) throw new Error('This account cannot keep a Frequent list.');
60
+ const data = normaliseFrequent(input);
61
+ await fs.mkdir(path.dirname(file), {recursive: true});
62
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
63
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
64
+ await fs.rename(tmp, file);
65
+ return {...data, saved: true};
66
+ }
@@ -320,6 +320,8 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
320
320
  // Each admin's own Dashboard Pro layout (keyed by user id); the site default beside it is tracked.
321
321
  '/content/dashboard/layouts/',
322
322
  '/content/dashboard/prefs/',
323
+ // Each admin's own sidebar Frequent list (keyed by user id).
324
+ '/content/sidebar/frequent/',
323
325
  '/config/connections.json*',
324
326
  '/config/keys/',
325
327
  '/plugins/*/data/*.jsonl',