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