domma-cms 1.8.33 → 1.8.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +1 -1
  3. package/plugins/docs/CLAUDE.md +318 -0
  4. package/plugins/docs/admin/css/docs.css +783 -0
  5. package/plugins/docs/admin/lib/api.js +340 -0
  6. package/plugins/docs/admin/lib/convert.js +482 -0
  7. package/plugins/docs/admin/lib/drafts.js +115 -0
  8. package/plugins/docs/admin/lib/editor.js +851 -0
  9. package/plugins/docs/admin/lib/find-replace.js +426 -0
  10. package/plugins/docs/admin/lib/folders.js +203 -0
  11. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  12. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  13. package/plugins/docs/admin/lib/send.js +156 -0
  14. package/plugins/docs/admin/lib/settings.js +142 -0
  15. package/plugins/docs/admin/lib/share.js +323 -0
  16. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  17. package/plugins/docs/admin/lib/templates.js +194 -0
  18. package/plugins/docs/admin/lib/versions.js +192 -0
  19. package/plugins/docs/admin/templates/docs.html +92 -0
  20. package/plugins/docs/admin/templates/editor.html +86 -0
  21. package/plugins/docs/admin/views/docs.js +777 -0
  22. package/plugins/docs/admin/views/editor.js +978 -0
  23. package/plugins/docs/config.js +24 -0
  24. package/plugins/docs/docs/guide.md +270 -0
  25. package/plugins/docs/lib/extensions.js +213 -0
  26. package/plugins/docs/lib/importer.js +72 -0
  27. package/plugins/docs/lib/pdf-worker.js +48 -0
  28. package/plugins/docs/lib/pdf.js +222 -0
  29. package/plugins/docs/lib/store.js +748 -0
  30. package/plugins/docs/plugin.js +1108 -0
  31. package/plugins/docs/plugin.json +89 -0
  32. package/plugins/docs/plugin.public.js +114 -0
  33. package/plugins/docs/templates/public-doc.html +36 -0
  34. package/plugins/docs/tests/_env.js +10 -0
  35. package/plugins/docs/tests/_pdf.js +73 -0
  36. package/plugins/docs/tests/convert.test.js +95 -0
  37. package/plugins/docs/tests/drafts.test.js +82 -0
  38. package/plugins/docs/tests/pdf.test.js +86 -0
  39. package/plugins/docs/tests/plugin.test.js +816 -0
  40. package/plugins/docs/tests/shortcuts.test.js +30 -0
  41. package/plugins/free-tier.lock.json +105 -1
  42. package/plugins/garage/CLAUDE.md +51 -0
  43. package/plugins/garage/admin/css/garage.css +73 -0
  44. package/plugins/garage/admin/lib/garage.js +236 -0
  45. package/plugins/garage/admin/templates/garage.html +86 -0
  46. package/plugins/garage/admin/views/garage.js +581 -0
  47. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  48. package/plugins/garage/config.js +18 -0
  49. package/plugins/garage/docs/guide.md +118 -0
  50. package/plugins/garage/plugin.js +471 -0
  51. package/plugins/garage/plugin.json +68 -0
  52. package/plugins/garage/tests/access.test.js +134 -0
  53. package/plugins/garage/tests/garage.test.js +106 -0
  54. package/plugins/messages/CLAUDE.md +230 -0
  55. package/plugins/messages/admin/css/messages.css +117 -0
  56. package/plugins/messages/admin/lib/messages.js +452 -0
  57. package/plugins/messages/admin/templates/messages.html +76 -0
  58. package/plugins/messages/admin/views/messages.js +1219 -0
  59. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  60. package/plugins/messages/collections/user-messages/schema.json +23 -0
  61. package/plugins/messages/config.js +14 -0
  62. package/plugins/messages/docs/guide.md +176 -0
  63. package/plugins/messages/lib/call.js +138 -0
  64. package/plugins/messages/plugin.js +994 -0
  65. package/plugins/messages/plugin.json +59 -0
  66. package/plugins/messages/plugin.public.js +52 -0
  67. package/plugins/messages/templates/call.html +6 -0
  68. package/plugins/messages/tests/after-send.test.js +22 -0
  69. package/plugins/messages/tests/api.test.js +187 -0
  70. package/plugins/messages/tests/call.test.js +497 -0
  71. package/plugins/messages/tests/drafts.test.js +25 -0
  72. package/plugins/messages/tests/messages.test.js +167 -0
  73. package/plugins/theme-roller/CLAUDE.md +65 -0
  74. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  75. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  76. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  77. package/plugins/theme-roller/admin/lib/view.js +88 -0
  78. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  79. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  80. package/plugins/theme-roller/config.js +1 -0
  81. package/plugins/theme-roller/docs/guide.md +104 -0
  82. package/plugins/theme-roller/plugin.js +263 -0
  83. package/plugins/theme-roller/plugin.json +42 -0
  84. package/plugins/theme-roller/tests/api.test.js +161 -0
  85. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  86. package/plugins/theme-roller/tests/themes.test.js +77 -0
  87. package/plugins/theme-roller/tests/view.test.js +55 -0
  88. package/server/routes/public.js +16 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The Docs API, and what the Docs screens share: the admin's helpers (loaded
