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,426 @@
1
+ /**
2
+ * Find and replace over whichever surface the editor is using.
3
+ *
4
+ * A textarea and a contenteditable have nothing in common at the DOM level,
5
+ * so both are reduced to the same thing first: the document's plain text plus
6
+ * a way to turn an offset in that text back into a position on the surface.
7
+ * Everything below then works in offsets and never asks which mode it is in.
8
+ *
9
+ * Searching is over TEXT, never over markup. Matching the HTML would let a
10
+ * search for "span" hit every tag in the document and a replace corrupt it,
11
+ * and a person searching a document means the words they can see.
12
+ *
13
+ * @module docs/admin/lib/find-replace
14
+ */
15
+
16
+ /**
17
+ * Guards a replace-all whose replacement contains its own search term.
18
+ * Nothing legitimate needs more passes than this; a loop does.
19
+ */
20
+ const MAX_REPLACEMENTS = 5000;
21
+
22
+ export class FindReplace {
23
+ constructor() {
24
+ this.editor = null;
25
+ this.bar = null;
26
+ this.findInput = null;
27
+ this.replaceInput = null;
28
+ this.caseInput = null;
29
+ this.status = null;
30
+ // Where the next "Find next" starts looking, so repeated presses walk
31
+ // the document instead of landing on the same match.
32
+ this._cursor = 0;
33
+ }
34
+
35
+ /**
36
+ * @param {object} editor a DocEditor
37
+ * @returns {this}
38
+ */
39
+ setEditor(editor) {
40
+ this.editor = editor;
41
+ return this;
42
+ }
43
+
44
+ /**
45
+ * Show the bar inside the editor pane, or focus it if it is already there.
46
+ *
47
+ * @param {HTMLElement} editorPane
48
+ * @returns {void}
49
+ */
50
+ show(editorPane) {
51
+ if (!editorPane) return;
52
+
53
+ if (!this.bar || !this.bar.isConnected) {
54
+ this.bar = this._buildBar();
55
+ const content = editorPane.querySelector('#doc-editor-content');
56
+ if (content) editorPane.insertBefore(this.bar, content);
57
+ else editorPane.prepend(this.bar);
58
+ if (typeof Domma !== 'undefined' && Domma.icons) Domma.icons.scan(this.bar);
59
+ }
60
+
61
+ this._cursor = 0;
62
+ this.findInput.focus();
63
+ this.findInput.select();
64
+ }
65
+
66
+ /**
67
+ * @returns {void}
68
+ */
69
+ hide() {
70
+ this.bar?.remove();
71
+ this.bar = null;
72
+ }
73
+
74
+ // -----------------------------------------------------------------------
75
+ // Searching
76
+ // -----------------------------------------------------------------------
77
+
78
+ /**
79
+ * Select the next match after the cursor, wrapping once.
80
+ *
81
+ * @returns {boolean} true if something was found
82
+ */
83
+ findNext() {
84
+ const term = this.findInput.value;
85
+ if (!term) return false;
86
+
87
+ const surface = this.editor?.getSurface();
88
+ if (!surface) return false;
89
+
90
+ const text = this._text();
91
+ const index = this._indexOf(text, term, this._cursor);
92
+
93
+ if (index === -1) {
94
+ // One wrap, then give up - a second wrap is the same search again.
95
+ const wrapped = this._indexOf(text, term, 0);
96
+ if (wrapped === -1) {
97
+ this._say('No matches');
98
+ return false;
99
+ }
100
+ this._cursor = wrapped;
101
+ this._selectRange(wrapped, term.length);
102
+ this._say('Wrapped to the first match');
103
+ this._cursor = wrapped + term.length;
104
+ return true;
105
+ }
106
+
107
+ this._selectRange(index, term.length);
108
+ this._cursor = index + term.length;
109
+ this._say('');
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * Replace the current selection when it is a match, then move on.
115
+ *
116
+ * @returns {void}
117
+ */
118
+ replaceCurrent() {
119
+ const term = this.findInput.value;
120
+ if (!term) return;
121
+
122
+ const replacement = this.replaceInput.value;
123
+ const text = this._text();
124
+
125
+ // The caret sits just past the last match, so that is where the match
126
+ // being looked at starts.
127
+ const start = Math.max(0, this._cursor - term.length);
128
+ const candidate = text.slice(start, start + term.length);
129
+ const matches = this.caseInput.checked
130
+ ? candidate === term
131
+ : candidate.toLowerCase() === term.toLowerCase();
132
+
133
+ if (!matches) {
134
+ // Nothing selected yet: find one first, so the button always does
135
+ // something rather than silently doing nothing on first press.
136
+ this.findNext();
137
+ return;
138
+ }
139
+
140
+ if (this.editor.readOnly) { E.toast('This document is open read only.', {type: 'info'}); return; }
141
+ if (this.editor.suggesting) { E.toast('Replace is off while suggesting - change the words in the page instead.', {type: 'info'}); return; }
142
+ this._replaceRange(start, term.length, replacement);
143
+ this._cursor = start + replacement.length;
144
+ this.editor.notifyChanged();
145
+ this.findNext();
146
+ }
147
+
148
+ /**
149
+ * @returns {void}
150
+ */
151
+ replaceAll() {
152
+ if (this.editor?.readOnly) { E.toast('This document is open read only.', {type: 'info'}); return; }
153
+ if (this.editor?.suggesting) { E.toast('Replace is off while suggesting - change the words in the page instead.', {type: 'info'}); return; }
154
+ const term = this.findInput.value;
155
+ if (!term) return;
156
+
157
+ const replacement = this.replaceInput.value;
158
+ let from = 0;
159
+ let count = 0;
160
+
161
+ while (count < MAX_REPLACEMENTS) {
162
+ const index = this._indexOf(this._text(), term, from);
163
+ if (index === -1) break;
164
+ this._replaceRange(index, term.length, replacement);
165
+ // Resume past what was just written, or replacing "a" with "aa"
166
+ // never terminates.
167
+ from = index + replacement.length;
168
+ count++;
169
+ }
170
+
171
+ if (count) this.editor.notifyChanged();
172
+ this._cursor = 0;
173
+ this._say(count ? `Replaced ${count}` : 'No matches');
174
+ }
175
+
176
+ // -----------------------------------------------------------------------
177
+ // Surface abstraction
178
+ // -----------------------------------------------------------------------
179
+
180
+ /**
181
+ * @returns {string} the surface's plain text
182
+ */
183
+ _text() {
184
+ const surface = this.editor.getSurface();
185
+ return this.editor.isMarkdown() ? surface.value : this._collect(surface).text;
186
+ }
187
+
188
+ /**
189
+ * Walk a contenteditable's text nodes once, recording where each one
190
+ * starts in the concatenated string. That map is what lets an offset in
191
+ * the flat text become a Range in the tree.
192
+ *
193
+ * @param {HTMLElement} root
194
+ * @returns {{text: string, nodes: {node: Text, start: number}[]}}
195
+ */
196
+ _collect(root) {
197
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
198
+ const nodes = [];
199
+ let text = '';
200
+
201
+ let node = walker.nextNode();
202
+ while (node) {
203
+ nodes.push({node, start: text.length});
204
+ text += node.nodeValue;
205
+ node = walker.nextNode();
206
+ }
207
+
208
+ return {text, nodes};
209
+ }
210
+
211
+ /**
212
+ * @param {string} haystack
213
+ * @param {string} needle
214
+ * @param {number} from
215
+ * @returns {number}
216
+ */
217
+ _indexOf(haystack, needle, from) {
218
+ if (this.caseInput.checked) return haystack.indexOf(needle, from);
219
+ return haystack.toLowerCase().indexOf(needle.toLowerCase(), from);
220
+ }
221
+
222
+ /**
223
+ * Turn a flat-text offset and length into a live DOM Range.
224
+ *
225
+ * A match may begin in one text node and end in another - "hello" split
226
+ * by a stray <b> - which is exactly why this returns a Range rather than
227
+ * a single node and offset.
228
+ *
229
+ * @param {number} start
230
+ * @param {number} length
231
+ * @returns {Range|null}
232
+ */
233
+ _rangeFor(start, length) {
234
+ const {nodes} = this._collect(this.editor.getSurface());
235
+ const end = start + length;
236
+
237
+ let startNode = null;
238
+ let startOffset = 0;
239
+ let endNode = null;
240
+ let endOffset = 0;
241
+
242
+ for (const entry of nodes) {
243
+ const nodeEnd = entry.start + entry.node.nodeValue.length;
244
+ if (startNode === null && start < nodeEnd) {
245
+ startNode = entry.node;
246
+ startOffset = start - entry.start;
247
+ }
248
+ if (end <= nodeEnd) {
249
+ endNode = entry.node;
250
+ endOffset = end - entry.start;
251
+ break;
252
+ }
253
+ }
254
+
255
+ if (!startNode || !endNode) return null;
256
+
257
+ const range = document.createRange();
258
+ range.setStart(startNode, startOffset);
259
+ range.setEnd(endNode, endOffset);
260
+ return range;
261
+ }
262
+
263
+ /**
264
+ * @param {number} start
265
+ * @param {number} length
266
+ * @returns {void}
267
+ */
268
+ _selectRange(start, length) {
269
+ const surface = this.editor.getSurface();
270
+
271
+ if (this.editor.isMarkdown()) {
272
+ surface.focus();
273
+ surface.setSelectionRange(start, start + length);
274
+ return;
275
+ }
276
+
277
+ const range = this._rangeFor(start, length);
278
+ if (!range) return;
279
+
280
+ const selection = window.getSelection();
281
+ selection.removeAllRanges();
282
+ selection.addRange(range);
283
+
284
+ // Bring it into view without stealing focus from the find box, so
285
+ // Enter keeps stepping through matches.
286
+ const rect = range.getBoundingClientRect();
287
+ const bounds = surface.getBoundingClientRect();
288
+ if (rect.bottom > bounds.bottom || rect.top < bounds.top) {
289
+ surface.scrollTop += rect.top - bounds.top - bounds.height / 2;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * @param {number} start
295
+ * @param {number} length
296
+ * @param {string} replacement
297
+ * @returns {void}
298
+ */
299
+ _replaceRange(start, length, replacement) {
300
+ const surface = this.editor.getSurface();
301
+ // Reading only (a viewer, or someone else is editing): find, never replace.
302
+ if (this.editor.readOnly) throw new Error('read only');
303
+
304
+ if (this.editor.isMarkdown()) {
305
+ surface.setRangeText(replacement, start, start + length, 'end');
306
+ return;
307
+ }
308
+
309
+ const range = this._rangeFor(start, length);
310
+ if (!range) return;
311
+
312
+ range.deleteContents();
313
+ // An empty replacement must not insert an empty text node: it would
314
+ // add an entry to the walk that shifts nothing and confuses nothing,
315
+ // but it accumulates one per replacement over a long document.
316
+ if (replacement) range.insertNode(document.createTextNode(replacement));
317
+ surface.normalize();
318
+ }
319
+
320
+ // -----------------------------------------------------------------------
321
+ // The bar
322
+ // -----------------------------------------------------------------------
323
+
324
+ /**
325
+ * @returns {HTMLElement}
326
+ */
327
+ _buildBar() {
328
+ const bar = document.createElement('div');
329
+ bar.className = 'docs-find';
330
+
331
+ this.findInput = document.createElement('input');
332
+ this.findInput.type = 'text';
333
+ this.findInput.className = 'form-input form-input-sm';
334
+ this.findInput.placeholder = 'Find';
335
+ this.findInput.setAttribute('aria-label', 'Find');
336
+ // A new search term starts from the top, not from wherever the last
337
+ // one left the caret.
338
+ this.findInput.addEventListener('input', () => {
339
+ this._cursor = 0;
340
+ this._say('');
341
+ });
342
+ this.findInput.addEventListener('keydown', (e) => {
343
+ if (e.key === 'Enter') {
344
+ e.preventDefault();
345
+ this.findNext();
346
+ } else if (e.key === 'Escape') {
347
+ e.preventDefault();
348
+ this.hide();
349
+ }
350
+ });
351
+ bar.appendChild(this.findInput);
352
+
353
+ this.replaceInput = document.createElement('input');
354
+ this.replaceInput.type = 'text';
355
+ this.replaceInput.className = 'form-input form-input-sm';
356
+ this.replaceInput.placeholder = 'Replace with';
357
+ this.replaceInput.setAttribute('aria-label', 'Replace with');
358
+ this.replaceInput.addEventListener('keydown', (e) => {
359
+ if (e.key === 'Escape') {
360
+ e.preventDefault();
361
+ this.hide();
362
+ }
363
+ });
364
+ bar.appendChild(this.replaceInput);
365
+
366
+ bar.appendChild(this._button('Next', () => this.findNext(), 'btn-outline'));
367
+ bar.appendChild(this._button('Replace', () => this.replaceCurrent(), 'btn-outline'));
368
+ bar.appendChild(this._button('All', () => this.replaceAll(), 'btn-outline'));
369
+
370
+ const caseLabel = document.createElement('label');
371
+ caseLabel.className = 'docs-find__case';
372
+ this.caseInput = document.createElement('input');
373
+ this.caseInput.type = 'checkbox';
374
+ caseLabel.appendChild(this.caseInput);
375
+ caseLabel.appendChild(document.createTextNode('Match case'));
376
+ this.caseInput.addEventListener('change', () => {
377
+ this._cursor = 0;
378
+ });
379
+ bar.appendChild(caseLabel);
380
+
381
+ this.status = document.createElement('span');
382
+ this.status.className = 'docs-find__status';
383
+ this.status.setAttribute('role', 'status');
384
+ bar.appendChild(this.status);
385
+
386
+ const close = document.createElement('button');
387
+ close.type = 'button';
388
+ close.className = 'btn btn-sm btn-ghost docs-find__close';
389
+ close.title = 'Close find and replace';
390
+ close.setAttribute('aria-label', 'Close find and replace');
391
+ const icon = document.createElement('span');
392
+ icon.dataset.icon = 'x';
393
+ icon.dataset.iconSize = '14';
394
+ close.appendChild(icon);
395
+ close.addEventListener('click', () => this.hide());
396
+ bar.appendChild(close);
397
+
398
+ return bar;
399
+ }
400
+
401
+ /**
402
+ * @param {string} text
403
+ * @param {Function} handler
404
+ * @param {string} variant
405
+ * @returns {HTMLElement}
406
+ */
407
+ _button(text, handler, variant) {
408
+ const btn = document.createElement('button');
409
+ btn.type = 'button';
410
+ btn.className = `btn btn-sm ${variant}`;
411
+ btn.textContent = text;
412
+ // mousedown would fire before the contenteditable selection settles;
413
+ // click is right here because the buttons work from offsets, not from
414
+ // whatever happens to be selected.
415
+ btn.addEventListener('click', handler);
416
+ return btn;
417
+ }
418
+
419
+ /**
420
+ * @param {string} message
421
+ * @returns {void}
422
+ */
423
+ _say(message) {
424
+ if (this.status) this.status.textContent = message;
425
+ }
426
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The folder pane.
3
+ *
4
+ * Folders are a flat list on the wire, each carrying a `parentId`, so the
5
+ * tree is assembled here rather than on the server. A cycle in that data -
6
+ * two folders naming each other as parent - would otherwise recurse until the
7
+ * tab dies, so `buildTree` only ever visits a folder once.
8
+ *
9
+ * "All documents" is not a folder. It is the null selection, drawn as a row
10
+ * because a person needs somewhere to click to get back out of a folder.
11
+ *
12
+ * This module owns the data and the edits. Drawing is the screen's: it binds
13
+ * `rows()` with M.applyBindings, so a rename or a delete here only has to say
14
+ * that something changed (`onChange`) and the pane follows.
15
+ *
16
+ * @module docs/admin/lib/folders
17
+ */
18
+
19
+ import {api, promptWith} from './api.js';
20
+
21
+ export class FolderManager {
22
+ /**
23
+ * @param {object} [options]
24
+ * @param {Function} [options.onFolderSelect] called with the folder id, or null for all
25
+ * @param {Function} [options.onChange] called after the list is reloaded
26
+ */
27
+ constructor(options = {}) {
28
+ this.onFolderSelect = options.onFolderSelect || (() => {});
29
+ this.onChange = options.onChange || (() => {});
30
+ this.folders = [];
31
+ this.currentFolderId = null;
32
+ }
33
+
34
+ /**
35
+ * Fetch the folder list. A failure leaves the previous list in place -
36
+ * an empty pane would read as "you have no folders".
37
+ *
38
+ * @returns {Promise<object[]>}
39
+ */
40
+ async load() {
41
+ try {
42
+ const folders = await api('/folders');
43
+ this.folders = Array.isArray(folders) ? folders : [];
44
+ } catch (err) {
45
+ E.toast(err.message || 'Failed to load folders', {type: 'error'});
46
+ }
47
+ this.onChange(this.folders);
48
+ return this.folders;
49
+ }
50
+
51
+ /**
52
+ * @param {string|null} id
53
+ * @returns {object|null}
54
+ */
55
+ byId(id) {
56
+ return this.folders.find(f => f.id === id) || null;
57
+ }
58
+
59
+ /**
60
+ * The tree flattened into drawing order, "All documents" first. Each row
61
+ * carries its depth, so the pane is one keyed list rather than a nest.
62
+ *
63
+ * @returns {{id: string|null, key: string, name: string, depth: number, icon: string}[]}
64
+ */
65
+ rows() {
66
+ const out = [{id: null, key: '__all', name: 'All documents', depth: 0, icon: 'folder-open'}];
67
+ const walk = (nodes, depth) => nodes.forEach(node => {
68
+ out.push({id: node.id, key: node.id, name: node.name || 'Untitled', depth, icon: 'folder'});
69
+ walk(node.children, depth + 1);
70
+ });
71
+ walk(this.buildTree(), 0);
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Arrange the flat list into roots and children.
77
+ *
78
+ * A folder whose parent is missing - deleted, or owned by someone else
79
+ * under user scoping - is drawn at the root rather than dropped, because
80
+ * a folder you cannot see is a folder you cannot empty.
81
+ *
82
+ * @returns {object[]}
83
+ */
84
+ buildTree() {
85
+ const nodes = new Map();
86
+ this.folders.forEach(folder => nodes.set(folder.id, {...folder, children: []}));
87
+
88
+ const roots = [];
89
+ nodes.forEach(node => {
90
+ const parent = node.parentId ? nodes.get(node.parentId) : null;
91
+ if (parent && parent !== node && !this._isAncestor(node, parent, nodes)) {
92
+ parent.children.push(node);
93
+ } else {
94
+ roots.push(node);
95
+ }
96
+ });
97
+
98
+ const byName = (a, b) => (a.name || '').localeCompare(b.name || '');
99
+ roots.sort(byName);
100
+ nodes.forEach(node => node.children.sort(byName));
101
+ return roots;
102
+ }
103
+
104
+ /**
105
+ * Select a folder and tell the screen.
106
+ *
107
+ * @param {string|null} id
108
+ * @returns {void}
109
+ */
110
+ select(id) {
111
+ this.currentFolderId = id;
112
+ this.onFolderSelect(id);
113
+ }
114
+
115
+ /**
116
+ * Ask for a name and create a folder.
117
+ *
118
+ * @param {string|null} [parentId] defaults to the current selection
119
+ * @returns {Promise<object|null>} the new folder, or null if cancelled
120
+ */
121
+ async createFolder(parentId = this.currentFolderId) {
122
+ const name = await E.prompt('Folder name', {
123
+ title: parentId ? 'New subfolder' : 'New folder',
124
+ inputPlaceholder: 'e.g. Meeting notes'
125
+ });
126
+ if (!name || !name.trim()) return null;
127
+
128
+ try {
129
+ const folder = await api('/folders', 'POST', {name: name.trim(), parentId});
130
+ await this.load();
131
+ return folder;
132
+ } catch (err) {
133
+ E.toast(err.message || 'Failed to create folder', {type: 'error'});
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * @param {object} folder
140
+ * @returns {Promise<void>}
141
+ */
142
+ async renameFolder(folder) {
143
+ const name = await promptWith('Folder name', {title: 'Rename folder'}, folder.name || '');
144
+ if (!name || !name.trim() || name.trim() === folder.name) return;
145
+
146
+ try {
147
+ await api(`/folders/${encodeURIComponent(folder.id)}`, 'PUT', {name: name.trim()});
148
+ await this.load();
149
+ } catch (err) {
150
+ E.toast(err.message || 'Failed to rename folder', {type: 'error'});
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Delete a folder. The server moves its documents back to the root, so
156
+ * the confirmation says so rather than implying the documents go too.
157
+ *
158
+ * @param {object} folder
159
+ * @returns {Promise<void>}
160
+ */
161
+ async deleteFolder(folder) {
162
+ const confirmed = await E.confirm(
163
+ 'Delete this folder? Its documents move back to All documents.',
164
+ {title: 'Delete folder', confirmText: 'Delete'}
165
+ );
166
+ if (!confirmed) return;
167
+
168
+ try {
169
+ await api(`/folders/${encodeURIComponent(folder.id)}`, 'DELETE');
170
+ // Standing on a folder that no longer exists would leave the list
171
+ // filtered by a dead id and looking permanently empty.
172
+ if (this.currentFolderId === folder.id) this.currentFolderId = null;
173
+ await this.load();
174
+ this.onFolderSelect(this.currentFolderId);
175
+ E.toast('Folder deleted', {type: 'success'});
176
+ } catch (err) {
177
+ E.toast(err.message || 'Failed to delete folder', {type: 'error'});
178
+ }
179
+ }
180
+
181
+ // -----------------------------------------------------------------------
182
+ // Internals
183
+ // -----------------------------------------------------------------------
184
+
185
+ /**
186
+ * Would attaching `node` under `parent` close a loop?
187
+ *
188
+ * @param {object} node
189
+ * @param {object} parent
190
+ * @param {Map} nodes
191
+ * @returns {boolean}
192
+ */
193
+ _isAncestor(node, parent, nodes) {
194
+ const seen = new Set();
195
+ let cursor = parent;
196
+ while (cursor) {
197
+ if (cursor === node || seen.has(cursor.id)) return true;
198
+ seen.add(cursor.id);
199
+ cursor = cursor.parentId ? nodes.get(cursor.parentId) : null;
200
+ }
201
+ return false;
202
+ }
203
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Docs - ask before a slideover with unsaved changes closes (its button,
3
+ * Escape, the backdrop). The same helper as Sheets' (plugins cannot share a
4
+ * file): Domma's close() is wrapped once; a second close while the question is
5
+ * open is ignored; once the panel is really closing nothing asks again
6
+ * (onceClosed's later destroy() calls close() argument-less). `close(true)`
7
+ * closes without asking. Also: one panel of a kind at a time (claimPanel), and
8
+ * a panel that closes with the screen that opened it (tieToScreen).
9
+ *
10
+ * Call it AFTER kit.onceClosed(so, …): both wrap close(), and onceClosed's
11
+ * wrapper tears the panel down whatever an inner wrapper decided - so the
12
+ * question has to be the outer one.
13
+ *
14
+ * @module docs/admin/lib/panel-guard
15
+ */
16
+
17
+ /**
18
+ * @param {object} so - an E.slideover
19
+ * @param {() => boolean} dirty
20
+ * @param {string} [question]
21
+ * @param {() => boolean} [busy] - true while the panel's work is on its way (a send): a close then is
22
+ * ignored, because the panel closes itself when it lands (2.10)
23
+ * @returns {{done: () => void}} call done() once the work is saved or sent, so the close does not ask
24
+ */
25
+ export function guardClose(so, dirty, question = 'Discard the changes you have not saved?', busy = () => false) {
26
+ const close = so.close.bind(so);
27
+ let finished = false;
28
+ let asking = false;
29
+ so.close = async (force) => {
30
+ if (!finished && force !== true) {
31
+ if (busy()) return undefined;
32
+ if (dirty()) {
33
+ if (asking) return undefined;
34
+ asking = true;
35
+ const ok = await E.confirm(question);
36
+ asking = false;
37
+ if (!ok) return undefined;
38
+ }
39
+ }
40
+ finished = true;
41
+ return close();
42
+ };
43
+ return {done: () => { finished = true; }};
44
+ }
45
+
46
+ /**
47
+ * One panel of a kind at a time (2.10): a second click while it is opening or
48
+ * open does nothing. Returns the release - call it when the panel has closed,
49
+ * or when opening it failed (or it can never open again until a reload) - or
50
+ * null when one is already claimed.
51
+ *
52
+ * @param {string} kind
53
+ * @returns {(() => void)|null}
54
+ */
55
+ const claimed = new Set();
56
+ export function claimPanel(kind) {
57
+ if (claimed.has(kind)) return null;
58
+ claimed.add(kind);
59
+ let done = false;
60
+ return () => { if (!done) { done = true; claimed.delete(kind); } };
61
+ }
62
+
63
+ /**
64
+ * Tie a panel to the screen that opened it: it closes (without asking) when
65
+ * the screen goes. `life` is {alive(), add(fn) => remove}; absent = no tie.
66
+ *
67
+ * @returns {() => void} untie - call it when the panel closes by itself
68
+ */
69
+ export function tieToScreen(so, life) {
70
+ if (!life?.add) return () => {};
71
+ const off = life.add(() => { try { so.close(true); } catch { /* gone */ } });
72
+ return typeof off === 'function' ? off : () => {};
73
+ }