domma-cms 0.7.8 → 0.7.9

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.
@@ -33,8 +33,4 @@
33
33
  "enabled": true,
34
34
  "settings": {}
35
35
  },
36
- "data-transfer": {
37
- "enabled": true,
38
- "settings": {}
39
- }
40
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -1,172 +0,0 @@
1
- <div class="data-transfer-plugin">
2
-
3
- <div class="mb-4" style="display:flex;align-items:center;gap:0.75rem;">
4
- <span data-icon="hard-drive" style="width:28px;height:28px;opacity:0.8;"></span>
5
- <div>
6
- <h1 style="font-size:1.25rem;font-weight:600;margin:0;">Data Transfer</h1>
7
- <p style="font-size:0.875rem;color:var(--dm-text-muted);margin:0;">Bulk transfer, backup, restore, and clone
8
- collections.</p>
9
- </div>
10
- </div>
11
-
12
- <div id="dt-tabs" class="tabs">
13
- <div class="tab-list" role="tablist">
14
- <button class="tab-item active" data-tab="transfer">Transfer</button>
15
- <button class="tab-item" data-tab="backups">Backups</button>
16
- <button class="tab-item" data-tab="clone">Clone</button>
17
- </div>
18
-
19
- <div class="tab-content">
20
-
21
- <!-- ===== Transfer ===== -->
22
- <div class="tab-panel active" data-panel="transfer">
23
- <div class="card mt-4">
24
- <div class="card-header">
25
- <strong>Move Collections Between Storage Adapters</strong>
26
- </div>
27
- <div class="card-body">
28
-
29
- <div id="dt-mongo-warning" class="card mb-3"
30
- style="display:none;border-left:3px solid var(--dm-warning);">
31
- <div class="card-body"
32
- style="display:flex;align-items:center;gap:0.5rem;padding:0.65rem 0.875rem;">
33
- <span data-icon="alert-triangle" style="width:16px;height:16px;flex-shrink:0;"></span>
34
- <span style="font-size:0.875rem;">MongoDB is not connected. File &rarr; MongoDB transfers are unavailable.</span>
35
- </div>
36
- </div>
37
-
38
- <div class="form-group mb-4">
39
- <label class="form-label">Direction</label>
40
- <div style="display:flex;gap:1.5rem;margin-top:0.25rem;">
41
- <label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
42
- <input type="radio" name="dt-direction" value="file-to-mongo" id="dt-dir-ftm"
43
- checked class="form-check-input">
44
- File &rarr; MongoDB
45
- </label>
46
- <label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
47
- <input type="radio" name="dt-direction" value="mongo-to-file" id="dt-dir-mtf"
48
- class="form-check-input">
49
- MongoDB &rarr; File
50
- </label>
51
- </div>
52
- </div>
53
-
54
- <div id="dt-collections-loading" style="font-size:0.875rem;color:var(--dm-text-muted);">Loading
55
- collections…
56
- </div>
57
-
58
- <div id="dt-collections-wrap" style="display:none;">
59
- <table class="table mb-3" id="dt-collections-table">
60
- <thead>
61
- <tr>
62
- <th style="width:36px;">
63
- <input type="checkbox" id="dt-select-all" class="form-check-input"
64
- title="Select all">
65
- </th>
66
- <th>Collection</th>
67
- <th>Slug</th>
68
- <th>Adapter</th>
69
- <th>Entries</th>
70
- </tr>
71
- </thead>
72
- <tbody id="dt-collections-body"></tbody>
73
- </table>
74
-
75
- <button class="btn btn-primary" id="dt-transfer-btn">
76
- <span data-icon="arrow-left-right" style="width:14px;height:14px;"></span>
77
- Transfer Selected
78
- </button>
79
- </div>
80
-
81
- <div id="dt-transfer-results" style="display:none;margin-top:1.5rem;">
82
- <strong style="display:block;margin-bottom:0.5rem;">Results</strong>
83
- <div id="dt-transfer-results-body"></div>
84
- </div>
85
-
86
- </div>
87
- </div>
88
- </div>
89
-
90
- <!-- ===== Backups ===== -->
91
- <div class="tab-panel" data-panel="backups">
92
- <div class="card mt-4">
93
- <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
94
- <strong>Backup Snapshots</strong>
95
- <button class="btn btn-primary btn-sm" id="dt-create-backup-btn">
96
- <span data-icon="download" style="width:14px;height:14px;"></span>
97
- Create Backup
98
- </button>
99
- </div>
100
- <div class="card-body">
101
- <div id="dt-backups-loading" style="font-size:0.875rem;color:var(--dm-text-muted);">Loading
102
- backups…
103
- </div>
104
- <div id="dt-backups-wrap" style="display:none;">
105
- <table class="table" id="dt-backups-table">
106
- <thead>
107
- <tr>
108
- <th>Timestamp</th>
109
- <th>Collections</th>
110
- <th>Total Entries</th>
111
- <th style="width:180px;">Actions</th>
112
- </tr>
113
- </thead>
114
- <tbody id="dt-backups-body"></tbody>
115
- </table>
116
- <p id="dt-backups-empty"
117
- style="display:none;font-size:0.875rem;color:var(--dm-text-muted);">No backups yet.</p>
118
- </div>
119
- </div>
120
- </div>
121
- </div>
122
-
123
- <!-- ===== Clone ===== -->
124
- <div class="tab-panel" data-panel="clone">
125
- <div class="card mt-4" style="max-width:520px;">
126
- <div class="card-header">
127
- <strong>Clone a Collection</strong>
128
- </div>
129
- <div class="card-body">
130
-
131
- <div class="form-group mb-3">
132
- <label class="form-label" for="dt-clone-source">Source Collection</label>
133
- <select class="form-input" id="dt-clone-source">
134
- <option value="">— select —</option>
135
- </select>
136
- </div>
137
-
138
- <div class="form-group mb-3">
139
- <label class="form-label" for="dt-clone-title">New Title</label>
140
- <input type="text" class="form-input" id="dt-clone-title"
141
- placeholder="e.g. My Collection Copy">
142
- </div>
143
-
144
- <div class="form-group mb-3">
145
- <label class="form-label" for="dt-clone-slug">
146
- New Slug
147
- <span style="font-size:0.8rem;color:var(--dm-text-muted);">(auto-generated)</span>
148
- </label>
149
- <input type="text" class="form-input" id="dt-clone-slug"
150
- placeholder="e.g. my-collection-copy">
151
- </div>
152
-
153
- <div class="form-group mb-4">
154
- <label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
155
- <input type="checkbox" class="form-check-input" id="dt-clone-entries">
156
- Copy entries (with new IDs)
157
- </label>
158
- </div>
159
-
160
- <button class="btn btn-primary" id="dt-clone-btn">
161
- <span data-icon="copy" style="width:14px;height:14px;"></span>
162
- Clone Collection
163
- </button>
164
-
165
- </div>
166
- </div>
167
- </div>
168
-
169
- </div>
170
- </div>
171
-
172
- </div>
@@ -1,558 +0,0 @@
1
- /**
2
- * Data Transfer Plugin — Admin View
3
- *
4
- * Three tabs: Transfer, Backups, Clone.
5
- * Uses raw adapter-level API for faithful bulk operations.
6
- *
7
- * Pattern: follows garage plugin conventions.
8
- * - api() — fetch wrapper with CMS Bearer token (same as garage)
9
- * - registerIcons() — registers arrow-left-right (not in Domma built-ins)
10
- * - loadCollections() — populates Transfer tab table
11
- * - loadBackups() — populates Backups tab table
12
- * - wireClone() — wires Clone tab form
13
- */
14
-
15
- const BASE = '/api/plugins/data-transfer';
16
-
17
- // ---------------------------------------------------------------------------
18
- // HTTP helper
19
- // ---------------------------------------------------------------------------
20
-
21
- /**
22
- * Authenticated fetch helper. Mirrors the garage plugin pattern exactly.
23
- *
24
- * @param {string} url
25
- * @param {string} [method]
26
- * @param {object} [body]
27
- * @returns {Promise<any>}
28
- */
29
- async function api(url, method = 'GET', body) {
30
- const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
31
- if (body !== undefined) {
32
- opts.headers['Content-Type'] = 'application/json';
33
- opts.body = JSON.stringify(body);
34
- }
35
- const res = await fetch(url, opts);
36
- if (!res.ok) {
37
- const err = await res.json().catch(() => ({error: res.statusText}));
38
- throw new Error(err.error || res.statusText);
39
- }
40
- return res.json();
41
- }
42
-
43
- // ---------------------------------------------------------------------------
44
- // Icons
45
- // ---------------------------------------------------------------------------
46
-
47
- /**
48
- * Register the arrow-left-right icon — two outward-pointing arrowheads joined
49
- * by a horizontal line. Not in the Domma built-in set so registered here.
50
- */
51
- function registerIcons() {
52
- try {
53
- I.register('arrow-left-right', {
54
- viewBox: '0 0 24 24',
55
- path: 'M7 16 L3 12 L7 8 M3 12 L21 12 M17 8 L21 12 L17 16',
56
- stroke: 'currentColor',
57
- fill: 'none',
58
- strokeWidth: 2,
59
- strokeLinecap: 'round',
60
- strokeLinejoin: 'round'
61
- });
62
- } catch {
63
- // Already registered — safe to ignore on re-mount
64
- }
65
- }
66
-
67
- // ---------------------------------------------------------------------------
68
- // Helpers
69
- // ---------------------------------------------------------------------------
70
-
71
- /**
72
- * Format an ISO backup ID (filesystem-safe) into a readable local datetime.
73
- *
74
- * @param {string} id - e.g. "2026-03-28T14-30-00.000Z"
75
- * @returns {string}
76
- */
77
- function formatBackupId(id) {
78
- const iso = id.replace(/T(\d{2})-(\d{2})-(\d{2})/, 'T$1:$2:$3');
79
- return new Date(iso).toLocaleString();
80
- }
81
-
82
- /**
83
- * Slugify a title string for the clone slug field.
84
- *
85
- * @param {string} title
86
- * @returns {string}
87
- */
88
- function slugify(title) {
89
- return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
90
- }
91
-
92
- /**
93
- * Create a status badge element.
94
- *
95
- * @param {'ok'|'error'|'skipped'} status
96
- * @returns {HTMLElement}
97
- */
98
- function createStatusBadge(status) {
99
- const map = {ok: 'badge-success', error: 'badge-danger', skipped: 'badge-secondary'};
100
- const span = document.createElement('span');
101
- span.className = `badge ${map[status] || 'badge-secondary'}`;
102
- span.textContent = status;
103
- return span;
104
- }
105
-
106
- /**
107
- * Create a small icon button element.
108
- *
109
- * @param {string} label
110
- * @param {string} iconName
111
- * @param {string} className
112
- * @returns {HTMLButtonElement}
113
- */
114
- function createIconBtn(label, iconName, className) {
115
- const btn = document.createElement('button');
116
- btn.className = className;
117
- const icon = document.createElement('span');
118
- icon.dataset.icon = iconName;
119
- icon.style.cssText = 'width:12px;height:12px;';
120
- btn.appendChild(icon);
121
- if (label) {
122
- btn.appendChild(document.createTextNode(` ${label}`));
123
- }
124
- return btn;
125
- }
126
-
127
- // ---------------------------------------------------------------------------
128
- // Transfer tab
129
- // ---------------------------------------------------------------------------
130
-
131
- /**
132
- * Load collections list and populate the transfer table.
133
- * Disables File→Mongo direction if MongoDB is not connected.
134
- *
135
- * @param {object} $container - Domma wrapper
136
- */
137
- async function loadCollections($container) {
138
- let res;
139
- try {
140
- res = await api(`${BASE}/collections`);
141
- } catch (err) {
142
- const loading = $container.find('#dt-collections-loading').get(0);
143
- loading.textContent = `Could not load collections: ${err.message}`;
144
- loading.style.color = 'var(--dm-danger)';
145
- return;
146
- }
147
-
148
- $container.find('#dt-collections-loading').hide();
149
- $container.find('#dt-collections-wrap').show();
150
-
151
- if (!res.mongoConnected) {
152
- $container.find('#dt-mongo-warning').show();
153
- $container.find('#dt-dir-ftm').get(0).disabled = true;
154
- $container.find('#dt-dir-mtf').get(0).checked = true;
155
- }
156
-
157
- const tbody = $container.find('#dt-collections-body').get(0);
158
- tbody.innerHTML = '';
159
-
160
- for (const col of res.collections) {
161
- const tr = document.createElement('tr');
162
-
163
- const checkTd = document.createElement('td');
164
- const check = document.createElement('input');
165
- check.type = 'checkbox';
166
- check.className = 'dt-col-check form-check-input';
167
- check.dataset.slug = col.slug;
168
- check.dataset.adapter = col.adapter;
169
- checkTd.appendChild(check);
170
-
171
- const titleTd = document.createElement('td');
172
- titleTd.textContent = col.title;
173
-
174
- const slugTd = document.createElement('td');
175
- const code = document.createElement('code');
176
- code.textContent = col.slug;
177
- slugTd.appendChild(code);
178
-
179
- const adapterTd = document.createElement('td');
180
- const badge = document.createElement('span');
181
- badge.className = 'badge badge-secondary';
182
- badge.textContent = col.adapter;
183
- adapterTd.appendChild(badge);
184
-
185
- const countTd = document.createElement('td');
186
- countTd.textContent = col.entryCount;
187
-
188
- tr.appendChild(checkTd);
189
- tr.appendChild(titleTd);
190
- tr.appendChild(slugTd);
191
- tr.appendChild(adapterTd);
192
- tr.appendChild(countTd);
193
- tbody.appendChild(tr);
194
- }
195
-
196
- // Populate clone source dropdown with the same data
197
- const cloneSelect = $container.find('#dt-clone-source').get(0);
198
- while (cloneSelect.options.length > 1) cloneSelect.remove(1);
199
- for (const col of res.collections) {
200
- const opt = document.createElement('option');
201
- opt.value = col.slug;
202
- opt.textContent = `${col.title} (${col.slug})`;
203
- cloneSelect.appendChild(opt);
204
- }
205
-
206
- I.scan();
207
- }
208
-
209
- /**
210
- * Wire the select-all checkbox for the transfer table.
211
- *
212
- * @param {object} $container
213
- */
214
- function wireSelectAll($container) {
215
- $container.find('#dt-select-all').get(0).addEventListener('change', function () {
216
- const checked = this.checked;
217
- $container.find('.dt-col-check').each(function () {
218
- this.checked = checked;
219
- });
220
- });
221
- }
222
-
223
- /**
224
- * Execute bulk transfer for checked collections.
225
- *
226
- * @param {object} $container
227
- */
228
- async function executeTransfer($container) {
229
- const checked = [];
230
- $container.find('.dt-col-check').each(function () {
231
- if (this.checked) checked.push(this.dataset.slug);
232
- });
233
-
234
- if (checked.length === 0) {
235
- E.toast('Select at least one collection.', {type: 'warning'});
236
- return;
237
- }
238
-
239
- const direction = $container.find('input[name="dt-direction"]:checked').val();
240
- const btn = $container.find('#dt-transfer-btn').get(0);
241
- btn.disabled = true;
242
- btn.textContent = 'Transferring…';
243
-
244
- try {
245
- const res = await api(`${BASE}/transfer`, 'POST', {slugs: checked, direction});
246
- renderTransferResults($container, res.results);
247
- E.toast('Transfer complete.', {type: 'success'});
248
- await loadCollections($container);
249
- } catch (err) {
250
- E.toast(`Transfer failed: ${err.message || 'Unknown error'}`, {type: 'error'});
251
- } finally {
252
- btn.disabled = false;
253
- const icon = document.createElement('span');
254
- icon.dataset.icon = 'arrow-left-right';
255
- icon.style.cssText = 'width:14px;height:14px;';
256
- btn.textContent = ' Transfer Selected';
257
- btn.prepend(icon);
258
- I.scan();
259
- }
260
- }
261
-
262
- /**
263
- * Render per-collection transfer results below the table.
264
- *
265
- * @param {object} $container
266
- * @param {Array<object>} results
267
- */
268
- function renderTransferResults($container, results) {
269
- const wrap = $container.find('#dt-transfer-results').get(0);
270
- const body = $container.find('#dt-transfer-results-body').get(0);
271
-
272
- body.textContent = '';
273
-
274
- for (const r of results) {
275
- const row = document.createElement('div');
276
- row.style.cssText = 'display:flex;align-items:center;gap:0.75rem;padding:0.35rem 0;border-bottom:1px solid var(--dm-border);font-size:0.875rem;';
277
-
278
- row.appendChild(createStatusBadge(r.status));
279
-
280
- const slugCode = document.createElement('code');
281
- slugCode.textContent = r.slug;
282
- row.appendChild(slugCode);
283
-
284
- const detail = document.createElement('span');
285
- detail.style.color = 'var(--dm-text-muted)';
286
- detail.textContent = r.count !== undefined
287
- ? `${r.count} entries`
288
- : (r.reason || r.error || '');
289
- row.appendChild(detail);
290
-
291
- body.appendChild(row);
292
- }
293
-
294
- wrap.style.display = 'block';
295
- }
296
-
297
- // ---------------------------------------------------------------------------
298
- // Backups tab
299
- // ---------------------------------------------------------------------------
300
-
301
- /**
302
- * Load and render the backups list.
303
- *
304
- * @param {object} $container
305
- */
306
- async function loadBackups($container) {
307
- $container.find('#dt-backups-loading').show();
308
- $container.find('#dt-backups-wrap').hide();
309
-
310
- let res;
311
- try {
312
- res = await api(`${BASE}/backups`);
313
- } catch (err) {
314
- const loading = $container.find('#dt-backups-loading').get(0);
315
- loading.textContent = `Could not load backups: ${err.message}`;
316
- loading.style.color = 'var(--dm-danger)';
317
- return;
318
- }
319
-
320
- $container.find('#dt-backups-loading').hide();
321
- $container.find('#dt-backups-wrap').show();
322
-
323
- const tbody = $container.find('#dt-backups-body').get(0);
324
- const empty = $container.find('#dt-backups-empty').get(0);
325
- tbody.textContent = '';
326
-
327
- if (res.backups.length === 0) {
328
- empty.style.display = '';
329
- return;
330
- }
331
- empty.style.display = 'none';
332
-
333
- for (const backup of res.backups) {
334
- const tr = document.createElement('tr');
335
- tr.dataset.backupId = backup.id;
336
-
337
- const dateCell = document.createElement('td');
338
- dateCell.textContent = formatBackupId(backup.id);
339
-
340
- const colCell = document.createElement('td');
341
- colCell.textContent = backup.collections.length;
342
-
343
- const entryCell = document.createElement('td');
344
- entryCell.textContent = backup.totalEntries;
345
-
346
- const actionsCell = document.createElement('td');
347
-
348
- const restoreBtn = createIconBtn('Restore', 'upload', 'btn btn-secondary btn-xs mr-1');
349
- restoreBtn.addEventListener('click', () => promptRestore($container, backup));
350
-
351
- const deleteBtn = createIconBtn('', 'trash', 'btn btn-danger btn-xs');
352
- deleteBtn.addEventListener('click', () => deleteBackup($container, backup.id));
353
-
354
- actionsCell.appendChild(restoreBtn);
355
- actionsCell.appendChild(deleteBtn);
356
-
357
- tr.appendChild(dateCell);
358
- tr.appendChild(colCell);
359
- tr.appendChild(entryCell);
360
- tr.appendChild(actionsCell);
361
- tbody.appendChild(tr);
362
- }
363
-
364
- I.scan();
365
- }
366
-
367
- /**
368
- * Prompt the user for restore mode then execute.
369
- *
370
- * TODO: Implement a richer restore mode selection UI here.
371
- *
372
- * The restore API accepts { mode: 'replace' | 'merge', slugs?: string[] }.
373
- * - 'replace' clears existing entries before inserting — a clean, full restore.
374
- * - 'merge' appends backup entries alongside existing data — for partial restores.
375
- *
376
- * Your task: replace the E.confirm() below with a proper selection UI so the
377
- * admin can consciously choose between the two modes. Ideas:
378
- * a) A modal with two distinct buttons ("Replace" / "Merge") instead of a
379
- * single confirm — clearer intent, harder to misclick.
380
- * b) A radio group inside an E.modal() followed by a single confirm button,
381
- * which also lets you add a collection filter checklist.
382
- * c) Two separate "Restore (Replace)" / "Restore (Merge)" action buttons
383
- * per row in the backups table.
384
- *
385
- * Constraints:
386
- * - Use E.confirm() or E.modal() — NOT window.confirm()
387
- * - Call executeRestore($container, backup.id, mode) once the user confirms
388
- * - mode must be the string 'replace' or 'merge'
389
- *
390
- * @param {object} $container
391
- * @param {object} backup - the backup manifest object
392
- */
393
- async function promptRestore($container, backup) {
394
- // TODO: Replace with a mode-selection UI (see jsdoc above).
395
- const confirmed = await E.confirm(
396
- `Restore backup from ${formatBackupId(backup.id)}?\n\nThis uses REPLACE mode — existing entries in each restored collection will be cleared first.`
397
- );
398
- if (!confirmed) return;
399
- await executeRestore($container, backup.id, 'replace');
400
- }
401
-
402
- /**
403
- * Execute a restore operation.
404
- *
405
- * @param {object} $container
406
- * @param {string} backupId
407
- * @param {'replace'|'merge'} mode
408
- * @param {string[]} [slugs] - optional subset of collections to restore
409
- */
410
- async function executeRestore($container, backupId, mode, slugs) {
411
- try {
412
- const body = {mode};
413
- if (slugs) body.slugs = slugs;
414
- const res = await api(`${BASE}/backups/${backupId}/restore`, 'POST', body);
415
- const ok = res.results.filter(r => r.status === 'ok').length;
416
- const errs = res.results.filter(r => r.status === 'error').length;
417
- E.toast(
418
- errs > 0
419
- ? `Restored ${ok} collection(s), ${errs} error(s).`
420
- : `Restored ${ok} collection(s) successfully.`,
421
- {type: errs > 0 ? 'warning' : 'success'}
422
- );
423
- } catch (err) {
424
- E.toast(`Restore failed: ${err.message || 'Unknown error'}`, {type: 'error'});
425
- }
426
- }
427
-
428
- /**
429
- * Delete a backup after confirmation.
430
- *
431
- * @param {object} $container
432
- * @param {string} backupId
433
- */
434
- async function deleteBackup($container, backupId) {
435
- const confirmed = await E.confirm(
436
- `Delete backup from ${formatBackupId(backupId)}? This cannot be undone.`
437
- );
438
- if (!confirmed) return;
439
-
440
- try {
441
- await api(`${BASE}/backups/${backupId}`, 'DELETE');
442
- E.toast('Backup deleted.', {type: 'success'});
443
- await loadBackups($container);
444
- } catch (err) {
445
- E.toast(`Delete failed: ${err.message || 'Unknown error'}`, {type: 'error'});
446
- }
447
- }
448
-
449
- // ---------------------------------------------------------------------------
450
- // Clone tab
451
- // ---------------------------------------------------------------------------
452
-
453
- /**
454
- * Wire the title → slug auto-generation and clone button.
455
- *
456
- * @param {object} $container
457
- */
458
- function wireClone($container) {
459
- const titleInput = $container.find('#dt-clone-title').get(0);
460
- const slugInput = $container.find('#dt-clone-slug').get(0);
461
- let slugEdited = false;
462
-
463
- titleInput.addEventListener('input', () => {
464
- if (!slugEdited) slugInput.value = slugify(titleInput.value);
465
- });
466
- slugInput.addEventListener('input', () => {
467
- slugEdited = !!slugInput.value;
468
- });
469
-
470
- $container.find('#dt-clone-btn').on('click', async () => {
471
- const sourceSlug = $container.find('#dt-clone-source').val();
472
- const newTitle = titleInput.value.trim();
473
- const newSlug = slugInput.value.trim() || undefined;
474
- const copyEntries = $container.find('#dt-clone-entries').get(0).checked;
475
-
476
- if (!sourceSlug || !newTitle) {
477
- E.toast('Choose a source collection and enter a title.', {type: 'warning'});
478
- return;
479
- }
480
-
481
- const btn = $container.find('#dt-clone-btn').get(0);
482
- btn.disabled = true;
483
- btn.textContent = 'Cloning…';
484
-
485
- try {
486
- const res = await api(`${BASE}/clone`, 'POST', {sourceSlug, newTitle, newSlug, copyEntries});
487
- E.toast(
488
- copyEntries
489
- ? `Cloned to "${res.slug}" with ${res.copiedEntries} entries.`
490
- : `Cloned to "${res.slug}".`,
491
- {type: 'success'}
492
- );
493
- $container.find('#dt-clone-source').val('');
494
- titleInput.value = '';
495
- slugInput.value = '';
496
- slugEdited = false;
497
- $container.find('#dt-clone-entries').get(0).checked = false;
498
- } catch (err) {
499
- E.toast(`Clone failed: ${err.message || 'Unknown error'}`, {type: 'error'});
500
- } finally {
501
- btn.disabled = false;
502
- const icon = document.createElement('span');
503
- icon.dataset.icon = 'copy';
504
- icon.style.cssText = 'width:14px;height:14px;';
505
- btn.textContent = ' Clone Collection';
506
- btn.prepend(icon);
507
- I.scan();
508
- }
509
- });
510
- }
511
-
512
- // ---------------------------------------------------------------------------
513
- // Mount
514
- // ---------------------------------------------------------------------------
515
-
516
- export const dataTransferView = {
517
- templateUrl: '/plugins/data-transfer/admin/templates/data-transfer.html',
518
-
519
- async onMount($container) {
520
- registerIcons();
521
- E.tabs($container.find('#dt-tabs').get(0));
522
-
523
- // Transfer tab
524
- wireSelectAll($container);
525
- await loadCollections($container);
526
- $container.find('#dt-transfer-btn').on('click', () => executeTransfer($container));
527
-
528
- // Backups tab — pre-fetch and reload on tab switch
529
- $container.find('[data-tab="backups"]').on('click', () => loadBackups($container));
530
- loadBackups($container);
531
-
532
- $container.find('#dt-create-backup-btn').on('click', async () => {
533
- const btn = $container.find('#dt-create-backup-btn').get(0);
534
- btn.disabled = true;
535
- btn.textContent = 'Creating…';
536
- try {
537
- await api(`${BASE}/backups`, 'POST', {});
538
- E.toast('Backup created.', {type: 'success'});
539
- await loadBackups($container);
540
- } catch (err) {
541
- E.toast(`Backup failed: ${err.message || 'Unknown error'}`, {type: 'error'});
542
- } finally {
543
- btn.disabled = false;
544
- const icon = document.createElement('span');
545
- icon.dataset.icon = 'download';
546
- icon.style.cssText = 'width:14px;height:14px;';
547
- btn.textContent = ' Create Backup';
548
- btn.prepend(icon);
549
- I.scan();
550
- }
551
- });
552
-
553
- // Clone tab
554
- wireClone($container);
555
-
556
- I.scan();
557
- }
558
- };
@@ -1,9 +0,0 @@
1
- /**
2
- * Data Transfer Plugin — Default Configuration
3
- *
4
- * Override any of these via config/plugins.json under "data-transfer.settings".
5
- */
6
- export default {
7
- /** Maximum number of backups to retain. Oldest are pruned automatically. */
8
- maxBackups: 50
9
- };
@@ -1,424 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
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
- }