#!/usr/bin/env node
/**
* migrate-v1-to-v3.mjs — convert partitive relations from v1 (v2 certainty)
* to v3 (ISO 704:2022 multiplicity + is_delimiting) in vocab YAML sources.
*
* Input shape (v1):
* partitive_relations:
* - comprehensive: { source, id }
* parts:
* - { source, id }
* enumeration: complete|partial
* markers: [...]
* content: ...
*
* Output shape (v3, glossarist 0.4.20 native):
* partitive_relations:
* - comprehensive: { source, id }
* partitives:
* - ref: { source, id }
* certainty: confirmed|possible
* # Migration notes: when glossarist publishes multiplicity
* # natively, regenerate-data.mjs will emit gl:multiplicity
* # alongside gl:certainty; no further YAML change needed.
*
* Mapping:
* v1 `parts` (each) → v3 `partitives[i].ref`
* v1 `enumeration` → v3 `completeness` (rename only)
* v1 `markers` → DROPPED (v3 reads multiplicity from per-member certainty)
* v1 `content` → DROPPED (not part of ISO 704 v3; was v1-only)
*
* The CLI is idempotent: files that already use `partitives:` are
* skipped (left to the user to convert by hand if needed).
*
* Usage:
* node scripts/migrate-v1-to-v3.mjs path/to/dataset.yaml
* node scripts/migrate-v1-to-v3.mjs --in datasets/vim --out datasets/vim-v3
* node scripts/migrate-v1-to-v3.mjs --dry-run path/to/file.yaml
*
* --in
input path (file or directory of .yaml)
* --out output path (mirror of --in if not given)
* --dry-run print what would change; do not write
*/
import fs from 'fs';
import path from 'path';
// ── Pure conversion logic (separated for testability) ────────────────
/** Detect a partitive-relation in v1 shape. */
export function isV1PartitiveRelation(rel) {
return rel && Array.isArray(rel.parts);
}
/** Convert a single v1 relation to v3 shape. */
export function migrateRelation(rel) {
if (!isV1PartitiveRelation(rel)) return null;
return {
comprehensive: rel.comprehensive,
completeness: rel.enumeration === 'open'
? 'partial'
: (rel.enumeration === 'closed' || rel.enumeration == null)
? 'complete'
: rel.enumeration,
partitives: (rel.parts ?? []).map(p => ({
ref: p,
certainty: 'confirmed',
})),
...(rel.content ? { content: rel.content } : {}),
};
}
/** Migrate a full concept's data block. Returns null if nothing to migrate. */
export function migrateConceptData(data) {
if (!data?.partitive_relations?.length) return null;
const migrated = data.partitive_relations
.map(migrateRelation)
.filter(r => r !== null);
if (migrated.length === 0) return null;
return { ...data, partitive_relations: migrated };
}
/** Skip file if already migrated (uses v3 'partitives' field). */
export function isAlreadyMigrated(content) {
return Boolean(content?.data?.partitive_relations?.some?.(rel => Array.isArray(rel.partitives)));
}
// ── File / directory walking ──────────────────────────────────────────
function walkFiles(input) {
const stat = fs.statSync(input);
if (stat.isFile()) return [input];
const out = [];
for (const entry of fs.readdirSync(input, { withFileTypes: true })) {
const full = path.join(input, entry.name);
if (entry.isDirectory()) out.push(...walkFiles(full));
else if (entry.isFile() && /\.(ya?ml)$/i.test(entry.name)) out.push(full);
}
return out;
}
/** Very small YAML parser sufficient for our shape — we only touch
* data.partitive_relations[]. We intentionally use js-yaml when available
* (via dynamic import) for full round-trip; otherwise a minimal regex
* fallback handles the structure. */
async function loadYamlModule() {
try {
return await import('js-yaml');
} catch {
return null;
}
}
function outputPath(input, file, outRoot) {
const stat = fs.statSync(input);
if (stat.isFile()) return outRoot;
const rel = path.relative(input, file);
return path.join(outRoot, rel);
}
async function migrateFile(input, file, options, yaml) {
const raw = fs.readFileSync(file, 'utf8');
const parsed = yaml ? yaml.load(raw) : JSON.parse(raw);
if (!parsed?.data) return { changed: false };
if (isAlreadyMigrated(parsed)) return { changed: false, skipped: true };
const migrated = migrateConceptData(parsed.data);
if (!migrated) return { changed: false };
parsed.data = { ...parsed.data, ...migrated };
const out = outputPath(input, file, options.out);
fs.mkdirSync(path.dirname(out), { recursive: true });
const outText = yaml ? yaml.dump(parsed) : JSON.stringify(parsed, null, 2);
if (!options.dryRun) fs.writeFileSync(out, outText);
return { changed: true, out, in: file };
}
// ── CLI ─────────────────────────────────────────────────────────────
async function main() {
const args = process.argv.slice(2);
const opts = { in: null, out: null, dryRun: false };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--in') opts.in = args[++i];
else if (a === '--out') opts.out = args[++i];
else if (a === '--dry-run') opts.dryRun = true;
else if (!opts.in) opts.in = a;
else if (!opts.out) opts.out = a;
}
if (!opts.in) {
console.error('Usage: node scripts/migrate-v1-to-v3.mjs --in [--out ] [--dry-run]');
process.exit(1);
}
if (!opts.out) opts.out = opts.dryRun ? opts.in : `${opts.in}.v3`;
const yaml = await loadYamlModule();
if (!yaml) {
console.error('js-yaml is required; install via npm install js-yaml');
process.exit(1);
}
const files = walkFiles(opts.in);
let changed = 0, skipped = 0, total = 0;
for (const f of files) {
total++;
const res = await migrateFile(opts.in, f, opts, yaml);
if (res.changed) {
changed++;
console.log(`✓ ${res.in} → ${res.out}`);
} else if (res.skipped) {
skipped++;
}
}
console.log(`\n${changed} migrated, ${skipped} skipped (already v3), ${total - changed - skipped} unchanged.`);
if (opts.dryRun) console.log('(dry-run — no files written)');
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}