domma-cms 0.92.1 → 0.94.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 +5 -3
- package/admin/css/admin.css +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/action-editor-arrange.js +1 -1
- package/admin/js/lib/api-tokens-arrange.js +2 -2
- package/admin/js/lib/block-editor-arrange.js +1 -1
- package/admin/js/lib/blocks-arrange.js +1 -1
- package/admin/js/lib/collection-entries-arrange.js +1 -1
- package/admin/js/lib/components-arrange.js +1 -1
- package/admin/js/lib/dashboard-arrange.js +1 -1
- package/admin/js/lib/dates.js +1 -0
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/media-arrange.js +1 -1
- package/admin/js/lib/notifications-arrange.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/related.js +1 -1
- package/admin/js/lib/timeline-builder.js +2 -2
- package/admin/js/templates/action-editor.html +6 -5
- package/admin/js/templates/actions-list.html +1 -1
- package/admin/js/templates/contacts.html +1 -1
- package/admin/js/templates/docs/api-actions.html +86 -60
- package/admin/js/templates/docs/api-authentication.html +159 -123
- package/admin/js/templates/docs/api-builder.html +197 -0
- package/admin/js/templates/docs/api-collections.html +199 -259
- package/admin/js/templates/docs/api-external.html +225 -0
- package/admin/js/templates/docs/api-forms.html +268 -0
- package/admin/js/templates/docs/api-layouts.html +70 -45
- package/admin/js/templates/docs/api-media.html +57 -80
- package/admin/js/templates/docs/api-navigation.html +66 -22
- package/admin/js/templates/docs/api-pages.html +109 -129
- package/admin/js/templates/docs/api-plugins.html +123 -61
- package/admin/js/templates/docs/api-scaffold.html +185 -0
- package/admin/js/templates/docs/api-settings.html +72 -64
- package/admin/js/templates/docs/api-users.html +74 -107
- package/admin/js/templates/docs/api-views.html +68 -54
- package/admin/js/templates/docs/components-howto.html +20 -17
- package/admin/js/templates/docs/components-reference.html +13 -16
- package/admin/js/templates/docs/components-rules.html +7 -6
- package/admin/js/templates/docs/components-walkthrough.html +19 -19
- package/admin/js/templates/docs/tutorial-crud.html +71 -40
- package/admin/js/templates/docs/tutorial-forms.html +51 -35
- package/admin/js/templates/docs/tutorial-plugin.html +132 -56
- package/admin/js/templates/docs/usage-actions.html +61 -15
- package/admin/js/templates/docs/usage-collections.html +108 -0
- package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
- package/admin/js/templates/docs/usage-dconfig.html +0 -3
- package/admin/js/templates/docs/usage-editions.html +213 -0
- package/admin/js/templates/docs/usage-media.html +22 -6
- package/admin/js/templates/docs/usage-navigation.html +74 -18
- package/admin/js/templates/docs/usage-pages.html +60 -20
- package/admin/js/templates/docs/usage-plugins.html +89 -17
- package/admin/js/templates/docs/usage-shortcodes.html +123 -70
- package/admin/js/templates/docs/usage-site-settings.html +50 -18
- package/admin/js/templates/docs/usage-tools.html +73 -0
- package/admin/js/templates/docs/usage-users-roles.html +99 -20
- package/admin/js/templates/docs/usage-views.html +36 -19
- package/admin/js/templates/documentation.html +153 -32
- package/admin/js/templates/page-editor.html +0 -5
- package/admin/js/templates/plugin-guide.html +15 -0
- package/admin/js/templates/plugin-guides.html +21 -0
- package/admin/js/templates/pro-docs.html +53 -234
- package/admin/js/templates/tutorials.html +5 -4
- package/admin/js/views/actions-list.js +3 -3
- package/admin/js/views/analytics.js +5 -5
- package/admin/js/views/api-endpoint-editor.js +2 -2
- package/admin/js/views/block-editor.js +4 -4
- package/admin/js/views/blocks.js +4 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collection-entries.js +7 -7
- package/admin/js/views/component-editor.js +2 -2
- package/admin/js/views/contacts.js +22 -20
- package/admin/js/views/context-menu-editor.js +5 -5
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/form-editor.js +4 -4
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/media.js +3 -3
- package/admin/js/views/menu-editor.js +13 -13
- package/admin/js/views/menu-locations.js +2 -2
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/page-editor.js +8 -8
- package/admin/js/views/plugin-guides.js +5 -0
- package/admin/js/views/project-detail.js +2 -2
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/role-editor.js +4 -4
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +17 -17
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +3 -3
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +2 -2
- package/admin/js/views/view-editor.js +1 -1
- package/bin/cli.js +13 -13
- package/bin/lib/node-version.js +29 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +3 -2
- package/plugins/_lib/admin/mail/reader-view.js +7 -6
- package/plugins/_lib/admin/mail/scheduling.js +4 -2
- package/plugins/_lib/admin/mail/templates.js +4 -4
- package/plugins/_lib/admin/ui/dates.js +85 -0
- package/plugins/blog/CLAUDE.md +31 -22
- package/plugins/blog/admin/views/blog.js +3 -2
- package/plugins/blog/admin/views/comments.js +2 -1
- package/plugins/blog/admin/views/post-editor.js +4 -4
- package/plugins/blog/blocks/blog-card-row.html +1 -1
- package/plugins/blog/blocks/blog-card.html +2 -2
- package/plugins/blog/blocks/blog-post-classic.html +2 -2
- package/plugins/blog/blocks/blog-post-essay.html +2 -2
- package/plugins/blog/blocks/blog-post-feature.html +2 -2
- package/plugins/blog/blocks/blog-post-minimal.html +2 -2
- package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
- package/plugins/blog/blocks/blog-post-split.html +2 -2
- package/plugins/blog/docs/guide.md +205 -0
- package/plugins/blog/lib/layouts.js +3 -3
- package/plugins/blog/lib/page.js +2 -1
- package/plugins/blog/plugin.js +3 -3
- package/plugins/blog/plugin.json +4 -4
- package/plugins/blog/tests/layouts.test.js +6 -0
- package/plugins/feedback/CLAUDE.md +22 -3
- package/plugins/feedback/admin/lib/kit.js +6 -7
- package/plugins/feedback/admin/views/feedback.js +79 -10
- package/plugins/feedback/admin/views/send.js +28 -6
- package/plugins/feedback/docs/guide.md +95 -0
- package/plugins/feedback/lib/receiver.js +9 -2
- package/plugins/feedback/lib/sender.js +3 -2
- package/plugins/feedback/plugin.js +54 -6
- package/plugins/feedback/plugin.json +4 -4
- package/plugins/feedback/tests/api.test.js +74 -2
- package/plugins/free-tier.lock.json +49 -44
- package/plugins/mail-reader/CLAUDE.md +33 -18
- package/plugins/mail-reader/docs/guide.md +147 -0
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/security/CLAUDE.md +4 -1
- package/plugins/security/admin/views/security.js +5 -5
- package/plugins/security/docs/guide.md +170 -0
- package/plugins/security/plugin.js +2 -1
- package/plugins/security/plugin.json +2 -1
- package/plugins/shopping-cart/CLAUDE.md +7 -1
- package/plugins/shopping-cart/admin/lib/kit.js +5 -2
- package/plugins/shopping-cart/admin/views/orders.js +4 -4
- package/plugins/shopping-cart/admin/views/overview.js +2 -2
- package/plugins/shopping-cart/docs/guide.md +191 -0
- package/plugins/shopping-cart/lib/render.js +2 -1
- package/plugins/shopping-cart/plugin.json +3 -3
- package/public/js/collection-browser.js +2 -2
- package/public/js/site.js +1 -1
- package/scripts/gen-instance-secret.js +3 -1
- package/scripts/setup.js +3 -1
- package/server/middleware/auth.js +2 -1
- package/server/routes/api/actions.js +47 -27
- package/server/routes/api/blocks.js +2 -1
- package/server/routes/api/collections.js +16 -52
- package/server/routes/api/contacts.js +66 -3
- package/server/routes/api/documentation.js +42 -0
- package/server/routes/api/notifications.js +3 -2
- package/server/routes/api/users.js +10 -6
- package/server/server.js +16 -1
- package/server/services/actions.js +110 -34
- package/server/services/adapterRegistry.js +169 -16
- package/server/services/adapters/FileAdapter.js +25 -0
- package/server/services/adapters/MongoAdapter.js +23 -0
- package/server/services/collections.js +104 -1
- package/server/services/connectionManager.js +12 -0
- package/server/services/dates.js +81 -0
- package/server/services/docs.js +13 -2
- package/server/services/markdown.js +75 -26
- package/server/services/notification-sources.js +6 -5
- package/server/services/passwordReset.js +2 -1
- package/server/services/permissionRegistry.js +3 -2
- package/server/services/pluginGuides.js +255 -0
- package/server/services/pluginInstaller.js +54 -13
- package/server/services/plugins.js +29 -1
- package/server/services/presetCollections.js +31 -5
- package/server/services/renderer.js +2 -2
- package/server/services/sidebarBadges.js +3 -1
- package/server/services/tools.js +4 -2
- package/server/templates/page.html +2 -2
|
@@ -4,18 +4,31 @@
|
|
|
4
4
|
* Resolves the correct storage adapter for a collection slug.
|
|
5
5
|
*
|
|
6
6
|
* Resolution order:
|
|
7
|
-
* 1.
|
|
8
|
-
* 2.
|
|
9
|
-
*
|
|
7
|
+
* 1. System presets (roles, user profiles, projects, ...) → always FileAdapter
|
|
8
|
+
* 2. User-data presets (Notes, Todo, Contacts) → MongoDB only when declared,
|
|
9
|
+
* the connection works, and the files hold none of its rows; else files
|
|
10
|
+
* (resolveUserDataPreset below)
|
|
11
|
+
* 3. schema.json `storage.adapter` field:
|
|
12
|
+
* - "mongodb" → MongoAdapter
|
|
10
13
|
* - "file" or absent → FileAdapter
|
|
11
14
|
*
|
|
12
15
|
* Adapters are cached per slug. Call `invalidate(slug)` after a schema update.
|
|
13
16
|
*/
|
|
14
17
|
import fileAdapter from './adapters/FileAdapter.js';
|
|
15
|
-
import {PRESET_COLLECTION_SLUGS} from './presetCollections.js';
|
|
18
|
+
import {PRESET_COLLECTION_SLUGS, USER_DATA_PRESET_SLUGS} from './presetCollections.js';
|
|
19
|
+
|
|
20
|
+
/** People's own data (Notes, Todo, Contacts): MongoDB when the site can have it. */
|
|
21
|
+
const USER_DATA_SLUGS = new Set(USER_DATA_PRESET_SLUGS);
|
|
16
22
|
|
|
17
23
|
/** Collections that must always use file-based storage (system presets). */
|
|
18
|
-
const PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
|
|
24
|
+
const PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS.filter(s => !USER_DATA_SLUGS.has(s))]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* User-data presets left on files because both stores hold rows, or the files
|
|
28
|
+
* hold rows the declared MongoDB does not: slug -> {declared, used, fileRows, mongoRows, reason}.
|
|
29
|
+
* Read by getStorageConflicts(); logged once per resolution.
|
|
30
|
+
*/
|
|
31
|
+
const conflicts = new Map();
|
|
19
32
|
|
|
20
33
|
/** Adapter instance cache, keyed by slug. */
|
|
21
34
|
const cache = new Map();
|
|
@@ -30,12 +43,23 @@ export async function getAdapter(slug) {
|
|
|
30
43
|
if (PRESET_SLUGS.has(slug)) return fileAdapter;
|
|
31
44
|
if (cache.has(slug)) return cache.get(slug);
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
const adapter = await resolveAdapter(slug);
|
|
46
|
+
const adapter = USER_DATA_SLUGS.has(slug) ? await resolveUserDataPreset(slug) : await resolveAdapter(slug);
|
|
35
47
|
cache.set(slug, adapter);
|
|
36
48
|
return adapter;
|
|
37
49
|
}
|
|
38
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Whether a collection's storage is fixed to files whatever its schema says
|
|
53
|
+
* (the system presets). A storage move is refused for these. The user-data
|
|
54
|
+
* presets are not fixed: a move is how a site settles rows left in both stores.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} slug
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function isFixedToFile(slug) {
|
|
60
|
+
return PRESET_SLUGS.has(slug);
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
/**
|
|
40
64
|
* Invalidate the cached adapter for a slug.
|
|
41
65
|
* Call this after updating a collection's schema (e.g. changing storage config).
|
|
@@ -44,18 +68,75 @@ export async function getAdapter(slug) {
|
|
|
44
68
|
*/
|
|
45
69
|
export function invalidate(slug) {
|
|
46
70
|
cache.delete(slug);
|
|
71
|
+
conflicts.delete(slug);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The user-data presets kept on files although they declare MongoDB (or the
|
|
76
|
+
* other way round), with row counts - for a status screen or a log reader.
|
|
77
|
+
*
|
|
78
|
+
* @returns {Record<string, {declared: string, used: string, fileRows: number, mongoRows: number, reason: string}>}
|
|
79
|
+
*/
|
|
80
|
+
export function getStorageConflicts() {
|
|
81
|
+
return Object.fromEntries(conflicts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where an adapter keeps its entries, as a storage block - `{adapter: 'file'}`
|
|
86
|
+
* or `{adapter: 'mongodb', connection}`. Describes the adapter actually in use,
|
|
87
|
+
* which is not always what schema.json declares (a failed connection falls
|
|
88
|
+
* back to files).
|
|
89
|
+
*
|
|
90
|
+
* @param {object} adapter
|
|
91
|
+
* @returns {{adapter: string, connection?: string}}
|
|
92
|
+
*/
|
|
93
|
+
export function describeAdapter(adapter) {
|
|
94
|
+
if (adapter === fileAdapter || adapter?.constructor?.name === 'FileAdapter') return {adapter: 'file'};
|
|
95
|
+
return {adapter: 'mongodb', connection: adapter?.connection || 'default'};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The adapter for a storage block, with NO fallback: a MongoDB connection that
|
|
100
|
+
* is not configured or not connected throws. For moving entries between
|
|
101
|
+
* stores, where "fell back to files" would copy them into the very place they
|
|
102
|
+
* came from.
|
|
103
|
+
*
|
|
104
|
+
* @param {{adapter?: string, connection?: string}} storage
|
|
105
|
+
* @returns {Promise<object>}
|
|
106
|
+
* @throws {Error} If the MongoDB connection is not available
|
|
107
|
+
*/
|
|
108
|
+
export async function adapterForStorage(storage) {
|
|
109
|
+
if ((storage?.adapter || 'file') !== 'mongodb') return fileAdapter;
|
|
110
|
+
const connection = storage.connection || 'default';
|
|
111
|
+
const {getDb} = await import('./connectionManager.js');
|
|
112
|
+
let db;
|
|
113
|
+
try {
|
|
114
|
+
db = getDb(connection);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(`MongoDB connection "${connection}" is not available - check Settings > Connections`);
|
|
117
|
+
}
|
|
118
|
+
return mongoAdapterFor(db, connection);
|
|
47
119
|
}
|
|
48
120
|
|
|
49
121
|
// ---------------------------------------------------------------------------
|
|
50
122
|
// Internal
|
|
51
123
|
// ---------------------------------------------------------------------------
|
|
52
124
|
|
|
125
|
+
/** One MongoAdapter per Db, so its ensured-index memo is shared across slugs. */
|
|
126
|
+
const mongoAdapters = new WeakMap();
|
|
127
|
+
|
|
128
|
+
async function mongoAdapterFor(db, connection) {
|
|
129
|
+
if (mongoAdapters.has(db)) return mongoAdapters.get(db);
|
|
130
|
+
const {MongoAdapter} = await import('./adapters/MongoAdapter.js');
|
|
131
|
+
const adapter = new MongoAdapter(db);
|
|
132
|
+
adapter.connection = connection;
|
|
133
|
+
mongoAdapters.set(db, adapter);
|
|
134
|
+
return adapter;
|
|
135
|
+
}
|
|
136
|
+
|
|
53
137
|
/**
|
|
54
138
|
* Determine which adapter to use based on schema storage config.
|
|
55
139
|
*
|
|
56
|
-
* Phase 1: always returns FileAdapter.
|
|
57
|
-
* Phase 2+: conditionally returns MongoAdapter.
|
|
58
|
-
*
|
|
59
140
|
* @param {string} slug
|
|
60
141
|
* @returns {Promise<object>}
|
|
61
142
|
*/
|
|
@@ -76,6 +157,83 @@ async function resolveAdapter(slug) {
|
|
|
76
157
|
return fileAdapter;
|
|
77
158
|
}
|
|
78
159
|
|
|
160
|
+
/** Is a named connection in config/connections.json at all? (A free site has none.) */
|
|
161
|
+
async function isConfigured(connection) {
|
|
162
|
+
try {
|
|
163
|
+
const {getConfig} = await import('../config.js');
|
|
164
|
+
return Boolean(getConfig('connections')?.[connection]);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The store for a user-data preset (Notes, Todo, Contacts).
|
|
172
|
+
*
|
|
173
|
+
* MongoDB is a Pro feature, so the preset's MongoDB declaration is honoured
|
|
174
|
+
* only where the site has a working connection - a free site, with no
|
|
175
|
+
* connection, keeps its files without a word. And no site may switch to an
|
|
176
|
+
* empty store under its users: where the files already hold rows, the
|
|
177
|
+
* collection stays on files, even if MongoDB is empty; where both hold rows,
|
|
178
|
+
* it stays on files and the clash is logged with the counts, for someone to
|
|
179
|
+
* move one set across (the Storage move keeps ids). Only files with no rows
|
|
180
|
+
* give way to MongoDB. A declared-file preset stays on files, and MongoDB rows
|
|
181
|
+
* it cannot see are logged the same way.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} slug
|
|
184
|
+
* @returns {Promise<object>}
|
|
185
|
+
*/
|
|
186
|
+
async function resolveUserDataPreset(slug) {
|
|
187
|
+
let schema = null;
|
|
188
|
+
try {
|
|
189
|
+
const {getCollection} = await import('./collections.js');
|
|
190
|
+
schema = await getCollection(slug);
|
|
191
|
+
} catch { /* unreadable schema - files */ }
|
|
192
|
+
|
|
193
|
+
const declared = schema?.storage?.adapter === 'mongodb' ? 'mongodb' : 'file';
|
|
194
|
+
const connection = schema?.storage?.connection || 'default';
|
|
195
|
+
|
|
196
|
+
let mongo = null;
|
|
197
|
+
try {
|
|
198
|
+
mongo = await adapterForStorage({adapter: 'mongodb', connection});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// Declared, configured and down: say so, as for any collection. Not
|
|
201
|
+
// configured at all is a free site, and files are simply its store.
|
|
202
|
+
if (declared === 'mongodb' && await isConfigured(connection)) {
|
|
203
|
+
console.warn(`[adapterRegistry] "${slug}" declares MongoDB but connection "${connection}" is down: using files. ${err.message}`);
|
|
204
|
+
import('./notification-sources.js').then(m => m.storageDown(connection, err.message)).catch(() => {});
|
|
205
|
+
}
|
|
206
|
+
return fileAdapter;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let fileRows, mongoRows;
|
|
210
|
+
try {
|
|
211
|
+
fileRows = await fileAdapter.count(slug);
|
|
212
|
+
// Counted straight off the Db: the adapter's count() would create
|
|
213
|
+
// cms_<slug> and its index, a write, on a site that may stay on files.
|
|
214
|
+
const {getDb} = await import('./connectionManager.js');
|
|
215
|
+
mongoRows = await getDb(connection).collection(`cms_${slug}`).countDocuments({});
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (declared === 'mongodb') console.warn(`[adapterRegistry] "${slug}" declares MongoDB but it could not be read (${err.message}): using files.`);
|
|
218
|
+
return fileAdapter;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const report = (reason) => {
|
|
222
|
+
conflicts.set(slug, {declared, used: 'file', connection, fileRows, mongoRows, reason});
|
|
223
|
+
console.warn(`[adapterRegistry] "${slug}" stays on files: ${reason} (files: ${fileRows} rows, MongoDB "${connection}": ${mongoRows} rows). Move one set with the collection's Storage move to settle it.`);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (declared === 'file') {
|
|
227
|
+
if (mongoRows > 0) report('it declares file storage but MongoDB also holds rows');
|
|
228
|
+
return fileAdapter;
|
|
229
|
+
}
|
|
230
|
+
if (fileRows > 0) {
|
|
231
|
+
report(mongoRows > 0 ? 'both stores hold rows' : 'it declares MongoDB, which is empty, but its rows are in files');
|
|
232
|
+
return fileAdapter;
|
|
233
|
+
}
|
|
234
|
+
return mongo;
|
|
235
|
+
}
|
|
236
|
+
|
|
79
237
|
/**
|
|
80
238
|
* Resolve a MongoAdapter for a collection schema.
|
|
81
239
|
* Loaded dynamically so the mongodb package is never required in the free version.
|
|
@@ -85,12 +243,7 @@ async function resolveAdapter(slug) {
|
|
|
85
243
|
*/
|
|
86
244
|
async function resolveMongoAdapter(schema) {
|
|
87
245
|
try {
|
|
88
|
-
|
|
89
|
-
const { getDb } = await import('./connectionManager.js');
|
|
90
|
-
const db = getDb(connectionName);
|
|
91
|
-
|
|
92
|
-
const { MongoAdapter } = await import('./adapters/MongoAdapter.js');
|
|
93
|
-
return new MongoAdapter(db);
|
|
246
|
+
return await adapterForStorage(schema.storage);
|
|
94
247
|
} catch (err) {
|
|
95
248
|
console.warn(`[adapterRegistry] Failed to load MongoAdapter for "${schema.slug}": ${err.message}. Falling back to FileAdapter.`);
|
|
96
249
|
import('./notification-sources.js').then(m => m.storageDown(schema.storage?.connection || 'default', err.message)).catch(() => {});
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* insertMany(slug, entries) → { imported, skipped, errors }
|
|
17
17
|
* count(slug) → number
|
|
18
18
|
* replaceAll(slug, entries) → void (file only - one write for a whole-collection rewrite)
|
|
19
|
+
* archive(slug) → string|null (set the entries aside after a storage move)
|
|
19
20
|
*/
|
|
20
21
|
import fs from 'fs/promises';
|
|
21
22
|
import path from 'path';
|
|
@@ -220,6 +221,30 @@ export class FileAdapter {
|
|
|
220
221
|
async replaceAll(slug, entries) {
|
|
221
222
|
await write(slug, entries);
|
|
222
223
|
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Set the entries aside after they were moved to another store: data.json
|
|
227
|
+
* is renamed to data.json.bak (or a timestamped .bak when one is already
|
|
228
|
+
* there), so nothing is deleted and the collection reads empty here.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} slug
|
|
231
|
+
* @returns {Promise<string|null>} The backup's file name, or null if there was no data.json
|
|
232
|
+
*/
|
|
233
|
+
async archive(slug) {
|
|
234
|
+
const from = dataPath(slug);
|
|
235
|
+
let to = `${from}.bak`;
|
|
236
|
+
try {
|
|
237
|
+
await fs.access(to);
|
|
238
|
+
to = `${from}.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`;
|
|
239
|
+
} catch { /* no earlier backup - the plain name is free */ }
|
|
240
|
+
try {
|
|
241
|
+
await fs.rename(from, to);
|
|
242
|
+
return path.basename(to);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err.code === 'ENOENT') return null;
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
223
248
|
}
|
|
224
249
|
|
|
225
250
|
export default new FileAdapter();
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* all(slug) → entry[]
|
|
18
18
|
* insertMany(slug, entries) → { imported, skipped, errors }
|
|
19
19
|
* count(slug) → number
|
|
20
|
+
* archive(slug) → string|null (set the entries aside after a storage move)
|
|
20
21
|
*/
|
|
21
22
|
import { toMongoQuery } from '../filterEngine.js';
|
|
22
23
|
|
|
@@ -219,6 +220,28 @@ export class MongoAdapter {
|
|
|
219
220
|
this._ensured.delete(slug);
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Set the entries aside after they were moved to another store: the
|
|
225
|
+
* backing `cms_<slug>` collection is renamed to
|
|
226
|
+
* `cms_<slug>__moved_<timestamp>`, so nothing is deleted, the slug reads
|
|
227
|
+
* empty here, and a later move back into MongoDB does not collide with
|
|
228
|
+
* the old copies.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} slug
|
|
231
|
+
* @returns {Promise<string|null>} The archive's collection name, or null if there was nothing to archive
|
|
232
|
+
*/
|
|
233
|
+
async archive(slug) {
|
|
234
|
+
const to = `${PREFIX}${slug}__moved_${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
235
|
+
try {
|
|
236
|
+
await this._db.collection(`${PREFIX}${slug}`).rename(to);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err.codeName === 'NamespaceNotFound') return null;
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
this._ensured.delete(slug);
|
|
242
|
+
return to;
|
|
243
|
+
}
|
|
244
|
+
|
|
222
245
|
/**
|
|
223
246
|
* Return all entries (used for export).
|
|
224
247
|
*
|
|
@@ -10,7 +10,7 @@ import fs from 'fs/promises';
|
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import {v4 as uuidv4} from 'uuid';
|
|
12
12
|
import {config} from '../config.js';
|
|
13
|
-
import {getAdapter, invalidate} from './adapterRegistry.js';
|
|
13
|
+
import {adapterForStorage, describeAdapter, getAdapter, invalidate, isFixedToFile} from './adapterRegistry.js';
|
|
14
14
|
import * as cache from './cache/index.js';
|
|
15
15
|
import {typedEntryData} from './entryValues.js';
|
|
16
16
|
import {toCsv} from '../../public/js/collection-export.mjs';
|
|
@@ -481,6 +481,101 @@ export async function clearEntries(slug) {
|
|
|
481
481
|
return result;
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// Storage migration
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
/** An error carrying the HTTP status the route should answer with. */
|
|
489
|
+
function statusError(statusCode, message, extra = {}) {
|
|
490
|
+
return Object.assign(new Error(message), {statusCode}, extra);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Move every entry of a collection to another store (file <-> MongoDB, or
|
|
495
|
+
* between MongoDB connections) and point the schema at it.
|
|
496
|
+
*
|
|
497
|
+
* Entries are copied VERBATIM - the same id, data and meta (createdAt,
|
|
498
|
+
* updatedAt, createdBy, source, ...) - so references, links and row
|
|
499
|
+
* ownership keep working. It is a move, not a re-entry: nothing is
|
|
500
|
+
* re-validated, because an entry that no longer passes validation must not
|
|
501
|
+
* be the one that is lost.
|
|
502
|
+
*
|
|
503
|
+
* Refuses (and changes nothing) when the target already holds an entry with
|
|
504
|
+
* one of the ids being moved, or when the source holds the same id twice.
|
|
505
|
+
* The old copy is set aside with the adapter's archive() - data.json becomes
|
|
506
|
+
* data.json.bak, a Mongo collection is renamed - never deleted.
|
|
507
|
+
*
|
|
508
|
+
* @param {string} slug
|
|
509
|
+
* @param {{adapter: string, connection?: string}} storage - the target
|
|
510
|
+
* @returns {Promise<{migrated: number, total: number, archived: string|null}>}
|
|
511
|
+
* @throws {Error} with `statusCode` 400/404/409/503
|
|
512
|
+
*/
|
|
513
|
+
export async function migrateStorage(slug, storage) {
|
|
514
|
+
if (!storage?.adapter) throw statusError(400, 'storage.adapter is required');
|
|
515
|
+
if (!['file', 'mongodb'].includes(storage.adapter)) throw statusError(400, `Unknown storage adapter "${storage.adapter}"`);
|
|
516
|
+
const target = storage.adapter === 'mongodb'
|
|
517
|
+
? {adapter: 'mongodb', connection: storage.connection || 'default'}
|
|
518
|
+
: {adapter: 'file'};
|
|
519
|
+
|
|
520
|
+
const schema = await getCollection(slug);
|
|
521
|
+
if (!schema) throw statusError(404, 'Collection not found');
|
|
522
|
+
if (isFixedToFile(slug)) throw statusError(400, `"${slug}" is a system collection and always uses file storage`);
|
|
523
|
+
|
|
524
|
+
// The source is the store in use, which is not always the declared one
|
|
525
|
+
// (a MongoDB connection that failed falls back to files).
|
|
526
|
+
const sourceAdapter = await getAdapter(slug);
|
|
527
|
+
const source = describeAdapter(sourceAdapter);
|
|
528
|
+
if (source.adapter === target.adapter && source.connection === target.connection) {
|
|
529
|
+
throw statusError(400, 'Source and target storage are the same');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let targetAdapter;
|
|
533
|
+
try {
|
|
534
|
+
targetAdapter = await adapterForStorage(target);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
throw statusError(503, err.message);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const entries = await sourceAdapter.all(slug);
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
if (!entry.id) entry.id = uuidv4(); // a hand-written row with no id - give it one now
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const seen = new Set();
|
|
545
|
+
const repeated = new Set();
|
|
546
|
+
for (const {id} of entries) (seen.has(id) ? repeated : seen).add(id);
|
|
547
|
+
if (repeated.size) {
|
|
548
|
+
throw statusError(409, `The current storage holds ${repeated.size} id${repeated.size === 1 ? '' : 's'} more than once (${[...repeated].slice(0, 5).join(', ')}) - fix those entries before moving`, {collisions: [...repeated]});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const existing = await targetAdapter.all(slug);
|
|
552
|
+
const clashes = existing.map(e => e.id).filter(id => seen.has(id));
|
|
553
|
+
if (clashes.length) {
|
|
554
|
+
throw statusError(409, `The target storage already holds ${clashes.length} of these entries (same id${clashes.length === 1 ? '' : 's'}: ${clashes.slice(0, 5).join(', ')}${clashes.length > 5 ? ', ...' : ''}) - probably left from an earlier move. Nothing was moved; clear or remove them there first.`, {collisions: clashes});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (entries.length) {
|
|
558
|
+
try {
|
|
559
|
+
await targetAdapter.insertMany(slug, entries);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
// Take back whatever did land, so a retry is not refused as a collision.
|
|
562
|
+
for (const {id} of entries) await targetAdapter.remove(slug, id).catch(() => {});
|
|
563
|
+
throw statusError(500, `Moving the entries failed, nothing was changed: ${err.message}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
await updateCollection(slug, {...schema, storage: target});
|
|
568
|
+
|
|
569
|
+
let archived = null;
|
|
570
|
+
try {
|
|
571
|
+
archived = await sourceAdapter.archive?.(slug) ?? null;
|
|
572
|
+
} catch (err) {
|
|
573
|
+
console.warn(`[collections] migrateStorage("${slug}"): entries moved but the old copy was not set aside: ${err.message}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {migrated: entries.length, total: entries.length, archived};
|
|
577
|
+
}
|
|
578
|
+
|
|
484
579
|
// ---------------------------------------------------------------------------
|
|
485
580
|
// Import / Export
|
|
486
581
|
// ---------------------------------------------------------------------------
|
|
@@ -542,6 +637,14 @@ export async function importEntries(slug, incoming, { createdBy = null } = {}) {
|
|
|
542
637
|
errors.push(valErrors.join('; '));
|
|
543
638
|
continue;
|
|
544
639
|
}
|
|
640
|
+
// The same reference check as createEntry/updateEntry - an import must
|
|
641
|
+
// not be the one way to store a reference to an entry that is not there.
|
|
642
|
+
const refErrors = await validateReferences(schema, data);
|
|
643
|
+
if (refErrors.length) {
|
|
644
|
+
skipped++;
|
|
645
|
+
errors.push(refErrors.join('; '));
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
545
648
|
valid.push({
|
|
546
649
|
id: uuidv4(),
|
|
547
650
|
data,
|
|
@@ -101,3 +101,15 @@ export async function shutdown() {
|
|
|
101
101
|
await Promise.allSettled(closePromises);
|
|
102
102
|
clients.clear();
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Tests only: register (or, with `db` null, remove) a named connection without
|
|
107
|
+
* a real MongoClient, so adapter resolution can run against an in-memory Db.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} name
|
|
110
|
+
* @param {object|null} db
|
|
111
|
+
*/
|
|
112
|
+
export function _setDb(name, db) {
|
|
113
|
+
if (db) clients.set(name, {client: {close: async () => {}}, db});
|
|
114
|
+
else clients.delete(name);
|
|
115
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates as the server writes them for a person - emails, notifications,
|
|
3
|
+
* sidebar badge popovers, rendered pages: dd/mm/yyyy, and dd/mm/yyyy HH:mm
|
|
4
|
+
* where a time matters. The admin's twin is admin/js/lib/dates.js.
|
|
5
|
+
*
|
|
6
|
+
* Display only. Stored values, API payloads, sitemap/RSS/iCal/JSON-LD,
|
|
7
|
+
* HTTP headers, filenames, logs and sort keys stay ISO.
|
|
8
|
+
*
|
|
9
|
+
* Times are the server's local time unless `{utc: true}` is passed - then
|
|
10
|
+
* the caller says "UTC" beside it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
14
|
+
|
|
15
|
+
/** A bare calendar day. Parsed as a LOCAL day: `new Date('2026-09-21')` is UTC midnight. */
|
|
16
|
+
const DAY_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A Date from whatever the API sent - ISO string, bare 'YYYY-MM-DD', epoch ms
|
|
20
|
+
* or a Date. Null for empty or unparseable input.
|
|
21
|
+
*
|
|
22
|
+
* @param {string|number|Date|null|undefined} value
|
|
23
|
+
* @returns {Date|null}
|
|
24
|
+
*/
|
|
25
|
+
export function toDate(value) {
|
|
26
|
+
if (value == null || value === '') return null;
|
|
27
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
28
|
+
if (typeof value === 'string') {
|
|
29
|
+
const m = DAY_ONLY.exec(value.trim());
|
|
30
|
+
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
31
|
+
}
|
|
32
|
+
const d = new Date(value);
|
|
33
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isDayOnly = (value) => typeof value === 'string' && DAY_ONLY.test(value.trim());
|
|
37
|
+
|
|
38
|
+
/** Empty for nothing; the raw text for something that is not a date, so it is never silently lost. */
|
|
39
|
+
const unparsed = (value) => (value == null ? '' : String(value));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 'dd/mm/yyyy'.
|
|
43
|
+
*
|
|
44
|
+
* @param {string|number|Date|null|undefined} value
|
|
45
|
+
* @param {{utc?: boolean}} [opts]
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function fmtDate(value, {utc = false} = {}) {
|
|
49
|
+
const d = toDate(value);
|
|
50
|
+
if (!d) return unparsed(value);
|
|
51
|
+
if (utc && !isDayOnly(value)) return `${pad(d.getUTCDate())}/${pad(d.getUTCMonth() + 1)}/${d.getUTCFullYear()}`;
|
|
52
|
+
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 'HH:mm' (24-hour).
|
|
57
|
+
*
|
|
58
|
+
* @param {string|number|Date|null|undefined} value
|
|
59
|
+
* @param {{utc?: boolean}} [opts]
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function fmtTime(value, {utc = false} = {}) {
|
|
63
|
+
const d = toDate(value);
|
|
64
|
+
if (!d) return unparsed(value);
|
|
65
|
+
if (utc) return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
|
66
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 'dd/mm/yyyy HH:mm'. A bare 'YYYY-MM-DD' has no time to show, so it gets the date alone.
|
|
71
|
+
*
|
|
72
|
+
* @param {string|number|Date|null|undefined} value
|
|
73
|
+
* @param {{utc?: boolean}} [opts]
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function fmtDateTime(value, {utc = false} = {}) {
|
|
77
|
+
if (isDayOnly(value)) return fmtDate(value);
|
|
78
|
+
const d = toDate(value);
|
|
79
|
+
if (!d) return unparsed(value);
|
|
80
|
+
return `${fmtDate(d, {utc})} ${fmtTime(d, {utc})}`;
|
|
81
|
+
}
|
package/server/services/docs.js
CHANGED
|
@@ -55,6 +55,9 @@ const MAP = [
|
|
|
55
55
|
['usage-views', 'usage/views', 'Views'],
|
|
56
56
|
['usage-actions', 'usage/actions', 'Actions'],
|
|
57
57
|
['usage-cta-shortcode', 'usage/cta-shortcode', 'CTA Shortcode'],
|
|
58
|
+
['usage-editions', 'usage/editions', 'Editions & Licences'],
|
|
59
|
+
['usage-collections', 'usage/collections', 'Collections & Forms'],
|
|
60
|
+
['usage-tools', 'usage/tools', 'Built-in Tools'],
|
|
58
61
|
['tutorial-crud', 'tutorials/crud', 'Building a CRUD App'],
|
|
59
62
|
['tutorial-plugin', 'tutorials/plugin', 'Writing a Plugin'],
|
|
60
63
|
['tutorial-forms', 'tutorials/forms', 'Form Follow-Up'],
|
|
@@ -72,7 +75,11 @@ const MAP = [
|
|
|
72
75
|
['api-plugins', 'api/plugins', 'Plugins API'],
|
|
73
76
|
['api-collections', 'api/collections', 'Collections API'],
|
|
74
77
|
['api-views', 'api/views', 'Views API'],
|
|
75
|
-
['api-actions', 'api/actions', 'Actions API']
|
|
78
|
+
['api-actions', 'api/actions', 'Actions API'],
|
|
79
|
+
['api-forms', 'api/forms', 'Forms API'],
|
|
80
|
+
['api-scaffold', 'api/scaffold', 'Scaffold API'],
|
|
81
|
+
['api-external', 'api/external', 'External API & Tokens'],
|
|
82
|
+
['api-builder', 'api/builder', 'API Builder']
|
|
76
83
|
];
|
|
77
84
|
|
|
78
85
|
// Admin-SPA hash routes and stale /resources/… paths don't resolve on the
|
|
@@ -134,6 +141,10 @@ function rewriteLinks(html) {
|
|
|
134
141
|
for (const [from, to] of Object.entries(LINK_MAP)) {
|
|
135
142
|
out = out.split(`href="${from}"`).join(`href="${to}"`);
|
|
136
143
|
}
|
|
144
|
+
// Usage and API pages share their tail with the public handbook; plugin guides
|
|
145
|
+
// are admin-only (they depend on what is installed), so they open the admin.
|
|
146
|
+
out = out.replace(/href="#\/docs\/(usage|api)\/([a-z0-9-]+)"/g, 'href="/domma-docs/$1/$2"');
|
|
147
|
+
out = out.replace(/href="#\/docs\/plugins([^"]*)"/g, 'href="/admin#/docs/plugins$1"');
|
|
137
148
|
// Any remaining /resources/… link has no doc equivalent - unwrap to text.
|
|
138
149
|
out = out.replace(/<a\b[^>]*\shref="\/resources\/[^"]*"[^>]*>([\s\S]*?)<\/a>/gi, '$1');
|
|
139
150
|
return out;
|
|
@@ -347,7 +358,7 @@ export async function renderDocPage(docPath, opts = {}) {
|
|
|
347
358
|
}
|
|
348
359
|
|
|
349
360
|
/**
|
|
350
|
-
* Every valid doc path (home +
|
|
361
|
+
* Every valid doc path (home + every leaf + 3 section landings). The Components
|
|
351
362
|
* landing is the 'components' leaf, already in MAP. `renderDocPage` returning
|
|
352
363
|
* null is the authoritative miss; this is an enumeration aid (e.g. sitemap).
|
|
353
364
|
*
|