domma-cms 0.7.0 → 0.7.5

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.
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Data Transfer Plugin — Backend
3
+ *
4
+ * Provides bulk collection transfer between file and MongoDB storage, plus
5
+ * timestamped backups and collection cloning. All operations use raw adapter
6
+ * access (adapter.all / adapter.insertMany) so original IDs and metadata are
7
+ * preserved — in contrast to the per-collection migrate-storage endpoint which
8
+ * calls createEntry() and generates new UUIDs.
9
+ *
10
+ * Routes (prefixed /api/plugins/data-transfer/):
11
+ * GET /collections — List transferable collections with adapter info
12
+ * POST /transfer — Bulk transfer selected collections
13
+ * POST /backups — Create a timestamped backup snapshot
14
+ * GET /backups — List available backups
15
+ * GET /backups/:id — Get single backup manifest
16
+ * POST /backups/:id/restore — Restore from a backup
17
+ * DELETE /backups/:id — Delete a backup
18
+ * POST /clone — Clone a collection (schema + optional entries)
19
+ */
20
+ import fs from 'fs/promises';
21
+ import path from 'path';
22
+ import {fileURLToPath} from 'url';
23
+ import {randomUUID} from 'crypto';
24
+ import {createCollection, getCollection, listCollections, updateCollection} from '../../server/services/collections.js';
25
+ import {getAdapter} from '../../server/services/adapterRegistry.js';
26
+ import {PRESET_COLLECTION_SLUGS} from '../../server/services/presetCollections.js';
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+ const BACKUPS_DIR = path.join(__dirname, 'backups');
30
+
31
+ /** Slugs that are always forced to FileAdapter — cannot be transferred. */
32
+ const PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Convert an ISO timestamp to a filesystem-safe backup ID.
40
+ * Replaces colons with hyphens.
41
+ *
42
+ * @param {string} iso
43
+ * @returns {string}
44
+ */
45
+ function toBackupId(iso) {
46
+ return iso.replace(/:/g, '-');
47
+ }
48
+
49
+ /**
50
+ * Convert a backup ID back to an ISO timestamp string.
51
+ *
52
+ * @param {string} id
53
+ * @returns {string}
54
+ */
55
+ function fromBackupId(id) {
56
+ // Restore the two colons that belong in a full ISO timestamp (HH-MM-SS → HH:MM:SS)
57
+ return id.replace(/T(\d{2})-(\d{2})-(\d{2})/, 'T$1:$2:$3');
58
+ }
59
+
60
+ /**
61
+ * Read a backup manifest from disk.
62
+ *
63
+ * @param {string} id
64
+ * @returns {Promise<object>}
65
+ * @throws If directory or manifest does not exist
66
+ */
67
+ async function readManifest(id) {
68
+ const manifestPath = path.join(BACKUPS_DIR, id, 'manifest.json');
69
+ const raw = await fs.readFile(manifestPath, 'utf8');
70
+ return JSON.parse(raw);
71
+ }
72
+
73
+ /**
74
+ * Enforce the maxBackups limit. Deletes oldest backup directories when
75
+ * the count exceeds the configured maximum.
76
+ *
77
+ * @param {number} max
78
+ */
79
+ async function pruneBackups(max) {
80
+ const entries = await fs.readdir(BACKUPS_DIR).catch(() => []);
81
+ if (entries.length <= max) return;
82
+
83
+ // Sort ascending (oldest first) by directory name (ISO timestamp prefix)
84
+ const sorted = entries.sort();
85
+ const toDelete = sorted.slice(0, entries.length - max);
86
+ await Promise.allSettled(
87
+ toDelete.map(name => fs.rm(path.join(BACKUPS_DIR, name), {recursive: true, force: true}))
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Determine the target storage config for a given transfer direction.
93
+ *
94
+ * @param {'file-to-mongo'|'mongo-to-file'} direction
95
+ * @returns {{ storage: object }}
96
+ */
97
+ function targetStorageConfig(direction) {
98
+ if (direction === 'file-to-mongo') {
99
+ return {adapter: 'mongodb', connection: 'default'};
100
+ }
101
+ return {adapter: 'file'};
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Plugin
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export default async function dataTransferPlugin(fastify, options) {
109
+ const {authenticate, requireAdmin} = options.auth;
110
+ const guard = [authenticate, requireAdmin];
111
+
112
+ // Ensure backup directory exists
113
+ await fs.mkdir(BACKUPS_DIR, {recursive: true});
114
+
115
+ // -------------------------------------------------------------------------
116
+ // GET /collections — list transferable collections
117
+ // -------------------------------------------------------------------------
118
+
119
+ fastify.get('/collections', {preHandler: guard}, async (_request, reply) => {
120
+ const all = await listCollections();
121
+
122
+ // Check MongoDB connectivity (best-effort)
123
+ let mongoConnected = false;
124
+ try {
125
+ const {isConnected} = await import('../../server/services/connectionManager.js');
126
+ mongoConnected = isConnected('default');
127
+ } catch {
128
+ // connectionManager not available (free mode) — leave false
129
+ }
130
+
131
+ const collections = all
132
+ .filter(col => !PRESET_SLUGS.has(col.slug))
133
+ .map(col => ({
134
+ slug: col.slug,
135
+ title: col.title,
136
+ adapter: col.storage?.adapter || 'file',
137
+ connection: col.storage?.connection || null,
138
+ entryCount: col.entryCount ?? 0
139
+ }));
140
+
141
+ return {collections, mongoConnected};
142
+ });
143
+
144
+ // -------------------------------------------------------------------------
145
+ // POST /transfer — bulk transfer selected collections
146
+ // -------------------------------------------------------------------------
147
+
148
+ fastify.post('/transfer', {preHandler: guard}, async (request, reply) => {
149
+ const {slugs, direction} = request.body || {};
150
+
151
+ if (!Array.isArray(slugs) || slugs.length === 0) {
152
+ return reply.status(400).send({error: 'slugs must be a non-empty array'});
153
+ }
154
+ if (direction !== 'file-to-mongo' && direction !== 'mongo-to-file') {
155
+ return reply.status(400).send({error: 'direction must be "file-to-mongo" or "mongo-to-file"'});
156
+ }
157
+
158
+ const results = [];
159
+
160
+ for (const slug of slugs) {
161
+ if (PRESET_SLUGS.has(slug)) {
162
+ results.push({slug, status: 'skipped', reason: 'preset collection — always file-based'});
163
+ continue;
164
+ }
165
+
166
+ try {
167
+ const schema = await getCollection(slug);
168
+ if (!schema) {
169
+ results.push({slug, status: 'error', error: 'Collection not found'});
170
+ continue;
171
+ }
172
+
173
+ const currentAdapter = schema.storage?.adapter || 'file';
174
+ const expectedSource = direction === 'file-to-mongo' ? 'file' : 'mongodb';
175
+ if (currentAdapter !== expectedSource) {
176
+ results.push({slug, status: 'skipped', reason: `already on ${currentAdapter}`});
177
+ continue;
178
+ }
179
+
180
+ // Step 1: read all entries from current adapter before schema change
181
+ const sourceAdapter = await getAdapter(slug);
182
+ const entries = await sourceAdapter.all(slug);
183
+
184
+ // Step 2: update schema to new adapter (invalidates cache)
185
+ const newStorage = targetStorageConfig(direction);
186
+ await updateCollection(slug, {...schema, storage: newStorage});
187
+
188
+ // Step 3: insert raw into new adapter (preserves IDs and meta)
189
+ const targetAdapter = await getAdapter(slug);
190
+ await targetAdapter.clear(slug);
191
+ if (entries.length > 0) {
192
+ await targetAdapter.insertMany(slug, entries);
193
+ }
194
+
195
+ // Step 4: archive old data.json when leaving file storage
196
+ if (direction === 'file-to-mongo') {
197
+ try {
198
+ const dataPath = path.join(
199
+ process.cwd(), 'content', 'collections', slug, 'data.json'
200
+ );
201
+ await fs.rename(dataPath, dataPath + '.bak');
202
+ } catch {
203
+ // data.json may not exist — not an error
204
+ }
205
+ }
206
+
207
+ results.push({slug, status: 'ok', count: entries.length});
208
+ } catch (err) {
209
+ fastify.log.error(`[data-transfer] Transfer failed for "${slug}": ${err.message}`);
210
+ results.push({slug, status: 'error', error: err.message});
211
+ }
212
+ }
213
+
214
+ return {results};
215
+ });
216
+
217
+ // -------------------------------------------------------------------------
218
+ // POST /backups — create a timestamped backup
219
+ // -------------------------------------------------------------------------
220
+
221
+ fastify.post('/backups', {preHandler: guard}, async (request, reply) => {
222
+ const {slugs} = request.body || {};
223
+
224
+ const allCollections = await listCollections();
225
+ const targets = slugs
226
+ ? allCollections.filter(col => slugs.includes(col.slug))
227
+ : allCollections;
228
+
229
+ const timestamp = new Date().toISOString();
230
+ const backupId = toBackupId(timestamp);
231
+ const backupDir = path.join(BACKUPS_DIR, backupId);
232
+ await fs.mkdir(backupDir, {recursive: true});
233
+
234
+ const manifestCollections = [];
235
+
236
+ for (const col of targets) {
237
+ try {
238
+ const schema = await getCollection(col.slug);
239
+ const adapter = await getAdapter(col.slug);
240
+ const entries = await adapter.all(col.slug);
241
+
242
+ await fs.writeFile(
243
+ path.join(backupDir, `${col.slug}.json`),
244
+ JSON.stringify({schema, entries}, null, 2)
245
+ );
246
+
247
+ manifestCollections.push({
248
+ slug: col.slug,
249
+ title: col.title,
250
+ entryCount: entries.length,
251
+ adapter: schema?.storage?.adapter || 'file'
252
+ });
253
+ } catch (err) {
254
+ fastify.log.warn(`[data-transfer] Backup skipped "${col.slug}": ${err.message}`);
255
+ }
256
+ }
257
+
258
+ const manifest = {
259
+ id: backupId,
260
+ timestamp,
261
+ collections: manifestCollections,
262
+ totalEntries: manifestCollections.reduce((sum, c) => sum + c.entryCount, 0)
263
+ };
264
+
265
+ await fs.writeFile(
266
+ path.join(backupDir, 'manifest.json'),
267
+ JSON.stringify(manifest, null, 2)
268
+ );
269
+
270
+ // Prune old backups
271
+ await pruneBackups(options.settings.maxBackups ?? 50);
272
+
273
+ return reply.status(201).send(manifest);
274
+ });
275
+
276
+ // -------------------------------------------------------------------------
277
+ // GET /backups — list all backups
278
+ // -------------------------------------------------------------------------
279
+
280
+ fastify.get('/backups', {preHandler: guard}, async (_request, reply) => {
281
+ const entries = await fs.readdir(BACKUPS_DIR).catch(() => []);
282
+ const backups = [];
283
+
284
+ for (const id of entries.sort().reverse()) {
285
+ try {
286
+ const manifest = await readManifest(id);
287
+ backups.push(manifest);
288
+ } catch {
289
+ // Corrupt backup directory — skip
290
+ }
291
+ }
292
+
293
+ return {backups};
294
+ });
295
+
296
+ // -------------------------------------------------------------------------
297
+ // GET /backups/:id — get single backup manifest
298
+ // -------------------------------------------------------------------------
299
+
300
+ fastify.get('/backups/:id', {preHandler: guard}, async (request, reply) => {
301
+ try {
302
+ const manifest = await readManifest(request.params.id);
303
+ return manifest;
304
+ } catch {
305
+ return reply.status(404).send({error: 'Backup not found'});
306
+ }
307
+ });
308
+
309
+ // -------------------------------------------------------------------------
310
+ // POST /backups/:id/restore — restore collections from a backup
311
+ // -------------------------------------------------------------------------
312
+
313
+ fastify.post('/backups/:id/restore', {preHandler: guard}, async (request, reply) => {
314
+ const {mode = 'merge', slugs} = request.body || {};
315
+
316
+ if (mode !== 'replace' && mode !== 'merge') {
317
+ return reply.status(400).send({error: 'mode must be "replace" or "merge"'});
318
+ }
319
+
320
+ let manifest;
321
+ try {
322
+ manifest = await readManifest(request.params.id);
323
+ } catch {
324
+ return reply.status(404).send({error: 'Backup not found'});
325
+ }
326
+
327
+ const targets = slugs
328
+ ? manifest.collections.filter(c => slugs.includes(c.slug))
329
+ : manifest.collections;
330
+
331
+ const results = [];
332
+
333
+ for (const entry of targets) {
334
+ try {
335
+ const backupFile = path.join(BACKUPS_DIR, request.params.id, `${entry.slug}.json`);
336
+ const raw = await fs.readFile(backupFile, 'utf8');
337
+ const {schema, entries} = JSON.parse(raw);
338
+
339
+ // Recreate collection if it no longer exists
340
+ const existing = await getCollection(entry.slug);
341
+ if (!existing) {
342
+ const {slug: _s, createdAt: _c, updatedAt: _u, preset: _p, ...rest} = schema;
343
+ await createCollection({...rest, slug: entry.slug});
344
+ }
345
+
346
+ const adapter = await getAdapter(entry.slug);
347
+
348
+ if (mode === 'replace') {
349
+ await adapter.clear(entry.slug);
350
+ }
351
+
352
+ if (entries.length > 0) {
353
+ await adapter.insertMany(entry.slug, entries);
354
+ }
355
+
356
+ results.push({slug: entry.slug, status: 'ok', restored: entries.length});
357
+ } catch (err) {
358
+ fastify.log.error(`[data-transfer] Restore failed for "${entry.slug}": ${err.message}`);
359
+ results.push({slug: entry.slug, status: 'error', error: err.message});
360
+ }
361
+ }
362
+
363
+ return {results};
364
+ });
365
+
366
+ // -------------------------------------------------------------------------
367
+ // DELETE /backups/:id — delete a backup
368
+ // -------------------------------------------------------------------------
369
+
370
+ fastify.delete('/backups/:id', {preHandler: guard}, async (request, reply) => {
371
+ const backupDir = path.join(BACKUPS_DIR, request.params.id);
372
+ try {
373
+ await fs.access(backupDir);
374
+ } catch {
375
+ return reply.status(404).send({error: 'Backup not found'});
376
+ }
377
+
378
+ await fs.rm(backupDir, {recursive: true, force: true});
379
+ return {ok: true};
380
+ });
381
+
382
+ // -------------------------------------------------------------------------
383
+ // POST /clone — clone a collection
384
+ // -------------------------------------------------------------------------
385
+
386
+ fastify.post('/clone', {preHandler: guard}, async (request, reply) => {
387
+ const {sourceSlug, newTitle, newSlug, copyEntries = false} = request.body || {};
388
+
389
+ if (!sourceSlug || !newTitle) {
390
+ return reply.status(400).send({error: 'sourceSlug and newTitle are required'});
391
+ }
392
+
393
+ const source = await getCollection(sourceSlug);
394
+ if (!source) {
395
+ return reply.status(404).send({error: 'Source collection not found'});
396
+ }
397
+
398
+ const targetSlug = newSlug || newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
399
+ const existing = await getCollection(targetSlug);
400
+ if (existing) {
401
+ return reply.status(409).send({error: `Collection "${targetSlug}" already exists`});
402
+ }
403
+
404
+ // Build clean schema for new collection (drop system fields)
405
+ const {slug: _s, createdAt: _c, updatedAt: _u, preset: _p, ...rest} = source;
406
+ await createCollection({...rest, title: newTitle, slug: targetSlug});
407
+
408
+ let copiedCount = 0;
409
+ if (copyEntries) {
410
+ const sourceAdapter = await getAdapter(sourceSlug);
411
+ const entries = await sourceAdapter.all(sourceSlug);
412
+
413
+ // Generate new UUIDs to avoid ID conflicts across collections
414
+ const remapped = entries.map(e => ({...e, id: randomUUID()}));
415
+ const targetAdapter = await getAdapter(targetSlug);
416
+ if (remapped.length > 0) {
417
+ await targetAdapter.insertMany(targetSlug, remapped);
418
+ }
419
+ copiedCount = remapped.length;
420
+ }
421
+
422
+ return reply.status(201).send({slug: targetSlug, copiedEntries: copiedCount});
423
+ });
424
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "data-transfer",
3
+ "displayName": "Data Transfer",
4
+ "version": "1.0.0",
5
+ "description": "Bulk transfer, backup, restore, and clone collections between file and MongoDB storage.",
6
+ "author": "Darryl Waterhouse",
7
+ "date": "2026-03-28",
8
+ "icon": "hard-drive",
9
+ "admin": {
10
+ "sidebar": [
11
+ {
12
+ "id": "data-transfer",
13
+ "text": "Data Transfer",
14
+ "icon": "hard-drive",
15
+ "url": "#/plugins/data-transfer",
16
+ "section": "#/plugins/data-transfer"
17
+ }
18
+ ],
19
+ "routes": [
20
+ {
21
+ "path": "/plugins/data-transfer",
22
+ "view": "plugin-data-transfer",
23
+ "title": "Data Transfer - Domma CMS"
24
+ }
25
+ ],
26
+ "views": {
27
+ "plugin-data-transfer": {
28
+ "entry": "data-transfer/admin/views/data-transfer.js?v=4",
29
+ "exportName": "dataTransferView"
30
+ }
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,81 @@
1
+ <div class="garage-plugin">
2
+
3
+ <!-- Lookup Card -->
4
+ <div class="card mb-4">
5
+ <div class="card-header" style="display:flex;align-items:center;gap:0.5rem;">
6
+ <span data-icon="truck"></span>
7
+ <strong>Vehicle Lookup</strong>
8
+ </div>
9
+ <div class="card-body">
10
+ <div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
11
+ <input
12
+ id="reg-input"
13
+ type="text"
14
+ class="form-input"
15
+ placeholder="AB12 CDE"
16
+ maxlength="8"
17
+ autocomplete="off"
18
+ spellcheck="false"
19
+ style="width:160px;background:#FFD900;color:#000;font-family:'Arial Black',Arial,sans-serif;font-size:1.25rem;font-weight:900;text-transform:uppercase;text-align:center;letter-spacing:3px;border:3px solid #000;border-radius:6px;padding:0.4rem 0.5rem;"
20
+ >
21
+ <button id="lookup-btn" class="btn btn-primary">
22
+ <span data-icon="search"></span> Look Up
23
+ </button>
24
+ <span id="rate-limit-info" style="font-size:0.8rem;color:var(--dm-text-muted,#888);"></span>
25
+ </div>
26
+ <div id="lookup-error" style="display:none;margin-top:0.75rem;" class="text-danger"></div>
27
+ </div>
28
+ </div>
29
+
30
+ <!-- Tabs -->
31
+ <div id="garage-tabs" class="tabs">
32
+ <div class="tab-list">
33
+ <button class="tab-item active">Results</button>
34
+ <button class="tab-item" id="tab-btn-garage">My Garage</button>
35
+ <button class="tab-item" id="tab-btn-history">History</button>
36
+ </div>
37
+ <div class="tab-content">
38
+
39
+ <!-- Results Tab -->
40
+ <div class="tab-panel active" id="tab-results">
41
+ <div id="vehicle-result" style="display:none;margin-top:1rem;"></div>
42
+ <div id="result-empty" style="text-align:center;padding:3rem 1rem;color:var(--dm-text-muted,#888);">
43
+ <span data-icon="truck"
44
+ style="font-size:2.5rem;display:block;margin-bottom:0.75rem;opacity:0.4;"></span>
45
+ Enter a registration number above to look up a vehicle.
46
+ </div>
47
+ </div>
48
+
49
+ <!-- My Garage Tab -->
50
+ <div class="tab-panel" id="tab-garage">
51
+ <div style="display:flex;align-items:center;margin:1rem 0 0.75rem;">
52
+ <span id="garage-count" style="font-size:0.8rem;color:var(--dm-text-muted,#888);"></span>
53
+ </div>
54
+ <div id="garage-table" style="border-radius:8px;overflow:hidden;"></div>
55
+ <div id="garage-empty"
56
+ style="display:none;text-align:center;padding:3rem 1rem;color:var(--dm-text-muted,#888);">
57
+ <span data-icon="truck"
58
+ style="font-size:2.5rem;display:block;margin-bottom:0.75rem;opacity:0.4;"></span>
59
+ No saved vehicles yet. Look up a vehicle and save it to your garage.
60
+ </div>
61
+ </div>
62
+
63
+ <!-- History Tab -->
64
+ <div class="tab-panel" id="tab-history">
65
+ <div style="display:flex;gap:0.5rem;align-items:center;margin:1rem 0 0.75rem;flex-wrap:wrap;">
66
+ <span id="history-count" style="font-size:0.8rem;color:var(--dm-text-muted,#888);flex:1;"></span>
67
+ <button id="clear-history-btn" class="btn btn-sm btn-danger">
68
+ <span data-icon="trash"></span> Clear History
69
+ </button>
70
+ </div>
71
+ <div id="history-table" style="border-radius:8px;overflow:hidden;"></div>
72
+ <div id="history-empty"
73
+ style="display:none;text-align:center;padding:3rem 1rem;color:var(--dm-text-muted,#888);">
74
+ No lookup history yet.
75
+ </div>
76
+ </div>
77
+
78
+ </div>
79
+ </div>
80
+
81
+ </div>