domma-cms 0.55.1 → 0.66.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/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending the away message.
|
|
3
|
+
*
|
|
4
|
+
* The decision - and every reason not to - is `_lib/admin/mail/vacation.js`,
|
|
5
|
+
* which is pure. This is the half that talks to SMTP, and it is deliberately
|
|
6
|
+
* thin: an auto-responder's bugs are decisions, not transport, and a decision
|
|
7
|
+
* that lives behind a mail server is a decision nobody can test.
|
|
8
|
+
*
|
|
9
|
+
* ## The two halves of not causing a loop
|
|
10
|
+
*
|
|
11
|
+
* `shouldAutoReply` refuses to answer anything that announced itself as
|
|
12
|
+
* automatic. `autoReplyHeaders` makes OUR reply announce itself the same way,
|
|
13
|
+
* so the responder at the far end refuses it in turn. Either half alone
|
|
14
|
+
* leaves a loop that only stops when somebody notices.
|
|
15
|
+
*
|
|
16
|
+
* @module _lib/mail/vacationRunner
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
autoReplyHeaders,
|
|
20
|
+
isActive,
|
|
21
|
+
normaliseVacation,
|
|
22
|
+
replySubject,
|
|
23
|
+
shouldAutoReply
|
|
24
|
+
} from '../admin/mail/vacation.js';
|
|
25
|
+
import {textToHtml} from '../admin/mail/identity.js';
|
|
26
|
+
import {sendMessage} from './send.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How many away messages may go out in one sync pass.
|
|
30
|
+
*
|
|
31
|
+
* A ceiling on the blast radius of a mistake. If something has gone wrong -
|
|
32
|
+
* a guard that does not hold, a folder full of mail that suddenly looks new -
|
|
33
|
+
* the damage is bounded at ten messages rather than at the size of the
|
|
34
|
+
* mailbox, and the next pass will do ten more only if it is still right to.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_PER_PASS = 10;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Answer whatever has just arrived and deserves an answer.
|
|
40
|
+
*
|
|
41
|
+
* Must be called with NO mailbox lock held - it sends, which opens its own
|
|
42
|
+
* connections. Failures are collected rather than thrown: one message that
|
|
43
|
+
* could not be answered must not stop the rest, and it must certainly not
|
|
44
|
+
* fail the sync pass it is riding on.
|
|
45
|
+
*
|
|
46
|
+
* @param {{pool: object, store: object, account: object, poolKey: string,
|
|
47
|
+
* connection: object, smtp: object, messages: object[],
|
|
48
|
+
* config: object, addresses: string[], now?: Date}} deps
|
|
49
|
+
* @returns {Promise<{considered: number, replied: number, skipped: object[], errors: string[]}>}
|
|
50
|
+
*/
|
|
51
|
+
export async function runVacation({
|
|
52
|
+
pool, store, account, poolKey, connection, smtp, messages, config, addresses, now = new Date()
|
|
53
|
+
}) {
|
|
54
|
+
const report = {considered: messages?.length ?? 0, replied: 0, skipped: [], errors: []};
|
|
55
|
+
|
|
56
|
+
const vacation = normaliseVacation(account);
|
|
57
|
+
if (!isActive(vacation, now)) return report;
|
|
58
|
+
// Nothing to send it with. Reported rather than silent: a mailbox with an
|
|
59
|
+
// away message switched on and no outgoing server is a mailbox whose
|
|
60
|
+
// owner believes people are being answered.
|
|
61
|
+
if (!smtp) {
|
|
62
|
+
report.errors.push('No outgoing server is configured, so nothing could be answered.');
|
|
63
|
+
return report;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const message of messages ?? []) {
|
|
67
|
+
if (report.replied >= MAX_PER_PASS) break;
|
|
68
|
+
|
|
69
|
+
const sender = (message.from ?? [])[0]?.address ?? '';
|
|
70
|
+
const lastRepliedAt = sender ? await store.lastAutoReply(account.id, sender) : null;
|
|
71
|
+
|
|
72
|
+
const verdict = shouldAutoReply({message, vacation, addresses, lastRepliedAt, now});
|
|
73
|
+
if (!verdict.reply) {
|
|
74
|
+
report.skipped.push({uid: message.uid, reason: verdict.reason});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await sendMessage({
|
|
80
|
+
smtp,
|
|
81
|
+
draft: {
|
|
82
|
+
to: [{name: (message.from ?? [])[0]?.name ?? '', address: sender}],
|
|
83
|
+
subject: replySubject(vacation, message.subject),
|
|
84
|
+
body: vacation.bodyText,
|
|
85
|
+
html: vacation.bodyHtml || textToHtml(vacation.bodyText),
|
|
86
|
+
// Threaded onto what they sent, so the answer arrives
|
|
87
|
+
// under their own message rather than as a new one.
|
|
88
|
+
inReplyTo: message.messageId ?? null,
|
|
89
|
+
references: message.messageId ? [message.messageId] : []
|
|
90
|
+
},
|
|
91
|
+
pool,
|
|
92
|
+
poolKey,
|
|
93
|
+
connection,
|
|
94
|
+
allowInsecureTLS: config.allowInsecureTLS === true,
|
|
95
|
+
// The half that protects everybody else.
|
|
96
|
+
headers: autoReplyHeaders(),
|
|
97
|
+
// Not filed. One holiday produces a hundred of these, and a
|
|
98
|
+
// Sent folder that is mostly "Out of office" is one nobody can
|
|
99
|
+
// find anything in - the reply log is the audit trail instead.
|
|
100
|
+
fileCopy: false
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Recorded AFTER it has gone. The other order would mean a send
|
|
104
|
+
// that failed still counted as "already told", and that person
|
|
105
|
+
// would never hear anything.
|
|
106
|
+
await store.noteAutoReply(account.id, sender, now);
|
|
107
|
+
report.replied += 1;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
report.errors.push(`${sender}: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return report;
|
|
114
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changing mail, rather than reading it.
|
|
3
|
+
*
|
|
4
|
+
* Everything here needs a read-write mailbox, which is why it is a separate
|
|
5
|
+
* module rather than more routes beside the read surface: the free edition
|
|
6
|
+
* registers that surface and must never gain any of this by accident.
|
|
7
|
+
*
|
|
8
|
+
* Each operation applies on the server first and updates the mirror second.
|
|
9
|
+
* The other order is tempting - the UI feels faster - but it means a failed
|
|
10
|
+
* write leaves the mirror claiming something that is not true, and the next
|
|
11
|
+
* sync silently reverts it, which reads as the application losing the change.
|
|
12
|
+
*
|
|
13
|
+
* @module _lib/mail/write
|
|
14
|
+
*/
|
|
15
|
+
import {ENVELOPE_QUERY, toRecord} from './envelope.js';
|
|
16
|
+
|
|
17
|
+
/** Flags a caller may set. Anything else is refused rather than passed through. */
|
|
18
|
+
export const WRITABLE_FLAGS = new Set(['\\Seen', '\\Flagged', '\\Answered', '\\Draft']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check a requested flag change is something we are willing to make.
|
|
22
|
+
*
|
|
23
|
+
* `\Deleted` is deliberately absent: deleting is its own operation with its
|
|
24
|
+
* own consequences, not a flag a generic endpoint should be able to set.
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} flags
|
|
27
|
+
* @returns {{ok: true} | {ok: false, error: string}}
|
|
28
|
+
*/
|
|
29
|
+
export function validateFlags(flags) {
|
|
30
|
+
if (!Array.isArray(flags)) return {ok: false, error: 'Flags must be an array.'};
|
|
31
|
+
const bad = flags.filter(flag => !WRITABLE_FLAGS.has(flag));
|
|
32
|
+
if (bad.length) {
|
|
33
|
+
return {ok: false, error: `Unsupported flag(s): ${bad.join(', ')}.`};
|
|
34
|
+
}
|
|
35
|
+
return {ok: true};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Add or remove flags on one message.
|
|
40
|
+
*
|
|
41
|
+
* @param {{pool: object, store: object|null, account: object, poolKey: string,
|
|
42
|
+
* connection: object, folder: string, uid: number,
|
|
43
|
+
* add?: string[], remove?: string[]}} options
|
|
44
|
+
* @returns {Promise<{uid: number, flags: string[]}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function setMessageFlags({
|
|
47
|
+
pool, store, account, poolKey, connection, folder, uid, add = [], remove = []
|
|
48
|
+
}) {
|
|
49
|
+
const flags = await pool.withMailbox(poolKey, connection, folder, async (client) => {
|
|
50
|
+
if (add.length) await client.messageFlagsAdd({uid: String(uid)}, add, {uid: true});
|
|
51
|
+
if (remove.length) await client.messageFlagsRemove({uid: String(uid)}, remove, {uid: true});
|
|
52
|
+
|
|
53
|
+
// Read back rather than assume: another client may have changed
|
|
54
|
+
// something in between, and the mirror should hold what is true on the
|
|
55
|
+
// server rather than what we asked for.
|
|
56
|
+
const fetched = await client.fetchOne(uid, {flags: true}, {uid: true});
|
|
57
|
+
return [...(fetched?.flags ?? [])];
|
|
58
|
+
}, {writable: true});
|
|
59
|
+
|
|
60
|
+
if (store) {
|
|
61
|
+
await store.setFlags(account.id, folder, [{uid, flags}]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {uid, flags};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Put the moved messages into the destination's mirror straight away.
|
|
69
|
+
*
|
|
70
|
+
* Without this a move takes a message out of the source mirror and the
|
|
71
|
+
* destination does not learn of it until its next poll - so it is in neither
|
|
72
|
+
* folder for up to the poll interval, which reads as having lost it. Worst
|
|
73
|
+
* where it matters most: "Not spam" is supposed to put mail back in the Inbox,
|
|
74
|
+
* and the whole point is seeing it arrive.
|
|
75
|
+
*
|
|
76
|
+
* Only for a folder the mirror already tracks. An untracked destination is
|
|
77
|
+
* listed live from IMAP, so it is already correct, and writing rows for it
|
|
78
|
+
* would seed a mirror nothing asked for.
|
|
79
|
+
*
|
|
80
|
+
* Failure here is not failure of the move: the message HAS moved, and the
|
|
81
|
+
* next sync will pick it up regardless. Reported, never thrown.
|
|
82
|
+
*
|
|
83
|
+
* @param {{pool: object, store: object|null, account: object, poolKey: string,
|
|
84
|
+
* connection: object, destination: string, uids: number[]}} options
|
|
85
|
+
* @returns {Promise<boolean>} whether the destination mirror was updated
|
|
86
|
+
*/
|
|
87
|
+
async function ingestMoved({pool, store, account, poolKey, connection, destination, uids}) {
|
|
88
|
+
if (!store || !uids.length) return false;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const state = await store.getFolder(account.id, destination);
|
|
92
|
+
if (!state?.lastSyncAt) return false;
|
|
93
|
+
|
|
94
|
+
const set = uids.join(',');
|
|
95
|
+
const rows = [];
|
|
96
|
+
await pool.withMailbox(poolKey, connection, destination, async (client) => {
|
|
97
|
+
for await (const message of client.fetch(set, ENVELOPE_QUERY, {uid: true})) {
|
|
98
|
+
rows.push(toRecord(message));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (rows.length) await store.putMessages(account.id, destination, rows);
|
|
103
|
+
return rows.length > 0;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Move one message to another folder.
|
|
111
|
+
*
|
|
112
|
+
* The mirror entry is removed rather than rewritten: the message gets a new
|
|
113
|
+
* UID in the destination, and inventing one would put a record in the store
|
|
114
|
+
* that matches nothing on the server. The destination picks it up on its next
|
|
115
|
+
* sync, if it is mirrored at all.
|
|
116
|
+
*
|
|
117
|
+
* @param {{pool: object, store: object|null, account: object, poolKey: string,
|
|
118
|
+
* connection: object, folder: string, uid: number, destination: string}} options
|
|
119
|
+
* @returns {Promise<{moved: boolean, destination: string}>}
|
|
120
|
+
*/
|
|
121
|
+
export async function moveMessage({
|
|
122
|
+
pool, store, account, poolKey, connection, folder, uid, destination
|
|
123
|
+
}) {
|
|
124
|
+
const result = await pool.withMailbox(poolKey, connection, folder, async (client) => {
|
|
125
|
+
return client.messageMove({uid: String(uid)}, destination, {uid: true});
|
|
126
|
+
}, {writable: true});
|
|
127
|
+
|
|
128
|
+
if (store) {
|
|
129
|
+
await store.removeMessages(account.id, folder, [uid]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Where the server supports UIDPLUS it tells us the UID the message was
|
|
133
|
+
// given at its destination, which is the only way back: an undo has to
|
|
134
|
+
// name the message it is moving, and it is not the one we sent. Without
|
|
135
|
+
// UIDPLUS there is no new UID to report, and the caller must not offer an
|
|
136
|
+
// undo it cannot perform.
|
|
137
|
+
const raw = result?.uidMap?.get?.(uid) ?? result?.uidMap?.get?.(String(uid)) ?? null;
|
|
138
|
+
const newUid = raw === null ? null : Number(raw);
|
|
139
|
+
|
|
140
|
+
// Read-only mailboxes are opened for this, so it cannot mark anything seen.
|
|
141
|
+
const mirrored = newUid === null
|
|
142
|
+
? false
|
|
143
|
+
: await ingestMoved({pool, store, account, poolKey, connection, destination, uids: [newUid]});
|
|
144
|
+
|
|
145
|
+
return {moved: true, destination, newUid, mirrored};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Delete a message.
|
|
150
|
+
*
|
|
151
|
+
* Deleting mail means moving it to Trash, not destroying it - that is what
|
|
152
|
+
* every mail client means by the word, and what a user expects to be able to
|
|
153
|
+
* undo. Only when the server offers no Trash, or the message is already in it,
|
|
154
|
+
* does this fall back to the IMAP meaning: flag `\\Deleted` and expunge, which
|
|
155
|
+
* is irreversible.
|
|
156
|
+
*
|
|
157
|
+
* This is deliberately its own operation rather than a flag the generic
|
|
158
|
+
* endpoint may set. `\\Deleted` through a flags API would let a stray call
|
|
159
|
+
* destroy mail with no destination, no fallback and no name saying so.
|
|
160
|
+
*
|
|
161
|
+
* @param {{pool: object, store: object|null, account: object, poolKey: string,
|
|
162
|
+
* connection: object, folder: string, uid: number, trash?: string|null}} options
|
|
163
|
+
* @returns {Promise<{deleted: true, movedTo: string|null, expunged: boolean}>}
|
|
164
|
+
*/
|
|
165
|
+
export async function deleteMessage({
|
|
166
|
+
pool, store, account, poolKey, connection, folder, uid, trash = null
|
|
167
|
+
}) {
|
|
168
|
+
// Find Trash by SPECIAL-USE rather than by name: it is "Bin" on some
|
|
169
|
+
// servers and localised on others, and guessing the name is how mail ends
|
|
170
|
+
// up expunged on a server that had somewhere to put it.
|
|
171
|
+
let destination = trash;
|
|
172
|
+
if (destination === null) {
|
|
173
|
+
const boxes = await pool.withClient(poolKey, connection, client => client.list());
|
|
174
|
+
destination = boxes.find(box => box.specialUse === '\\Trash')?.path ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (destination && destination !== folder) {
|
|
178
|
+
const moved = await moveMessage({
|
|
179
|
+
pool, store, account, poolKey, connection, folder, uid, destination
|
|
180
|
+
});
|
|
181
|
+
return {deleted: true, movedTo: destination, newUid: moved.newUid, expunged: false, mirrored: moved.mirrored};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// No Trash, or already there: this is the irreversible path.
|
|
185
|
+
await pool.withMailbox(poolKey, connection, folder, async (client) => {
|
|
186
|
+
await client.messageFlagsAdd({uid: String(uid)}, ['\\Deleted'], {uid: true});
|
|
187
|
+
await client.messageDelete({uid: String(uid)}, {uid: true});
|
|
188
|
+
}, {writable: true});
|
|
189
|
+
|
|
190
|
+
if (store) await store.removeMessages(account.id, folder, [uid]);
|
|
191
|
+
return {deleted: true, movedTo: null, newUid: null, expunged: true};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Normalise a UID set for an IMAP command.
|
|
196
|
+
*
|
|
197
|
+
* IMAP takes a comma-separated set, so a hundred messages are one command
|
|
198
|
+
* rather than a hundred round trips. Values are coerced and validated here
|
|
199
|
+
* because they arrive from a request body.
|
|
200
|
+
*
|
|
201
|
+
* @param {unknown} uids
|
|
202
|
+
* @returns {{ok: true, uids: number[], set: string} | {ok: false, error: string}}
|
|
203
|
+
*/
|
|
204
|
+
export function normaliseUids(uids) {
|
|
205
|
+
if (!Array.isArray(uids) || !uids.length) {
|
|
206
|
+
return {ok: false, error: 'No messages were selected.'};
|
|
207
|
+
}
|
|
208
|
+
const clean = [...new Set(uids.map(Number))].filter(n => Number.isInteger(n) && n > 0);
|
|
209
|
+
if (!clean.length) return {ok: false, error: 'No valid message ids were given.'};
|
|
210
|
+
if (clean.length > 500) return {ok: false, error: 'Too many messages at once - select fewer than 500.'};
|
|
211
|
+
return {ok: true, uids: clean, set: clean.join(',')};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Add or remove flags across many messages at once.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} options - as setMessageFlags, but with `uids` and `set`
|
|
218
|
+
* @returns {Promise<{updated: {uid: number, flags: string[]}[]}>}
|
|
219
|
+
*/
|
|
220
|
+
export async function setFlagsBulk({
|
|
221
|
+
pool, store, account, poolKey, connection, folder, uids, set, add = [], remove = []
|
|
222
|
+
}) {
|
|
223
|
+
const updated = await pool.withMailbox(poolKey, connection, folder, async (client) => {
|
|
224
|
+
if (add.length) await client.messageFlagsAdd({uid: set}, add, {uid: true});
|
|
225
|
+
if (remove.length) await client.messageFlagsRemove({uid: set}, remove, {uid: true});
|
|
226
|
+
|
|
227
|
+
// Read back what the server now holds, for the same reason the single
|
|
228
|
+
// version does: another client may have changed something, and the
|
|
229
|
+
// mirror should hold what is true rather than what was asked for.
|
|
230
|
+
const rows = [];
|
|
231
|
+
for await (const message of client.fetch(set, {uid: true, flags: true}, {uid: true})) {
|
|
232
|
+
rows.push({uid: message.uid, flags: [...(message.flags ?? [])]});
|
|
233
|
+
}
|
|
234
|
+
return rows;
|
|
235
|
+
}, {writable: true});
|
|
236
|
+
|
|
237
|
+
if (store && updated.length) await store.setFlags(account.id, folder, updated);
|
|
238
|
+
return {updated};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Move many messages at once.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} options
|
|
245
|
+
* @returns {Promise<{moved: number, destination: string, uidMap: Object.<string, number>}>}
|
|
246
|
+
*/
|
|
247
|
+
export async function moveBulk({
|
|
248
|
+
pool, store, account, poolKey, connection, folder, uids, set, destination
|
|
249
|
+
}) {
|
|
250
|
+
const result = await pool.withMailbox(poolKey, connection, folder, async (client) => {
|
|
251
|
+
return client.messageMove({uid: set}, destination, {uid: true});
|
|
252
|
+
}, {writable: true});
|
|
253
|
+
|
|
254
|
+
if (store) await store.removeMessages(account.id, folder, uids);
|
|
255
|
+
|
|
256
|
+
// Carried so a bulk move can be undone the same way a single one is.
|
|
257
|
+
const uidMap = {};
|
|
258
|
+
if (result?.uidMap?.forEach) {
|
|
259
|
+
result.uidMap.forEach((to, from) => { uidMap[String(from)] = Number(to); });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const mirrored = await ingestMoved({
|
|
263
|
+
pool, store, account, poolKey, connection, destination,
|
|
264
|
+
uids: Object.values(uidMap)
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return {moved: uids.length, destination, uidMap, mirrored};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Delete many messages at once.
|
|
272
|
+
*
|
|
273
|
+
* Same meaning as the single version: move to Trash where there is one, and
|
|
274
|
+
* only expunge where there is not.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} options
|
|
277
|
+
* @returns {Promise<{deleted: number, movedTo: string|null, uidMap: object, expunged: boolean}>}
|
|
278
|
+
*/
|
|
279
|
+
export async function deleteBulk({
|
|
280
|
+
pool, store, account, poolKey, connection, folder, uids, set
|
|
281
|
+
}) {
|
|
282
|
+
const boxes = await pool.withClient(poolKey, connection, client => client.list());
|
|
283
|
+
const trash = boxes.find(box => box.specialUse === '\\Trash')?.path ?? null;
|
|
284
|
+
|
|
285
|
+
if (trash && trash !== folder) {
|
|
286
|
+
const moved = await moveBulk({
|
|
287
|
+
pool, store, account, poolKey, connection, folder, uids, set, destination: trash
|
|
288
|
+
});
|
|
289
|
+
return {deleted: uids.length, movedTo: trash, uidMap: moved.uidMap, expunged: false, mirrored: moved.mirrored};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await pool.withMailbox(poolKey, connection, folder, async (client) => {
|
|
293
|
+
await client.messageFlagsAdd({uid: set}, ['\\Deleted'], {uid: true});
|
|
294
|
+
await client.messageDelete({uid: set}, {uid: true});
|
|
295
|
+
}, {writable: true});
|
|
296
|
+
|
|
297
|
+
if (store) await store.removeMessages(account.id, folder, uids);
|
|
298
|
+
return {deleted: uids.length, movedTo: null, uidMap: {}, expunged: true};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The keywords a server-side spam learner watches.
|
|
303
|
+
*
|
|
304
|
+
* These are IMAP keywords, not system flags, so a server may refuse them
|
|
305
|
+
* outright - which is why setting them is best-effort below rather than a
|
|
306
|
+
* precondition for the move.
|
|
307
|
+
*/
|
|
308
|
+
export const JUNK_KEYWORD = '$Junk';
|
|
309
|
+
export const NOT_JUNK_KEYWORD = '$NotJunk';
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Decide what marking a message as spam (or not) should actually do.
|
|
313
|
+
*
|
|
314
|
+
* Pure, so the interesting part - which is the folder arithmetic, not the
|
|
315
|
+
* IMAP - can be tested without a server.
|
|
316
|
+
*
|
|
317
|
+
* Marking as spam moves to Junk. Marking as NOT spam only moves when the
|
|
318
|
+
* message is sitting in Junk: saying "this isn't spam" about something already
|
|
319
|
+
* filed in a project folder is a correction to the learner, not a request to
|
|
320
|
+
* have it yanked back to the Inbox.
|
|
321
|
+
*
|
|
322
|
+
* @param {{spam: boolean, folder: string, junkFolder: string|null,
|
|
323
|
+
* inboxFolder?: string}} options
|
|
324
|
+
* @returns {{destination: string|null, add: string[], remove: string[]}}
|
|
325
|
+
*/
|
|
326
|
+
export function planSpamMove({spam, folder, junkFolder, inboxFolder = 'INBOX'}) {
|
|
327
|
+
const inJunk = Boolean(junkFolder) && folder === junkFolder;
|
|
328
|
+
|
|
329
|
+
if (spam) {
|
|
330
|
+
return {
|
|
331
|
+
destination: junkFolder && !inJunk ? junkFolder : null,
|
|
332
|
+
add: [JUNK_KEYWORD],
|
|
333
|
+
remove: [NOT_JUNK_KEYWORD]
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
destination: inJunk && inboxFolder !== folder ? inboxFolder : null,
|
|
339
|
+
add: [NOT_JUNK_KEYWORD],
|
|
340
|
+
remove: [JUNK_KEYWORD]
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Where this server keeps junk mail, by SPECIAL-USE.
|
|
346
|
+
*
|
|
347
|
+
* Never by name, for the reason Trash is not found by name either: it is
|
|
348
|
+
* "Spam" on one server, "Junk E-mail" on the next, and localised on the one
|
|
349
|
+
* after that.
|
|
350
|
+
*
|
|
351
|
+
* @param {object} pool
|
|
352
|
+
* @param {string} poolKey
|
|
353
|
+
* @param {object} connection
|
|
354
|
+
* @returns {Promise<string|null>}
|
|
355
|
+
*/
|
|
356
|
+
export async function findJunkFolder(pool, poolKey, connection) {
|
|
357
|
+
const boxes = await pool.withClient(poolKey, connection, client => client.list());
|
|
358
|
+
return boxes.find(box => box.specialUse === '\\Junk')?.path ?? null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Apply the keywords, tolerating a server that will not have them.
|
|
363
|
+
*
|
|
364
|
+
* A server without PERMANENTFLAGS for arbitrary keywords rejects these, and
|
|
365
|
+
* that must not cost the move - the folder is what the person asked for, the
|
|
366
|
+
* keyword is a hint to the filter. Reported honestly either way.
|
|
367
|
+
*
|
|
368
|
+
* @param {object} options
|
|
369
|
+
* @returns {Promise<{ok: boolean, rows: {uid: number, flags: string[]}[]}>}
|
|
370
|
+
*/
|
|
371
|
+
async function applyJunkKeywords({pool, poolKey, connection, folder, set, add, remove}) {
|
|
372
|
+
try {
|
|
373
|
+
const rows = await pool.withMailbox(poolKey, connection, folder, async (client) => {
|
|
374
|
+
if (add.length) await client.messageFlagsAdd({uid: set}, add, {uid: true});
|
|
375
|
+
if (remove.length) await client.messageFlagsRemove({uid: set}, remove, {uid: true});
|
|
376
|
+
|
|
377
|
+
// Read back rather than assume, exactly as setMessageFlags does:
|
|
378
|
+
// a server may have refused the keyword, and the caller has to be
|
|
379
|
+
// able to tell that from having applied it.
|
|
380
|
+
const out = [];
|
|
381
|
+
for await (const message of client.fetch(set, {uid: true, flags: true}, {uid: true})) {
|
|
382
|
+
out.push({uid: message.uid, flags: [...(message.flags ?? [])]});
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}, {writable: true});
|
|
386
|
+
return {ok: true, rows};
|
|
387
|
+
} catch {
|
|
388
|
+
return {ok: false, rows: []};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Mark one message as spam, or as not spam.
|
|
394
|
+
*
|
|
395
|
+
* Keywords first, move second: after the move the message has a new UID in
|
|
396
|
+
* another folder, and the one held here no longer addresses it.
|
|
397
|
+
*
|
|
398
|
+
* @param {{pool: object, store: object|null, account: object, poolKey: string,
|
|
399
|
+
* connection: object, folder: string, uid: number, spam: boolean,
|
|
400
|
+
* junkFolder?: string|null, inboxFolder?: string}} options
|
|
401
|
+
* @returns {Promise<{spam: boolean, movedTo: string|null, newUid: number|null,
|
|
402
|
+
* keywords: boolean}>}
|
|
403
|
+
*/
|
|
404
|
+
export async function setSpam({
|
|
405
|
+
pool, store, account, poolKey, connection, folder, uid,
|
|
406
|
+
spam, junkFolder = null, inboxFolder = 'INBOX'
|
|
407
|
+
}) {
|
|
408
|
+
const junk = junkFolder ?? await findJunkFolder(pool, poolKey, connection);
|
|
409
|
+
const plan = planSpamMove({spam, folder, junkFolder: junk, inboxFolder});
|
|
410
|
+
|
|
411
|
+
const applied = await applyJunkKeywords({
|
|
412
|
+
pool, poolKey, connection, folder, set: String(uid),
|
|
413
|
+
add: plan.add, remove: plan.remove
|
|
414
|
+
});
|
|
415
|
+
const keywords = applied.ok;
|
|
416
|
+
|
|
417
|
+
if (!plan.destination) {
|
|
418
|
+
// Nothing moves, so the mirror keeps the row - and it has to carry the
|
|
419
|
+
// new keyword, or the menu goes on offering the action just taken.
|
|
420
|
+
if (store && applied.rows.length) {
|
|
421
|
+
await store.setFlags(account.id, folder, applied.rows);
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
spam, movedTo: null, newUid: null, keywords,
|
|
425
|
+
flags: applied.rows[0]?.flags ?? null, mirrored: false
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const moved = await moveMessage({
|
|
430
|
+
pool, store, account, poolKey, connection, folder, uid,
|
|
431
|
+
destination: plan.destination
|
|
432
|
+
});
|
|
433
|
+
return {spam, movedTo: plan.destination, newUid: moved.newUid, keywords, mirrored: moved.mirrored};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Mark many messages as spam, or as not spam.
|
|
438
|
+
*
|
|
439
|
+
* @param {object} options - as setSpam, but with `uids` and `set`
|
|
440
|
+
* @returns {Promise<{spam: boolean, count: number, movedTo: string|null,
|
|
441
|
+
* uidMap: object, keywords: boolean}>}
|
|
442
|
+
*/
|
|
443
|
+
export async function setSpamBulk({
|
|
444
|
+
pool, store, account, poolKey, connection, folder, uids, set,
|
|
445
|
+
spam, junkFolder = null, inboxFolder = 'INBOX'
|
|
446
|
+
}) {
|
|
447
|
+
const junk = junkFolder ?? await findJunkFolder(pool, poolKey, connection);
|
|
448
|
+
const plan = planSpamMove({spam, folder, junkFolder: junk, inboxFolder});
|
|
449
|
+
|
|
450
|
+
const applied = await applyJunkKeywords({
|
|
451
|
+
pool, poolKey, connection, folder, set, add: plan.add, remove: plan.remove
|
|
452
|
+
});
|
|
453
|
+
const keywords = applied.ok;
|
|
454
|
+
|
|
455
|
+
if (!plan.destination) {
|
|
456
|
+
if (store && applied.rows.length) {
|
|
457
|
+
await store.setFlags(account.id, folder, applied.rows);
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
spam, count: uids.length, movedTo: null, uidMap: {}, keywords,
|
|
461
|
+
updated: applied.rows, mirrored: false
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const moved = await moveBulk({
|
|
466
|
+
pool, store, account, poolKey, connection, folder, uids, set,
|
|
467
|
+
destination: plan.destination
|
|
468
|
+
});
|
|
469
|
+
return {
|
|
470
|
+
spam, count: uids.length, movedTo: plan.destination,
|
|
471
|
+
uidMap: moved.uidMap, keywords, mirrored: moved.mirrored
|
|
472
|
+
};
|
|
473
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping an installed collection's schema in step with the one a plugin ships.
|
|
3
|
+
*
|
|
4
|
+
* `setupPlugin()` creates a plugin's collections on first enable and then never
|
|
5
|
+
* looks at them again - `if (!existing) createCollection(...)`. So a field added
|
|
6
|
+
* to a shipped schema reaches a FRESH install and nowhere else: every existing
|
|
7
|
+
* site keeps the field list it was created with. The values still store and read
|
|
8
|
+
* back, because entries are plain objects, but nothing that reads the schema -
|
|
9
|
+
* the collection editor, the entries screen, `required` validation - knows the
|
|
10
|
+
* field is there.
|
|
11
|
+
*
|
|
12
|
+
* This closes that gap, and only ever ADDS. It never removes, renames or
|
|
13
|
+
* reorders, because a site may have edited its own schema and a plugin update
|
|
14
|
+
* is no reason to throw that away.
|
|
15
|
+
*
|
|
16
|
+
* @module _lib/schemaSync
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'fs/promises';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Add any field the shipped schema declares that the installed one lacks.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} slug installed collection slug
|
|
24
|
+
* @param {string} shippedSchemaPath absolute path to the plugin's schema.json
|
|
25
|
+
* @param {object} services `{getCollection, updateCollection}`
|
|
26
|
+
* @returns {Promise<string[]>} the names of the fields that were added
|
|
27
|
+
*/
|
|
28
|
+
export async function ensureSchemaFields(slug, shippedSchemaPath, services) {
|
|
29
|
+
const { getCollection, updateCollection } = services;
|
|
30
|
+
|
|
31
|
+
const installed = await getCollection(slug).catch(() => null);
|
|
32
|
+
if (!installed) return []; // not installed yet; setupPlugin will create it
|
|
33
|
+
|
|
34
|
+
let shipped;
|
|
35
|
+
try {
|
|
36
|
+
shipped = JSON.parse(await fs.readFile(shippedSchemaPath, 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return []; // no shipped schema to compare against
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const have = new Set((installed.fields ?? []).map(f => f.name));
|
|
42
|
+
const missing = (shipped.fields ?? []).filter(f => f.name && !have.has(f.name));
|
|
43
|
+
if (!missing.length) return [];
|
|
44
|
+
|
|
45
|
+
await updateCollection(slug, { fields: [...(installed.fields ?? []), ...missing] });
|
|
46
|
+
return missing.map(f => f.name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The same, for every collection a plugin ships.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} collectionsDir the plugin's `collections/` directory
|
|
53
|
+
* @param {object} services `{getCollection, updateCollection}`
|
|
54
|
+
* @param {object} [log] anything with `.info`; console by default
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
57
|
+
export async function syncPluginSchemas(collectionsDir, services, log = console) {
|
|
58
|
+
let dirs;
|
|
59
|
+
try {
|
|
60
|
+
dirs = await fs.readdir(collectionsDir, { withFileTypes: true });
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const dir of dirs.filter(d => d.isDirectory())) {
|
|
66
|
+
const schemaPath = `${collectionsDir}/${dir.name}/schema.json`;
|
|
67
|
+
let slug;
|
|
68
|
+
try {
|
|
69
|
+
slug = JSON.parse(await fs.readFile(schemaPath, 'utf8')).slug;
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!slug) continue;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const added = await ensureSchemaFields(slug, schemaPath, services);
|
|
77
|
+
if (added.length) log.info?.(`[schemaSync] ${slug}: added ${added.join(', ')}`);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// A schema that will not update is not a reason to refuse to boot.
|
|
80
|
+
log.warn?.(`[schemaSync] ${slug}: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
File without changes
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
<div class="view-header
|
|
1
|
+
<div class="view-header">
|
|
2
2
|
<div>
|
|
3
|
-
<h1
|
|
4
|
-
<p class="
|
|
3
|
+
<h1>PLUGIN_DISPLAY_NAME</h1>
|
|
4
|
+
<p class="text-muted text-sm mb-0">Manage PLUGIN_DISPLAY_NAME.</p>
|
|
5
5
|
</div>
|
|
6
|
-
<div class="
|
|
6
|
+
<div class="view-header-actions">
|
|
7
7
|
<button id="btn-new-item" class="btn btn-primary">
|
|
8
8
|
<span data-icon="plus"></span> New Item
|
|
9
9
|
</button>
|