@dboio/cli 0.4.2 → 0.6.0

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.
@@ -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, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline } 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' && typeof value.value === 'string') {
17
- return Buffer.from(value.value, 'base64').toString('utf8');
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,55 @@ 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
+ * **Note on Bins/app/:**
48
+ * Files placed in Bins/app/ are purely for local organization. During push operations,
49
+ * the "app/" subdirectory is treated specially and stripped from path comparisons,
50
+ * as these files are served from the root path on the server without the "app/" prefix.
51
+ * Other custom bin directories (like "tpl/") maintain their directory name in the path.
52
+ *
53
+ * @param {string} pathValue - Server-side Path value
54
+ * @param {Object} structure - Bin hierarchy structure from structure.json
55
+ * @returns {string} - Local directory path under Bins/
56
+ */
57
+ function resolvePathToBinsDir(pathValue, structure) {
58
+ const cleaned = pathValue.replace(/^\/+|\/+$/g, '');
59
+ if (!cleaned) return BINS_DIR;
60
+
61
+ // Extract the directory portion (strip filename if path contains one)
62
+ const pathExt = extname(cleaned);
63
+ let dirPart;
64
+ if (pathExt) {
65
+ const lastSlash = cleaned.lastIndexOf('/');
66
+ dirPart = lastSlash >= 0 ? cleaned.substring(0, lastSlash) : null;
67
+ } else {
68
+ dirPart = cleaned;
69
+ }
70
+
71
+ if (!dirPart) return BINS_DIR;
72
+
73
+ // Try to match the directory path to an existing bin in structure.json
74
+ const bin = findBinByPath(dirPart, structure);
75
+ if (bin) {
76
+ return resolveBinPath(bin.binId, structure);
77
+ }
78
+
79
+ // No matching bin — place under Bins/<dir>
80
+ return `${BINS_DIR}/${dirPart}`;
81
+ }
82
+
30
83
  export const cloneCommand = new Command('clone')
31
84
  .description('Clone an app from DBO.io to a local project structure')
32
85
  .argument('[source]', 'Local JSON file path (optional)')
