domma-cms 0.78.1 → 0.79.1

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.
@@ -50,14 +50,12 @@ export function createDiagnosticsSection(host) {
50
50
  * @returns {Promise<void>}
51
51
  */
52
52
  async function render(container, ctx) {
53
+ const kit = await import(`./section-kit.js?v=${ctx.assetVersion ?? '1'}`);
53
54
  const server = await ctx.api(`${ctx.base}/diagnostics`);
54
55
  const loaded = loadedAssetVersions();
55
56
 
56
- const stamp = document.createElement('div');
57
- stamp.className = 'text-sm font-semibold mb-2';
58
57
  const entryVersion = loaded['mail.js'] ?? '(unknown)';
59
- stamp.textContent = `${server.plugin.name} ${server.plugin.version ?? '?'} · build ${entryVersion}`;
60
- container.appendChild(stamp);
58
+ kit.heading(container, `${server.plugin.name} ${server.plugin.version ?? '?'} \u00b7 build ${entryVersion}`);
61
59
 
62
60
  // The comparison that matters: the token this page was served under
63
61
  // against the token the server would issue now. Comparing a module's
@@ -67,35 +65,18 @@ export function createDiagnosticsSection(host) {
67
65
  const running = loaded['mail.js'];
68
66
  const stale = running && running !== '(unversioned)' && String(served) !== String(running);
69
67
 
70
- const verdict = document.createElement('p');
71
- verdict.className = 'text-sm mb-3';
72
- if (stale) {
73
- verdict.className = 'text-sm mb-3 text-danger';
74
- verdict.textContent = `This page is running an older build than the server has (${running} vs ${served}). Reload with a hard refresh before reporting anything else.`;
75
- } else {
76
- verdict.className = 'text-sm mb-3 text-muted';
77
- verdict.textContent = 'This page is running the build the server is serving.';
78
- }
79
- container.appendChild(verdict);
68
+ const verdict = kit.note(container, stale
69
+ ? `This page is running an older build than the server has (${running} vs ${served}). Reload with a hard refresh before reporting anything else.`
70
+ : 'This page is running the build the server is serving.');
71
+ if (stale) verdict.classList.add('text-danger');
80
72
 
81
73
  const errors = host.recentErrors();
82
- const errorLine = document.createElement('p');
83
- errorLine.className = 'text-sm text-muted mb-3';
84
- errorLine.textContent = errors.length
74
+ kit.note(container, errors.length
85
75
  ? `${errors.length} error(s) captured since this view opened - included in the report.`
86
- : 'No errors captured since this view opened.';
87
- container.appendChild(errorLine);
76
+ : 'No errors captured since this view opened.');
88
77
 
89
- const actions = document.createElement('div');
90
- actions.className = 'flex gap-2 items-center';
91
-
92
- const status = document.createElement('span');
93
- status.className = 'flex-1 text-sm text-muted';
94
-
95
- const copy = document.createElement('button');
96
- copy.className = 'btn btn-sm btn-primary';
97
- copy.textContent = 'Copy diagnostics';
98
- copy.addEventListener('click', async () => {
78
+ const bar = kit.actionBar(container);
79
+ kit.button(bar.row, 'Copy diagnostics', async () => {
99
80
  const report = {
100
81
  page: {
101
82
  url: location.href,
@@ -109,24 +90,16 @@ export function createDiagnosticsSection(host) {
109
90
  const text = JSON.stringify(report, null, 2);
110
91
  try {
111
92
  await navigator.clipboard.writeText(text);
112
- status.className = 'flex-1 text-sm text-success';
113
- status.textContent = 'Copied. Paste it into the report.';
93
+ bar.say('Copied. Paste it into the report.', 'ok');
114
94
  } catch {
115
95
  // Clipboard access can be refused; showing the text is still
116
96
  // better than losing it.
117
- const box = document.createElement('textarea');
97
+ const box = kit.box(container, 'form-textarea mail-diag-log', 'textarea');
118
98
  box.value = text;
119
- box.className = 'form-textarea mail-diag-log';
120
- container.appendChild(box);
121
99
  box.select();
122
- status.className = 'flex-1 text-sm text-muted';
123
- status.textContent = 'Clipboard unavailable - select and copy the text below.';
100
+ bar.say('Clipboard unavailable - select and copy the text below.');
124
101
  }
125
- });
126
-
127
- actions.appendChild(status);
128
- actions.appendChild(copy);
129
- container.appendChild(actions);
102
+ }, {variant: 'primary'});
130
103
  }
131
104
 
132
105
  return {
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
28
28
  import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
29
+ import {hooks} from '../../services/hooks.js';
29
30
 
30
31
  const CONTACTS_SLUG = 'contacts-contacts';
31
32
  const GROUPS_SLUG = 'contacts-groups';
@@ -348,6 +349,7 @@ export async function contactsRoutes(fastify, opts = {}) {
348
349
  if (uid && entry.data.userId !== uid) continue;
349
350
  await deleteEntry(CONTACTS_SLUG, id).catch(() => {
350
351
  });
352
+ hooks.emit('contact:deleted', {id, userId: entry.data.userId ?? null});
351
353
  deleted++;
352
354
  }
353
355
 
@@ -364,6 +366,8 @@ export async function contactsRoutes(fastify, opts = {}) {
364
366
  if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
365
367
 
366
368
  await deleteEntry(CONTACTS_SLUG, id);
369
+ // Anything pointing at it (Calendar's event links) can let go now (0.79).
370
+ hooks.emit('contact:deleted', {id, userId: entry.data.userId ?? null});
367
371
  return reply.send({ ok: true });
368
372
  });
369
373
 
@@ -18,6 +18,7 @@
18
18
  import {ago, clip, plural} from '../../services/sidebarBadges.js';
19
19
  import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
20
20
  import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
21
+ import {hooks} from '../../services/hooks.js';
21
22
 
22
23
  const SLUG = 'notes-notes';
23
24
 
@@ -149,6 +150,8 @@ export async function notesRoutes(fastify, opts = {}) {
149
150
  if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Note not found'});
150
151
 
151
152
  await deleteEntry(SLUG, id);
153
+ // Anything pointing at it (Calendar's event links) can let go now (0.79).
154
+ hooks.emit('note:deleted', {id, userId: entry.data.userId ?? null});
152
155
  return reply.send({ success: true });
153
156
  });
154
157
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Related items - /api/related (0.79)
3
+ *
4
+ * What plugins show beside a core record - Calendar's "Events" on a contact or
5
+ * a note. Core never names the plugin: providers register through
6
+ * `registerRelatedItems` (services/hooks.js) and this route asks them.
7
+ *
8
+ * Core does the one check a provider cannot: that the viewer may see the
9
+ * record at all. Contacts and Notes are per-user, so a record that is not the
10
+ * viewer's answers 404 exactly as its own API does - no provider is asked.
11
+ *
12
+ * GET /api/related/providers?kind=contact who can add to a contact: [{id, label, icon, actions}]
13
+ * GET /api/related?kind=contact&id=<id> {providers: [{id, label, icon, items, error?}]}
14
+ */
15
+ import {authenticate as defaultAuthenticate} from '../../middleware/auth.js';
16
+ import {getEntry} from '../../services/collections.js';
17
+ import {getRelatedProviders, RELATED_KINDS} from '../../services/hooks.js';
18
+ import {getPermissionsFor} from '../../services/roles.js';
19
+ import {getEffectiveRoles} from '../../services/userRoles.js';
20
+
21
+ /** Each kind: its Tool's permission resource and its collection. */
22
+ const KINDS = {
23
+ contact: {resource: 'contacts', slug: 'contacts-contacts'},
24
+ note: {resource: 'notes', slug: 'notes-notes'}
25
+ };
26
+
27
+ /** How long one provider may take before it is left out of the answer. */
28
+ export const PROVIDER_TIMEOUT_MS = 1500;
29
+
30
+ const withTimeout = (promise, ms) => Promise.race([
31
+ promise,
32
+ new Promise((_, reject) => setTimeout(() => reject(new Error('took too long')), ms))
33
+ ]);
34
+
35
+ /**
36
+ * @param {import('fastify').FastifyInstance} fastify
37
+ * @param {{authenticate?: Function}} [opts] - injectable for tests
38
+ */
39
+ export async function relatedRoutes(fastify, opts = {}) {
40
+ const authenticate = opts.authenticate || defaultAuthenticate;
41
+
42
+ const viewer = (request) => {
43
+ const roles = getEffectiveRoles(request.user);
44
+ return {id: request.user?.id ?? request.user?.sub ?? null, role: request.user?.role ?? null, roles};
45
+ };
46
+ const mayRead = (user, kind) => user.roles.some(r => getPermissionsFor(KINDS[kind].resource, 'read').includes(r));
47
+
48
+ fastify.get('/providers', {preHandler: [authenticate]}, async (request, reply) => {
49
+ const kind = String(request.query?.kind ?? '');
50
+ if (!RELATED_KINDS.includes(kind)) return reply.code(400).send({error: `kind must be one of ${RELATED_KINDS.join(', ')}`});
51
+ if (!mayRead(viewer(request), kind)) return reply.code(403).send({error: 'Forbidden'});
52
+ return getRelatedProviders(kind).map(({id, label, icon, actions}) => ({id, label, icon, actions}));
53
+ });
54
+
55
+ fastify.get('/', {preHandler: [authenticate]}, async (request, reply) => {
56
+ const kind = String(request.query?.kind ?? '');
57
+ const id = String(request.query?.id ?? '');
58
+ if (!RELATED_KINDS.includes(kind) || !id) return reply.code(400).send({error: 'kind and id are required'});
59
+ const user = viewer(request);
60
+ if (!mayRead(user, kind)) return reply.code(403).send({error: 'Forbidden'});
61
+
62
+ const entry = await getEntry(KINDS[kind].slug, id).catch(() => null);
63
+ if (!entry || (user.id && entry.data?.userId !== user.id)) return reply.code(404).send({error: 'Not found'});
64
+
65
+ const providers = await Promise.all(getRelatedProviders(kind).map(async (p) => {
66
+ try {
67
+ const items = await withTimeout(Promise.resolve(p.list({id, user})), PROVIDER_TIMEOUT_MS);
68
+ return {id: p.id, label: p.label, icon: p.icon, items: Array.isArray(items) ? items : []};
69
+ } catch (err) {
70
+ request.log?.warn?.({err, provider: p.id}, '[related] provider failed');
71
+ return {id: p.id, label: p.label, icon: p.icon, items: [], error: 'Could not be loaded just now.'};
72
+ }
73
+ }));
74
+ return {providers};
75
+ });
76
+ }
@@ -18,6 +18,8 @@
18
18
  import {clip, plural} from '../../services/sidebarBadges.js';
19
19
  import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
20
20
  import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
21
+ import {registerCalendarSource} from '../../services/hooks.js';
22
+ import {getPermissionsFor} from '../../services/roles.js';
21
23
 
22
24
  const SLUG = 'todo-items';
23
25
 
@@ -37,6 +39,38 @@ function toTodo(entry) {
37
39
  };
38
40
  }
39
41
 
42
+ /** 'YYYY-MM-DD' + n days, by the calendar. */
43
+ const addDays = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
44
+
45
+ /**
46
+ * The Calendar's "To-dos" layer (0.79): the viewer's OPEN to-dos with a due
47
+ * day, as all-day items on that day (UTC midnights, end exclusive - the
48
+ * Calendar's all-day shape). Completed ones stay off: the calendar shows what
49
+ * is still to do. Only for someone who may read Todo at all.
50
+ *
51
+ * @param {{from: string, to: string, user: {id: string, roles?: string[], role?: string}}} q
52
+ */
53
+ export async function todoCalendarItems({from, to, user}) {
54
+ const roles = Array.isArray(user?.roles) ? user.roles : [user?.role].filter(Boolean);
55
+ if (!user?.id || !roles.some(r => getPermissionsFor('todo', 'read').includes(r))) return [];
56
+ const {entries} = await listEntries(SLUG, {limit: 10000});
57
+ return entries.map(toTodo)
58
+ .filter(t => t.userId === user.id && t.status !== 'completed')
59
+ .map(t => ({t, day: /^(\d{4}-\d{2}-\d{2})/.exec(String(t.dueAt ?? ''))?.[1]}))
60
+ .filter(({day}) => day && day >= from && day < to)
61
+ .map(({t, day}) => ({
62
+ id: t.id,
63
+ title: String(t.text || 'To-do').trim() || 'To-do',
64
+ startAt: `${day}T00:00:00.000Z`,
65
+ endAt: `${addDays(day, 1)}T00:00:00.000Z`,
66
+ allDay: true,
67
+ url: '#/todo',
68
+ meta: t.priority === 'high' ? 'high priority' : ''
69
+ }));
70
+ }
71
+
72
+ registerCalendarSource({id: 'todo', label: 'To-dos', icon: 'check-square', tone: 'warning', list: todoCalendarItems});
73
+
40
74
  /**
41
75
  * Auth is injectable so a test can drive these routes without registering
42
76
  * @fastify/jwt or a role store - the same shape dashboard.js and plugins.js use.
package/server/server.js CHANGED
@@ -464,6 +464,7 @@ const { notesRoutes } = await import('./routes/api/notes.js');
464
464
  const { todoRoutes } = await import('./routes/api/todo.js');
465
465
  const { analyticsRoutes } = await import('./routes/api/analytics.js');
466
466
  const { contactsRoutes } = await import('./routes/api/contacts.js');
467
+ const { relatedRoutes } = await import('./routes/api/related.js');
467
468
  const { pluginMarketplaceRoutes } = await import('./routes/api/plugin-marketplace.js');
468
469
  const { collectionsRoutes } = await import('./routes/api/collections.js');
469
470
  const { formsRoutes } = await import('./routes/api/forms.js');
@@ -515,6 +516,8 @@ await app.register(notesRoutes, {prefix: '/api/notes'});
515
516
  await app.register(todoRoutes, {prefix: '/api/todo'});
516
517
  await app.register(analyticsRoutes, {prefix: '/api/analytics'});
517
518
  await app.register(contactsRoutes, {prefix: '/api/contacts'});
519
+ // What plugins show beside a contact or a note (Calendar's Events) - 0.79.
520
+ await app.register(relatedRoutes, {prefix: '/api/related'});
518
521
 
519
522
  // ---------------------------------------------------------------------------
520
523
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
@@ -207,3 +207,101 @@ export function registerComponent({name, source} = {}) {
207
207
  export function getPluginComponents() {
208
208
  return [..._pluginComponents.values()];
209
209
  }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // 6. Calendar sources (0.79) - dated items other Tools show on the Calendar
213
+ // ---------------------------------------------------------------------------
214
+ //
215
+ // The Calendar plugin ASKS for these when it draws a range; nothing is copied
216
+ // into it, so nothing can fall out of step. A source returns only the viewer's
217
+ // own items - Notes, Contacts and Todo are per-user. Core never imports the
218
+ // Calendar: with it not installed, sources are registered and never asked.
219
+
220
+ const _calendarSources = new Map(); // id -> {id, label, icon, tone, list, owner}
221
+
222
+ /**
223
+ * Register a calendar source.
224
+ *
225
+ * `list({from, to, user})` - `from`/`to` are 'YYYY-MM-DD' (from inclusive, to
226
+ * exclusive); `user` is `{id, role, roles}` (roles = primary + additional).
227
+ * Resolves to `[{id, title, startAt, endAt?, allDay?, url?, meta?}]`. An
228
+ * all-day item's times are UTC midnights, `endAt` exclusive - as Calendar's own.
229
+ *
230
+ * @param {{id: string, label: string, icon?: string, tone?: string, list: Function, owner?: string}} source
231
+ * @returns {boolean} false when the source is malformed (it is then ignored)
232
+ */
233
+ export function registerCalendarSource(source = {}) {
234
+ const {id, label, list} = source;
235
+ if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(id) || typeof list !== 'function') return false;
236
+ _calendarSources.set(id, {
237
+ id,
238
+ label: typeof label === 'string' && label.trim() ? label.trim() : id,
239
+ icon: typeof source.icon === 'string' ? source.icon : 'calendar',
240
+ tone: typeof source.tone === 'string' ? source.tone : 'info',
241
+ owner: typeof source.owner === 'string' ? source.owner : 'core',
242
+ list
243
+ });
244
+ return true;
245
+ }
246
+
247
+ /** @param {string} id */
248
+ export function unregisterCalendarSource(id) {
249
+ _calendarSources.delete(id);
250
+ }
251
+
252
+ /** Every registered calendar source, in registration order. */
253
+ export function getCalendarSources() {
254
+ return [..._calendarSources.values()];
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // 7. Related items (0.79) - "Events" on a contact or a note
259
+ // ---------------------------------------------------------------------------
260
+ //
261
+ // A plugin that points AT core records (Calendar's event links) registers a
262
+ // provider per kind; GET /api/related asks each one for what relates to one
263
+ // record, after core has checked the viewer may see that record at all.
264
+
265
+ export const RELATED_KINDS = ['contact', 'note'];
266
+ const _relatedProviders = new Map(); // kind -> Map(id -> provider)
267
+
268
+ /**
269
+ * Register what a plugin can show beside a core record.
270
+ *
271
+ * `list({id, user})` resolves to `[{id, title, when?, startAt?, allDay?, meta?, url?, past?}]`
272
+ * - with `startAt` and no `when`, the admin writes the time in the reader's zone.
273
+ * `actions` are shown in the record's menu, `{id}` in a url replaced by the
274
+ * record's id: `[{label, icon?, url}]`.
275
+ *
276
+ * @param {'contact'|'note'} kind
277
+ * @param {{id: string, label: string, icon?: string, list: Function, actions?: object[], owner?: string}} provider
278
+ * @returns {boolean}
279
+ */
280
+ export function registerRelatedItems(kind, provider = {}) {
281
+ if (!RELATED_KINDS.includes(kind)) return false;
282
+ const {id, list} = provider;
283
+ if (typeof id !== 'string' || !id || typeof list !== 'function') return false;
284
+ if (!_relatedProviders.has(kind)) _relatedProviders.set(kind, new Map());
285
+ const actions = (Array.isArray(provider.actions) ? provider.actions : [])
286
+ .filter(a => a && typeof a.label === 'string' && typeof a.url === 'string' && a.url.startsWith('#/'))
287
+ .map(a => ({label: a.label, icon: typeof a.icon === 'string' ? a.icon : 'arrow-right', url: a.url}));
288
+ _relatedProviders.get(kind).set(id, {
289
+ id,
290
+ label: typeof provider.label === 'string' && provider.label.trim() ? provider.label.trim() : id,
291
+ icon: typeof provider.icon === 'string' ? provider.icon : 'link',
292
+ owner: typeof provider.owner === 'string' ? provider.owner : 'core',
293
+ actions,
294
+ list
295
+ });
296
+ return true;
297
+ }
298
+
299
+ /** @param {string} kind @param {string} id */
300
+ export function unregisterRelatedItems(kind, id) {
301
+ _relatedProviders.get(kind)?.delete(id);
302
+ }
303
+
304
+ /** The providers for one kind of record. */
305
+ export function getRelatedProviders(kind) {
306
+ return [...(_relatedProviders.get(kind)?.values() ?? [])];
307
+ }
@@ -16,7 +16,10 @@ import matter from 'gray-matter';
16
16
  import {getConfig, saveConfig} from '../config.js';
17
17
  import {authenticate, requireAdmin, requirePermission, requireRole, requireVisibility} from '../middleware/auth.js';
18
18
  import {
19
+ getCalendarSources,
19
20
  hooks,
21
+ registerCalendarSource,
22
+ registerRelatedItems,
20
23
  registerMenuLocation,
21
24
  registerSanitizeRules,
22
25
  registerShortcode,
@@ -475,6 +478,10 @@ export async function registerPlugins(fastify) {
475
478
  // Custom themes the plugin offers (the Theme Roller) - bound to the
476
479
  // plugin, so one plugin cannot replace another's. See themeSettings.js.
477
480
  registerThemes: (provider) => registerThemeProvider(manifest.name, provider),
481
+ // Calendar sources and related items (0.79) - stamped with the plugin's name.
482
+ registerCalendarSource: (source) => registerCalendarSource({...source, owner: manifest.name}),
483
+ getCalendarSources,
484
+ registerRelatedItems: (kind, provider) => registerRelatedItems(kind, {...provider, owner: manifest.name}),
478
485
  on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name)},
479
486
  settings,
480
487
  config: {}