@dboio/cli 0.11.3 → 0.13.2
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 +126 -3
- package/bin/dbo.js +4 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/dbo/commands/dbo.md +65 -244
- package/plugins/claude/dbo/docs/_audit_required/API/all.md +40 -0
- package/plugins/claude/dbo/docs/_audit_required/API/app.md +38 -0
- package/plugins/claude/dbo/docs/_audit_required/API/athenticate.md +26 -0
- package/plugins/claude/dbo/docs/_audit_required/API/cache.md +29 -0
- package/plugins/claude/dbo/docs/_audit_required/API/content.md +14 -0
- package/plugins/claude/dbo/docs/_audit_required/API/data_source.md +28 -0
- package/plugins/claude/dbo/docs/_audit_required/API/email.md +18 -0
- package/plugins/claude/dbo/docs/_audit_required/API/input.md +25 -0
- package/plugins/claude/dbo/docs/_audit_required/API/instance.md +28 -0
- package/plugins/claude/dbo/docs/_audit_required/API/log.md +8 -0
- package/plugins/claude/dbo/docs/_audit_required/API/media.md +12 -0
- package/plugins/claude/dbo/docs/_audit_required/API/output_by_entity.md +12 -0
- package/plugins/claude/dbo/docs/_audit_required/API/upload.md +7 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-api-syntax.md +1487 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-code.md +111 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-performance.md +109 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-problems-syntax.md +97 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-product-market.md +119 -0
- package/plugins/claude/dbo/docs/_audit_required/dbo-white-paper.md +125 -0
- package/plugins/claude/dbo/docs/dbo-cheat-sheet.md +323 -0
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +2222 -0
- package/plugins/claude/dbo/docs/dbo-core-entities.md +878 -0
- package/plugins/claude/dbo/docs/dbo-output-customsql.md +677 -0
- package/plugins/claude/dbo/docs/dbo-output-query.md +967 -0
- package/plugins/claude/dbo/skills/cli/SKILL.md +62 -246
- package/src/commands/add.js +366 -62
- package/src/commands/build.js +102 -0
- package/src/commands/clone.js +602 -139
- package/src/commands/diff.js +4 -0
- package/src/commands/init.js +16 -2
- package/src/commands/input.js +3 -1
- package/src/commands/mv.js +12 -4
- package/src/commands/push.js +265 -70
- package/src/commands/rm.js +16 -3
- package/src/commands/run.js +81 -0
- package/src/lib/client.js +4 -7
- package/src/lib/config.js +39 -0
- package/src/lib/delta.js +7 -1
- package/src/lib/diff.js +24 -2
- package/src/lib/filenames.js +120 -41
- package/src/lib/ignore.js +6 -0
- package/src/lib/input-parser.js +13 -4
- package/src/lib/scripts.js +232 -0
- package/src/lib/toe-stepping.js +17 -2
- package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
package/src/commands/clone.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, writeFile, appendFile, mkdir, access, readdir, rename } from 'fs/promises';
|
|
2
|
+
import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat } from 'fs/promises';
|
|
3
3
|
import { join, basename, extname, dirname } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { DboClient } from '../lib/client.js';
|
|
6
6
|
import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
|
|
7
7
|
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, SCAFFOLD_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir, resolveEntityDirPath } from '../lib/structure.js';
|
|
8
8
|
import { log } from '../lib/logger.js';
|
|
9
|
-
import { buildUidFilename, detectLegacyDotUid } from '../lib/filenames.js';
|
|
9
|
+
import { buildUidFilename, buildContentFileName, stripUidFromFilename, hasUidInFilename, detectLegacyDotUid } from '../lib/filenames.js';
|
|
10
10
|
import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
|
|
11
|
-
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache } from '../lib/diff.js';
|
|
11
|
+
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache, findMetadataFiles } from '../lib/diff.js';
|
|
12
|
+
import { loadIgnore } from '../lib/ignore.js';
|
|
12
13
|
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
13
14
|
import { applyTrashIcon, ensureTrashIcon } from '../lib/folder-icon.js';
|
|
14
15
|
import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
|
|
@@ -43,6 +44,143 @@ function isWillDeleteFile(filename) {
|
|
|
43
44
|
return basename(filename).startsWith(WILL_DELETE_PREFIX);
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Collect all UIDs from the freshly fetched app JSON.
|
|
49
|
+
* Traverses all entity types in appJson.children.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} appJson - Parsed app JSON with children map
|
|
52
|
+
* @returns {Set<string>} Set of all UIDs present on the server
|
|
53
|
+
*/
|
|
54
|
+
export function collectServerUids(appJson) {
|
|
55
|
+
const uids = new Set();
|
|
56
|
+
if (!appJson?.children || typeof appJson.children !== 'object') return uids;
|
|
57
|
+
|
|
58
|
+
for (const [entityName, entries] of Object.entries(appJson.children)) {
|
|
59
|
+
if (!Array.isArray(entries)) continue;
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry && typeof entry === 'object' && entry.UID) {
|
|
62
|
+
uids.add(String(entry.UID));
|
|
63
|
+
}
|
|
64
|
+
// Output hierarchy: collect nested child UIDs too
|
|
65
|
+
if (entry && typeof entry === 'object' && entry.children) {
|
|
66
|
+
for (const childArr of Object.values(entry.children)) {
|
|
67
|
+
if (!Array.isArray(childArr)) continue;
|
|
68
|
+
for (const child of childArr) {
|
|
69
|
+
if (child && typeof child === 'object' && child.UID) {
|
|
70
|
+
uids.add(String(child.UID));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return uids;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Detect local metadata files whose UIDs are absent from the server response,
|
|
83
|
+
* then move them and their companion files to trash/.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} appJson - Fresh app JSON from server
|
|
86
|
+
* @param {import('ignore').Ignore} ig - Ignore instance for findMetadataFiles
|
|
87
|
+
* @param {object} sync - Parsed synchronize.json { delete, edit, add }
|
|
88
|
+
* @param {object} options - Clone options ({ entityFilter?, verbose? })
|
|
89
|
+
*/
|
|
90
|
+
export async function detectAndTrashOrphans(appJson, ig, sync, options) {
|
|
91
|
+
if (options.entityFilter) return;
|
|
92
|
+
if (!appJson?.children) return;
|
|
93
|
+
|
|
94
|
+
const serverUids = collectServerUids(appJson);
|
|
95
|
+
if (serverUids.size === 0) return;
|
|
96
|
+
|
|
97
|
+
// UIDs already queued for deletion in synchronize.json
|
|
98
|
+
const stagedDeleteUids = new Set(
|
|
99
|
+
(sync.delete || []).map(e => e.UID).filter(Boolean).map(String)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const metaFiles = await findMetadataFiles(process.cwd(), ig);
|
|
103
|
+
if (metaFiles.length === 0) return;
|
|
104
|
+
|
|
105
|
+
const trashDir = join(process.cwd(), 'trash');
|
|
106
|
+
const orphans = [];
|
|
107
|
+
|
|
108
|
+
for (const metaPath of metaFiles) {
|
|
109
|
+
let meta;
|
|
110
|
+
try {
|
|
111
|
+
meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!meta.UID) continue;
|
|
117
|
+
|
|
118
|
+
const uid = String(meta.UID);
|
|
119
|
+
if (stagedDeleteUids.has(uid)) continue;
|
|
120
|
+
if (serverUids.has(uid)) continue;
|
|
121
|
+
|
|
122
|
+
// Orphan — collect files to move
|
|
123
|
+
const metaDir = dirname(metaPath);
|
|
124
|
+
const filesToMove = [metaPath];
|
|
125
|
+
|
|
126
|
+
for (const col of (meta._contentColumns || [])) {
|
|
127
|
+
const ref = meta[col];
|
|
128
|
+
if (ref && String(ref).startsWith('@')) {
|
|
129
|
+
const refName = String(ref).substring(1);
|
|
130
|
+
const companionPath = refName.startsWith('/')
|
|
131
|
+
? join(process.cwd(), refName)
|
|
132
|
+
: join(metaDir, refName);
|
|
133
|
+
if (await fileExists(companionPath)) {
|
|
134
|
+
filesToMove.push(companionPath);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
140
|
+
const refName = String(meta._mediaFile).substring(1);
|
|
141
|
+
const mediaPath = refName.startsWith('/')
|
|
142
|
+
? join(process.cwd(), refName)
|
|
143
|
+
: join(metaDir, refName);
|
|
144
|
+
if (await fileExists(mediaPath)) {
|
|
145
|
+
filesToMove.push(mediaPath);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
orphans.push({ metaPath, uid, entity: meta._entity || 'unknown', filesToMove });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (orphans.length === 0) return;
|
|
153
|
+
|
|
154
|
+
await mkdir(trashDir, { recursive: true });
|
|
155
|
+
|
|
156
|
+
let trashed = 0;
|
|
157
|
+
|
|
158
|
+
for (const { metaPath, uid, entity, filesToMove } of orphans) {
|
|
159
|
+
log.dim(` Trashed: ${basename(metaPath)} (${entity}:${uid})`);
|
|
160
|
+
|
|
161
|
+
for (const filePath of filesToMove) {
|
|
162
|
+
const destBase = basename(filePath);
|
|
163
|
+
let destPath = join(trashDir, destBase);
|
|
164
|
+
|
|
165
|
+
// Collision: append timestamp suffix (same pattern as moveWillDeleteToTrash in push.js)
|
|
166
|
+
try { await stat(destPath); destPath = `${destPath}.${Date.now()}`; } catch {}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await rename(filePath, destPath);
|
|
170
|
+
trashed++;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
log.warn(` Could not trash: ${filePath} — ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (trashed > 0) {
|
|
178
|
+
await ensureTrashIcon(trashDir);
|
|
179
|
+
log.plain('');
|
|
180
|
+
log.warn(`Moved ${orphans.length} orphaned record(s) to trash (deleted on server)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
46
184
|
/**
|
|
47
185
|
* Resolve a content Path to a directory under Bins/.
|
|
48
186
|
*
|
|
@@ -144,9 +282,11 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
|
|
|
144
282
|
}
|
|
145
283
|
|
|
146
284
|
const uid = String(record.UID || record._id || 'untitled');
|
|
147
|
-
|
|
148
|
-
const filename =
|
|
149
|
-
|
|
285
|
+
// Companion: natural name, no UID
|
|
286
|
+
const filename = sanitizeFilename(buildContentFileName(record, uid));
|
|
287
|
+
// Metadata: still uses ~UID
|
|
288
|
+
const metaBase = buildUidFilename(name, uid);
|
|
289
|
+
const metaPath = join(dir, `${metaBase}.metadata.json`);
|
|
150
290
|
|
|
151
291
|
return { dir, filename, metaPath };
|
|
152
292
|
}
|
|
@@ -156,9 +296,15 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
|
|
|
156
296
|
* Replicates logic from processMediaEntries() for collision detection.
|
|
157
297
|
*/
|
|
158
298
|
export function resolveMediaPaths(record, structure) {
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
299
|
+
// Companion: use Filename column directly (natural name, no UID)
|
|
300
|
+
const companionFilename = sanitizeFilename(
|
|
301
|
+
record.Filename
|
|
302
|
+
|| `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// Base name and ext for metadata naming
|
|
306
|
+
const name = sanitizeFilename(companionFilename.replace(/\.[^.]+$/, ''));
|
|
307
|
+
const ext = (record.Extension || extname(companionFilename).substring(1) || 'bin').toLowerCase();
|
|
162
308
|
|
|
163
309
|
// Always place media by BinID; fall back to bins/ root
|
|
164
310
|
let dir = BINS_DIR;
|
|
@@ -169,12 +315,12 @@ export function resolveMediaPaths(record, structure) {
|
|
|
169
315
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
170
316
|
if (!dir) dir = BINS_DIR;
|
|
171
317
|
|
|
318
|
+
// Metadata: name~uid.ext.metadata.json (unchanged format)
|
|
172
319
|
const uid = String(record.UID || record._id || 'untitled');
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const metaPath = join(dir, `${finalFilename}.metadata.json`);
|
|
320
|
+
const metaBase = buildUidFilename(name, uid);
|
|
321
|
+
const metaPath = join(dir, `${metaBase}.${ext}.metadata.json`);
|
|
176
322
|
|
|
177
|
-
return { dir, filename:
|
|
323
|
+
return { dir, filename: companionFilename, metaPath };
|
|
178
324
|
}
|
|
179
325
|
|
|
180
326
|
/**
|
|
@@ -197,6 +343,224 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
|
|
|
197
343
|
return { dir: dirName, filename: finalName, metaPath };
|
|
198
344
|
}
|
|
199
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Resolve companion filename collisions within a shared BinID directory.
|
|
348
|
+
*
|
|
349
|
+
* Mutates the `filename` field in each entry to apply collision suffixes:
|
|
350
|
+
* - Content vs media same name → media gets "(media)" before extension
|
|
351
|
+
* - Same entity type duplicates → 2nd+ get "-1", "-2", ... suffix
|
|
352
|
+
*
|
|
353
|
+
* Companion files never contain ~UID. The metadata @reference stores the
|
|
354
|
+
* collision-suffixed name, so dbo rm/push/diff find metadata via @reference
|
|
355
|
+
* scan rather than filename derivation.
|
|
356
|
+
*
|
|
357
|
+
* @param {Array<{entity: string, uid: string, filename: string, dir: string}>} entries
|
|
358
|
+
*/
|
|
359
|
+
export function resolveFilenameCollisions(entries) {
|
|
360
|
+
// Group by dir + filename
|
|
361
|
+
const byPath = new Map();
|
|
362
|
+
for (const entry of entries) {
|
|
363
|
+
const key = `${entry.dir}/${entry.filename}`;
|
|
364
|
+
if (!byPath.has(key)) byPath.set(key, []);
|
|
365
|
+
byPath.get(key).push(entry);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
for (const group of byPath.values()) {
|
|
369
|
+
if (group.length <= 1) continue;
|
|
370
|
+
|
|
371
|
+
const contentGroup = group.filter(e => e.entity === 'content');
|
|
372
|
+
const mediaGroup = group.filter(e => e.entity === 'media');
|
|
373
|
+
|
|
374
|
+
// Content wins: media gets (media) suffix
|
|
375
|
+
if (contentGroup.length > 0 && mediaGroup.length > 0) {
|
|
376
|
+
for (const m of mediaGroup) {
|
|
377
|
+
const ext = extname(m.filename);
|
|
378
|
+
const base = basename(m.filename, ext);
|
|
379
|
+
m.filename = `${base}(media)${ext}`;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Same-entity duplicates: -1, -2, ... suffix (by insertion order)
|
|
384
|
+
for (const sameType of [contentGroup, mediaGroup]) {
|
|
385
|
+
if (sameType.length <= 1) continue;
|
|
386
|
+
for (let i = 1; i < sameType.length; i++) {
|
|
387
|
+
const ext = extname(sameType[i].filename);
|
|
388
|
+
const base = basename(sameType[i].filename, ext);
|
|
389
|
+
sameType[i].filename = `${base}-${i}${ext}`;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* If companion files on disk still use legacy ~UID naming,
|
|
397
|
+
* rename them to the natural filename and update @references in metadata.
|
|
398
|
+
*
|
|
399
|
+
* Handles two cases:
|
|
400
|
+
* A) @reference itself contains ~UID (e.g. "@colors~uid.css") → strip ~UID,
|
|
401
|
+
* rename file, update @reference in metadata, rewrite metadata file.
|
|
402
|
+
* B) @reference is already natural but file on disk has ~UID → just rename file.
|
|
403
|
+
*
|
|
404
|
+
* Returns true if any metadata @references were updated (caller should NOT
|
|
405
|
+
* overwrite metaPath after this — it's already been rewritten).
|
|
406
|
+
*/
|
|
407
|
+
async function detectAndRenameLegacyCompanions(metaPath, meta) {
|
|
408
|
+
const uid = meta.UID;
|
|
409
|
+
if (!uid) return false;
|
|
410
|
+
|
|
411
|
+
const metaDir = dirname(metaPath);
|
|
412
|
+
const contentCols = [...(meta._contentColumns || [])];
|
|
413
|
+
if (meta._mediaFile) contentCols.push('_mediaFile');
|
|
414
|
+
let metaChanged = false;
|
|
415
|
+
|
|
416
|
+
for (const col of contentCols) {
|
|
417
|
+
const ref = meta[col];
|
|
418
|
+
if (!ref || !String(ref).startsWith('@')) continue;
|
|
419
|
+
|
|
420
|
+
const refName = String(ref).substring(1);
|
|
421
|
+
// Resolve paths: @/ references are root-relative, others are metaDir-relative
|
|
422
|
+
const resolveRef = (name) => name.startsWith('/')
|
|
423
|
+
? join(process.cwd(), name)
|
|
424
|
+
: join(metaDir, name);
|
|
425
|
+
|
|
426
|
+
// Case A: @reference itself contains ~UID — strip it
|
|
427
|
+
if (hasUidInFilename(refName, uid)) {
|
|
428
|
+
const naturalName = stripUidFromFilename(refName, uid);
|
|
429
|
+
const legacyPath = resolveRef(refName);
|
|
430
|
+
const naturalPath = resolveRef(naturalName);
|
|
431
|
+
const legacyExists = await fileExists(legacyPath);
|
|
432
|
+
const naturalExists = await fileExists(naturalPath);
|
|
433
|
+
|
|
434
|
+
if (legacyExists && !naturalExists) {
|
|
435
|
+
// Rename legacy → natural
|
|
436
|
+
try {
|
|
437
|
+
await mkdir(dirname(naturalPath), { recursive: true });
|
|
438
|
+
await rename(legacyPath, naturalPath);
|
|
439
|
+
log.dim(` Legacy companion renamed: ${basename(legacyPath)} → ${basename(naturalPath)}`);
|
|
440
|
+
} catch { /* rename failed */ }
|
|
441
|
+
} else if (legacyExists && naturalExists) {
|
|
442
|
+
// Both exist (clone downloaded fresh copy) — move orphaned legacy file to trash
|
|
443
|
+
try {
|
|
444
|
+
const trashDir = join(process.cwd(), 'trash');
|
|
445
|
+
await mkdir(trashDir, { recursive: true });
|
|
446
|
+
await rename(legacyPath, join(trashDir, basename(legacyPath)));
|
|
447
|
+
await ensureTrashIcon(trashDir);
|
|
448
|
+
log.dim(` Trashed orphan legacy file: ${basename(legacyPath)}`);
|
|
449
|
+
} catch { /* non-critical */ }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Update @reference regardless (even if file rename failed, fix the reference)
|
|
453
|
+
meta[col] = `@${naturalName}`;
|
|
454
|
+
metaChanged = true;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Case B: @reference is natural but file on disk might still have ~UID
|
|
459
|
+
const naturalPath = resolveRef(refName);
|
|
460
|
+
if (await fileExists(naturalPath)) continue;
|
|
461
|
+
|
|
462
|
+
const ext = extname(refName);
|
|
463
|
+
const base = basename(refName, ext);
|
|
464
|
+
const legacyName = ext ? `${base}~${uid}${ext}` : `${base}~${uid}`;
|
|
465
|
+
const legacyPath = resolveRef(legacyName);
|
|
466
|
+
|
|
467
|
+
if (await fileExists(legacyPath)) {
|
|
468
|
+
try {
|
|
469
|
+
await mkdir(dirname(naturalPath), { recursive: true });
|
|
470
|
+
await rename(legacyPath, naturalPath);
|
|
471
|
+
log.dim(` Legacy companion renamed: ${basename(legacyPath)} → ${basename(naturalPath)}`);
|
|
472
|
+
} catch { /* rename failed */ }
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Rewrite metadata file if @references were updated
|
|
477
|
+
if (metaChanged) {
|
|
478
|
+
try {
|
|
479
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
480
|
+
} catch { /* non-critical */ }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return metaChanged;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Scan all directories under Bins/ for orphaned legacy ~UID companion files
|
|
488
|
+
* that no metadata @reference points to, and move them to trash/.
|
|
489
|
+
*
|
|
490
|
+
* A file is considered an orphan if:
|
|
491
|
+
* - It contains ~ in its name (potential legacy ~UID naming)
|
|
492
|
+
* - It's NOT a .metadata.json file
|
|
493
|
+
* - No .metadata.json in the same directory has an @reference pointing to it
|
|
494
|
+
*/
|
|
495
|
+
async function trashOrphanedLegacyCompanions() {
|
|
496
|
+
const binsDir = join(process.cwd(), BINS_DIR);
|
|
497
|
+
if (!await fileExists(binsDir)) return;
|
|
498
|
+
|
|
499
|
+
// Collect all @references from all metadata files, and all non-metadata files with ~
|
|
500
|
+
const referencedFiles = new Set(); // Set of absolute paths referenced by metadata
|
|
501
|
+
const tildeFiles = []; // non-metadata files containing ~
|
|
502
|
+
|
|
503
|
+
async function scan(dir) {
|
|
504
|
+
let entries;
|
|
505
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
506
|
+
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
if (entry.name.startsWith('.')) continue;
|
|
509
|
+
const full = join(dir, entry.name);
|
|
510
|
+
|
|
511
|
+
if (entry.isDirectory()) {
|
|
512
|
+
await scan(full);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (entry.name.endsWith('.metadata.json')) {
|
|
517
|
+
// Read metadata and collect all @references
|
|
518
|
+
try {
|
|
519
|
+
const meta = JSON.parse(await readFile(full, 'utf8'));
|
|
520
|
+
const cols = [...(meta._contentColumns || [])];
|
|
521
|
+
if (meta._mediaFile) cols.push('_mediaFile');
|
|
522
|
+
for (const col of cols) {
|
|
523
|
+
const ref = meta[col];
|
|
524
|
+
if (ref && String(ref).startsWith('@')) {
|
|
525
|
+
const refName = String(ref).substring(1);
|
|
526
|
+
// Handle both relative and @/ (root-relative) references
|
|
527
|
+
if (refName.startsWith('/')) {
|
|
528
|
+
referencedFiles.add(join(process.cwd(), refName));
|
|
529
|
+
} else {
|
|
530
|
+
referencedFiles.add(join(dir, refName));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
} catch { /* skip unreadable metadata */ }
|
|
535
|
+
} else if (entry.name.includes('~')) {
|
|
536
|
+
tildeFiles.push(full);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
await scan(binsDir);
|
|
542
|
+
|
|
543
|
+
// Filter to orphans: ~ files not referenced by any metadata
|
|
544
|
+
const orphans = tildeFiles.filter(f => !referencedFiles.has(f));
|
|
545
|
+
if (orphans.length === 0) return;
|
|
546
|
+
|
|
547
|
+
const trashDir = join(process.cwd(), 'trash');
|
|
548
|
+
await mkdir(trashDir, { recursive: true });
|
|
549
|
+
let trashed = 0;
|
|
550
|
+
|
|
551
|
+
for (const orphan of orphans) {
|
|
552
|
+
try {
|
|
553
|
+
await rename(orphan, join(trashDir, basename(orphan)));
|
|
554
|
+
trashed++;
|
|
555
|
+
} catch { /* non-critical */ }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (trashed > 0) {
|
|
559
|
+
await ensureTrashIcon(trashDir);
|
|
560
|
+
log.dim(` Trashed ${trashed} orphaned legacy ~UID companion file(s)`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
200
564
|
/**
|
|
201
565
|
* Build global registry of all files that will be created during clone.
|
|
202
566
|
* Returns Map<filePath, Array<{entity, record, dir, filename, metaPath}>>
|
|
@@ -204,25 +568,34 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
|
|
|
204
568
|
async function buildFileRegistry(appJson, structure, placementPrefs) {
|
|
205
569
|
const registry = new Map();
|
|
206
570
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
registry.set(filePath, []);
|
|
210
|
-
}
|
|
211
|
-
registry.get(filePath).push({ entity, record, dir, filename, metaPath });
|
|
212
|
-
}
|
|
571
|
+
// Collect all entries first for collision resolution
|
|
572
|
+
const allEntries = [];
|
|
213
573
|
|
|
214
574
|
// Process content records
|
|
215
575
|
for (const record of (appJson.children.content || [])) {
|
|
216
576
|
const { dir, filename, metaPath } = resolveRecordPaths(
|
|
217
577
|
'content', record, structure, placementPrefs.contentPlacement
|
|
218
578
|
);
|
|
219
|
-
|
|
579
|
+
allEntries.push({ entity: 'content', uid: record.UID, record, dir, filename, metaPath });
|
|
220
580
|
}
|
|
221
581
|
|
|
222
582
|
// Process media records
|
|
223
583
|
for (const record of (appJson.children.media || [])) {
|
|
224
584
|
const { dir, filename, metaPath } = resolveMediaPaths(record, structure);
|
|
225
|
-
|
|
585
|
+
allEntries.push({ entity: 'media', uid: record.UID, record, dir, filename, metaPath });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Auto-resolve content vs media collisions and same-entity duplicates
|
|
589
|
+
// (content wins, media gets "(media)" suffix; same-entity duplicates get "-N" suffix)
|
|
590
|
+
resolveFilenameCollisions(allEntries);
|
|
591
|
+
|
|
592
|
+
// Build registry with resolved filenames
|
|
593
|
+
for (const entry of allEntries) {
|
|
594
|
+
const filePath = join(entry.dir, entry.filename);
|
|
595
|
+
if (!registry.has(filePath)) {
|
|
596
|
+
registry.set(filePath, []);
|
|
597
|
+
}
|
|
598
|
+
registry.get(filePath).push(entry);
|
|
226
599
|
}
|
|
227
600
|
|
|
228
601
|
// Note: entity-dir records (Extensions/, Data Sources/, etc.) and generic entities
|
|
@@ -803,6 +1176,7 @@ export async function performClone(source, options = {}) {
|
|
|
803
1176
|
|
|
804
1177
|
// Step 4c: Detect and resolve file path collisions (skip in pull mode and entity-filter mode)
|
|
805
1178
|
let toDeleteUIDs = new Set();
|
|
1179
|
+
let resolvedFilenames = new Map(); // UID → resolved filename (after collision resolution)
|
|
806
1180
|
if (!options.pullMode && !entityFilter) {
|
|
807
1181
|
log.info('Scanning for file path collisions...');
|
|
808
1182
|
const fileRegistry = await buildFileRegistry(appJson, structure, placementPrefs);
|
|
@@ -811,6 +1185,15 @@ export async function performClone(source, options = {}) {
|
|
|
811
1185
|
if (toDeleteUIDs.size > 0) {
|
|
812
1186
|
await stageCollisionDeletions(toDeleteUIDs, appJson, options);
|
|
813
1187
|
}
|
|
1188
|
+
|
|
1189
|
+
// Build UID → filename map from the collision-resolved registry
|
|
1190
|
+
for (const entries of fileRegistry.values()) {
|
|
1191
|
+
for (const entry of entries) {
|
|
1192
|
+
if (entry.record.UID) {
|
|
1193
|
+
resolvedFilenames.set(entry.record.UID, entry.filename);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
814
1197
|
}
|
|
815
1198
|
|
|
816
1199
|
// Pre-load previous baseline for fast _LastUpdated string comparison in isServerNewer.
|
|
@@ -827,6 +1210,7 @@ export async function performClone(source, options = {}) {
|
|
|
827
1210
|
placementPrefs.contentPlacement,
|
|
828
1211
|
serverTz,
|
|
829
1212
|
toDeleteUIDs,
|
|
1213
|
+
resolvedFilenames,
|
|
830
1214
|
);
|
|
831
1215
|
}
|
|
832
1216
|
|
|
@@ -840,7 +1224,7 @@ export async function performClone(source, options = {}) {
|
|
|
840
1224
|
if (!entityFilter || entityFilter.has('media')) {
|
|
841
1225
|
const mediaEntries = appJson.children.media || [];
|
|
842
1226
|
if (mediaEntries.length > 0) {
|
|
843
|
-
mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, serverTz, toDeleteUIDs);
|
|
1227
|
+
mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, serverTz, toDeleteUIDs, resolvedFilenames);
|
|
844
1228
|
}
|
|
845
1229
|
}
|
|
846
1230
|
|
|
@@ -894,6 +1278,16 @@ export async function performClone(source, options = {}) {
|
|
|
894
1278
|
resetBaselineCache(); // invalidate so next operation reloads the fresh baseline
|
|
895
1279
|
}
|
|
896
1280
|
|
|
1281
|
+
// Step 8.5: Detect and trash orphaned local records (deleted on server)
|
|
1282
|
+
if (!entityFilter) {
|
|
1283
|
+
const ig = await loadIgnore();
|
|
1284
|
+
const sync = await loadSynchronize();
|
|
1285
|
+
await detectAndTrashOrphans(appJson, ig, sync, { ...options, entityFilter });
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Step 9: Trash orphaned legacy ~UID companion files that no metadata references
|
|
1289
|
+
await trashOrphanedLegacyCompanions();
|
|
1290
|
+
|
|
897
1291
|
log.plain('');
|
|
898
1292
|
const verb = options.pullMode ? 'Pull' : 'Clone';
|
|
899
1293
|
log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
|
|
@@ -1133,7 +1527,7 @@ async function updatePackageJson(appJson, config) {
|
|
|
1133
1527
|
* Process content entries: write files + metadata, return reference map.
|
|
1134
1528
|
* Returns array of { uid, metaPath } for app.json reference replacement.
|
|
1135
1529
|
*/
|
|
1136
|
-
async function processContentEntries(contents, structure, options, contentPlacement, serverTz, skipUIDs = new Set()) {
|
|
1530
|
+
async function processContentEntries(contents, structure, options, contentPlacement, serverTz, skipUIDs = new Set(), resolvedFilenames = new Map()) {
|
|
1137
1531
|
if (!contents || contents.length === 0) return [];
|
|
1138
1532
|
|
|
1139
1533
|
const refs = [];
|
|
@@ -1151,7 +1545,8 @@ async function processContentEntries(contents, structure, options, contentPlacem
|
|
|
1151
1545
|
log.dim(` Skipped ${record.Name || record.UID} (collision rejection)`);
|
|
1152
1546
|
continue;
|
|
1153
1547
|
}
|
|
1154
|
-
const
|
|
1548
|
+
const filenameOverride = resolvedFilenames.get(record.UID) || null;
|
|
1549
|
+
const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction, filenameOverride);
|
|
1155
1550
|
if (ref) refs.push(ref);
|
|
1156
1551
|
}
|
|
1157
1552
|
|
|
@@ -1378,33 +1773,47 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1378
1773
|
if (bulkAction.value !== 'overwrite_all') {
|
|
1379
1774
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
1380
1775
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
1381
|
-
|
|
1776
|
+
|
|
1777
|
+
// If local metadata has no _LastUpdated (e.g. from dbo add), treat as server-newer
|
|
1778
|
+
let localMissingLastUpdated = false;
|
|
1779
|
+
try {
|
|
1780
|
+
const localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
1781
|
+
if (!localMeta._LastUpdated) localMissingLastUpdated = true;
|
|
1782
|
+
} catch { /* unreadable */ }
|
|
1783
|
+
|
|
1784
|
+
const serverNewer = localMissingLastUpdated || isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'content', record.UID);
|
|
1382
1785
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
1383
1786
|
|
|
1384
1787
|
if (serverNewer) {
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1788
|
+
// Incomplete metadata (no _LastUpdated) from dbo add — auto-accept without prompting
|
|
1789
|
+
if (localMissingLastUpdated) {
|
|
1790
|
+
log.dim(` Completing metadata: ${finalName}`);
|
|
1791
|
+
// Fall through to write
|
|
1792
|
+
} else {
|
|
1793
|
+
const action = await promptChangeDetection(finalName, record, configWithTz, {
|
|
1794
|
+
serverDate,
|
|
1795
|
+
localDate: localSyncTime,
|
|
1796
|
+
});
|
|
1389
1797
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1798
|
+
if (action === 'skip') {
|
|
1799
|
+
log.dim(` Skipped ${finalName}`);
|
|
1800
|
+
refs.push({ uid: record.UID, metaPath });
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
if (action === 'skip_all') {
|
|
1804
|
+
bulkAction.value = 'skip_all';
|
|
1805
|
+
log.dim(` Skipped ${finalName}`);
|
|
1806
|
+
refs.push({ uid: record.UID, metaPath });
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
if (action === 'overwrite_all') {
|
|
1810
|
+
bulkAction.value = 'overwrite_all';
|
|
1811
|
+
}
|
|
1812
|
+
if (action === 'compare') {
|
|
1813
|
+
await inlineDiffAndMerge(record, metaPath, configWithTz);
|
|
1814
|
+
refs.push({ uid: record.UID, metaPath });
|
|
1815
|
+
continue;
|
|
1816
|
+
}
|
|
1408
1817
|
}
|
|
1409
1818
|
} else {
|
|
1410
1819
|
const locallyModified = await hasLocalModifications(metaPath, configWithTz);
|
|
@@ -1452,25 +1861,24 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1452
1861
|
|
|
1453
1862
|
// Check if this column should be extracted as a companion file
|
|
1454
1863
|
const extractInfo = contentColsToExtract.find(c => c.col === key);
|
|
1455
|
-
if (extractInfo
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
// Set timestamps on companion file
|
|
1465
|
-
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
1466
|
-
try {
|
|
1467
|
-
await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
1468
|
-
} catch { /* non-critical */ }
|
|
1469
|
-
}
|
|
1864
|
+
if (extractInfo) {
|
|
1865
|
+
const isBase64 = value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64';
|
|
1866
|
+
const decoded = isBase64 ? (resolveContentValue(value) ?? '') : (value ?? '');
|
|
1867
|
+
const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
|
|
1868
|
+
const colFilePath = join(dirName, colFileName);
|
|
1869
|
+
await writeFile(colFilePath, decoded);
|
|
1870
|
+
meta[key] = `@${colFileName}`;
|
|
1871
|
+
extractedContentCols.push(key);
|
|
1470
1872
|
|
|
1471
|
-
|
|
1472
|
-
|
|
1873
|
+
// Set timestamps on companion file
|
|
1874
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
1875
|
+
try {
|
|
1876
|
+
await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
1877
|
+
} catch { /* non-critical */ }
|
|
1473
1878
|
}
|
|
1879
|
+
|
|
1880
|
+
log.dim(` → ${colFilePath}`);
|
|
1881
|
+
continue;
|
|
1474
1882
|
}
|
|
1475
1883
|
|
|
1476
1884
|
// Other base64 columns not selected for extraction — decode inline
|
|
@@ -1841,8 +2249,34 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1841
2249
|
}
|
|
1842
2250
|
}
|
|
1843
2251
|
|
|
2252
|
+
// Rename legacy ~UID companion files and update @references in extension metadata
|
|
2253
|
+
if (await fileExists(metaPath)) {
|
|
2254
|
+
try {
|
|
2255
|
+
const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
2256
|
+
await detectAndRenameLegacyCompanions(metaPath, existingMeta);
|
|
2257
|
+
} catch { /* non-critical */ }
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// Check if any @reference content files are missing — force re-extraction if so
|
|
2261
|
+
let hasNewExtractions = contentColsToExtract.length > 0;
|
|
2262
|
+
if (!hasNewExtractions && await fileExists(metaPath)) {
|
|
2263
|
+
try {
|
|
2264
|
+
const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
2265
|
+
for (const col of (existingMeta._contentColumns || [])) {
|
|
2266
|
+
const ref = existingMeta[col];
|
|
2267
|
+
if (ref && String(ref).startsWith('@')) {
|
|
2268
|
+
const refName = String(ref).substring(1);
|
|
2269
|
+
const refPath = refName.startsWith('/') ? join(process.cwd(), refName) : join(dir, refName);
|
|
2270
|
+
if (!await fileExists(refPath)) {
|
|
2271
|
+
hasNewExtractions = true; // Force re-extraction
|
|
2272
|
+
break;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
} catch { /* non-critical */ }
|
|
2277
|
+
}
|
|
2278
|
+
|
|
1844
2279
|
// Change detection — same pattern as processEntityDirEntries()
|
|
1845
|
-
const hasNewExtractions = contentColsToExtract.length > 0;
|
|
1846
2280
|
// Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
|
|
1847
2281
|
const willDeleteExtMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
|
|
1848
2282
|
const extMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteExtMeta);
|
|
@@ -1889,32 +2323,30 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1889
2323
|
if (key === 'children') continue;
|
|
1890
2324
|
|
|
1891
2325
|
const extractInfo = contentColsToExtract.find(c => c.col === key);
|
|
1892
|
-
if (extractInfo
|
|
1893
|
-
|
|
1894
|
-
const decoded = resolveContentValue(value);
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
}
|
|
2326
|
+
if (extractInfo) {
|
|
2327
|
+
const isBase64 = value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64';
|
|
2328
|
+
const decoded = isBase64 ? (resolveContentValue(value) ?? '') : (value ?? '');
|
|
2329
|
+
let colFilePath, refValue;
|
|
2330
|
+
|
|
2331
|
+
if (mdColInfo && extractInfo.col === mdColInfo.col) {
|
|
2332
|
+
// Root placement: docs/<name>.md (natural name, no ~UID)
|
|
2333
|
+
const docFileName = `${name}.md`;
|
|
2334
|
+
colFilePath = join(DOCUMENTATION_DIR, docFileName);
|
|
2335
|
+
refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
|
|
2336
|
+
} else {
|
|
2337
|
+
const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
|
|
2338
|
+
colFilePath = join(dir, colFileName);
|
|
2339
|
+
refValue = `@${colFileName}`;
|
|
2340
|
+
}
|
|
1908
2341
|
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
}
|
|
1915
|
-
log.dim(` → ${colFilePath}`);
|
|
1916
|
-
continue;
|
|
2342
|
+
meta[key] = refValue;
|
|
2343
|
+
await writeFile(colFilePath, decoded);
|
|
2344
|
+
extractedCols.push(key);
|
|
2345
|
+
if (serverTz) {
|
|
2346
|
+
try { await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz); } catch {}
|
|
1917
2347
|
}
|
|
2348
|
+
log.dim(` → ${colFilePath}`);
|
|
2349
|
+
continue;
|
|
1918
2350
|
}
|
|
1919
2351
|
|
|
1920
2352
|
// Inline or non-extraction columns
|
|
@@ -1959,7 +2391,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1959
2391
|
* Process media entries: download binary files from server + create metadata.
|
|
1960
2392
|
* Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
|
|
1961
2393
|
*/
|
|
1962
|
-
async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set()) {
|
|
2394
|
+
async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set(), resolvedFilenames = new Map()) {
|
|
1963
2395
|
if (!mediaRecords || mediaRecords.length === 0) return [];
|
|
1964
2396
|
|
|
1965
2397
|
// Track stale records (404s) for cleanup prompt
|
|
@@ -2062,16 +2494,25 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2062
2494
|
if (!dir) dir = BINS_DIR;
|
|
2063
2495
|
await mkdir(dir, { recursive: true });
|
|
2064
2496
|
|
|
2065
|
-
//
|
|
2497
|
+
// Companion: natural name, no UID (use collision-resolved override if available)
|
|
2066
2498
|
const uid = String(record.UID || record._id || 'untitled');
|
|
2067
|
-
const
|
|
2068
|
-
const finalFilename = `${base}.${ext}`;
|
|
2499
|
+
const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
|
|
2069
2500
|
const filePath = join(dir, finalFilename);
|
|
2070
|
-
|
|
2501
|
+
// Metadata: name~uid.ext.metadata.json (unchanged format)
|
|
2502
|
+
const metaBase = buildUidFilename(name, uid);
|
|
2503
|
+
const metaPath = join(dir, `${metaBase}.${ext}.metadata.json`);
|
|
2071
2504
|
// usedNames retained for tracking
|
|
2072
2505
|
const fileKey = `${dir}/${name}.${ext}`;
|
|
2073
2506
|
usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
|
|
2074
2507
|
|
|
2508
|
+
// Rename legacy ~UID companion files to natural names if needed
|
|
2509
|
+
if (await fileExists(metaPath)) {
|
|
2510
|
+
try {
|
|
2511
|
+
const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
2512
|
+
await detectAndRenameLegacyCompanions(metaPath, existingMeta);
|
|
2513
|
+
} catch { /* non-critical */ }
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2075
2516
|
// Change detection for existing media files
|
|
2076
2517
|
// Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
|
|
2077
2518
|
const willDeleteMediaMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
|
|
@@ -2093,7 +2534,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2093
2534
|
const diffable = isDiffable(ext);
|
|
2094
2535
|
|
|
2095
2536
|
if (serverNewer) {
|
|
2096
|
-
const action = await promptChangeDetection(
|
|
2537
|
+
const action = await promptChangeDetection(finalFilename, record, configWithTz, {
|
|
2097
2538
|
diffable,
|
|
2098
2539
|
serverDate,
|
|
2099
2540
|
localDate: localSyncTime,
|
|
@@ -2124,7 +2565,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2124
2565
|
const locallyModified = await hasLocalModifications(metaPath, configWithTz);
|
|
2125
2566
|
if (locallyModified) {
|
|
2126
2567
|
const localDate = localSyncTime; // mtime already fetched above
|
|
2127
|
-
const action = await promptChangeDetection(
|
|
2568
|
+
const action = await promptChangeDetection(finalFilename, record, configWithTz, {
|
|
2128
2569
|
localIsNewer: true,
|
|
2129
2570
|
diffable,
|
|
2130
2571
|
serverDate,
|
|
@@ -2273,7 +2714,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2273
2714
|
* Process a single record: determine directory, write content file + metadata.
|
|
2274
2715
|
* Returns { uid, metaPath } or null.
|
|
2275
2716
|
*/
|
|
2276
|
-
async function processRecord(entityName, record, structure, options, usedNames, _placementPreference, serverTz, bulkAction = { value: null }) {
|
|
2717
|
+
async function processRecord(entityName, record, structure, options, usedNames, _placementPreference, serverTz, bulkAction = { value: null }, filenameOverride = null) {
|
|
2277
2718
|
let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
|
|
2278
2719
|
|
|
2279
2720
|
// Determine file extension (priority: Extension field > Name field > Path field > empty)
|
|
@@ -2296,7 +2737,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2296
2737
|
}
|
|
2297
2738
|
// If still no extension, check existing local metadata for a previously chosen extension.
|
|
2298
2739
|
// On re-clone, the Content @reference in the local metadata already has the extension
|
|
2299
|
-
// the user picked on the first clone (e.g. "@CurrentTask
|
|
2740
|
+
// the user picked on the first clone (e.g. "@CurrentTask.html" → "html").
|
|
2300
2741
|
if (!ext && record.UID) {
|
|
2301
2742
|
try {
|
|
2302
2743
|
const uid = String(record.UID);
|
|
@@ -2398,23 +2839,32 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2398
2839
|
|
|
2399
2840
|
await mkdir(dir, { recursive: true });
|
|
2400
2841
|
|
|
2401
|
-
// Always include UID in filename via tilde convention
|
|
2402
2842
|
const uid = String(record.UID || record._id || 'untitled');
|
|
2403
|
-
|
|
2843
|
+
// Companion: natural name, no UID (use collision-resolved override if available)
|
|
2844
|
+
const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
|
|
2845
|
+
// Metadata: still uses ~UID
|
|
2846
|
+
const metaBase = buildUidFilename(name, uid);
|
|
2404
2847
|
// usedNames retained for non-UID edge case tracking
|
|
2405
2848
|
const nameKey = `${dir}/${name}`;
|
|
2406
2849
|
usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
|
|
2407
2850
|
|
|
2408
|
-
// Write content file
|
|
2851
|
+
// Write content file — always create companion for content entities even if empty
|
|
2409
2852
|
const contentValue = record.Content;
|
|
2410
|
-
const hasContent = contentValue && (
|
|
2411
|
-
(typeof contentValue === 'object' && contentValue.
|
|
2412
|
-
(typeof contentValue === 'string'
|
|
2853
|
+
const hasContent = contentValue !== null && contentValue !== undefined && (
|
|
2854
|
+
(typeof contentValue === 'object' && contentValue.encoding === 'base64') ||
|
|
2855
|
+
(typeof contentValue === 'string')
|
|
2413
2856
|
);
|
|
2414
2857
|
|
|
2415
|
-
const fileName = ext ? `${finalName}.${ext}` : finalName;
|
|
2416
2858
|
const filePath = join(dir, fileName);
|
|
2417
|
-
const metaPath = join(dir, `${
|
|
2859
|
+
const metaPath = join(dir, `${metaBase}.metadata.json`);
|
|
2860
|
+
|
|
2861
|
+
// Rename legacy ~UID companion files to natural names if needed
|
|
2862
|
+
if (await fileExists(metaPath)) {
|
|
2863
|
+
try {
|
|
2864
|
+
const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
2865
|
+
await detectAndRenameLegacyCompanions(metaPath, existingMeta);
|
|
2866
|
+
} catch { /* non-critical */ }
|
|
2867
|
+
}
|
|
2418
2868
|
|
|
2419
2869
|
// Change detection: check if file already exists locally
|
|
2420
2870
|
// Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
|
|
@@ -2422,7 +2872,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2422
2872
|
const metaExistsForChangeDetect = await fileExists(metaPath) && !await fileExists(willDeleteMeta);
|
|
2423
2873
|
if (metaExistsForChangeDetect && !options.yes) {
|
|
2424
2874
|
if (bulkAction.value === 'skip_all') {
|
|
2425
|
-
log.dim(` Skipped ${
|
|
2875
|
+
log.dim(` Skipped ${fileName}`);
|
|
2426
2876
|
return { uid: record.UID, metaPath };
|
|
2427
2877
|
}
|
|
2428
2878
|
|
|
@@ -2430,51 +2880,66 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2430
2880
|
const config = await loadConfig();
|
|
2431
2881
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
2432
2882
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
2433
|
-
|
|
2883
|
+
|
|
2884
|
+
// If local metadata has no _LastUpdated (e.g. from dbo add with incomplete fields),
|
|
2885
|
+
// always treat as server-newer so pull populates missing columns.
|
|
2886
|
+
let localMissingLastUpdated = false;
|
|
2887
|
+
try {
|
|
2888
|
+
const localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
2889
|
+
if (!localMeta._LastUpdated) localMissingLastUpdated = true;
|
|
2890
|
+
} catch { /* unreadable — will be overwritten */ }
|
|
2891
|
+
|
|
2892
|
+
const serverNewer = localMissingLastUpdated || isServerNewer(localSyncTime, record._LastUpdated, configWithTz, entityName, record.UID);
|
|
2434
2893
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
2435
2894
|
|
|
2436
2895
|
if (serverNewer) {
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
});
|
|
2441
|
-
|
|
2442
|
-
if (action === 'skip') {
|
|
2443
|
-
log.dim(` Skipped ${finalName}.${ext}`);
|
|
2444
|
-
return { uid: record.UID, metaPath };
|
|
2445
|
-
}
|
|
2446
|
-
if (action === 'skip_all') {
|
|
2447
|
-
bulkAction.value = 'skip_all';
|
|
2448
|
-
log.dim(` Skipped ${finalName}.${ext}`);
|
|
2449
|
-
return { uid: record.UID, metaPath };
|
|
2450
|
-
}
|
|
2451
|
-
if (action === 'overwrite_all') {
|
|
2452
|
-
bulkAction.value = 'overwrite_all';
|
|
2896
|
+
// Incomplete metadata (no _LastUpdated) from dbo add — auto-accept without prompting
|
|
2897
|
+
if (localMissingLastUpdated) {
|
|
2898
|
+
log.dim(` Completing metadata: ${fileName}`);
|
|
2453
2899
|
// Fall through to write
|
|
2900
|
+
} else {
|
|
2901
|
+
const action = await promptChangeDetection(fileName, record, configWithTz, {
|
|
2902
|
+
serverDate,
|
|
2903
|
+
localDate: localSyncTime,
|
|
2904
|
+
});
|
|
2905
|
+
|
|
2906
|
+
if (action === 'skip') {
|
|
2907
|
+
log.dim(` Skipped ${fileName}`);
|
|
2908
|
+
return { uid: record.UID, metaPath };
|
|
2909
|
+
}
|
|
2910
|
+
if (action === 'skip_all') {
|
|
2911
|
+
bulkAction.value = 'skip_all';
|
|
2912
|
+
log.dim(` Skipped ${fileName}`);
|
|
2913
|
+
return { uid: record.UID, metaPath };
|
|
2914
|
+
}
|
|
2915
|
+
if (action === 'overwrite_all') {
|
|
2916
|
+
bulkAction.value = 'overwrite_all';
|
|
2917
|
+
// Fall through to write
|
|
2918
|
+
}
|
|
2919
|
+
if (action === 'compare') {
|
|
2920
|
+
await inlineDiffAndMerge(record, metaPath, configWithTz);
|
|
2921
|
+
return { uid: record.UID, metaPath };
|
|
2922
|
+
}
|
|
2923
|
+
// 'overwrite' falls through to normal write
|
|
2454
2924
|
}
|
|
2455
|
-
if (action === 'compare') {
|
|
2456
|
-
await inlineDiffAndMerge(record, metaPath, configWithTz);
|
|
2457
|
-
return { uid: record.UID, metaPath };
|
|
2458
|
-
}
|
|
2459
|
-
// 'overwrite' falls through to normal write
|
|
2460
2925
|
} else {
|
|
2461
2926
|
// Server _LastUpdated hasn't changed since last sync.
|
|
2462
2927
|
// Check if local content files were modified (user edits).
|
|
2463
2928
|
const locallyModified = await hasLocalModifications(metaPath, configWithTz);
|
|
2464
2929
|
if (locallyModified) {
|
|
2465
|
-
const action = await promptChangeDetection(
|
|
2930
|
+
const action = await promptChangeDetection(fileName, record, configWithTz, {
|
|
2466
2931
|
localIsNewer: true,
|
|
2467
2932
|
serverDate,
|
|
2468
2933
|
localDate: localSyncTime,
|
|
2469
2934
|
});
|
|
2470
2935
|
|
|
2471
2936
|
if (action === 'skip') {
|
|
2472
|
-
log.dim(` Kept local: ${
|
|
2937
|
+
log.dim(` Kept local: ${fileName}`);
|
|
2473
2938
|
return { uid: record.UID, metaPath };
|
|
2474
2939
|
}
|
|
2475
2940
|
if (action === 'skip_all') {
|
|
2476
2941
|
bulkAction.value = 'skip_all';
|
|
2477
|
-
log.dim(` Kept local: ${
|
|
2942
|
+
log.dim(` Kept local: ${fileName}`);
|
|
2478
2943
|
return { uid: record.UID, metaPath };
|
|
2479
2944
|
}
|
|
2480
2945
|
if (action === 'overwrite_all') {
|
|
@@ -2486,7 +2951,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2486
2951
|
}
|
|
2487
2952
|
// 'overwrite' falls through to normal write
|
|
2488
2953
|
} else {
|
|
2489
|
-
log.dim(` Up to date: ${
|
|
2954
|
+
log.dim(` Up to date: ${fileName}`);
|
|
2490
2955
|
return { uid: record.UID, metaPath };
|
|
2491
2956
|
}
|
|
2492
2957
|
}
|
|
@@ -2494,11 +2959,9 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2494
2959
|
}
|
|
2495
2960
|
|
|
2496
2961
|
if (hasContent) {
|
|
2497
|
-
const decoded = resolveContentValue(contentValue);
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
log.success(`Saved ${filePath}`);
|
|
2501
|
-
}
|
|
2962
|
+
const decoded = resolveContentValue(contentValue) ?? '';
|
|
2963
|
+
await writeFile(filePath, decoded);
|
|
2964
|
+
log.success(`Saved ${filePath}`);
|
|
2502
2965
|
}
|
|
2503
2966
|
|
|
2504
2967
|
// Build metadata
|
|
@@ -2515,7 +2978,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2515
2978
|
if (decoded && decoded.length > 200) {
|
|
2516
2979
|
// Large value: save as separate file
|
|
2517
2980
|
const colExt = guessExtensionForColumn(key);
|
|
2518
|
-
const colFileName = `${
|
|
2981
|
+
const colFileName = `${metaBase}-${key.toLowerCase()}.${colExt}`;
|
|
2519
2982
|
const colFilePath = join(dir, colFileName);
|
|
2520
2983
|
await writeFile(colFilePath, decoded);
|
|
2521
2984
|
meta[key] = `@${colFileName}`;
|