@@ -163,9 +216,18 @@ export async function performClone(source, options = {}) {
163
216
  if (['bin', 'content', 'output', 'media'].includes(entityName)) continue;
164
217
  if (!Array.isArray(entries)) continue;
165
218
 
166
- const refs = await processGenericEntries(entityName, entries, structure, options, placementPrefs.contentPlacement, serverTz);
167
- if (refs.length > 0) {
168
- otherRefs[entityName] = refs;
219
+ if (ENTITY_DIR_MAP[entityName]) {
220
+ // Entity types with project directories — process into their directory
221
+ const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
222
+ if (refs.length > 0) {
223
+ otherRefs[entityName] = refs;
224
+ }
225
+ } else {
226
+ // Other entity types — process into Bins/ (requires BinID)
227
+ const refs = await processGenericEntries(entityName, entries, structure, options, placementPrefs.contentPlacement, serverTz);
228
+ if (refs.length > 0) {
229
+ otherRefs[entityName] = refs;
230
+ }
169
231
  }
170
232
  }
171
233
 
@@ -177,6 +239,12 @@ export async function performClone(source, options = {}) {
177
239
  // Step 7: Save app.json with references
178
240
  await saveAppJson(appJson, contentRefs, otherRefs);
179
241
 
242
+ // Step 8: Create .app.json baseline for delta tracking
243
+ await saveBaselineFile(appJson);
244
+
245
+ // Step 9: Ensure .app.json is in .gitignore
246
+ await ensureGitignore(['.app.json']);
247
+
180
248
  log.plain('');
181
249
  log.success('Clone complete!');
182
250
  log.dim(' app.json saved to project root');
@@ -337,11 +405,12 @@ async function processContentEntries(contents, structure, options, contentPlacem
337
405
  const placementPreference = {
338
406
  value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
339
407
  };
408
+ const bulkAction = { value: null }; // Shared across records for overwrite_all / skip_all
340
409
 
341
410
  log.info(`Processing ${contents.length} content record(s)...`);
342
411
 
343
412
  for (const record of contents) {
344
- const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz);
413
+ const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
345
414
  if (ref) refs.push(ref);
346
415
  }
347
416
 
@@ -360,12 +429,13 @@ async function processGenericEntries(entityName, entries, structure, options, co
360
429
  const placementPreference = {
361
430
  value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
362
431
  };
432
+ const bulkAction = { value: null };
363
433
  let processed = 0;
364
434
 
365
435
  for (const record of entries) {
366
436
  if (!record.BinID) continue; // Skip entries without BinID
367
437
 
368
- const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz);
438
+ const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
369
439
  if (ref) {
370
440
  refs.push(ref);
371
441
  processed++;
@@ -379,6 +449,308 @@ async function processGenericEntries(entityName, entries, structure, options, co
379
449
  return refs;
380
450
  }
381
451
 
452
+ /**
453
+ * Process entity-dir entries: entities that map to DEFAULT_PROJECT_DIRS.
454
+ * These don't require a BinID — they go directly into their project directory
455
+ * (e.g., Extensions/, Data Sources/, etc.)
456
+ *
457
+ * Returns array of { uid, metaPath } for app.json reference replacement.
458
+ */
459
+ async function processEntityDirEntries(entityName, entries, options, serverTz) {
460
+ if (!entries || entries.length === 0) return [];
461
+
462
+ const dirName = ENTITY_DIR_MAP[entityName];
463
+ if (!dirName) return [];
464
+
465
+ await mkdir(dirName, { recursive: true });
466
+
467
+ const refs = [];
468
+ const usedNames = new Map();
469
+ const bulkAction = { value: null };
470
+ const config = await loadConfig();
471
+
472
+ // Determine filename column: saved preference, or prompt, or default
473
+ let filenameCol = null;
474
+
475
+ if (options.yes) {
476
+ // Non-interactive: use Name, fallback UID
477
+ filenameCol = null; // will resolve per-record below
478
+ } else {
479
+ // Check saved preference
480
+ const savedCol = await loadEntityDirPreference(entityName);
481
+ if (savedCol) {
482
+ filenameCol = savedCol;
483
+ log.dim(` Using saved filename column "${filenameCol}" for ${entityName}`);
484
+ } else {
485
+ // Prompt user to pick a filename column from available columns
486
+ const sampleRecord = entries[0];
487
+ const columns = Object.keys(sampleRecord).filter(k => k !== 'children' && !k.startsWith('_'));
488
+
489
+ if (columns.length > 0) {
490
+ const inquirer = (await import('inquirer')).default;
491
+
492
+ // Find best default (app_version prioritizes Number → Name → UID)
493
+ const defaultCol = (entityName === 'app_version' && columns.includes('Number')) ? 'Number'
494
+ : columns.includes('Name') ? 'Name'
495
+ : columns.includes('name') ? 'name'
496
+ : columns.includes('UID') ? 'UID'
497
+ : columns[0];
498
+
499
+ const { col } = await inquirer.prompt([{
500
+ type: 'list',
501
+ name: 'col',
502
+ message: `Which column should be used as the filename for ${entityName} records?`,
503
+ choices: columns,
504
+ default: defaultCol,
505
+ }]);
506
+ filenameCol = col;
507
+
508
+ // Save preference for future clones
509
+ await saveEntityDirPreference(entityName, filenameCol);
510
+ log.dim(` Saved filename column preference for ${entityName}`);
511
+ }
512
+ }
513
+ }
514
+
515
+ // Detect base64 content columns and prompt per-column for extraction
516
+ let contentColsToExtract = [];
517
+ if (!options.yes) {
518
+ // Collect base64 columns with a content snippet from the first record that has data
519
+ const base64Cols = []; // { col, snippet }
520
+ for (const record of entries) {
521
+ for (const [key, value] of Object.entries(record)) {
522
+ if (key === 'children' || key.startsWith('_')) continue;
523
+ if (value && typeof value === 'object' && !Array.isArray(value)
524
+ && value.encoding === 'base64' && value.value !== null) {
525
+ if (!base64Cols.find(c => c.col === key)) {
526
+ // Decode a snippet for preview
527
+ let snippet = '';
528
+ try {
529
+ const decoded = Buffer.from(value.value, 'base64').toString('utf8');
530
+ snippet = decoded.substring(0, 80).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
531
+ if (decoded.length > 80) snippet += '...';
532
+ } catch { /* ignore decode errors */ }
533
+ base64Cols.push({ col: key, snippet });
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ if (base64Cols.length > 0) {
540
+ // Load saved content extraction preferences
541
+ const savedExtractions = await loadEntityContentExtractions(entityName);
542
+ const newPreferences = savedExtractions ? { ...savedExtractions } : {};
543
+ let hasNewChoices = false;
544
+
545
+ const inquirer = (await import('inquirer')).default;
546
+
547
+ // Prompt per column: show snippet and ask extract yes/no + extension
548
+ for (const { col, snippet } of base64Cols) {
549
+ // Check if we have a saved preference for this column
550
+ if (savedExtractions && col in savedExtractions) {
551
+ const savedPref = savedExtractions[col];
552
+ if (savedPref === false) {
553
+ // User previously chose not to extract this column
554
+ log.dim(` Skipping "${col}" (saved preference: no extraction)`);
555
+ continue;
556
+ } else if (typeof savedPref === 'string') {
557
+ // User previously chose to extract with a specific extension
558
+ log.dim(` Extracting "${col}" as .${savedPref} file (saved preference)`);
559
+ contentColsToExtract.push({ col, ext: savedPref });
560
+ continue;
561
+ }
562
+ }
563
+
564
+ // No saved preference - prompt the user
565
+ const preview = snippet ? ` (${snippet})` : '';
566
+ const guessed = guessExtensionForColumn(col);
567
+
568
+ const { extract } = await inquirer.prompt([{
569
+ type: 'confirm',
570
+ name: 'extract',
571
+ message: `Extract "${col}" as companion file?${preview}`,
572
+ default: true,
573
+ }]);
574
+
575
+ if (extract) {
576
+ const { ext } = await inquirer.prompt([{
577
+ type: 'input',
578
+ name: 'ext',
579
+ message: `File extension for "${col}":`,
580
+ default: guessed,
581
+ }]);
582
+ const cleanExt = ext.replace(/^\./, '');
583
+ contentColsToExtract.push({ col, ext: cleanExt });
584
+ newPreferences[col] = cleanExt;
585
+ hasNewChoices = true;
586
+ } else {
587
+ // User chose not to extract - save this preference too
588
+ newPreferences[col] = false;
589
+ hasNewChoices = true;
590
+ }
591
+ }
592
+
593
+ // Save preferences if any new choices were made
594
+ if (hasNewChoices) {
595
+ await saveEntityContentExtractions(entityName, newPreferences);
596
+ log.dim(` Saved content extraction preferences for ${entityName}`);
597
+ }
598
+ }
599
+ }
600
+
601
+ log.info(`Processing ${entries.length} ${entityName} record(s) → ${dirName}/`);
602
+
603
+ for (const record of entries) {
604
+ // Determine filename for this record
605
+ let name;
606
+ if (filenameCol && record[filenameCol] !== null && record[filenameCol] !== undefined) {
607
+ name = sanitizeFilename(String(record[filenameCol]));
608
+ } else if (entityName === 'app_version' && record.Number) {
609
+ // Special fallback for app_version: Number is preferred over Name
610
+ name = sanitizeFilename(String(record.Number));
611
+ } else if (record.Name) {
612
+ name = sanitizeFilename(String(record.Name));
613
+ } else {
614
+ name = sanitizeFilename(String(record.UID || 'untitled'));
615
+ }
616
+
617
+ // Deduplicate filenames
618
+ const nameKey = `${dirName}/${name}`;
619
+ const count = usedNames.get(nameKey) || 0;
620
+ usedNames.set(nameKey, count + 1);
621
+ const finalName = count > 0 ? `${name}-${count + 1}` : name;
622
+
623
+ const metaPath = join(dirName, `${finalName}.metadata.json`);
624
+
625
+ // Change detection for existing files
626
+ // Skip change detection when user has selected new content columns to extract —
627
+ // we need to re-process all records to create the companion files
628
+ const hasNewExtractions = contentColsToExtract.length > 0;
629
+ if (await fileExists(metaPath) && !options.yes && !hasNewExtractions) {
630
+ if (bulkAction.value === 'skip_all') {
631
+ log.dim(` Skipped ${finalName}`);
632
+ refs.push({ uid: record.UID, metaPath });
633
+ continue;
634
+ }
635
+
636
+ if (bulkAction.value !== 'overwrite_all') {
637
+ const localSyncTime = await getLocalSyncTime(metaPath);
638
+ const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
639
+
640
+ if (serverNewer) {
641
+ const action = await promptChangeDetection(finalName, record, config);
642
+
643
+ if (action === 'skip') {
644
+ log.dim(` Skipped ${finalName}`);
645
+ refs.push({ uid: record.UID, metaPath });
646
+ continue;
647
+ }
648
+ if (action === 'skip_all') {
649
+ bulkAction.value = 'skip_all';
650
+ log.dim(` Skipped ${finalName}`);
651
+ refs.push({ uid: record.UID, metaPath });
652
+ continue;
653
+ }
654
+ if (action === 'overwrite_all') {
655
+ bulkAction.value = 'overwrite_all';
656
+ }
657
+ if (action === 'compare') {
658
+ await inlineDiffAndMerge(record, metaPath, config);
659
+ refs.push({ uid: record.UID, metaPath });
660
+ continue;
661
+ }
662
+ } else {
663
+ const locallyModified = await hasLocalModifications(metaPath, config);
664
+ if (locallyModified) {
665
+ const action = await promptChangeDetection(finalName, record, config, { localIsNewer: true });
666
+
667
+ if (action === 'skip') {
668
+ log.dim(` Kept local: ${finalName}`);
669
+ refs.push({ uid: record.UID, metaPath });
670
+ continue;
671
+ }
672
+ if (action === 'skip_all') {
673
+ bulkAction.value = 'skip_all';
674
+ log.dim(` Kept local: ${finalName}`);
675
+ refs.push({ uid: record.UID, metaPath });
676
+ continue;
677
+ }
678
+ if (action === 'overwrite_all') {
679
+ bulkAction.value = 'overwrite_all';
680
+ }
681
+ if (action === 'compare') {
682
+ await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
683
+ refs.push({ uid: record.UID, metaPath });
684
+ continue;
685
+ }
686
+ } else {
687
+ log.dim(` Up to date: ${finalName}`);
688
+ refs.push({ uid: record.UID, metaPath });
689
+ continue;
690
+ }
691
+ }
692
+ }
693
+ }
694
+
695
+ // Build metadata
696
+ const meta = {};
697
+ const extractedContentCols = [];
698
+
699
+ for (const [key, value] of Object.entries(record)) {
700
+ if (key === 'children') continue;
701
+
702
+ // Check if this column should be extracted as a companion file
703
+ const extractInfo = contentColsToExtract.find(c => c.col === key);
704
+ if (extractInfo && value && typeof value === 'object' && value.encoding === 'base64' && value.value !== null) {
705
+ const decoded = resolveContentValue(value);
706
+ if (decoded) {
707
+ const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
708
+ const colFilePath = join(dirName, colFileName);
709
+ await writeFile(colFilePath, decoded);
710
+ meta[key] = `@${colFileName}`;
711
+ extractedContentCols.push(key);
712
+
713
+ // Set timestamps on companion file
714
+ if (serverTz && (record._CreatedOn || record._LastUpdated)) {
715
+ try {
716
+ await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz);
717
+ } catch { /* non-critical */ }
718
+ }
719
+
720
+ log.dim(` → ${colFilePath}`);
721
+ continue;
722
+ }
723
+ }
724
+
725
+ // Other base64 columns not selected for extraction — decode inline
726
+ if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
727
+ meta[key] = resolveContentValue(value);
728
+ } else {
729
+ meta[key] = value;
730
+ }
731
+ }
732
+
733
+ meta._entity = entityName;
734
+ if (extractedContentCols.length > 0) {
735
+ meta._contentColumns = extractedContentCols;
736
+ }
737
+
738
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
739
+ log.success(`Saved ${metaPath}`);
740
+
741
+ // Set file timestamps from server dates
742
+ if (serverTz && (record._CreatedOn || record._LastUpdated)) {
743
+ try {
744
+ await setFileTimestamps(metaPath, record._CreatedOn, record._LastUpdated, serverTz);
745
+ } catch { /* non-critical */ }
746
+ }
747
+
748
+ refs.push({ uid: record.UID, metaPath });
749
+ }
750
+
751
+ return refs;
752
+ }
753
+
382
754
  /**
383
755
  * Process media entries: download binary files from server + create metadata.
384
756
  * Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
@@ -424,6 +796,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
424
796
  const placementPreference = {
425
797
  value: mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null,
426
798
  };
799
+ const mediaBulkAction = { value: null };
427
800
  let downloaded = 0;
428
801
  let failed = 0;
429
802
 
@@ -503,6 +876,76 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
503
876
  const filePath = join(dir, finalFilename);
504
877
  const metaPath = join(dir, `${finalFilename}.metadata.json`);
505
878
 
879
+ // Change detection for existing media files
880
+ if (await fileExists(metaPath) && !options.yes) {
881
+ if (mediaBulkAction.value === 'skip_all') {
882
+ log.dim(` Skipped ${finalFilename}`);
883
+ refs.push({ uid: record.UID, metaPath });
884
+ continue;
885
+ }
886
+
887
+ if (mediaBulkAction.value !== 'overwrite_all') {
888
+ const localSyncTime = await getLocalSyncTime(metaPath);
889
+ const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
890
+
891
+ if (serverNewer) {
892
+ const action = await promptChangeDetection(dedupName, record, config);
893
+
894
+ if (action === 'skip') {
895
+ log.dim(` Skipped ${finalFilename}`);
896
+ refs.push({ uid: record.UID, metaPath });
897
+ continue;
898
+ }
899
+ if (action === 'skip_all') {
900
+ mediaBulkAction.value = 'skip_all';
901
+ log.dim(` Skipped ${finalFilename}`);
902
+ refs.push({ uid: record.UID, metaPath });
903
+ continue;
904
+ }
905
+ if (action === 'overwrite_all') {
906
+ mediaBulkAction.value = 'overwrite_all';
907
+ }
908
+ if (action === 'compare') {
909
+ // For binary media, show metadata diffs only
910
+ await inlineDiffAndMerge(record, metaPath, config);
911
+ refs.push({ uid: record.UID, metaPath });
912
+ continue;
913
+ }
914
+ } else {
915
+ // Server _LastUpdated hasn't changed — check for local modifications
916
+ const locallyModified = await hasLocalModifications(metaPath, config);
917
+ if (locallyModified) {
918
+ const action = await promptChangeDetection(dedupName, record, config, { localIsNewer: true });
919
+
920
+ if (action === 'skip') {
921
+ log.dim(` Kept local: ${finalFilename}`);
922
+ refs.push({ uid: record.UID, metaPath });
923
+ continue;
924
+ }
925
+ if (action === 'skip_all') {
926
+ mediaBulkAction.value = 'skip_all';
927
+ log.dim(` Kept local: ${finalFilename}`);
928
+ refs.push({ uid: record.UID, metaPath });
929
+ continue;
930
+ }
931
+ if (action === 'overwrite_all') {
932
+ mediaBulkAction.value = 'overwrite_all';
933
+ }
934
+ if (action === 'compare') {
935
+ await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
936
+ refs.push({ uid: record.UID, metaPath });
937
+ continue;
938
+ }
939
+ // 'overwrite' falls through to download
940
+ } else {
941
+ log.dim(` Up to date: ${finalFilename}`);
942
+ refs.push({ uid: record.UID, metaPath });
943
+ continue;
944
+ }
945
+ }
946
+ }
947
+ }
948
+
506
949
  // Download the file
507
950
  try {
508
951
  const buffer = await client.getBuffer(`/api/media/${record.UID}`);
@@ -551,14 +994,35 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
551
994
  * Process a single record: determine directory, write content file + metadata.
552
995
  * Returns { uid, metaPath } or null.
553
996
  */
554
- async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz) {
997
+ async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction = { value: null }) {
555
998
  let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
556
- const ext = (record.Extension || 'txt').toLowerCase();
999
+
1000
+ // Determine file extension (priority: Extension field > Name field > Path field > empty)
1001
+ let ext = '';
1002
+ if (record.Extension) {
1003
+ // Use explicit Extension field
1004
+ ext = String(record.Extension).toLowerCase();
1005
+ } else {
1006
+ // Try to extract from Name field first
1007
+ if (record.Name) {
1008
+ const extractedExt = extname(String(record.Name));
1009
+ ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
1010
+ }
1011
+
1012
+ // If no extension from Name, try Path field as fallback
1013
+ if (!ext && record.Path) {
1014
+ const extractedExt = extname(String(record.Path));
1015
+ ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
1016
+ }
1017
+ }
1018
+ // If still no extension, ext remains '' (no extension)
557
1019
 
558
1020
  // Avoid double extension: if name already ends with .ext, strip it
559
- const extWithDot = `.${ext}`;
560
- if (name.toLowerCase().endsWith(extWithDot)) {
561
- name = name.substring(0, name.length - extWithDot.length);
1021
+ if (ext) {
1022
+ const extWithDot = `.${ext}`;
1023
+ if (name.toLowerCase().endsWith(extWithDot)) {
1024
+ name = name.substring(0, name.length - extWithDot.length);
1025
+ }
562
1026
  }
563
1027
 
564
1028
  // Determine target directory (default: bins/ for items without explicit placement)
@@ -603,7 +1067,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
603
1067
  } else if (hasBinID) {
604
1068
  dir = resolveBinPath(record.BinID, structure);
605
1069
  } else if (hasPath) {
606
- dir = record.Path;
1070
+ // BinID is null — resolve Path into a Bins/ location
1071
+ dir = resolvePathToBinsDir(record.Path, structure);
607
1072
  }
608
1073
 
609
1074
  // Clean up directory path
@@ -632,10 +1097,75 @@ async function processRecord(entityName, record, structure, options, usedNames,
632
1097
  (typeof contentValue === 'string' && contentValue.length > 0)
633
1098
  );
634
1099
 
635
- const fileName = `${finalName}.${ext}`;
1100
+ const fileName = ext ? `${finalName}.${ext}` : finalName;
636
1101
  const filePath = join(dir, fileName);
637
1102
  const metaPath = join(dir, `${finalName}.metadata.json`);
638
1103
 
1104
+ // Change detection: check if file already exists locally
1105
+ if (await fileExists(metaPath) && !options.yes) {
1106
+ if (bulkAction.value === 'skip_all') {
1107
+ log.dim(` Skipped ${finalName}.${ext}`);
1108
+ return { uid: record.UID, metaPath };
1109
+ }
1110
+
1111
+ if (bulkAction.value !== 'overwrite_all') {
1112
+ const localSyncTime = await getLocalSyncTime(metaPath);
1113
+ const config = await loadConfig();
1114
+ const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
1115
+
1116
+ if (serverNewer) {
1117
+ const action = await promptChangeDetection(finalName, record, config);
1118
+
1119
+ if (action === 'skip') {
1120
+ log.dim(` Skipped ${finalName}.${ext}`);
1121
+ return { uid: record.UID, metaPath };
1122
+ }
1123
+ if (action === 'skip_all') {
1124
+ bulkAction.value = 'skip_all';
1125
+ log.dim(` Skipped ${finalName}.${ext}`);
1126
+ return { uid: record.UID, metaPath };
1127
+ }
1128
+ if (action === 'overwrite_all') {
1129
+ bulkAction.value = 'overwrite_all';
1130
+ // Fall through to write
1131
+ }
1132
+ if (action === 'compare') {
1133
+ await inlineDiffAndMerge(record, metaPath, config);
1134
+ return { uid: record.UID, metaPath };
1135
+ }
1136
+ // 'overwrite' falls through to normal write
1137
+ } else {
1138
+ // Server _LastUpdated hasn't changed since last sync.
1139
+ // Check if local content files were modified (user edits).
1140
+ const locallyModified = await hasLocalModifications(metaPath, config);
1141
+ if (locallyModified) {
1142
+ const action = await promptChangeDetection(finalName, record, config, { localIsNewer: true });
1143
+
1144
+ if (action === 'skip') {
1145
+ log.dim(` Kept local: ${finalName}.${ext}`);
1146
+ return { uid: record.UID, metaPath };
1147
+ }
1148
+ if (action === 'skip_all') {
1149
+ bulkAction.value = 'skip_all';
1150
+ log.dim(` Kept local: ${finalName}.${ext}`);
1151
+ return { uid: record.UID, metaPath };
1152
+ }
1153
+ if (action === 'overwrite_all') {
1154
+ bulkAction.value = 'overwrite_all';
1155
+ }
1156
+ if (action === 'compare') {
1157
+ await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
1158
+ return { uid: record.UID, metaPath };
1159
+ }
1160
+ // 'overwrite' falls through to normal write
1161
+ } else {
1162
+ log.dim(` Up to date: ${finalName}.${ext}`);
1163
+ return { uid: record.UID, metaPath };
1164
+ }
1165
+ }
1166
+ }
1167
+ }
1168
+
639
1169
  if (hasContent) {
640
1170
  const decoded = resolveContentValue(contentValue);
641
1171
  if (decoded) {
@@ -694,7 +1224,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
694
1224
  /**
695
1225
  * Guess file extension for a column name.
696
1226
  */
697
- function guessExtensionForColumn(columnName) {
1227
+ export function guessExtensionForColumn(columnName) {
698
1228
  const lower = columnName.toLowerCase();
699
1229
  if (lower.includes('css')) return 'css';
700
1230
  if (lower.includes('js') || lower.includes('script')) return 'js';
@@ -740,3 +1270,51 @@ async function saveAppJson(appJson, contentRefs, otherRefs) {
740
1270
 
741
1271
  await writeFile('app.json', JSON.stringify(output, null, 2) + '\n');
742
1272
  }
1273
+
1274
+ /**
1275
+ * Save .app.json baseline file with decoded base64 values.
1276
+ * This file tracks the server state for delta detection.
1277
+ */
1278
+ async function saveBaselineFile(appJson) {
1279
+ // Deep clone the app JSON
1280
+ const baseline = JSON.parse(JSON.stringify(appJson));
1281
+
1282
+ // Recursively decode all base64 fields
1283
+ decodeBase64Fields(baseline);
1284
+
1285
+ // Save to .app.json
1286
+ await saveAppJsonBaseline(baseline);
1287
+
1288
+ log.dim(' .app.json baseline created (system-managed, do not edit)');
1289
+ }
1290
+
1291
+ /**
1292
+ * Recursively decode base64 fields in an object or array.
1293
+ * Modifies the input object in-place.
1294
+ */
1295
+ function decodeBase64Fields(obj) {
1296
+ if (!obj || typeof obj !== 'object') {
1297
+ return;
1298
+ }
1299
+
1300
+ if (Array.isArray(obj)) {
1301
+ for (const item of obj) {
1302
+ decodeBase64Fields(item);
1303
+ }
1304
+ return;
1305
+ }
1306
+
1307
+ // Process each property
1308
+ for (const [key, value] of Object.entries(obj)) {
1309
+ if (value && typeof value === 'object') {
1310
+ // Check if it's a base64 encoded value
1311
+ if (!Array.isArray(value) && value.encoding === 'base64' && typeof value.value === 'string') {
1312
+ // Decode using existing resolveContentValue function
1313
+ obj[key] = resolveContentValue(value);
1314
+ } else {
1315
+ // Recursively process nested objects/arrays
1316
+ decodeBase64Fields(value);
1317
+ }
1318
+ }
1319
+ }
1320
+ }