domma-cms 1.6.57 → 1.6.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.57",
3
+ "version": "1.6.58",
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",
@@ -362,3 +362,92 @@ export function holdEscapeOn(so) {
362
362
  if (hold.count === 0) so.options.closeOnEscape = hold.original;
363
363
  };
364
364
  }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Edition extensions
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /**
371
+ * Menu items an edition contributed (`extensions.folderItems` /
372
+ * `mailboxItems`), made safe to merge into a core menu.
373
+ *
374
+ * Whatever came back is checked rather than trusted: anything that is not a
375
+ * divider or a labelled item with a function action is dropped, leading,
376
+ * trailing and doubled dividers go, and every action is wrapped by `wrap` -
377
+ * which the view uses to run it on the next task (an item picked with Enter
378
+ * that opens a dialog must not hand that dialog the same Enter) and to turn
379
+ * a throw into a toast. `disabled` may be a boolean or a function.
380
+ *
381
+ * @param {*} list - what the extension returned
382
+ * @param {(action: Function) => Function} [wrap]
383
+ * @returns {object[]} Domma context-menu items
384
+ */
385
+ export function extensionMenuItems(list, wrap = (fn) => fn) {
386
+ const out = [];
387
+ for (const entry of Array.isArray(list) ? list : []) {
388
+ if (!entry || typeof entry !== 'object') continue;
389
+ if (entry.type === 'divider') {
390
+ if (out.length && out.at(-1).type !== 'divider') out.push({type: 'divider'});
391
+ continue;
392
+ }
393
+ if (typeof entry.label !== 'string' || !entry.label.trim() || typeof entry.action !== 'function') continue;
394
+ const item = {label: entry.label, action: wrap(entry.action)};
395
+ if (typeof entry.icon === 'string' && entry.icon) item.icon = entry.icon;
396
+ if (entry.danger === true) item.danger = true;
397
+ if (typeof entry.disabled === 'function') item.disabled = entry.disabled;
398
+ else if (entry.disabled === true) item.disabled = () => true;
399
+ out.push(item);
400
+ }
401
+ while (out.length && out[0].type === 'divider') out.shift();
402
+ while (out.length && out.at(-1).type === 'divider') out.pop();
403
+ return out;
404
+ }
405
+
406
+ /**
407
+ * Pane-head buttons an edition contributed (`extensions.paneActions`), checked
408
+ * the same way: each needs an id (a button id and a de-duplication key), a
409
+ * label (its tooltip and accessible name) and an onClick. An id seen twice
410
+ * keeps the first.
411
+ *
412
+ * @param {*} list
413
+ * @returns {{id: string, label: string, icon: string, onClick: Function, haspopup?: string}[]}
414
+ */
415
+ export function extensionPaneActions(list) {
416
+ const out = [];
417
+ const seen = new Set();
418
+ for (const entry of Array.isArray(list) ? list : []) {
419
+ if (!entry || typeof entry !== 'object') continue;
420
+ const id = typeof entry.id === 'string' ? entry.id.replace(/[^a-z0-9_-]/gi, '') : '';
421
+ if (!id || seen.has(id)) continue;
422
+ if (typeof entry.label !== 'string' || !entry.label.trim() || typeof entry.onClick !== 'function') continue;
423
+ seen.add(id);
424
+ const action = {id, label: entry.label, icon: typeof entry.icon === 'string' && entry.icon ? entry.icon : 'plus', onClick: entry.onClick};
425
+ if (['dialog', 'menu'].includes(entry.haspopup)) action.haspopup = entry.haspopup;
426
+ out.push(action);
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /**
432
+ * Put an edition's items into a core menu: above the menu's last group when
433
+ * that group holds a destructive item (Remove, Delete) - so the edition's
434
+ * entries never sit under the dangerous one - and at the end otherwise.
435
+ * Separated by a divider either way. Unchanged when there is nothing to add.
436
+ *
437
+ * @param {object[]} items - the core menu
438
+ * @param {object[]} extra - from extensionMenuItems()
439
+ * @returns {object[]}
440
+ */
441
+ export function mergeExtensionItems(items, extra) {
442
+ if (!extra?.length) return items;
443
+ const out = [...items];
444
+ let last = -1;
445
+ for (let i = out.length - 1; i >= 0; i -= 1) {
446
+ if (out[i]?.type === 'divider') { last = i; break; }
447
+ }
448
+ if (last > 0 && out.slice(last + 1).some(item => item?.danger === true)) {
449
+ out.splice(last, 0, {type: 'divider'}, ...extra);
450
+ return out;
451
+ }
452
+ return out.length ? [...out, {type: 'divider'}, ...extra] : [...extra];
453
+ }
@@ -57,7 +57,7 @@ const {
57
57
  esc, mailboxDraftsKey, mailboxDraftId, mailboxDraftData, mailboxFingerprint, normaliseDraftMap,
58
58
  pruneDraftMap, mailboxDraftDecision, mailboxRestoreQuestion, RESTORE_BUTTONS, settingsDiscardQuestion,
59
59
  onceOnly, accountMenuModel, folderReadMenuModel, mailboxRowMenuModel, menuItems, sidebarUrl, hasNewMail,
60
- holdEscapeOn
60
+ holdEscapeOn, extensionMenuItems, extensionPaneActions, mergeExtensionItems
61
61
  } = await import(versioned('reader-kit.js'));
62
62
 
63
63
  /**
@@ -183,7 +183,18 @@ function formatSize(bytes) {
183
183
  /**
184
184
  * Build the reader view for one plugin.
185
185
  *
186
- * @param {{pluginName: string, assetVersion?: string, settingsSections?: object[]}} options
186
+ * @param {{pluginName: string, assetVersion?: string, settingsSections?: object[],
187
+ * features?: object, extensions?: object}} options
188
+ * `extensions` lets an edition add actions to the reader itself, each a
189
+ * function given the reader's `view` handle (see `extensionView()`):
190
+ * - `paneActions(view)` -> `[{id, label, icon, onClick, haspopup?}]`, buttons
191
+ * in the Folders pane head beside New folder;
192
+ * - `folderItems(folder, view)` -> Domma menu items, added to a folder's
193
+ * menu (right-click, ⋮, Shift+F10);
194
+ * - `mailboxItems(account, view)` -> the same, on a mailbox row's menu.
195
+ * Optional, checked and feature-detected: an edition that passes it to an
196
+ * older reader just does not get the entries. Item actions run on the next
197
+ * task, so one picked with Enter cannot hand its own Enter to a dialog.
187
198
  * `settingsSections` lets an edition contribute settings without forking the
188
199
  * view: each is `{id, label, render(container, context)}`, and the shell
189
200
  * handles the tabs, the lazy drawing and a section that throws.
@@ -193,7 +204,8 @@ function formatSize(bytes) {
193
204
  * module is imported by a fixed path and carries no stamp of its own.
194
205
  * @returns {object} a Domma view
195
206
  */
196
- export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {}}) {
207
+ export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {},
208
+ extensions = {}}) {
197
209
  const BASE = `/api/plugins/${pluginName}`;
198
210
 
199
211
  // Deliberately NOT keyed by plugin. The two editions never run together - the
@@ -3418,6 +3430,8 @@ return {
3418
3430
  searchInputEl?.focus();
3419
3431
  break;
3420
3432
  case 'escape':
3433
+ // A panel on top owns Escape: it closes that, and only that.
3434
+ if (openPanels.size) break;
3421
3435
  if (selected.size) clearSelection();
3422
3436
  else if (searchQuery) clearSearch();
3423
3437
  break;
@@ -4795,6 +4809,184 @@ return {
4795
4809
  }
4796
4810
  }
4797
4811
 
4812
+ // ---------------------------------------------------------------
4813
+ // Edition extensions (createReaderView's `extensions` option)
4814
+ // ---------------------------------------------------------------
4815
+
4816
+ /**
4817
+ * What an extension is handed: enough to open a panel of its own
4818
+ * against this mailbox and to tell the reader something changed.
4819
+ * Read through getters, so a handle kept by an open panel still sees
4820
+ * the current mailbox and folders.
4821
+ *
4822
+ * @type {object}
4823
+ */
4824
+ const extensionView = Object.freeze({
4825
+ pluginName,
4826
+ base: BASE,
4827
+ api,
4828
+ assetVersion,
4829
+ /** The mailbox on screen (a mailbox row's menu passes its own). */
4830
+ get account() { return mailboxes.find(b => b.id === currentAccountId) ?? null; },
4831
+ get mailboxes() { return [...mailboxes]; },
4832
+ get folders() { return [...folders]; },
4833
+ get currentFolder() { return currentFolder; },
4834
+ /** False once the screen has gone - check it after every await. */
4835
+ here,
4836
+ /**
4837
+ * Run `fn` once when the screen goes (or is mounted again).
4838
+ *
4839
+ * @param {Function} fn
4840
+ * @returns {Function} cancel it
4841
+ */
4842
+ onLeave(fn) {
4843
+ if (typeof fn !== 'function') return () => {};
4844
+ let live = true;
4845
+ lifetime.add(() => { if (live) { live = false; fn(); } });
4846
+ return () => { live = false; };
4847
+ },
4848
+ /**
4849
+ * Have the reader look after a slideover the extension opened:
4850
+ * Escape belongs to it while it is on top (the reader's own
4851
+ * Escape - clear the selection, the search - does not also
4852
+ * answer), it is closed when the screen goes, and it is taken
4853
+ * out of the page once closed. Call before `open()`.
4854
+ *
4855
+ * @param {object} slideover - from E.slideover
4856
+ * @param {{onClosed?: Function}} [options]
4857
+ * @returns {object} the slideover
4858
+ */
4859
+ panel(slideover, {onClosed} = {}) {
4860
+ return guardPanel(slideover, () => false, '', typeof onClosed === 'function' ? onClosed : () => {});
4861
+ },
4862
+ /** Read the folder list again, staying in the folder that is open. */
4863
+ refreshFolders: () => refreshFolders(),
4864
+ /**
4865
+ * The same as a settings section's `changed`, so a panel written
4866
+ * for one works from the other: reload means the folders (and the
4867
+ * open list) of the mailbox on screen.
4868
+ *
4869
+ * @param {{reload?: boolean}} [options]
4870
+ * @returns {Promise<void>}
4871
+ */
4872
+ async changed({reload = false} = {}) {
4873
+ profileFor = null;
4874
+ refreshOutbox();
4875
+ if (reload) await refreshFolders();
4876
+ }
4877
+ });
4878
+
4879
+ /**
4880
+ * The folder list again, keeping the open folder when it still
4881
+ * exists (a full loadFolders goes back to the inbox), and the open
4882
+ * list with it - an import into the folder on screen shows at once.
4883
+ *
4884
+ * @returns {Promise<void>}
4885
+ */
4886
+ async function refreshFolders() {
4887
+ if (!here() || !currentAccountId) return;
4888
+ const account = currentAccountId;
4889
+ const seq = ++foldersSeq;
4890
+ try {
4891
+ const params = new URLSearchParams({account, counts: '1'});
4892
+ const result = await api(`${BASE}/folders?${params}`);
4893
+ if (!here() || seq !== foldersSeq || currentAccountId !== account) return;
4894
+ if (!result.folders?.some(f => f.path === currentFolder)) {
4895
+ await loadFolders({quiet: true});
4896
+ return;
4897
+ }
4898
+ folders = result.folders;
4899
+ renderFolders();
4900
+ if (!searchQuery) await loadMessages({fresh: true, quiet: true});
4901
+ if (here()) refreshBadge();
4902
+ } catch (err) {
4903
+ if (here()) handleError(err);
4904
+ }
4905
+ }
4906
+
4907
+ /**
4908
+ * Call one of the edition's extension functions, surviving one that
4909
+ * is missing, throws or returns rubbish.
4910
+ *
4911
+ * @param {string} name
4912
+ * @param {...*} args
4913
+ * @returns {*}
4914
+ */
4915
+ function callExtension(name, ...args) {
4916
+ const fn = extensions?.[name];
4917
+ if (typeof fn !== 'function') return [];
4918
+ try {
4919
+ return fn(...args, extensionView) ?? [];
4920
+ } catch (err) {
4921
+ console.warn(`[mail] extensions.${name} failed:`, err);
4922
+ return [];
4923
+ }
4924
+ }
4925
+
4926
+ /**
4927
+ * An extension's action, run on the next task - a menu item picked
4928
+ * with Enter that opens a dialog must not hand it that same Enter -
4929
+ * and only while the screen is still here, with a throw shown.
4930
+ *
4931
+ * @param {Function} fn
4932
+ * @returns {Function}
4933
+ */
4934
+ function deferExtension(fn) {
4935
+ return (...args) => {
4936
+ setTimeout(() => {
4937
+ if (!here()) return;
4938
+ Promise.resolve()
4939
+ .then(() => fn(...args))
4940
+ .catch(err => E.toast(err?.message || 'That could not be done.', {type: 'error'}));
4941
+ }, 0);
4942
+ };
4943
+ }
4944
+
4945
+ /**
4946
+ * The menu items an extension adds for one row.
4947
+ *
4948
+ * @param {'folderItems'|'mailboxItems'} name
4949
+ * @param {object} subject - the folder or mailbox
4950
+ * @returns {object[]}
4951
+ */
4952
+ function extensionItems(name, subject) {
4953
+ return extensionMenuItems(callExtension(name, subject), deferExtension);
4954
+ }
4955
+
4956
+ /**
4957
+ * The extension's buttons in the Folders pane head, beside New folder.
4958
+ *
4959
+ * @returns {void}
4960
+ */
4961
+ function addPaneActions() {
4962
+ const actions = extensionPaneActions(callExtension('paneActions'));
4963
+ if (!actions.length) return;
4964
+ const head = foldersEl?.closest('.mail-folders-pane')?.querySelector('.mail-pane-head');
4965
+ if (!head) return;
4966
+ const before = head.querySelector('#mail-settings-btn');
4967
+ for (const action of actions) {
4968
+ const id = `mail-ext-${action.id}`;
4969
+ if (head.querySelector(`#${id}`)) continue;
4970
+ const button = document.createElement('button');
4971
+ button.type = 'button';
4972
+ button.className = 'btn btn-sm btn-ghost';
4973
+ button.id = id;
4974
+ button.title = action.label;
4975
+ button.setAttribute('aria-label', action.label);
4976
+ if (action.haspopup) button.setAttribute('aria-haspopup', action.haspopup);
4977
+ const icon = document.createElement('span');
4978
+ icon.setAttribute('data-icon', action.icon);
4979
+ icon.setAttribute('data-icon-size', '14');
4980
+ button.appendChild(icon);
4981
+ // A click needs no deferring, but it costs nothing and keeps
4982
+ // every entry point the same.
4983
+ button.addEventListener('click', deferExtension(() => action.onClick(extensionView)));
4984
+ if (before?.parentNode === head) head.insertBefore(button, before);
4985
+ else head.appendChild(button);
4986
+ }
4987
+ try { Domma.icons?.scan?.(head); } catch { /* the title still names it */ }
4988
+ }
4989
+
4798
4990
  /**
4799
4991
  * Right-click a folder to manage it.
4800
4992
  *
@@ -4844,7 +5036,7 @@ return {
4844
5036
  * @returns {object[]}
4845
5037
  */
4846
5038
  function accountMenuItemsFor(box) {
4847
- return menuItems(accountMenuModel({
5039
+ return mergeExtensionItems(menuItems(accountMenuModel({
4848
5040
  isCurrent: box.id === currentAccountId,
4849
5041
  isDefault: box.isDefault === true,
4850
5042
  count: mailboxes.length
@@ -4854,7 +5046,7 @@ return {
4854
5046
  edit: () => openMailboxForm(box),
4855
5047
  default: () => toggleDefaultMailbox(box),
4856
5048
  remove: () => removeMailbox(box)
4857
- });
5049
+ }), extensionItems('mailboxItems', box));
4858
5050
  }
4859
5051
 
4860
5052
  /**
@@ -4895,8 +5087,9 @@ return {
4895
5087
  const locked = protectedUse || isInbox;
4896
5088
 
4897
5089
  const read = folderReadItems(folder);
4898
- if (!features.folders) return read;
4899
- return [
5090
+ const extra = extensionItems('folderItems', folder);
5091
+ if (!features.folders) return mergeExtensionItems(read, extra);
5092
+ return mergeExtensionItems([
4900
5093
  ...read,
4901
5094
  {type: 'divider'},
4902
5095
  {
@@ -4934,7 +5127,7 @@ return {
4934
5127
  disabled: () => locked,
4935
5128
  action: () => deleteFolder(folder)
4936
5129
  }
4937
- ];
5130
+ ], extra);
4938
5131
  }
4939
5132
 
4940
5133
  /**
@@ -5872,6 +6065,7 @@ return {
5872
6065
  newFolder.addEventListener('click', () => createFolder(null, {pickParent: true}));
5873
6066
  }
5874
6067
  }
6068
+ addPaneActions();
5875
6069
 
5876
6070
  // A real <input> here, unlike the row ticks: this one is not inside a
5877
6071
  // button, so it can be what it is.