@dboio/cli 0.4.2 → 0.5.1
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/README.md +246 -70
- package/bin/dbo.js +7 -3
- package/package.json +9 -3
- package/src/commands/clone.js +469 -14
- package/src/commands/diff.js +246 -0
- package/src/commands/init.js +30 -22
- package/src/commands/install.js +526 -69
- package/src/commands/mv.js +869 -0
- package/src/commands/pull.js +6 -0
- package/src/commands/push.js +63 -21
- package/src/commands/rm.js +337 -0
- package/src/commands/status.js +28 -1
- package/src/lib/config.js +195 -0
- package/src/lib/diff.js +740 -0
- package/src/lib/save-to-disk.js +71 -4
- package/src/lib/structure.js +36 -0
- package/src/plugins/claudecommands/dbo.md +37 -6
- package/src/commands/update.js +0 -168
package/src/commands/clone.js
CHANGED
|
@@ -2,24 +2,28 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
|
3
3
|
import { join, basename, extname } from 'path';
|
|
4
4
|
import { DboClient } from '../lib/client.js';
|
|
5
|
-
import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore } from '../lib/config.js';
|
|
6
|
-
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, BINS_DIR, DEFAULT_PROJECT_DIRS } from '../lib/structure.js';
|
|
5
|
+
import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference } from '../lib/config.js';
|
|
6
|
+
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP } from '../lib/structure.js';
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
8
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
9
|
+
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge } from '../lib/diff.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Resolve a column value that may be base64-encoded.
|
|
12
13
|
* Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
|
|
13
14
|
*/
|
|
14
|
-
function resolveContentValue(value) {
|
|
15
|
+
export function resolveContentValue(value) {
|
|
15
16
|
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
-
&& value.encoding === 'base64'
|
|
17
|
-
|
|
17
|
+
&& value.encoding === 'base64') {
|
|
18
|
+
// Base64 object: decode if value is a string, otherwise empty string
|
|
19
|
+
return typeof value.value === 'string'
|
|
20
|
+
? Buffer.from(value.value, 'base64').toString('utf8')
|
|
21
|
+
: '';
|
|
18
22
|
}
|
|
19
23
|
return value !== null && value !== undefined ? String(value) : null;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
function sanitizeFilename(name) {
|
|
26
|
+
export function sanitizeFilename(name) {
|
|
23
27
|
return name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '-').substring(0, 200);
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -27,6 +31,45 @@ async function fileExists(path) {
|
|
|
27
31
|
try { await access(path); return true; } catch { return false; }
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a content Path to a directory under Bins/.
|
|
36
|
+
*
|
|
37
|
+
* When a record has BinID=null but a Path like "tpl/ticket-menu-list.html"
|
|
38
|
+
* or "app/nima-test-test.txt", we need to place it inside Bins/:
|
|
39
|
+
*
|
|
40
|
+
* 1. Extract the directory portion of the Path (e.g. "tpl", "app")
|
|
41
|
+
* 2. Try to match it to an existing bin in structure.json
|
|
42
|
+
* - If found → use the resolved bin path (e.g. "Bins/app")
|
|
43
|
+
* 3. If not found → place under Bins/<dir> (e.g. "Bins/tpl")
|
|
44
|
+
*
|
|
45
|
+
* This ensures content files always land inside Bins/, never at the project root.
|
|
46
|
+
*/
|
|
47
|
+
function resolvePathToBinsDir(pathValue, structure) {
|
|
48
|
+
const cleaned = pathValue.replace(/^\/+|\/+$/g, '');
|
|
49
|
+
if (!cleaned) return BINS_DIR;
|
|
50
|
+
|
|
51
|
+
// Extract the directory portion (strip filename if path contains one)
|
|
52
|
+
const pathExt = extname(cleaned);
|
|
53
|
+
let dirPart;
|
|
54
|
+
if (pathExt) {
|
|
55
|
+
const lastSlash = cleaned.lastIndexOf('/');
|
|
56
|
+
dirPart = lastSlash >= 0 ? cleaned.substring(0, lastSlash) : null;
|
|
57
|
+
} else {
|
|
58
|
+
dirPart = cleaned;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!dirPart) return BINS_DIR;
|
|
62
|
+
|
|
63
|
+
// Try to match the directory path to an existing bin in structure.json
|
|
64
|
+
const bin = findBinByPath(dirPart, structure);
|
|
65
|
+
if (bin) {
|
|
66
|
+
return resolveBinPath(bin.binId, structure);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// No matching bin — place under Bins/<dir>
|
|
70
|
+
return `${BINS_DIR}/${dirPart}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
30
73
|
export const cloneCommand = new Command('clone')
|
|
31
74
|
.description('Clone an app from DBO.io to a local project structure')
|
|
32
75
|
.argument('[source]', 'Local JSON file path (optional)')
|
|
@@ -163,9 +206,18 @@ export async function performClone(source, options = {}) {
|
|
|
163
206
|
if (['bin', 'content', 'output', 'media'].includes(entityName)) continue;
|
|
164
207
|
if (!Array.isArray(entries)) continue;
|
|
165
208
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
209
|
+
if (ENTITY_DIR_MAP[entityName]) {
|
|
210
|
+
// Entity types with project directories — process into their directory
|
|
211
|
+
const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
|
|
212
|
+
if (refs.length > 0) {
|
|
213
|
+
otherRefs[entityName] = refs;
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
// Other entity types — process into Bins/ (requires BinID)
|
|
217
|
+
const refs = await processGenericEntries(entityName, entries, structure, options, placementPrefs.contentPlacement, serverTz);
|
|
218
|
+
if (refs.length > 0) {
|
|
219
|
+
otherRefs[entityName] = refs;
|
|
220
|
+
}
|
|
169
221
|
}
|
|
170
222
|
}
|
|
171
223
|
|
|
@@ -337,11 +389,12 @@ async function processContentEntries(contents, structure, options, contentPlacem
|
|
|
337
389
|
const placementPreference = {
|
|
338
390
|
value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
|
|
339
391
|
};
|
|
392
|
+
const bulkAction = { value: null }; // Shared across records for overwrite_all / skip_all
|
|
340
393
|
|
|
341
394
|
log.info(`Processing ${contents.length} content record(s)...`);
|
|
342
395
|
|
|
343
396
|
for (const record of contents) {
|
|
344
|
-
const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz);
|
|
397
|
+
const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
|
|
345
398
|
if (ref) refs.push(ref);
|
|
346
399
|
}
|
|
347
400
|
|
|
@@ -360,12 +413,13 @@ async function processGenericEntries(entityName, entries, structure, options, co
|
|
|
360
413
|
const placementPreference = {
|
|
361
414
|
value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
|
|
362
415
|
};
|
|
416
|
+
const bulkAction = { value: null };
|
|
363
417
|
let processed = 0;
|
|
364
418
|
|
|
365
419
|
for (const record of entries) {
|
|
366
420
|
if (!record.BinID) continue; // Skip entries without BinID
|
|
367
421
|
|
|
368
|
-
const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz);
|
|
422
|
+
const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
|
|
369
423
|
if (ref) {
|
|
370
424
|
refs.push(ref);
|
|
371
425
|
processed++;
|
|
@@ -379,6 +433,270 @@ async function processGenericEntries(entityName, entries, structure, options, co
|
|
|
379
433
|
return refs;
|
|
380
434
|
}
|
|
381
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Process entity-dir entries: entities that map to DEFAULT_PROJECT_DIRS.
|
|
438
|
+
* These don't require a BinID — they go directly into their project directory
|
|
439
|
+
* (e.g., Extensions/, Data Sources/, etc.)
|
|
440
|
+
*
|
|
441
|
+
* Returns array of { uid, metaPath } for app.json reference replacement.
|
|
442
|
+
*/
|
|
443
|
+
async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
444
|
+
if (!entries || entries.length === 0) return [];
|
|
445
|
+
|
|
446
|
+
const dirName = ENTITY_DIR_MAP[entityName];
|
|
447
|
+
if (!dirName) return [];
|
|
448
|
+
|
|
449
|
+
await mkdir(dirName, { recursive: true });
|
|
450
|
+
|
|
451
|
+
const refs = [];
|
|
452
|
+
const usedNames = new Map();
|
|
453
|
+
const bulkAction = { value: null };
|
|
454
|
+
const config = await loadConfig();
|
|
455
|
+
|
|
456
|
+
// Determine filename column: saved preference, or prompt, or default
|
|
457
|
+
let filenameCol = null;
|
|
458
|
+
|
|
459
|
+
if (options.yes) {
|
|
460
|
+
// Non-interactive: use Name, fallback UID
|
|
461
|
+
filenameCol = null; // will resolve per-record below
|
|
462
|
+
} else {
|
|
463
|
+
// Check saved preference
|
|
464
|
+
const savedCol = await loadEntityDirPreference(entityName);
|
|
465
|
+
if (savedCol) {
|
|
466
|
+
filenameCol = savedCol;
|
|
467
|
+
log.dim(` Using saved filename column "${filenameCol}" for ${entityName}`);
|
|
468
|
+
} else {
|
|
469
|
+
// Prompt user to pick a filename column from available columns
|
|
470
|
+
const sampleRecord = entries[0];
|
|
471
|
+
const columns = Object.keys(sampleRecord).filter(k => k !== 'children' && !k.startsWith('_'));
|
|
472
|
+
|
|
473
|
+
if (columns.length > 0) {
|
|
474
|
+
const inquirer = (await import('inquirer')).default;
|
|
475
|
+
|
|
476
|
+
// Find best default
|
|
477
|
+
const defaultCol = columns.includes('Name') ? 'Name'
|
|
478
|
+
: columns.includes('name') ? 'name'
|
|
479
|
+
: columns.includes('UID') ? 'UID'
|
|
480
|
+
: columns[0];
|
|
481
|
+
|
|
482
|
+
const { col } = await inquirer.prompt([{
|
|
483
|
+
type: 'list',
|
|
484
|
+
name: 'col',
|
|
485
|
+
message: `Which column should be used as the filename for ${entityName} records?`,
|
|
486
|
+
choices: columns,
|
|
487
|
+
default: defaultCol,
|
|
488
|
+
}]);
|
|
489
|
+
filenameCol = col;
|
|
490
|
+
|
|
491
|
+
// Save preference for future clones
|
|
492
|
+
await saveEntityDirPreference(entityName, filenameCol);
|
|
493
|
+
log.dim(` Saved filename column preference for ${entityName}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Detect base64 content columns and prompt per-column for extraction
|
|
499
|
+
let contentColsToExtract = [];
|
|
500
|
+
if (!options.yes) {
|
|
501
|
+
// Collect base64 columns with a content snippet from the first record that has data
|
|
502
|
+
const base64Cols = []; // { col, snippet }
|
|
503
|
+
for (const record of entries) {
|
|
504
|
+
for (const [key, value] of Object.entries(record)) {
|
|
505
|
+
if (key === 'children' || key.startsWith('_')) continue;
|
|
506
|
+
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
507
|
+
&& value.encoding === 'base64' && value.value !== null) {
|
|
508
|
+
if (!base64Cols.find(c => c.col === key)) {
|
|
509
|
+
// Decode a snippet for preview
|
|
510
|
+
let snippet = '';
|
|
511
|
+
try {
|
|
512
|
+
const decoded = Buffer.from(value.value, 'base64').toString('utf8');
|
|
513
|
+
snippet = decoded.substring(0, 80).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
514
|
+
if (decoded.length > 80) snippet += '...';
|
|
515
|
+
} catch { /* ignore decode errors */ }
|
|
516
|
+
base64Cols.push({ col: key, snippet });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (base64Cols.length > 0) {
|
|
523
|
+
const inquirer = (await import('inquirer')).default;
|
|
524
|
+
|
|
525
|
+
// Prompt per column: show snippet and ask extract yes/no + extension
|
|
526
|
+
for (const { col, snippet } of base64Cols) {
|
|
527
|
+
const preview = snippet ? ` (${snippet})` : '';
|
|
528
|
+
const guessed = guessExtensionForColumn(col);
|
|
529
|
+
|
|
530
|
+
const { extract } = await inquirer.prompt([{
|
|
531
|
+
type: 'confirm',
|
|
532
|
+
name: 'extract',
|
|
533
|
+
message: `Extract "${col}" as companion file?${preview}`,
|
|
534
|
+
default: true,
|
|
535
|
+
}]);
|
|
536
|
+
|
|
537
|
+
if (extract) {
|
|
538
|
+
const { ext } = await inquirer.prompt([{
|
|
539
|
+
type: 'input',
|
|
540
|
+
name: 'ext',
|
|
541
|
+
message: `File extension for "${col}":`,
|
|
542
|
+
default: guessed,
|
|
543
|
+
}]);
|
|
544
|
+
contentColsToExtract.push({ col, ext: ext.replace(/^\./, '') });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
log.info(`Processing ${entries.length} ${entityName} record(s) → ${dirName}/`);
|
|
551
|
+
|
|
552
|
+
for (const record of entries) {
|
|
553
|
+
// Determine filename for this record
|
|
554
|
+
let name;
|
|
555
|
+
if (filenameCol && record[filenameCol] !== null && record[filenameCol] !== undefined) {
|
|
556
|
+
name = sanitizeFilename(String(record[filenameCol]));
|
|
557
|
+
} else if (record.Name) {
|
|
558
|
+
name = sanitizeFilename(String(record.Name));
|
|
559
|
+
} else {
|
|
560
|
+
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Deduplicate filenames
|
|
564
|
+
const nameKey = `${dirName}/${name}`;
|
|
565
|
+
const count = usedNames.get(nameKey) || 0;
|
|
566
|
+
usedNames.set(nameKey, count + 1);
|
|
567
|
+
const finalName = count > 0 ? `${name}-${count + 1}` : name;
|
|
568
|
+
|
|
569
|
+
const metaPath = join(dirName, `${finalName}.metadata.json`);
|
|
570
|
+
|
|
571
|
+
// Change detection for existing files
|
|
572
|
+
// Skip change detection when user has selected new content columns to extract —
|
|
573
|
+
// we need to re-process all records to create the companion files
|
|
574
|
+
const hasNewExtractions = contentColsToExtract.length > 0;
|
|
575
|
+
if (await fileExists(metaPath) && !options.yes && !hasNewExtractions) {
|
|
576
|
+
if (bulkAction.value === 'skip_all') {
|
|
577
|
+
log.dim(` Skipped ${finalName}`);
|
|
578
|
+
refs.push({ uid: record.UID, metaPath });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (bulkAction.value !== 'overwrite_all') {
|
|
583
|
+
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
584
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
|
|
585
|
+
|
|
586
|
+
if (serverNewer) {
|
|
587
|
+
const action = await promptChangeDetection(finalName, record, config);
|
|
588
|
+
|
|
589
|
+
if (action === 'skip') {
|
|
590
|
+
log.dim(` Skipped ${finalName}`);
|
|
591
|
+
refs.push({ uid: record.UID, metaPath });
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (action === 'skip_all') {
|
|
595
|
+
bulkAction.value = 'skip_all';
|
|
596
|
+
log.dim(` Skipped ${finalName}`);
|
|
597
|
+
refs.push({ uid: record.UID, metaPath });
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (action === 'overwrite_all') {
|
|
601
|
+
bulkAction.value = 'overwrite_all';
|
|
602
|
+
}
|
|
603
|
+
if (action === 'compare') {
|
|
604
|
+
await inlineDiffAndMerge(record, metaPath, config);
|
|
605
|
+
refs.push({ uid: record.UID, metaPath });
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
} else {
|
|
609
|
+
const locallyModified = await hasLocalModifications(metaPath, config);
|
|
610
|
+
if (locallyModified) {
|
|
611
|
+
const action = await promptChangeDetection(finalName, record, config, { localIsNewer: true });
|
|
612
|
+
|
|
613
|
+
if (action === 'skip') {
|
|
614
|
+
log.dim(` Kept local: ${finalName}`);
|
|
615
|
+
refs.push({ uid: record.UID, metaPath });
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (action === 'skip_all') {
|
|
619
|
+
bulkAction.value = 'skip_all';
|
|
620
|
+
log.dim(` Kept local: ${finalName}`);
|
|
621
|
+
refs.push({ uid: record.UID, metaPath });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (action === 'overwrite_all') {
|
|
625
|
+
bulkAction.value = 'overwrite_all';
|
|
626
|
+
}
|
|
627
|
+
if (action === 'compare') {
|
|
628
|
+
await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
|
|
629
|
+
refs.push({ uid: record.UID, metaPath });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
log.dim(` Up to date: ${finalName}`);
|
|
634
|
+
refs.push({ uid: record.UID, metaPath });
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Build metadata
|
|
642
|
+
const meta = {};
|
|
643
|
+
const extractedContentCols = [];
|
|
644
|
+
|
|
645
|
+
for (const [key, value] of Object.entries(record)) {
|
|
646
|
+
if (key === 'children') continue;
|
|
647
|
+
|
|
648
|
+
// Check if this column should be extracted as a companion file
|
|
649
|
+
const extractInfo = contentColsToExtract.find(c => c.col === key);
|
|
650
|
+
if (extractInfo && value && typeof value === 'object' && value.encoding === 'base64' && value.value !== null) {
|
|
651
|
+
const decoded = resolveContentValue(value);
|
|
652
|
+
if (decoded) {
|
|
653
|
+
const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
|
|
654
|
+
const colFilePath = join(dirName, colFileName);
|
|
655
|
+
await writeFile(colFilePath, decoded);
|
|
656
|
+
meta[key] = `@${colFileName}`;
|
|
657
|
+
extractedContentCols.push(key);
|
|
658
|
+
|
|
659
|
+
// Set timestamps on companion file
|
|
660
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
661
|
+
try {
|
|
662
|
+
await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
663
|
+
} catch { /* non-critical */ }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
log.dim(` → ${colFilePath}`);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Other base64 columns not selected for extraction — decode inline
|
|
672
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
|
|
673
|
+
meta[key] = resolveContentValue(value);
|
|
674
|
+
} else {
|
|
675
|
+
meta[key] = value;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
meta._entity = entityName;
|
|
680
|
+
if (extractedContentCols.length > 0) {
|
|
681
|
+
meta._contentColumns = extractedContentCols;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
685
|
+
log.success(`Saved ${metaPath}`);
|
|
686
|
+
|
|
687
|
+
// Set file timestamps from server dates
|
|
688
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
689
|
+
try {
|
|
690
|
+
await setFileTimestamps(metaPath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
691
|
+
} catch { /* non-critical */ }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
refs.push({ uid: record.UID, metaPath });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return refs;
|
|
698
|
+
}
|
|
699
|
+
|
|
382
700
|
/**
|
|
383
701
|
* Process media entries: download binary files from server + create metadata.
|
|
384
702
|
* Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
|
|
@@ -424,6 +742,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
424
742
|
const placementPreference = {
|
|
425
743
|
value: mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null,
|
|
426
744
|
};
|
|
745
|
+
const mediaBulkAction = { value: null };
|
|
427
746
|
let downloaded = 0;
|
|
428
747
|
let failed = 0;
|
|
429
748
|
|
|
@@ -503,6 +822,76 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
503
822
|
const filePath = join(dir, finalFilename);
|
|
504
823
|
const metaPath = join(dir, `${finalFilename}.metadata.json`);
|
|
505
824
|
|
|
825
|
+
// Change detection for existing media files
|
|
826
|
+
if (await fileExists(metaPath) && !options.yes) {
|
|
827
|
+
if (mediaBulkAction.value === 'skip_all') {
|
|
828
|
+
log.dim(` Skipped ${finalFilename}`);
|
|
829
|
+
refs.push({ uid: record.UID, metaPath });
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (mediaBulkAction.value !== 'overwrite_all') {
|
|
834
|
+
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
835
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
|
|
836
|
+
|
|
837
|
+
if (serverNewer) {
|
|
838
|
+
const action = await promptChangeDetection(dedupName, record, config);
|
|
839
|
+
|
|
840
|
+
if (action === 'skip') {
|
|
841
|
+
log.dim(` Skipped ${finalFilename}`);
|
|
842
|
+
refs.push({ uid: record.UID, metaPath });
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
if (action === 'skip_all') {
|
|
846
|
+
mediaBulkAction.value = 'skip_all';
|
|
847
|
+
log.dim(` Skipped ${finalFilename}`);
|
|
848
|
+
refs.push({ uid: record.UID, metaPath });
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (action === 'overwrite_all') {
|
|
852
|
+
mediaBulkAction.value = 'overwrite_all';
|
|
853
|
+
}
|
|
854
|
+
if (action === 'compare') {
|
|
855
|
+
// For binary media, show metadata diffs only
|
|
856
|
+
await inlineDiffAndMerge(record, metaPath, config);
|
|
857
|
+
refs.push({ uid: record.UID, metaPath });
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
} else {
|
|
861
|
+
// Server _LastUpdated hasn't changed — check for local modifications
|
|
862
|
+
const locallyModified = await hasLocalModifications(metaPath, config);
|
|
863
|
+
if (locallyModified) {
|
|
864
|
+
const action = await promptChangeDetection(dedupName, record, config, { localIsNewer: true });
|
|
865
|
+
|
|
866
|
+
if (action === 'skip') {
|
|
867
|
+
log.dim(` Kept local: ${finalFilename}`);
|
|
868
|
+
refs.push({ uid: record.UID, metaPath });
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
if (action === 'skip_all') {
|
|
872
|
+
mediaBulkAction.value = 'skip_all';
|
|
873
|
+
log.dim(` Kept local: ${finalFilename}`);
|
|
874
|
+
refs.push({ uid: record.UID, metaPath });
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (action === 'overwrite_all') {
|
|
878
|
+
mediaBulkAction.value = 'overwrite_all';
|
|
879
|
+
}
|
|
880
|
+
if (action === 'compare') {
|
|
881
|
+
await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
|
|
882
|
+
refs.push({ uid: record.UID, metaPath });
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
// 'overwrite' falls through to download
|
|
886
|
+
} else {
|
|
887
|
+
log.dim(` Up to date: ${finalFilename}`);
|
|
888
|
+
refs.push({ uid: record.UID, metaPath });
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
506
895
|
// Download the file
|
|
507
896
|
try {
|
|
508
897
|
const buffer = await client.getBuffer(`/api/media/${record.UID}`);
|
|
@@ -551,7 +940,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
551
940
|
* Process a single record: determine directory, write content file + metadata.
|
|
552
941
|
* Returns { uid, metaPath } or null.
|
|
553
942
|
*/
|
|
554
|
-
async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz) {
|
|
943
|
+
async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction = { value: null }) {
|
|
555
944
|
let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
|
|
556
945
|
const ext = (record.Extension || 'txt').toLowerCase();
|
|
557
946
|
|
|
@@ -603,7 +992,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
603
992
|
} else if (hasBinID) {
|
|
604
993
|
dir = resolveBinPath(record.BinID, structure);
|
|
605
994
|
} else if (hasPath) {
|
|
606
|
-
|
|
995
|
+
// BinID is null — resolve Path into a Bins/ location
|
|
996
|
+
dir = resolvePathToBinsDir(record.Path, structure);
|
|
607
997
|
}
|
|
608
998
|
|
|
609
999
|
// Clean up directory path
|
|
@@ -636,6 +1026,71 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
636
1026
|
const filePath = join(dir, fileName);
|
|
637
1027
|
const metaPath = join(dir, `${finalName}.metadata.json`);
|
|
638
1028
|
|
|
1029
|
+
// Change detection: check if file already exists locally
|
|
1030
|
+
if (await fileExists(metaPath) && !options.yes) {
|
|
1031
|
+
if (bulkAction.value === 'skip_all') {
|
|
1032
|
+
log.dim(` Skipped ${finalName}.${ext}`);
|
|
1033
|
+
return { uid: record.UID, metaPath };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (bulkAction.value !== 'overwrite_all') {
|
|
1037
|
+
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
1038
|
+
const config = await loadConfig();
|
|
1039
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
|
|
1040
|
+
|
|
1041
|
+
if (serverNewer) {
|
|
1042
|
+
const action = await promptChangeDetection(finalName, record, config);
|
|
1043
|
+
|
|
1044
|
+
if (action === 'skip') {
|
|
1045
|
+
log.dim(` Skipped ${finalName}.${ext}`);
|
|
1046
|
+
return { uid: record.UID, metaPath };
|
|
1047
|
+
}
|
|
1048
|
+
if (action === 'skip_all') {
|
|
1049
|
+
bulkAction.value = 'skip_all';
|
|
1050
|
+
log.dim(` Skipped ${finalName}.${ext}`);
|
|
1051
|
+
return { uid: record.UID, metaPath };
|
|
1052
|
+
}
|
|
1053
|
+
if (action === 'overwrite_all') {
|
|
1054
|
+
bulkAction.value = 'overwrite_all';
|
|
1055
|
+
// Fall through to write
|
|
1056
|
+
}
|
|
1057
|
+
if (action === 'compare') {
|
|
1058
|
+
await inlineDiffAndMerge(record, metaPath, config);
|
|
1059
|
+
return { uid: record.UID, metaPath };
|
|
1060
|
+
}
|
|
1061
|
+
// 'overwrite' falls through to normal write
|
|
1062
|
+
} else {
|
|
1063
|
+
// Server _LastUpdated hasn't changed since last sync.
|
|
1064
|
+
// Check if local content files were modified (user edits).
|
|
1065
|
+
const locallyModified = await hasLocalModifications(metaPath, config);
|
|
1066
|
+
if (locallyModified) {
|
|
1067
|
+
const action = await promptChangeDetection(finalName, record, config, { localIsNewer: true });
|
|
1068
|
+
|
|
1069
|
+
if (action === 'skip') {
|
|
1070
|
+
log.dim(` Kept local: ${finalName}.${ext}`);
|
|
1071
|
+
return { uid: record.UID, metaPath };
|
|
1072
|
+
}
|
|
1073
|
+
if (action === 'skip_all') {
|
|
1074
|
+
bulkAction.value = 'skip_all';
|
|
1075
|
+
log.dim(` Kept local: ${finalName}.${ext}`);
|
|
1076
|
+
return { uid: record.UID, metaPath };
|
|
1077
|
+
}
|
|
1078
|
+
if (action === 'overwrite_all') {
|
|
1079
|
+
bulkAction.value = 'overwrite_all';
|
|
1080
|
+
}
|
|
1081
|
+
if (action === 'compare') {
|
|
1082
|
+
await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
|
|
1083
|
+
return { uid: record.UID, metaPath };
|
|
1084
|
+
}
|
|
1085
|
+
// 'overwrite' falls through to normal write
|
|
1086
|
+
} else {
|
|
1087
|
+
log.dim(` Up to date: ${finalName}.${ext}`);
|
|
1088
|
+
return { uid: record.UID, metaPath };
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
639
1094
|
if (hasContent) {
|
|
640
1095
|
const decoded = resolveContentValue(contentValue);
|
|
641
1096
|
if (decoded) {
|
|
@@ -694,7 +1149,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
694
1149
|
/**
|
|
695
1150
|
* Guess file extension for a column name.
|
|
696
1151
|
*/
|
|
697
|
-
function guessExtensionForColumn(columnName) {
|
|
1152
|
+
export function guessExtensionForColumn(columnName) {
|
|
698
1153
|
const lower = columnName.toLowerCase();
|
|
699
1154
|
if (lower.includes('css')) return 'css';
|
|
700
1155
|
if (lower.includes('js') || lower.includes('script')) return 'js';
|