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.
Files changed (177) hide show
  1. package/CLAUDE.md +5 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -1
  5. package/admin/js/lib/api-tokens-arrange.js +2 -2
  6. package/admin/js/lib/block-editor-arrange.js +1 -1
  7. package/admin/js/lib/blocks-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/components-arrange.js +1 -1
  10. package/admin/js/lib/dashboard-arrange.js +1 -1
  11. package/admin/js/lib/dates.js +1 -0
  12. package/admin/js/lib/forms-arrange.js +1 -1
  13. package/admin/js/lib/media-arrange.js +1 -1
  14. package/admin/js/lib/notifications-arrange.js +1 -1
  15. package/admin/js/lib/pages-arrange.js +1 -1
  16. package/admin/js/lib/related.js +1 -1
  17. package/admin/js/lib/timeline-builder.js +2 -2
  18. package/admin/js/templates/action-editor.html +6 -5
  19. package/admin/js/templates/actions-list.html +1 -1
  20. package/admin/js/templates/contacts.html +1 -1
  21. package/admin/js/templates/docs/api-actions.html +86 -60
  22. package/admin/js/templates/docs/api-authentication.html +159 -123
  23. package/admin/js/templates/docs/api-builder.html +197 -0
  24. package/admin/js/templates/docs/api-collections.html +199 -259
  25. package/admin/js/templates/docs/api-external.html +225 -0
  26. package/admin/js/templates/docs/api-forms.html +268 -0
  27. package/admin/js/templates/docs/api-layouts.html +70 -45
  28. package/admin/js/templates/docs/api-media.html +57 -80
  29. package/admin/js/templates/docs/api-navigation.html +66 -22
  30. package/admin/js/templates/docs/api-pages.html +109 -129
  31. package/admin/js/templates/docs/api-plugins.html +123 -61
  32. package/admin/js/templates/docs/api-scaffold.html +185 -0
  33. package/admin/js/templates/docs/api-settings.html +72 -64
  34. package/admin/js/templates/docs/api-users.html +74 -107
  35. package/admin/js/templates/docs/api-views.html +68 -54
  36. package/admin/js/templates/docs/components-howto.html +20 -17
  37. package/admin/js/templates/docs/components-reference.html +13 -16
  38. package/admin/js/templates/docs/components-rules.html +7 -6
  39. package/admin/js/templates/docs/components-walkthrough.html +19 -19
  40. package/admin/js/templates/docs/tutorial-crud.html +71 -40
  41. package/admin/js/templates/docs/tutorial-forms.html +51 -35
  42. package/admin/js/templates/docs/tutorial-plugin.html +132 -56
  43. package/admin/js/templates/docs/usage-actions.html +61 -15
  44. package/admin/js/templates/docs/usage-collections.html +108 -0
  45. package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
  46. package/admin/js/templates/docs/usage-dconfig.html +0 -3
  47. package/admin/js/templates/docs/usage-editions.html +213 -0
  48. package/admin/js/templates/docs/usage-media.html +22 -6
  49. package/admin/js/templates/docs/usage-navigation.html +74 -18
  50. package/admin/js/templates/docs/usage-pages.html +60 -20
  51. package/admin/js/templates/docs/usage-plugins.html +89 -17
  52. package/admin/js/templates/docs/usage-shortcodes.html +123 -70
  53. package/admin/js/templates/docs/usage-site-settings.html +50 -18
  54. package/admin/js/templates/docs/usage-tools.html +73 -0
  55. package/admin/js/templates/docs/usage-users-roles.html +99 -20
  56. package/admin/js/templates/docs/usage-views.html +36 -19
  57. package/admin/js/templates/documentation.html +153 -32
  58. package/admin/js/templates/page-editor.html +0 -5
  59. package/admin/js/templates/plugin-guide.html +15 -0
  60. package/admin/js/templates/plugin-guides.html +21 -0
  61. package/admin/js/templates/pro-docs.html +53 -234
  62. package/admin/js/templates/tutorials.html +5 -4
  63. package/admin/js/views/actions-list.js +3 -3
  64. package/admin/js/views/analytics.js +5 -5
  65. package/admin/js/views/api-endpoint-editor.js +2 -2
  66. package/admin/js/views/block-editor.js +4 -4
  67. package/admin/js/views/blocks.js +4 -4
  68. package/admin/js/views/collection-editor.js +4 -4
  69. package/admin/js/views/collection-entries.js +7 -7
  70. package/admin/js/views/component-editor.js +2 -2
  71. package/admin/js/views/contacts.js +22 -20
  72. package/admin/js/views/context-menu-editor.js +5 -5
  73. package/admin/js/views/doc-pages.js +1 -1
  74. package/admin/js/views/form-editor.js +4 -4
  75. package/admin/js/views/form-submissions.js +2 -2
  76. package/admin/js/views/index.js +1 -1
  77. package/admin/js/views/media.js +3 -3
  78. package/admin/js/views/menu-editor.js +13 -13
  79. package/admin/js/views/menu-locations.js +2 -2
  80. package/admin/js/views/my-profile.js +1 -1
  81. package/admin/js/views/page-editor.js +8 -8
  82. package/admin/js/views/plugin-guides.js +5 -0
  83. package/admin/js/views/project-detail.js +2 -2
  84. package/admin/js/views/project-settings.js +1 -1
  85. package/admin/js/views/role-editor.js +4 -4
  86. package/admin/js/views/search.js +2 -2
  87. package/admin/js/views/seo.js +17 -17
  88. package/admin/js/views/settings.js +3 -3
  89. package/admin/js/views/theme.js +3 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +2 -2
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/bin/cli.js +13 -13
  94. package/bin/lib/node-version.js +29 -0
  95. package/package.json +1 -1
  96. package/plugins/_lib/admin/mail/compose-window.js +3 -2
  97. package/plugins/_lib/admin/mail/reader-view.js +7 -6
  98. package/plugins/_lib/admin/mail/scheduling.js +4 -2
  99. package/plugins/_lib/admin/mail/templates.js +4 -4
  100. package/plugins/_lib/admin/ui/dates.js +85 -0
  101. package/plugins/blog/CLAUDE.md +31 -22
  102. package/plugins/blog/admin/views/blog.js +3 -2
  103. package/plugins/blog/admin/views/comments.js +2 -1
  104. package/plugins/blog/admin/views/post-editor.js +4 -4
  105. package/plugins/blog/blocks/blog-card-row.html +1 -1
  106. package/plugins/blog/blocks/blog-card.html +2 -2
  107. package/plugins/blog/blocks/blog-post-classic.html +2 -2
  108. package/plugins/blog/blocks/blog-post-essay.html +2 -2
  109. package/plugins/blog/blocks/blog-post-feature.html +2 -2
  110. package/plugins/blog/blocks/blog-post-minimal.html +2 -2
  111. package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
  112. package/plugins/blog/blocks/blog-post-split.html +2 -2
  113. package/plugins/blog/docs/guide.md +205 -0
  114. package/plugins/blog/lib/layouts.js +3 -3
  115. package/plugins/blog/lib/page.js +2 -1
  116. package/plugins/blog/plugin.js +3 -3
  117. package/plugins/blog/plugin.json +4 -4
  118. package/plugins/blog/tests/layouts.test.js +6 -0
  119. package/plugins/feedback/CLAUDE.md +22 -3
  120. package/plugins/feedback/admin/lib/kit.js +6 -7
  121. package/plugins/feedback/admin/views/feedback.js +79 -10
  122. package/plugins/feedback/admin/views/send.js +28 -6
  123. package/plugins/feedback/docs/guide.md +95 -0
  124. package/plugins/feedback/lib/receiver.js +9 -2
  125. package/plugins/feedback/lib/sender.js +3 -2
  126. package/plugins/feedback/plugin.js +54 -6
  127. package/plugins/feedback/plugin.json +4 -4
  128. package/plugins/feedback/tests/api.test.js +74 -2
  129. package/plugins/free-tier.lock.json +49 -44
  130. package/plugins/mail-reader/CLAUDE.md +33 -18
  131. package/plugins/mail-reader/docs/guide.md +147 -0
  132. package/plugins/mail-reader/plugin.json +1 -1
  133. package/plugins/security/CLAUDE.md +4 -1
  134. package/plugins/security/admin/views/security.js +5 -5
  135. package/plugins/security/docs/guide.md +170 -0
  136. package/plugins/security/plugin.js +2 -1
  137. package/plugins/security/plugin.json +2 -1
  138. package/plugins/shopping-cart/CLAUDE.md +7 -1
  139. package/plugins/shopping-cart/admin/lib/kit.js +5 -2
  140. package/plugins/shopping-cart/admin/views/orders.js +4 -4
  141. package/plugins/shopping-cart/admin/views/overview.js +2 -2
  142. package/plugins/shopping-cart/docs/guide.md +191 -0
  143. package/plugins/shopping-cart/lib/render.js +2 -1
  144. package/plugins/shopping-cart/plugin.json +3 -3
  145. package/public/js/collection-browser.js +2 -2
  146. package/public/js/site.js +1 -1
  147. package/scripts/gen-instance-secret.js +3 -1
  148. package/scripts/setup.js +3 -1
  149. package/server/middleware/auth.js +2 -1
  150. package/server/routes/api/actions.js +47 -27
  151. package/server/routes/api/blocks.js +2 -1
  152. package/server/routes/api/collections.js +16 -52
  153. package/server/routes/api/contacts.js +66 -3
  154. package/server/routes/api/documentation.js +42 -0
  155. package/server/routes/api/notifications.js +3 -2
  156. package/server/routes/api/users.js +10 -6
  157. package/server/server.js +16 -1
  158. package/server/services/actions.js +110 -34
  159. package/server/services/adapterRegistry.js +169 -16
  160. package/server/services/adapters/FileAdapter.js +25 -0
  161. package/server/services/adapters/MongoAdapter.js +23 -0
  162. package/server/services/collections.js +104 -1
  163. package/server/services/connectionManager.js +12 -0
  164. package/server/services/dates.js +81 -0
  165. package/server/services/docs.js +13 -2
  166. package/server/services/markdown.js +75 -26
  167. package/server/services/notification-sources.js +6 -5
  168. package/server/services/passwordReset.js +2 -1
  169. package/server/services/permissionRegistry.js +3 -2
  170. package/server/services/pluginGuides.js +255 -0
  171. package/server/services/pluginInstaller.js +54 -13
  172. package/server/services/plugins.js +29 -1
  173. package/server/services/presetCollections.js +31 -5
  174. package/server/services/renderer.js +2 -2
  175. package/server/services/sidebarBadges.js +3 -1
  176. package/server/services/tools.js +4 -2
  177. 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. Preset collections (e.g. roles) → always FileAdapter
8
- * 2. schema.json `storage.adapter` field:
9
- * - "mongodb" → MongoAdapter (Phase 2+)
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
- // Read schema to check storage config (Phase 2+: may return MongoAdapter)
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
- const connectionName = schema.storage?.connection || 'default';
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
+ }
@@ -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 + 29 leaves + 3 section landings). The Components
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
  *