3
+ * at run time - a plugin never imports admin/ statically), escaping, small
4
+ * DOM fragments and the add-on loader.
5
+ *
6
+ * Calls go through Domma's `H`, which the admin wraps to attach the access
7
+ * token and refresh it on a 401. It throws with the server's `error` text.
8
+ *
9
+ * @module docs/admin/lib/api
10
+ */
11
+
12
+ export const BASE = '/api/plugins/docs';
13
+ export const HOME = '#/plugins/docs';
14
+ export const docHref = (id) => `#/plugins/docs/d/${encodeURIComponent(id)}`;
15
+
16
+ const seg = (id) => encodeURIComponent(String(id));
17
+
18
+ /**
19
+ * Call a Docs endpoint - the old single-function form, kept for the libs
20
+ * (folders, templates, versions) that use it.
21
+ *
22
+ * @param {string} path
23
+ * @param {string} [method='GET']
24
+ * @param {*} [body]
25
+ * @param {object} [headers]
26
+ * @returns {Promise<*>}
27
+ */
28
+ export function api(path, method = 'GET', body, headers) {
29
+ return H.request(method, BASE + path, body ?? null, {headers: headers || {}, silent: true});
30
+ }
31
+
32
+ /** One browser tab's editing session - what the edit lock is held by. */
33
+ export function newSession() {
34
+ return (crypto.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`).replace(/[^A-Za-z0-9-]/g, '');
35
+ }
36
+
37
+ const sessionHeader = (session) => (session ? {'X-Docs-Session': session} : {});
38
+
39
+ export const docsApi = {
40
+ settings: {
41
+ get: () => api('/settings'),
42
+ save: (d) => api('/settings', 'PUT', d)
43
+ },
44
+ people: () => api('/people'),
45
+ list: (params = {}) => {
46
+ const q = new URLSearchParams();
47
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') q.set(k, v);
48
+ return api(`/documents${q.toString() ? `?${q}` : ''}`);
49
+ },
50
+ get: (id, session, {poll = false} = {}) => api(`/documents/${seg(id)}${poll ? '?poll=1' : ''}`, 'GET', null, sessionHeader(session)),
51
+ create: (d) => api('/documents', 'POST', d),
52
+ update: (id, d, session) => api(`/documents/${seg(id)}`, 'PUT', d, sessionHeader(session)),
53
+ trash: (id) => api(`/documents/${seg(id)}`, 'DELETE'),
54
+ purge: (id) => api(`/documents/${seg(id)}?permanent=1`, 'DELETE'),
55
+ restore: (id) => api(`/documents/${seg(id)}/restore`, 'POST', {}),
56
+ emptyTrash: () => api('/trash/empty', 'POST', {}),
57
+ duplicate: (id, d = {}) => api(`/documents/${seg(id)}/duplicate`, 'POST', d),
58
+ star: (id, on) => api(`/documents/${seg(id)}/star`, 'POST', {on}),
59
+ importFile: (d) => api('/documents/import', 'POST', d),
60
+ // 2.13: PDF documents - the file goes up as base64 (the same road Docs Pro's Word import takes).
61
+ uploadPdf: (d) => api('/documents/upload', 'POST', d),
62
+ replaceFile: (id, d, session) => api(`/documents/${seg(id)}/file`, 'PUT', d, sessionHeader(session)),
63
+ fileUrl: (id, {download = false} = {}) => `${BASE}/documents/${seg(id)}/file${download ? '?download=1' : ''}`,
64
+ // The frame's address: the file by ticket (GET /documents/:id answers `fileTicket`), since a frame carries no bearer header.
65
+ viewUrl: (id, ticket) => `${BASE}/documents/${seg(id)}/view?t=${encodeURIComponent(ticket)}`,
66
+ versionFileUrl: (id, n, {download = false} = {}) => `${BASE}/documents/${seg(id)}/versions/${seg(n)}/file${download ? '?download=1' : ''}`,
67
+ shares: (id, d) => api(`/documents/${seg(id)}/shares`, 'PUT', d),
68
+ linkOn: (id) => api(`/documents/${seg(id)}/link`, 'POST', {}),
69
+ linkOff: (id) => api(`/documents/${seg(id)}/link`, 'DELETE'),
70
+ send: (id, d) => api(`/documents/${seg(id)}/send`, 'POST', d),
71
+ lock: (id, session, force = false) => api(`/documents/${seg(id)}/lock`, 'POST', {session, force}),
72
+ unlock: (id, session) => api(`/documents/${seg(id)}/lock?session=${seg(session)}`, 'DELETE'),
73
+ versions: (id) => api(`/documents/${seg(id)}/versions`),
74
+ version: (id, n) => api(`/documents/${seg(id)}/versions/${seg(n)}`),
75
+ restoreVersion: (id, n, session) => api(`/documents/${seg(id)}/versions/${seg(n)}/restore`, 'POST', {}, sessionHeader(session))
76
+ };
77
+
78
+ /**
79
+ * Let go of the edit lock while the page itself is going away (a closed
80
+ * tab). A normal call may be cut off mid-flight; `keepalive` is not.
81
+ */
82
+ export function unlockOnUnload(id, session) {
83
+ try {
84
+ const raw = S.get('auth_token');
85
+ const token = typeof raw === 'string' ? raw : '';
86
+ fetch(`${BASE}/documents/${seg(id)}/lock?session=${seg(session)}`, {
87
+ method: 'DELETE', keepalive: true, headers: token ? {Authorization: `Bearer ${token}`} : {}
88
+ }).catch(() => {});
89
+ } catch { /* best effort */ }
90
+ }
91
+
92
+ /**
93
+ * Sanitise document HTML for the admin. DOMPurify directly, because the
94
+ * admin's Domma.sanitize drops data-* attributes - and checklists live in
95
+ * data-type / data-checked. Without DOMPurify the HTML goes in as the server
96
+ * stored it (the server sanitised it on the way in).
97
+ *
98
+ * @param {string} html
99
+ * @returns {string}
100
+ */
101
+ // Suggestion marks (2.5) are always kept, so they stay suggestions even while Docs Pro is off.
102
+ const DOC_ATTRS = ['target', 'data-type', 'data-checked', 'data-sug', 'data-by', 'data-name', 'data-at'];
103
+
104
+ /** Let an add-on keep more attributes in documents (Docs Pro comment anchors). */
105
+ export function allowDocAttrs(list) {
106
+ for (const a of list || []) if (/^[a-z][a-z0-9-]*$/.test(a) && !/^on/.test(a) && !DOC_ATTRS.includes(a)) DOC_ATTRS.push(a);
107
+ }
108
+
109
+ /**
110
+ * What a document says to the world (2.5): add-ons' filters over the body
111
+ * before it leaves Docs - a download, print, a new Page or Blog post. Docs
112
+ * Pro takes out comment marks and shows pending suggestions as not made.
113
+ * Keyed, so an add-on installed again replaces its own filter.
114
+ */
115
+ const contentFilters = new Map();
116
+
117
+ /** Pending suggestions as not made - Docs' own, whether or not Docs Pro is on (the server's withoutSuggestions). */
118
+ export function withoutSuggestions(html) {
119
+ if (!html.includes('data-sug')) return html;
120
+ const t = document.createElement('template');
121
+ t.innerHTML = html;
122
+ t.content.querySelectorAll('ins[data-sug]').forEach(el => el.remove());
123
+ t.content.querySelectorAll('del[data-sug]').forEach(el => el.replaceWith(...el.childNodes));
124
+ return t.innerHTML;
125
+ }
126
+ export function setContentFilter(key, fn) {
127
+ if (typeof fn === 'function') contentFilters.set(String(key), fn); else contentFilters.delete(String(key));
128
+ }
129
+ export function outgoing(html) {
130
+ let out = withoutSuggestions(String(html ?? ''));
131
+ for (const fn of contentFilters.values()) { try { out = String(fn(out) ?? out); } catch { /* an add-on's problem */ } }
132
+ return out;
133
+ }
134
+
135
+ export function sanitizeHtml(html) {
136
+ const P = window.DOMPurify;
137
+ if (P?.sanitize) {
138
+ // Only the data-* attributes documents use: a data-on-click or a help marker
139
+ // in someone's body must never reach the admin's own bindings.
140
+ return P.sanitize(String(html ?? ''), {
141
+ ALLOW_DATA_ATTR: false,
142
+ ADD_ATTR: DOC_ATTRS,
143
+ FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select', 'iframe', 'object', 'embed'],
144
+ FORBID_ATTR: ['id', 'name']
145
+ });
146
+ }
147
+ // No sanitiser loaded: show the text, never raw markup.
148
+ const t = document.createElement('div');
149
+ t.textContent = String(html ?? '').replace(/<[^>]+>/g, ' ');
150
+ return t.innerHTML;
151
+ }
152
+
153
+ /** E.confirm, titles and toasts render HTML: user text goes in escaped. */
154
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
155
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
156
+
157
+ /** Text as text, whatever it arrived as. */
158
+ export function asText(value) {
159
+ return value === null || value === undefined ? '' : String(value);
160
+ }
161
+
162
+ /** A small DOM tree from an HTML string that holds NO unescaped user data. */
163
+ export function fragment(html) {
164
+ const t = document.createElement('template');
165
+ t.innerHTML = html.trim();
166
+ return t.content.firstElementChild;
167
+ }
168
+
169
+ /** An icon as an SVG string, for templates built in JS (empty when the name is unknown). */
170
+ export function icon(name, size = 16) {
171
+ try { return window.I?.html?.(name, {size}) || ''; } catch { return ''; }
172
+ }
173
+
174
+ let kit = null;
175
+
176
+ /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
177
+ export function loadKit() {
178
+ if (!kit) {
179
+ kit = Promise.all([
180
+ import('/admin/js/lib/tool-kit.js').catch(() => ({})),
181
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
182
+ import('/admin/js/lib/help-popover.js').catch(() => ({}))
183
+ ]).then(([tk, chrome, help]) => ({
184
+ onceClosed: tk.onceClosed || ((so, fn) => {
185
+ const c = so.close.bind(so);
186
+ let done = false;
187
+ so.close = (...a) => {
188
+ const r = c(...a);
189
+ if (!done) { done = true; fn?.(); setTimeout(() => { try { so.destroy?.(); } catch { /* gone */ } so.element?.remove(); }, 400); }
190
+ return r;
191
+ };
192
+ }),
193
+ openMenuAt: tk.openMenuAt || ((el) => {
194
+ const r = el.getBoundingClientRect();
195
+ el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
196
+ }),
197
+ settingsStore: tk.settingsStore || ((key, normalise) => ({
198
+ read() { try { return normalise(S.get(key)); } catch { return normalise(null); } },
199
+ write(v) { try { S.set(key, v); } catch { /* convenience only */ } }
200
+ })),
201
+ viewLifetime: tk.viewLifetime || ((pattern) => {
202
+ let fns = [];
203
+ const run = () => { const l = fns; fns = []; l.forEach(fn => { try { fn?.(); } catch { /* gone */ } }); };
204
+ const onHash = () => { if (!pattern.test(location.hash)) run(); };
205
+ return {
206
+ add(fn) { fns.push(fn); return () => { const at = fns.indexOf(fn); if (at !== -1) fns.splice(at, 1); }; },
207
+ reset() { run(); window.addEventListener('hashchange', onHash); fns.push(() => window.removeEventListener('hashchange', onHash)); }
208
+ };
209
+ }),
210
+ addBannerAction: chrome.addBannerAction || (() => null),
211
+ sidebarCount: tk.sidebarCount || (() => {}),
212
+ helpPopovers: help.helpPopovers || (() => () => {})
213
+ }));
214
+ }
215
+ return kit;
216
+ }
217
+
218
+ /**
219
+ * Add-ons (Docs Pro) the server says are registered: their admin modules,
220
+ * imported once. Each exports `install(host)`; a module that fails to load
221
+ * is skipped - Docs works without it.
222
+ *
223
+ * @param {Array<{module:string}>} list from GET /settings `extensions`
224
+ * @returns {Promise<Array<object>>}
225
+ */
226
+ const loaded = new Map();
227
+ export async function loadExtensions(list) {
228
+ const out = [];
229
+ for (const ext of Array.isArray(list) ? list : []) {
230
+ if (!ext?.module || !String(ext.module).startsWith('/plugins/')) continue;
231
+ if (!loaded.has(ext.module)) loaded.set(ext.module, import(ext.module).catch((err) => { console.warn('[docs] add-on failed to load', ext.module, err); return null; }));
232
+ const mod = await loaded.get(ext.module);
233
+ if (mod) out.push({...ext, mod});
234
+ }
235
+ return out;
236
+ }
237
+
238
+ /** A file (an upload) as base64, for the JSON body of an upload. */
239
+ export function readBase64(file) {
240
+ return new Promise((resolve, reject) => {
241
+ const r = new FileReader();
242
+ r.onload = () => resolve(String(r.result || '').replace(/^data:[^,]*,/, ''));
243
+ r.onerror = () => reject(new Error(`Could not read ${file.name}`));
244
+ r.readAsDataURL(file);
245
+ });
246
+ }
247
+
248
+ /** Is this a PDF, by name or type? (2.13) */
249
+ export const isPdfFile = (file) => Boolean(file) && (/\.pdf$/i.test(file.name || '') || file.type === 'application/pdf');
250
+
251
+ /**
252
+ * A Docs route that answers bytes (a PDF), fetched with the signed-in token
253
+ * - an <iframe> cannot carry a bearer header, so the file comes here as a
254
+ * Blob and the frame shows the blob's address (2.13).
255
+ *
256
+ * @param {string} url from docsApi.fileUrl / versionFileUrl
257
+ * @returns {Promise<Blob>}
258
+ */
259
+ export async function fetchBlob(url) {
260
+ const raw = S.get('auth_token');
261
+ const token = typeof raw === 'string' ? raw : '';
262
+ const res = await fetch(url, {headers: token ? {Authorization: `Bearer ${token}`} : {}, cache: 'no-store'});
263
+ if (!res.ok) {
264
+ let msg = '';
265
+ try { msg = (await res.json())?.error || ''; } catch { /* no body */ }
266
+ throw new Error(msg || (res.status === 404 ? 'The file could not be found.' : `The file could not be loaded (${res.status}).`));
267
+ }
268
+ return res.blob();
269
+ }
270
+
271
+ /** Save a Blob as a file. */
272
+ export function downloadBlob(name, blob) {
273
+ const url = URL.createObjectURL(blob);
274
+ const a = document.createElement('a');
275
+ a.href = url;
276
+ a.download = name;
277
+ document.body.appendChild(a);
278
+ a.click();
279
+ a.remove();
280
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
281
+ }
282
+
283
+ /** Download a string as a file. */
284
+ export function download(name, text, type) {
285
+ const blob = new Blob([text], {type});
286
+ const url = URL.createObjectURL(blob);
287
+ const a = document.createElement('a');
288
+ a.href = url;
289
+ a.download = name;
290
+ document.body.appendChild(a);
291
+ a.click();
292
+ a.remove();
293
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
294
+ }
295
+
296
+ /**
297
+ * Print a document on its own - the browser's dialog, where "Save as PDF"
298
+ * lives. A hidden frame holding just the document, so the admin around it
299
+ * never reaches the page.
300
+ */
301
+ export function printHtml(fullHtml) {
302
+ const frame = document.createElement('iframe');
303
+ frame.setAttribute('aria-hidden', 'true');
304
+ frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden';
305
+ document.body.appendChild(frame);
306
+ const doc = frame.contentDocument;
307
+ doc.open();
308
+ doc.write(fullHtml);
309
+ doc.close();
310
+ const go = () => {
311
+ try { frame.contentWindow.focus(); frame.contentWindow.print(); } catch { /* blocked */ }
312
+ setTimeout(() => frame.remove(), 60000);
313
+ };
314
+ // Images must be in before the page is laid out for print.
315
+ const imgs = [...doc.images].filter(i => !i.complete);
316
+ if (!imgs.length) setTimeout(go, 50);
317
+ else Promise.all(imgs.map(i => new Promise(r => { i.onload = i.onerror = r; }))).then(go);
318
+ }
319
+
320
+ /**
321
+ * E.prompt with a starting value that survives any characters. E.prompt's own
322
+ * `inputValue` is sanitised into a value="…" string, which turns &quot; back
323
+ * into a bare quote - a title like Q3 "draft" opened as `Q3 ` and saved cut
324
+ * short. The dialog's input exists as soon as E.prompt returns, so the value is
325
+ * set as a property instead.
326
+ *
327
+ * @param {string} message
328
+ * @param {object} options - E.prompt options, without inputValue
329
+ * @param {string} value
330
+ * @returns {Promise<string|null>}
331
+ */
332
+ export function promptWith(message, options, value) {
333
+ const answer = E.prompt(message, options);
334
+ const input = [...document.querySelectorAll('.dm-dialog-input')].at(-1);
335
+ if (input) {
336
+ input.value = String(value ?? '');
337
+ setTimeout(() => { try { input.select(); } catch { /* gone */ } }, 60);
338
+ }
339
+ return answer;
340
+ }