domma-cms 0.78.1 → 0.79.0
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/admin/css/admin.css +1 -1
- package/admin/js/lib/related.js +1 -0
- package/admin/js/views/contacts.js +11 -10
- package/admin/js/views/notes.js +7 -6
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +389 -430
- package/plugins/_lib/admin/mail/diagnostics-section.js +14 -41
- package/server/routes/api/contacts.js +4 -0
- package/server/routes/api/notes.js +3 -0
- package/server/routes/api/related.js +76 -0
- package/server/routes/api/todo.js +34 -0
- package/server/server.js +3 -0
- package/server/services/hooks.js +98 -0
- package/server/services/plugins.js +7 -0
|
@@ -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
|
-
|
|
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 =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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)
|
package/server/services/hooks.js
CHANGED
|
@@ -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: {}
|