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.
- package/config/plugins.json +0 -4
- package/package.json +1 -1
- package/plugins/data-transfer/admin/templates/data-transfer.html +0 -172
- package/plugins/data-transfer/admin/views/data-transfer.js +0 -558
- package/plugins/data-transfer/config.js +0 -9
- package/plugins/data-transfer/plugin.js +0 -424
- package/plugins/data-transfer/plugin.json +0 -33
package/config/plugins.json
CHANGED
package/package.json
CHANGED
|
@@ -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 → 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 → 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 → 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,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
|
-
}
|