@dboio/cli 0.15.3 → 0.17.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.
- package/README.md +165 -76
- package/bin/dbo.js +2 -2
- package/package.json +1 -1
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +165 -76
- package/src/commands/adopt.js +534 -0
- package/src/commands/clone.js +365 -143
- package/src/commands/init.js +42 -1
- package/src/commands/input.js +2 -32
- package/src/commands/mv.js +3 -3
- package/src/commands/push.js +13 -9
- package/src/commands/rm.js +2 -2
- package/src/lib/columns.js +1 -0
- package/src/lib/config.js +83 -1
- package/src/lib/delta.js +3 -2
- package/src/lib/dependencies.js +217 -2
- package/src/lib/diff.js +9 -11
- package/src/lib/filenames.js +3 -3
- package/src/lib/ignore.js +1 -0
- package/src/{commands/add.js → lib/insert.js} +133 -478
- package/src/lib/logger.js +35 -0
- package/src/lib/metadata-schema.js +492 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/schema.js +53 -0
- package/src/lib/structure.js +3 -3
- package/src/lib/tagging.js +1 -1
- package/src/lib/toe-stepping.js +2 -2
- package/src/migrations/007-natural-entity-companion-filenames.js +5 -2
- package/src/migrations/011-schema-driven-metadata.js +120 -0
package/src/commands/clone.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat } from 'fs/promises';
|
|
2
|
+
import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat, utimes } 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
|
-
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';
|
|
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, resolveFieldValue } from '../lib/structure.js';
|
|
8
8
|
import { log } from '../lib/logger.js';
|
|
9
9
|
import { buildUidFilename, buildContentFileName, buildMetaFilename, isMetadataFile, parseMetaFilename, stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
|
|
10
10
|
import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
|
|
@@ -12,9 +12,12 @@ import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDet
|
|
|
12
12
|
import { loadIgnore } from '../lib/ignore.js';
|
|
13
13
|
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
14
14
|
import { applyTrashIcon, ensureTrashIcon, tagProjectFiles } from '../lib/tagging.js';
|
|
15
|
-
import {
|
|
15
|
+
import { loadMetadataSchema, saveMetadataSchema, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord, generateMetadataFromSchema, parseReferenceExpression, mergeDescriptorSchemaFromDependencies } from '../lib/metadata-schema.js';
|
|
16
|
+
import { fetchSchema, loadSchema, saveSchema, isSchemaStale, SCHEMA_FILE } from '../lib/schema.js';
|
|
16
17
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
17
18
|
import { upsertDeployEntry } from '../lib/deploy-config.js';
|
|
19
|
+
import { syncDependencies, parseDependenciesColumn } from '../lib/dependencies.js';
|
|
20
|
+
import { mergeDependencies } from '../lib/config.js';
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Resolve a column value that may be base64-encoded.
|
|
@@ -124,7 +127,7 @@ export async function detectAndTrashOrphans(appJson, ig, sync, options) {
|
|
|
124
127
|
const metaDir = dirname(metaPath);
|
|
125
128
|
const filesToMove = [metaPath];
|
|
126
129
|
|
|
127
|
-
for (const col of (meta._contentColumns || [])) {
|
|
130
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
|
|
128
131
|
const ref = meta[col];
|
|
129
132
|
if (ref && String(ref).startsWith('@')) {
|
|
130
133
|
const refName = String(ref).substring(1);
|
|
@@ -411,7 +414,7 @@ async function detectAndRenameLegacyCompanions(metaPath, meta) {
|
|
|
411
414
|
if (!uid) return false;
|
|
412
415
|
|
|
413
416
|
const metaDir = dirname(metaPath);
|
|
414
|
-
const contentCols = [...(meta._contentColumns || [])];
|
|
417
|
+
const contentCols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
|
|
415
418
|
if (meta._mediaFile) contentCols.push('_mediaFile');
|
|
416
419
|
let metaChanged = false;
|
|
417
420
|
|
|
@@ -475,10 +478,13 @@ async function detectAndRenameLegacyCompanions(metaPath, meta) {
|
|
|
475
478
|
}
|
|
476
479
|
}
|
|
477
480
|
|
|
478
|
-
// Rewrite metadata file if @references were updated
|
|
481
|
+
// Rewrite metadata file if @references were updated (preserve timestamps
|
|
482
|
+
// so the write doesn't cause false "local changes" during change detection)
|
|
479
483
|
if (metaChanged) {
|
|
480
484
|
try {
|
|
485
|
+
const before = await stat(metaPath);
|
|
481
486
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
487
|
+
await utimes(metaPath, before.atime, before.mtime);
|
|
482
488
|
} catch { /* non-critical */ }
|
|
483
489
|
}
|
|
484
490
|
|
|
@@ -519,7 +525,7 @@ async function trashOrphanedLegacyCompanions() {
|
|
|
519
525
|
// Read metadata and collect all @references
|
|
520
526
|
try {
|
|
521
527
|
const meta = JSON.parse(await readFile(full, 'utf8'));
|
|
522
|
-
const cols = [...(meta._contentColumns || [])];
|
|
528
|
+
const cols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
|
|
523
529
|
if (meta._mediaFile) cols.push('_mediaFile');
|
|
524
530
|
for (const col of cols) {
|
|
525
531
|
const ref = meta[col];
|
|
@@ -804,7 +810,11 @@ export const cloneCommand = new Command('clone')
|
|
|
804
810
|
.option('-y, --yes', 'Auto-accept all prompts')
|
|
805
811
|
.option('--media-placement <placement>', 'Set media placement: fullpath or binpath (default: bin)')
|
|
806
812
|
.option('-v, --verbose', 'Show HTTP request details')
|
|
813
|
+
.option('--schema', 'Re-fetch schema.json from server before cloning')
|
|
807
814
|
.option('--no-migrate', 'Skip pending migrations for this invocation')
|
|
815
|
+
.option('--no-deps', 'Skip dependency cloning after clone')
|
|
816
|
+
.option('--dependencies <apps>', 'Sync specific dependency apps (comma-separated short-names)')
|
|
817
|
+
.option('--configure', 'Re-prompt for filename columns and companion file extraction preferences')
|
|
808
818
|
.action(async (source, options) => {
|
|
809
819
|
try {
|
|
810
820
|
await runPendingMigrations(options);
|
|
@@ -1009,6 +1019,35 @@ export async function performClone(source, options = {}) {
|
|
|
1009
1019
|
const effectiveDomain = options.domain || config.domain;
|
|
1010
1020
|
let appJson;
|
|
1011
1021
|
|
|
1022
|
+
// Fetch schema if missing, explicitly requested, or server has a newer version
|
|
1023
|
+
let schema = await loadSchema();
|
|
1024
|
+
let shouldFetch = !schema || options.schema;
|
|
1025
|
+
if (!shouldFetch && schema) {
|
|
1026
|
+
try {
|
|
1027
|
+
shouldFetch = await isSchemaStale({ domain: effectiveDomain, verbose: options.verbose });
|
|
1028
|
+
if (shouldFetch) log.dim(` Server schema is newer — refreshing schema.json`);
|
|
1029
|
+
} catch {
|
|
1030
|
+
// Can't check — continue with local schema
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (shouldFetch) {
|
|
1034
|
+
try {
|
|
1035
|
+
schema = await fetchSchema({ domain: effectiveDomain, verbose: options.verbose });
|
|
1036
|
+
await saveSchema(schema);
|
|
1037
|
+
log.dim(` Saved schema.json`);
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
if (!schema) log.warn(` Could not fetch schema: ${err.message}`);
|
|
1040
|
+
// Continue with stale schema or null
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Regenerate metadata_schema.json for any new entity types
|
|
1045
|
+
if (schema) {
|
|
1046
|
+
const existing = await loadMetadataSchema();
|
|
1047
|
+
const updated = generateMetadataFromSchema(schema, existing ?? {});
|
|
1048
|
+
await saveMetadataSchema(updated);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1012
1051
|
// Step 1: Source mismatch detection (skip in pull mode)
|
|
1013
1052
|
// Warn when the user provides an explicit source that differs from the stored one.
|
|
1014
1053
|
const storedCloneSource = options.pullMode ? null : await loadCloneSource();
|
|
@@ -1092,6 +1131,16 @@ export async function performClone(source, options = {}) {
|
|
|
1092
1131
|
});
|
|
1093
1132
|
await saveCloneSource(activeSource || 'default');
|
|
1094
1133
|
log.dim(' Updated .dbo/config.json with app metadata');
|
|
1134
|
+
|
|
1135
|
+
// Merge Dependencies from app.json into .dbo/config.json
|
|
1136
|
+
// Always ensure at least ["_system"] is persisted
|
|
1137
|
+
const fromApp = parseDependenciesColumn(appJson.Dependencies);
|
|
1138
|
+
if (fromApp.length > 0) {
|
|
1139
|
+
await mergeDependencies(fromApp);
|
|
1140
|
+
log.dim(` Merged app.json Dependencies: ${fromApp.join(', ')}`);
|
|
1141
|
+
} else {
|
|
1142
|
+
await mergeDependencies([]);
|
|
1143
|
+
}
|
|
1095
1144
|
}
|
|
1096
1145
|
|
|
1097
1146
|
// Detect and store ModifyKey for locked/production apps (skip in pull mode)
|
|
@@ -1120,6 +1169,41 @@ export async function performClone(source, options = {}) {
|
|
|
1120
1169
|
await updatePackageJson(appJson, config);
|
|
1121
1170
|
}
|
|
1122
1171
|
|
|
1172
|
+
// Step 3b: Sync dependency apps — before main content processing (skip in pull mode, entity filter, no-deps)
|
|
1173
|
+
if (!options.pullMode && !options.entity && !options.noDeps) {
|
|
1174
|
+
const explicitDeps = options.dependencies
|
|
1175
|
+
? options.dependencies.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
|
1176
|
+
: null;
|
|
1177
|
+
if (explicitDeps && explicitDeps.length > 0) {
|
|
1178
|
+
await mergeDependencies(explicitDeps);
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
await syncDependencies({
|
|
1182
|
+
domain: effectiveDomain,
|
|
1183
|
+
force: explicitDeps ? true : options.force,
|
|
1184
|
+
schema: options.schema,
|
|
1185
|
+
verbose: options.verbose,
|
|
1186
|
+
systemSchemaPath: join(process.cwd(), SCHEMA_FILE),
|
|
1187
|
+
only: explicitDeps || undefined,
|
|
1188
|
+
});
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
log.warn(` Dependency sync failed: ${err.message}`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Merge extension descriptor definitions from dependency schemas
|
|
1194
|
+
// (e.g. operator provides descriptor_definition entries for apps that lack their own)
|
|
1195
|
+
try {
|
|
1196
|
+
const localSchema = await loadMetadataSchema();
|
|
1197
|
+
if (localSchema) {
|
|
1198
|
+
const merged = await mergeDescriptorSchemaFromDependencies(localSchema);
|
|
1199
|
+
if (merged) {
|
|
1200
|
+
await saveMetadataSchema(localSchema);
|
|
1201
|
+
log.dim(' Merged extension descriptor schema from dependencies');
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
} catch { /* non-critical */ }
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1123
1207
|
// Step 4: Create default project directories + bin structure
|
|
1124
1208
|
const bins = appJson.children.bin || [];
|
|
1125
1209
|
const structure = buildBinHierarchy(bins, appJson.AppID);
|
|
@@ -1578,26 +1662,23 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1578
1662
|
const usedNames = new Map(); // name → count, for collision resolution
|
|
1579
1663
|
const config = await loadConfig();
|
|
1580
1664
|
|
|
1581
|
-
// Determine filename column: saved preference, or
|
|
1665
|
+
// Determine filename column: saved preference, or auto-default, or prompt (--configure)
|
|
1582
1666
|
let filenameCol = null;
|
|
1583
1667
|
|
|
1584
1668
|
if (options.yes) {
|
|
1585
1669
|
// Non-interactive: use Name, fallback UID
|
|
1586
1670
|
filenameCol = null; // will resolve per-record below
|
|
1587
1671
|
} else {
|
|
1588
|
-
// Check saved preference
|
|
1589
|
-
const savedCol = await loadEntityDirPreference(entityName);
|
|
1672
|
+
// Check saved preference (skip if --configure to allow re-prompting)
|
|
1673
|
+
const savedCol = options.configure ? null : await loadEntityDirPreference(entityName);
|
|
1590
1674
|
if (savedCol) {
|
|
1591
1675
|
filenameCol = savedCol;
|
|
1592
1676
|
log.dim(` Using saved filename column "${filenameCol}" for ${entityName}`);
|
|
1593
1677
|
} else {
|
|
1594
|
-
// Prompt user to pick a filename column from available columns
|
|
1595
1678
|
const sampleRecord = entries[0];
|
|
1596
1679
|
const columns = Object.keys(sampleRecord).filter(k => k !== 'children' && !k.startsWith('_'));
|
|
1597
1680
|
|
|
1598
1681
|
if (columns.length > 0) {
|
|
1599
|
-
const inquirer = (await import('inquirer')).default;
|
|
1600
|
-
|
|
1601
1682
|
// Find best default (app_version prioritizes Number → Name → UID)
|
|
1602
1683
|
const defaultCol = (entityName === 'app_version' && columns.includes('Number')) ? 'Number'
|
|
1603
1684
|
: columns.includes('Name') ? 'Name'
|
|
@@ -1605,18 +1686,25 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1605
1686
|
: columns.includes('UID') ? 'UID'
|
|
1606
1687
|
: columns[0];
|
|
1607
1688
|
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1689
|
+
if (options.configure) {
|
|
1690
|
+
// --configure: prompt user to pick
|
|
1691
|
+
const inquirer = (await import('inquirer')).default;
|
|
1692
|
+
const { col } = await inquirer.prompt([{
|
|
1693
|
+
type: 'list',
|
|
1694
|
+
name: 'col',
|
|
1695
|
+
message: `Which column should be used as the filename for ${entityName} records?`,
|
|
1696
|
+
choices: columns,
|
|
1697
|
+
default: defaultCol,
|
|
1698
|
+
}]);
|
|
1699
|
+
filenameCol = col;
|
|
1700
|
+
} else {
|
|
1701
|
+
// Auto-apply best default silently
|
|
1702
|
+
filenameCol = defaultCol;
|
|
1703
|
+
}
|
|
1616
1704
|
|
|
1617
1705
|
// Save preference for future clones
|
|
1618
1706
|
await saveEntityDirPreference(entityName, filenameCol);
|
|
1619
|
-
log.dim(`
|
|
1707
|
+
log.dim(` Using filename column "${filenameCol}" for ${entityName}`);
|
|
1620
1708
|
}
|
|
1621
1709
|
}
|
|
1622
1710
|
}
|
|
@@ -1646,13 +1734,11 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1646
1734
|
}
|
|
1647
1735
|
|
|
1648
1736
|
if (base64Cols.length > 0) {
|
|
1649
|
-
// Load saved content extraction preferences
|
|
1650
|
-
const savedExtractions = await loadEntityContentExtractions(entityName);
|
|
1737
|
+
// Load saved content extraction preferences (skip if --configure to allow re-prompting)
|
|
1738
|
+
const savedExtractions = options.configure ? null : await loadEntityContentExtractions(entityName);
|
|
1651
1739
|
const newPreferences = savedExtractions ? { ...savedExtractions } : {};
|
|
1652
1740
|
let hasNewChoices = false;
|
|
1653
1741
|
|
|
1654
|
-
const inquirer = (await import('inquirer')).default;
|
|
1655
|
-
|
|
1656
1742
|
// Prompt per column: show snippet and ask extract yes/no + extension
|
|
1657
1743
|
for (const { col, snippet } of base64Cols) {
|
|
1658
1744
|
// Check if we have a saved preference for this column
|
|
@@ -1670,32 +1756,42 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1670
1756
|
}
|
|
1671
1757
|
}
|
|
1672
1758
|
|
|
1673
|
-
// No saved preference
|
|
1674
|
-
const preview = snippet ? ` (${snippet})` : '';
|
|
1759
|
+
// No saved preference
|
|
1675
1760
|
const guessed = guessExtensionForColumn(col);
|
|
1676
1761
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
default: true,
|
|
1682
|
-
}]);
|
|
1762
|
+
if (options.configure) {
|
|
1763
|
+
// --configure: prompt user
|
|
1764
|
+
const preview = snippet ? ` (${snippet})` : '';
|
|
1765
|
+
const inquirer = (await import('inquirer')).default;
|
|
1683
1766
|
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
default: guessed,
|
|
1767
|
+
const { extract } = await inquirer.prompt([{
|
|
1768
|
+
type: 'confirm',
|
|
1769
|
+
name: 'extract',
|
|
1770
|
+
message: `Extract "${col}" as companion file?${preview}`,
|
|
1771
|
+
default: true,
|
|
1690
1772
|
}]);
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1773
|
+
|
|
1774
|
+
if (extract) {
|
|
1775
|
+
const { ext } = await inquirer.prompt([{
|
|
1776
|
+
type: 'input',
|
|
1777
|
+
name: 'ext',
|
|
1778
|
+
message: `File extension for "${col}":`,
|
|
1779
|
+
default: guessed,
|
|
1780
|
+
}]);
|
|
1781
|
+
const cleanExt = ext.replace(/^\./, '');
|
|
1782
|
+
contentColsToExtract.push({ col, ext: cleanExt });
|
|
1783
|
+
newPreferences[col] = cleanExt;
|
|
1784
|
+
hasNewChoices = true;
|
|
1785
|
+
} else {
|
|
1786
|
+
newPreferences[col] = false;
|
|
1787
|
+
hasNewChoices = true;
|
|
1788
|
+
}
|
|
1695
1789
|
} else {
|
|
1696
|
-
//
|
|
1697
|
-
|
|
1790
|
+
// Auto-apply: extract with guessed extension
|
|
1791
|
+
contentColsToExtract.push({ col, ext: guessed });
|
|
1792
|
+
newPreferences[col] = guessed;
|
|
1698
1793
|
hasNewChoices = true;
|
|
1794
|
+
log.dim(` Auto-extracting "${col}" as .${guessed} file`);
|
|
1699
1795
|
}
|
|
1700
1796
|
}
|
|
1701
1797
|
|
|
@@ -1788,7 +1884,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1788
1884
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
1789
1885
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
1790
1886
|
|
|
1791
|
-
// If local metadata has no _LastUpdated (e.g. from dbo
|
|
1887
|
+
// If local metadata has no _LastUpdated (e.g. from dbo adopt), treat as server-newer
|
|
1792
1888
|
let localMissingLastUpdated = false;
|
|
1793
1889
|
try {
|
|
1794
1890
|
const localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
@@ -1799,7 +1895,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1799
1895
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
1800
1896
|
|
|
1801
1897
|
if (serverNewer) {
|
|
1802
|
-
// Incomplete metadata (no _LastUpdated) from dbo
|
|
1898
|
+
// Incomplete metadata (no _LastUpdated) from dbo adopt — auto-accept without prompting
|
|
1803
1899
|
if (localMissingLastUpdated) {
|
|
1804
1900
|
log.dim(` Completing metadata: ${name}`);
|
|
1805
1901
|
// Fall through to write
|
|
@@ -1906,7 +2002,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1906
2002
|
|
|
1907
2003
|
meta._entity = entityName;
|
|
1908
2004
|
if (extractedContentCols.length > 0) {
|
|
1909
|
-
meta.
|
|
2005
|
+
meta._companionReferenceColumns = extractedContentCols;
|
|
1910
2006
|
}
|
|
1911
2007
|
|
|
1912
2008
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
@@ -1924,7 +2020,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1924
2020
|
|
|
1925
2021
|
// --- Seed metadata template if not already present ---
|
|
1926
2022
|
if (entries.length > 0) {
|
|
1927
|
-
const templates = await
|
|
2023
|
+
const templates = await loadMetadataSchema();
|
|
1928
2024
|
if (templates !== null) {
|
|
1929
2025
|
const existing = getTemplateCols(templates, entityName, null);
|
|
1930
2026
|
if (!existing) {
|
|
@@ -1932,7 +2028,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1932
2028
|
const extractedColNames = contentColsToExtract.map(c => c.col);
|
|
1933
2029
|
const cols = buildTemplateFromCloneRecord(firstRecord, extractedColNames);
|
|
1934
2030
|
setTemplateCols(templates, entityName, null, cols);
|
|
1935
|
-
await
|
|
2031
|
+
await saveMetadataSchema(templates);
|
|
1936
2032
|
}
|
|
1937
2033
|
}
|
|
1938
2034
|
}
|
|
@@ -1942,25 +2038,51 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1942
2038
|
|
|
1943
2039
|
// ─── Extension Descriptor Sub-directory Processing ────────────────────────
|
|
1944
2040
|
|
|
2041
|
+
/**
|
|
2042
|
+
* Parse form-control-code and form-control-title from a descriptor_definition String5 value.
|
|
2043
|
+
* Returns { colToExt: Map<col,ext>, colToTitle: Map<col,title> }.
|
|
2044
|
+
*/
|
|
2045
|
+
function parseFormControlCode(string5) {
|
|
2046
|
+
if (!string5) return { colToExt: new Map(), colToTitle: new Map() };
|
|
2047
|
+
const colToExt = new Map();
|
|
2048
|
+
const colToTitle = new Map();
|
|
2049
|
+
try {
|
|
2050
|
+
const params = new URLSearchParams(string5.startsWith('&') ? string5.slice(1) : string5);
|
|
2051
|
+
const codeStr = params.get('form-control-code');
|
|
2052
|
+
if (codeStr) {
|
|
2053
|
+
for (const pair of codeStr.split(',')) {
|
|
2054
|
+
const [col, ext] = pair.split('|');
|
|
2055
|
+
if (col?.trim() && ext?.trim()) colToExt.set(col.trim(), ext.trim().toLowerCase());
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
const titleStr = params.get('form-control-title');
|
|
2059
|
+
if (titleStr) {
|
|
2060
|
+
for (const pair of titleStr.split(',')) {
|
|
2061
|
+
const [col, title] = pair.split('|');
|
|
2062
|
+
if (col?.trim() && title?.trim()) colToTitle.set(col.trim(), title.trim());
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
} catch { /* malformed params — ignore */ }
|
|
2066
|
+
return { colToExt, colToTitle };
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1945
2069
|
/**
|
|
1946
2070
|
* Scan extension records for descriptor_definition entries.
|
|
1947
|
-
* Builds the mapping, persists to structure.json,
|
|
1948
|
-
*
|
|
2071
|
+
* Builds the mapping, persists to structure.json, creates sub-directories
|
|
2072
|
+
* for every mapped descriptor, and populates metadata_schema.json from
|
|
2073
|
+
* form-control-code entries.
|
|
1949
2074
|
* Returns the mapping object.
|
|
1950
2075
|
*
|
|
1951
2076
|
* @param {Object[]} extensionEntries
|
|
1952
|
-
* @param {Object} structure
|
|
2077
|
+
* @param {Object} structure - Current bin structure (from loadStructureFile)
|
|
2078
|
+
* @param {Object} metadataSchema - Current metadata schema (from loadMetadataSchema)
|
|
1953
2079
|
* @returns {Promise<Object<string,string>>}
|
|
1954
2080
|
*/
|
|
1955
|
-
async function buildDescriptorPrePass(extensionEntries, structure) {
|
|
2081
|
+
async function buildDescriptorPrePass(extensionEntries, structure, metadataSchema) {
|
|
1956
2082
|
const { mapping, warnings } = buildDescriptorMapping(extensionEntries);
|
|
1957
2083
|
|
|
1958
2084
|
for (const w of warnings) log.warn(` descriptor_definition: ${w}`);
|
|
1959
2085
|
|
|
1960
|
-
// Always create Unsupported/ — even if empty, users see at a glance what couldn't be mapped
|
|
1961
|
-
await mkdir(EXTENSION_UNSUPPORTED_DIR, { recursive: true });
|
|
1962
|
-
log.dim(` ${EXTENSION_UNSUPPORTED_DIR}/`);
|
|
1963
|
-
|
|
1964
2086
|
// Create one sub-directory per mapped descriptor name
|
|
1965
2087
|
for (const dirName of new Set(Object.values(mapping))) {
|
|
1966
2088
|
const fullDir = `${EXTENSION_DESCRIPTORS_DIR}/${dirName}`;
|
|
@@ -1985,9 +2107,63 @@ async function buildDescriptorPrePass(extensionEntries, structure) {
|
|
|
1985
2107
|
await saveDescriptorMapping(structure, mapping);
|
|
1986
2108
|
log.dim(` Saved descriptorMapping to .dbo/structure.json`);
|
|
1987
2109
|
|
|
2110
|
+
// Parse form-control-code from descriptor_definition records → populate metadata_schema.json
|
|
2111
|
+
const descriptorDefs = extensionEntries.filter(r =>
|
|
2112
|
+
resolveFieldValue(r.Descriptor) === 'descriptor_definition'
|
|
2113
|
+
);
|
|
2114
|
+
let schemaUpdated = false;
|
|
2115
|
+
for (const defRecord of descriptorDefs) {
|
|
2116
|
+
const descriptor = resolveFieldValue(defRecord.String1);
|
|
2117
|
+
if (!descriptor) continue;
|
|
2118
|
+
const string5 = resolveFieldValue(defRecord.String5);
|
|
2119
|
+
const { colToExt, colToTitle } = parseFormControlCode(string5);
|
|
2120
|
+
if (colToExt.size === 0) continue;
|
|
2121
|
+
|
|
2122
|
+
// Only populate if no entry already exists for this descriptor
|
|
2123
|
+
const existing = getTemplateCols(metadataSchema, 'extension', descriptor);
|
|
2124
|
+
if (existing) continue;
|
|
2125
|
+
|
|
2126
|
+
const refEntries = [];
|
|
2127
|
+
for (const [col, ext] of colToExt) {
|
|
2128
|
+
const title = colToTitle.get(col);
|
|
2129
|
+
const titleSuffix = title ? `{${title}}` : '';
|
|
2130
|
+
refEntries.push(`${col}=@reference:@Name[${ext}]${titleSuffix}`);
|
|
2131
|
+
}
|
|
2132
|
+
setTemplateCols(metadataSchema, 'extension', descriptor, refEntries);
|
|
2133
|
+
schemaUpdated = true;
|
|
2134
|
+
}
|
|
2135
|
+
if (schemaUpdated) await saveMetadataSchema(metadataSchema);
|
|
2136
|
+
|
|
1988
2137
|
return mapping;
|
|
1989
2138
|
}
|
|
1990
2139
|
|
|
2140
|
+
/**
|
|
2141
|
+
* Derive companion column @reference expressions from metadata_schema.json for a descriptor.
|
|
2142
|
+
* Returns array of parsed @reference expression objects.
|
|
2143
|
+
*/
|
|
2144
|
+
function getDescriptorCompanionCols(metadataSchema, descriptor) {
|
|
2145
|
+
const cols = getTemplateCols(metadataSchema, 'extension', descriptor) ?? [];
|
|
2146
|
+
const refs = [];
|
|
2147
|
+
for (const col of cols) {
|
|
2148
|
+
const parsed = parseReferenceExpression(col);
|
|
2149
|
+
if (parsed) refs.push(parsed);
|
|
2150
|
+
}
|
|
2151
|
+
return refs;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* Derive filename column from metadata_schema.json @reference entries for a descriptor.
|
|
2156
|
+
* Returns the column name (e.g., "Name") or falls back to 'Name'.
|
|
2157
|
+
*/
|
|
2158
|
+
function getDescriptorFilenameCol(metadataSchema, descriptor) {
|
|
2159
|
+
const cols = getTemplateCols(metadataSchema, 'extension', descriptor) ?? [];
|
|
2160
|
+
for (const col of cols) {
|
|
2161
|
+
const parsed = parseReferenceExpression(col);
|
|
2162
|
+
if (parsed?.filenameCol?.startsWith('@')) return parsed.filenameCol.slice(1);
|
|
2163
|
+
}
|
|
2164
|
+
return 'Name';
|
|
2165
|
+
}
|
|
2166
|
+
|
|
1991
2167
|
/**
|
|
1992
2168
|
* Resolve filename column and content extraction preferences for one descriptor.
|
|
1993
2169
|
* Prompts the user on first use; saves to config.json; respects -y and --force.
|
|
@@ -2006,16 +2182,16 @@ async function resolveDescriptorPreferences(descriptor, records, options) {
|
|
|
2006
2182
|
let filenameCol;
|
|
2007
2183
|
const savedCol = await loadDescriptorFilenamePreference(descriptor);
|
|
2008
2184
|
|
|
2185
|
+
const defaultCol = columns.includes('Name') ? 'Name'
|
|
2186
|
+
: columns.includes('UID') ? 'UID' : columns[0];
|
|
2187
|
+
|
|
2009
2188
|
if (options.yes) {
|
|
2010
|
-
filenameCol =
|
|
2011
|
-
|
|
2012
|
-
} else if (savedCol && !options.force) {
|
|
2189
|
+
filenameCol = defaultCol;
|
|
2190
|
+
} else if (savedCol && !options.force && !options.configure) {
|
|
2013
2191
|
filenameCol = savedCol;
|
|
2014
2192
|
log.dim(` Filename column for "${descriptor}": "${filenameCol}" (saved)`);
|
|
2015
|
-
} else {
|
|
2193
|
+
} else if (options.configure) {
|
|
2016
2194
|
const inquirer = (await import('inquirer')).default;
|
|
2017
|
-
const defaultCol = columns.includes('Name') ? 'Name'
|
|
2018
|
-
: columns.includes('UID') ? 'UID' : columns[0];
|
|
2019
2195
|
const { col } = await inquirer.prompt([{
|
|
2020
2196
|
type: 'list', name: 'col',
|
|
2021
2197
|
message: `Filename column for "${descriptor}" extensions:`,
|
|
@@ -2024,6 +2200,11 @@ async function resolveDescriptorPreferences(descriptor, records, options) {
|
|
|
2024
2200
|
filenameCol = col;
|
|
2025
2201
|
await saveDescriptorFilenamePreference(descriptor, filenameCol);
|
|
2026
2202
|
log.dim(` Saved filename column for "${descriptor}": "${filenameCol}"`);
|
|
2203
|
+
} else {
|
|
2204
|
+
// Auto-apply default silently
|
|
2205
|
+
filenameCol = defaultCol;
|
|
2206
|
+
await saveDescriptorFilenamePreference(descriptor, filenameCol);
|
|
2207
|
+
log.dim(` Using filename column "${filenameCol}" for "${descriptor}"`);
|
|
2027
2208
|
}
|
|
2028
2209
|
|
|
2029
2210
|
// ── Content extraction ───────────────────────────────────────────────
|
|
@@ -2052,12 +2233,11 @@ async function resolveDescriptorPreferences(descriptor, records, options) {
|
|
|
2052
2233
|
}
|
|
2053
2234
|
|
|
2054
2235
|
if (base64Cols.length > 0) {
|
|
2055
|
-
const savedExtractions = options.force
|
|
2236
|
+
const savedExtractions = (options.force || options.configure)
|
|
2056
2237
|
? null
|
|
2057
2238
|
: await loadDescriptorContentExtractions(descriptor);
|
|
2058
2239
|
const newPreferences = savedExtractions ? { ...savedExtractions } : {};
|
|
2059
2240
|
let changed = false;
|
|
2060
|
-
const inquirer = (await import('inquirer')).default;
|
|
2061
2241
|
|
|
2062
2242
|
for (const { col, snippet } of base64Cols) {
|
|
2063
2243
|
if (savedExtractions) {
|
|
@@ -2069,24 +2249,35 @@ async function resolveDescriptorPreferences(descriptor, records, options) {
|
|
|
2069
2249
|
continue;
|
|
2070
2250
|
}
|
|
2071
2251
|
}
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
default
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
type: 'input', name: 'ext',
|
|
2082
|
-
message: `File extension for "${col}" (${descriptor}):`,
|
|
2083
|
-
default: guessed,
|
|
2252
|
+
|
|
2253
|
+
if (options.configure) {
|
|
2254
|
+
// --configure: prompt user
|
|
2255
|
+
const preview = snippet ? ` ("${snippet}")` : '';
|
|
2256
|
+
const inquirer = (await import('inquirer')).default;
|
|
2257
|
+
const { extract } = await inquirer.prompt([{
|
|
2258
|
+
type: 'confirm', name: 'extract',
|
|
2259
|
+
message: `Extract column "${col}" (${descriptor}) as companion file?${preview}`,
|
|
2260
|
+
default: true,
|
|
2084
2261
|
}]);
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2262
|
+
if (extract) {
|
|
2263
|
+
const guessed = guessExtensionForDescriptor(descriptor, col);
|
|
2264
|
+
const { ext } = await inquirer.prompt([{
|
|
2265
|
+
type: 'input', name: 'ext',
|
|
2266
|
+
message: `File extension for "${col}" (${descriptor}):`,
|
|
2267
|
+
default: guessed,
|
|
2268
|
+
}]);
|
|
2269
|
+
const cleanExt = ext.replace(/^\./, '');
|
|
2270
|
+
contentColsToExtract.push({ col, ext: cleanExt });
|
|
2271
|
+
newPreferences[col] = cleanExt;
|
|
2272
|
+
} else {
|
|
2273
|
+
newPreferences[col] = false;
|
|
2274
|
+
}
|
|
2088
2275
|
} else {
|
|
2089
|
-
|
|
2276
|
+
// Auto-apply: extract with guessed extension
|
|
2277
|
+
const guessed = guessExtensionForDescriptor(descriptor, col);
|
|
2278
|
+
contentColsToExtract.push({ col, ext: guessed });
|
|
2279
|
+
newPreferences[col] = guessed;
|
|
2280
|
+
log.dim(` Auto-extracting "${col}" for "${descriptor}" as .${guessed}`);
|
|
2090
2281
|
}
|
|
2091
2282
|
changed = true;
|
|
2092
2283
|
}
|
|
@@ -2116,24 +2307,30 @@ async function resolveDocumentationPlacement(options) {
|
|
|
2116
2307
|
if (options.yes) return 'inline';
|
|
2117
2308
|
|
|
2118
2309
|
const saved = await loadExtensionDocumentationMDPlacement();
|
|
2119
|
-
if (saved && !options.force) {
|
|
2310
|
+
if (saved && !options.force && !options.configure) {
|
|
2120
2311
|
log.dim(` Documentation MD placement: ${saved} (saved)`);
|
|
2121
2312
|
return saved;
|
|
2122
2313
|
}
|
|
2123
2314
|
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2315
|
+
let placement;
|
|
2316
|
+
if (options.configure) {
|
|
2317
|
+
const inquirer = (await import('inquirer')).default;
|
|
2318
|
+
({ placement } = await inquirer.prompt([{
|
|
2319
|
+
type: 'list', name: 'placement',
|
|
2320
|
+
message: 'Where should extracted documentation MD files be placed?',
|
|
2321
|
+
choices: [
|
|
2322
|
+
{ name: 'docs/<filename>.md — project root (recommended)', value: 'root' },
|
|
2323
|
+
{ name: 'extension/documentation/<filename>.md — inline alongside metadata', value: 'inline' },
|
|
2324
|
+
],
|
|
2325
|
+
default: 'root',
|
|
2326
|
+
}]));
|
|
2327
|
+
} else {
|
|
2328
|
+
// Auto-apply default
|
|
2329
|
+
placement = 'root';
|
|
2330
|
+
}
|
|
2134
2331
|
|
|
2135
2332
|
await saveExtensionDocumentationMDPlacement(placement);
|
|
2136
|
-
log.dim(`
|
|
2333
|
+
log.dim(` Documentation MD placement: ${placement}`);
|
|
2137
2334
|
return placement;
|
|
2138
2335
|
}
|
|
2139
2336
|
|
|
@@ -2158,7 +2355,8 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2158
2355
|
log.info(`Processing ${entries.length} extension record(s)...`);
|
|
2159
2356
|
|
|
2160
2357
|
// Step A: Pre-pass — build mapping + create directories
|
|
2161
|
-
const
|
|
2358
|
+
const metadataSchema = await loadMetadataSchema();
|
|
2359
|
+
const mapping = await buildDescriptorPrePass(entries, structure, metadataSchema);
|
|
2162
2360
|
|
|
2163
2361
|
// Clear documentation preferences when --force is used with --documentation-only
|
|
2164
2362
|
if (options.documentationOnly && options.force) {
|
|
@@ -2171,7 +2369,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2171
2369
|
// Step B: Group records by descriptor
|
|
2172
2370
|
const groups = new Map(); // descriptor → { dir, records[] }
|
|
2173
2371
|
for (const record of entries) {
|
|
2174
|
-
const descriptor = record.Descriptor || '
|
|
2372
|
+
const descriptor = record.Descriptor || '__no_descriptor__';
|
|
2175
2373
|
|
|
2176
2374
|
// --documentation-only: skip non-documentation records
|
|
2177
2375
|
if (options.documentationOnly && descriptor !== 'documentation') continue;
|
|
@@ -2186,13 +2384,14 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2186
2384
|
const descriptorPrefs = new Map();
|
|
2187
2385
|
let docPlacement = 'inline';
|
|
2188
2386
|
|
|
2189
|
-
for (const [descriptor
|
|
2190
|
-
if (descriptor === '
|
|
2191
|
-
descriptorPrefs.set(descriptor, { filenameCol:
|
|
2387
|
+
for (const [descriptor] of groups.entries()) {
|
|
2388
|
+
if (descriptor === '__no_descriptor__') {
|
|
2389
|
+
descriptorPrefs.set(descriptor, { filenameCol: 'Name', companionRefs: [] });
|
|
2192
2390
|
continue;
|
|
2193
2391
|
}
|
|
2194
|
-
const
|
|
2195
|
-
|
|
2392
|
+
const filenameCol = getDescriptorFilenameCol(metadataSchema, descriptor);
|
|
2393
|
+
const companionRefs = getDescriptorCompanionCols(metadataSchema, descriptor);
|
|
2394
|
+
descriptorPrefs.set(descriptor, { filenameCol, companionRefs });
|
|
2196
2395
|
|
|
2197
2396
|
if (descriptor === 'documentation') {
|
|
2198
2397
|
docPlacement = await resolveDocumentationPlacement(options);
|
|
@@ -2210,9 +2409,9 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2210
2409
|
const config = await loadConfig();
|
|
2211
2410
|
|
|
2212
2411
|
for (const [descriptor, { dir, records }] of groups.entries()) {
|
|
2213
|
-
const { filenameCol,
|
|
2412
|
+
const { filenameCol, companionRefs } = descriptorPrefs.get(descriptor);
|
|
2214
2413
|
const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
|
|
2215
|
-
const mdColInfo = useRootDoc ?
|
|
2414
|
+
const mdColInfo = useRootDoc ? companionRefs.find(r => r.extensionCol === 'md') : null;
|
|
2216
2415
|
const usedNames = new Map(); // name → count, for collision resolution within this descriptor group
|
|
2217
2416
|
|
|
2218
2417
|
log.info(`Processing ${records.length} "${descriptor}" extension(s) → ${dir}/`);
|
|
@@ -2284,11 +2483,11 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2284
2483
|
}
|
|
2285
2484
|
|
|
2286
2485
|
// Check if any @reference content files are missing — force re-extraction if so
|
|
2287
|
-
let hasNewExtractions =
|
|
2486
|
+
let hasNewExtractions = companionRefs.length > 0;
|
|
2288
2487
|
if (!hasNewExtractions && await fileExists(metaPath)) {
|
|
2289
2488
|
try {
|
|
2290
2489
|
const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
2291
|
-
for (const col of (existingMeta._contentColumns || [])) {
|
|
2490
|
+
for (const col of (existingMeta._companionReferenceColumns || existingMeta._contentColumns || [])) {
|
|
2292
2491
|
const ref = existingMeta[col];
|
|
2293
2492
|
if (ref && String(ref).startsWith('@')) {
|
|
2294
2493
|
const refName = String(ref).substring(1);
|
|
@@ -2348,19 +2547,23 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2348
2547
|
for (const [key, value] of Object.entries(record)) {
|
|
2349
2548
|
if (key === 'children') continue;
|
|
2350
2549
|
|
|
2351
|
-
const
|
|
2352
|
-
if (
|
|
2550
|
+
const companionRef = companionRefs.find(r => r.column.toLowerCase() === key.toLowerCase());
|
|
2551
|
+
if (companionRef) {
|
|
2353
2552
|
const isBase64 = value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64';
|
|
2354
2553
|
const decoded = isBase64 ? (resolveContentValue(value) ?? '') : (value ?? '');
|
|
2355
2554
|
let colFilePath, refValue;
|
|
2555
|
+
const ext = companionRef.extensionCol?.startsWith('@')
|
|
2556
|
+
? (String(record[companionRef.extensionCol.slice(1)] ?? '') || companionRef.extensionFallback || 'txt').toLowerCase()
|
|
2557
|
+
: (companionRef.extensionCol || companionRef.extensionFallback || 'txt');
|
|
2356
2558
|
|
|
2357
|
-
if (mdColInfo &&
|
|
2559
|
+
if (mdColInfo && companionRef.column === mdColInfo.column) {
|
|
2358
2560
|
// Root placement: docs/<name>.md (natural name, no ~UID)
|
|
2359
2561
|
const docFileName = `${name}.md`;
|
|
2360
2562
|
colFilePath = join(DOCUMENTATION_DIR, docFileName);
|
|
2361
2563
|
refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
|
|
2362
2564
|
} else {
|
|
2363
|
-
const
|
|
2565
|
+
const colSegment = companionRef.title ? sanitizeFilename(companionRef.title) : key;
|
|
2566
|
+
const colFileName = `${name}.${colSegment}.${ext}`;
|
|
2364
2567
|
colFilePath = join(dir, colFileName);
|
|
2365
2568
|
refValue = `@${colFileName}`;
|
|
2366
2569
|
}
|
|
@@ -2385,7 +2588,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2385
2588
|
}
|
|
2386
2589
|
|
|
2387
2590
|
meta._entity = 'extension';
|
|
2388
|
-
if (extractedCols.length > 0) meta.
|
|
2591
|
+
if (extractedCols.length > 0) meta._companionReferenceColumns = extractedCols;
|
|
2389
2592
|
|
|
2390
2593
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
2391
2594
|
if (serverTz) {
|
|
@@ -2397,15 +2600,15 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2397
2600
|
|
|
2398
2601
|
// --- Seed metadata template for this descriptor ---
|
|
2399
2602
|
if (records.length > 0) {
|
|
2400
|
-
const templates = await
|
|
2603
|
+
const templates = await loadMetadataSchema();
|
|
2401
2604
|
if (templates !== null) {
|
|
2402
2605
|
const existing = getTemplateCols(templates, 'extension', descriptor);
|
|
2403
2606
|
if (!existing) {
|
|
2404
2607
|
const firstRecord = records[0];
|
|
2405
|
-
const extractedCols =
|
|
2608
|
+
const extractedCols = companionRefs.map(r => r.column);
|
|
2406
2609
|
const cols = buildTemplateFromCloneRecord(firstRecord, extractedCols);
|
|
2407
2610
|
setTemplateCols(templates, 'extension', descriptor, cols);
|
|
2408
|
-
await
|
|
2611
|
+
await saveMetadataSchema(templates);
|
|
2409
2612
|
}
|
|
2410
2613
|
}
|
|
2411
2614
|
}
|
|
@@ -2688,8 +2891,27 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2688
2891
|
error: err.message,
|
|
2689
2892
|
});
|
|
2690
2893
|
|
|
2894
|
+
// For 404s, write metadata anyway so the file isn't re-prompted on next clone.
|
|
2895
|
+
// The media file is genuinely gone from the server — nothing to download.
|
|
2896
|
+
if (is404) {
|
|
2897
|
+
const staleMeta = {};
|
|
2898
|
+
for (const [key, value] of Object.entries(record)) {
|
|
2899
|
+
if (key === 'children') continue;
|
|
2900
|
+
staleMeta[key] = value;
|
|
2901
|
+
}
|
|
2902
|
+
staleMeta._entity = 'media';
|
|
2903
|
+
staleMeta._stale404 = true;
|
|
2904
|
+
try {
|
|
2905
|
+
await writeFile(metaPath, JSON.stringify(staleMeta, null, 2) + '\n');
|
|
2906
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
2907
|
+
await setFileTimestamps(metaPath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
2908
|
+
}
|
|
2909
|
+
refs.push({ uid: record.UID, metaPath });
|
|
2910
|
+
} catch { /* non-critical */ }
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2691
2913
|
failed++;
|
|
2692
|
-
continue; // Skip
|
|
2914
|
+
continue; // Skip media write if download failed
|
|
2693
2915
|
}
|
|
2694
2916
|
|
|
2695
2917
|
// Build metadata
|
|
@@ -2788,7 +3010,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2788
3010
|
const raw = await readFile(probeMeta, 'utf8');
|
|
2789
3011
|
const localMeta = JSON.parse(raw);
|
|
2790
3012
|
// Extract extension from Content @reference (e.g. "@Name~uid.html")
|
|
2791
|
-
for (const col of (localMeta._contentColumns || ['Content'])) {
|
|
3013
|
+
for (const col of (localMeta._companionReferenceColumns || localMeta._contentColumns || ['Content'])) {
|
|
2792
3014
|
const ref = localMeta[col];
|
|
2793
3015
|
if (typeof ref === 'string' && ref.startsWith('@')) {
|
|
2794
3016
|
const refExt = extname(ref);
|
|
@@ -2911,7 +3133,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2911
3133
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
2912
3134
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
2913
3135
|
|
|
2914
|
-
// If local metadata has no _LastUpdated (e.g. from dbo
|
|
3136
|
+
// If local metadata has no _LastUpdated (e.g. from dbo adopt with incomplete fields),
|
|
2915
3137
|
// always treat as server-newer so pull populates missing columns.
|
|
2916
3138
|
let localMissingLastUpdated = false;
|
|
2917
3139
|
try {
|
|
@@ -2923,7 +3145,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2923
3145
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
2924
3146
|
|
|
2925
3147
|
if (serverNewer) {
|
|
2926
|
-
// Incomplete metadata (no _LastUpdated) from dbo
|
|
3148
|
+
// Incomplete metadata (no _LastUpdated) from dbo adopt — auto-accept without prompting
|
|
2927
3149
|
if (localMissingLastUpdated) {
|
|
2928
3150
|
log.dim(` Completing metadata: ${fileName}`);
|
|
2929
3151
|
// Fall through to write
|
|
@@ -3014,8 +3236,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3014
3236
|
await writeFile(colFilePath, decoded);
|
|
3015
3237
|
await upsertDeployEntry(colFilePath, record.UID, entityName, key);
|
|
3016
3238
|
meta[key] = `@${colFileName}`;
|
|
3017
|
-
if (!meta.
|
|
3018
|
-
meta.
|
|
3239
|
+
if (!meta._companionReferenceColumns) meta._companionReferenceColumns = [];
|
|
3240
|
+
meta._companionReferenceColumns.push(key);
|
|
3019
3241
|
} else {
|
|
3020
3242
|
meta[key] = decoded;
|
|
3021
3243
|
}
|
|
@@ -3025,8 +3247,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
3025
3247
|
}
|
|
3026
3248
|
|
|
3027
3249
|
meta._entity = entityName;
|
|
3028
|
-
if (hasContent && !meta.
|
|
3029
|
-
meta.
|
|
3250
|
+
if (hasContent && !meta._companionReferenceColumns) {
|
|
3251
|
+
meta._companionReferenceColumns = ['Content'];
|
|
3030
3252
|
}
|
|
3031
3253
|
|
|
3032
3254
|
// Extension was derived from Name/Path for local filename purposes only.
|
|
@@ -3195,20 +3417,13 @@ async function resolveOutputFilenameColumns(appJson, options) {
|
|
|
3195
3417
|
const result = {};
|
|
3196
3418
|
|
|
3197
3419
|
for (const entityKey of OUTPUT_HIERARCHY_ENTITIES) {
|
|
3198
|
-
// Check saved preference
|
|
3199
|
-
const saved = await loadOutputFilenamePreference(entityKey);
|
|
3420
|
+
// Check saved preference (skip if --configure to allow re-prompting)
|
|
3421
|
+
const saved = options.configure ? null : await loadOutputFilenamePreference(entityKey);
|
|
3200
3422
|
if (saved) {
|
|
3201
3423
|
result[entityKey] = saved;
|
|
3202
3424
|
continue;
|
|
3203
3425
|
}
|
|
3204
3426
|
|
|
3205
|
-
// In -y mode use defaults
|
|
3206
|
-
if (options.yes) {
|
|
3207
|
-
result[entityKey] = defaults[entityKey];
|
|
3208
|
-
await saveOutputFilenamePreference(entityKey, defaults[entityKey]);
|
|
3209
|
-
continue;
|
|
3210
|
-
}
|
|
3211
|
-
|
|
3212
3427
|
// Find a sample record to get available columns
|
|
3213
3428
|
// Check top-level first, then look inside nested output children
|
|
3214
3429
|
let records = appJson.children[entityKey] || [];
|
|
@@ -3235,18 +3450,25 @@ async function resolveOutputFilenameColumns(appJson, options) {
|
|
|
3235
3450
|
if (columns.includes(fb)) { defaultCol = fb; break; }
|
|
3236
3451
|
}
|
|
3237
3452
|
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3453
|
+
let col;
|
|
3454
|
+
if (options.configure) {
|
|
3455
|
+
// --configure: prompt user to pick
|
|
3456
|
+
const inquirer = (await import('inquirer')).default;
|
|
3457
|
+
({ col } = await inquirer.prompt([{
|
|
3458
|
+
type: 'list',
|
|
3459
|
+
name: 'col',
|
|
3460
|
+
message: `Which column should be used as the filename for ${docNames[entityKey]} (${entityKey}) records?`,
|
|
3461
|
+
choices: columns,
|
|
3462
|
+
default: defaultCol,
|
|
3463
|
+
}]));
|
|
3464
|
+
} else {
|
|
3465
|
+
// Auto-apply best default silently
|
|
3466
|
+
col = defaultCol;
|
|
3467
|
+
}
|
|
3246
3468
|
|
|
3247
3469
|
result[entityKey] = col;
|
|
3248
3470
|
await saveOutputFilenamePreference(entityKey, col);
|
|
3249
|
-
log.dim(`
|
|
3471
|
+
log.dim(` Using filename column "${col}" for ${entityKey}`);
|
|
3250
3472
|
}
|
|
3251
3473
|
|
|
3252
3474
|
return result;
|
|
@@ -3336,9 +3558,9 @@ async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, ser
|
|
|
3336
3558
|
await writeFile(companionPath, hasContent ? decoded : '', 'utf8');
|
|
3337
3559
|
await upsertDeployEntry(companionPath, entityObj.UID || entityObj._uid, 'output', 'CustomSQL');
|
|
3338
3560
|
entityObj.CustomSQL = `@${companionName}`;
|
|
3339
|
-
entityObj.
|
|
3340
|
-
if (!entityObj.
|
|
3341
|
-
entityObj.
|
|
3561
|
+
entityObj._companionReferenceColumns = entityObj._companionReferenceColumns || [];
|
|
3562
|
+
if (!entityObj._companionReferenceColumns.includes('CustomSQL')) {
|
|
3563
|
+
entityObj._companionReferenceColumns.push('CustomSQL');
|
|
3342
3564
|
}
|
|
3343
3565
|
|
|
3344
3566
|
// Sync timestamps
|