@glossarist/concept-browser 0.7.140 → 0.7.141
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.141",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/build-edges.js
CHANGED
|
@@ -3,28 +3,6 @@ function buildConceptUri(uriBase, registerId, conceptId) {
|
|
|
3
3
|
return `${uriBase}/dataset/${registerId}/concept/${conceptId}`;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
// scripts/extract-source-refs.js
|
|
7
|
-
function extractSourceRefs(concept, registerId) {
|
|
8
|
-
const refs = /* @__PURE__ */ new Set();
|
|
9
|
-
for (const src of concept["gl:source"] || []) {
|
|
10
|
-
const origin = src["gl:origin"];
|
|
11
|
-
if (origin) {
|
|
12
|
-
const ref = origin["gl:ref"];
|
|
13
|
-
if (ref?.["gl:source"]) refs.add(ref["gl:source"]);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
for (const lc of Object.values(concept["gl:localizedConcept"] || {})) {
|
|
17
|
-
for (const src of lc["gl:source"] || []) {
|
|
18
|
-
const origin = src["gl:origin"];
|
|
19
|
-
if (origin) {
|
|
20
|
-
const ref = origin["gl:ref"];
|
|
21
|
-
if (ref?.["gl:source"]) refs.add(ref["gl:source"]);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return [...refs].map((source) => ({ source, registerId }));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
6
|
// scripts/build-edges.ts
|
|
29
7
|
import { readFileSync, writeFileSync, readdirSync, existsSync, realpathSync } from "fs";
|
|
30
8
|
import { join, dirname } from "path";
|
|
@@ -285,7 +263,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
|
|
|
285
263
|
warnBinaryPartitiveRedundancy(edges, relations, registerId);
|
|
286
264
|
allEdges.push(...edges);
|
|
287
265
|
allPartitiveRelations.push(...relations);
|
|
288
|
-
allSourceRefs.push(...
|
|
266
|
+
allSourceRefs.push(...(void 0)(data, registerId));
|
|
289
267
|
for (const edge of edges) {
|
|
290
268
|
if (edge.type === "domain" || edge.type === "section") {
|
|
291
269
|
domainConceptCount.set(edge.target, (domainConceptCount.get(edge.target) || 0) + 1);
|
package/scripts/generate-data.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// scripts/generate-data.ts
|
|
2
2
|
import fs2 from "fs";
|
|
3
|
+
import os from "os";
|
|
3
4
|
import path3 from "path";
|
|
5
|
+
import { Worker as WorkerThread } from "node:worker_threads";
|
|
4
6
|
import { createHash as createHash2 } from "node:crypto";
|
|
5
7
|
import yaml4 from "js-yaml";
|
|
6
8
|
import {
|
|
@@ -1252,18 +1254,104 @@ function getPrimaryDesignation(conceptYaml) {
|
|
|
1252
1254
|
}
|
|
1253
1255
|
return descs;
|
|
1254
1256
|
}
|
|
1257
|
+
async function inlineEmission(job, register, uriBase) {
|
|
1258
|
+
try {
|
|
1259
|
+
const outputs = await emitOutputSet([job.conceptInstance], {
|
|
1260
|
+
registerId: register,
|
|
1261
|
+
uriBase,
|
|
1262
|
+
formats: ["jsonld"]
|
|
1263
|
+
});
|
|
1264
|
+
return {
|
|
1265
|
+
termid: job.termid,
|
|
1266
|
+
ttl: conceptToTurtle(job.conceptInstance, { uriBase, registerId: register }),
|
|
1267
|
+
jsonld: outputs.jsonld,
|
|
1268
|
+
tbx: conceptsToTbx([job.conceptInstance], { registerId: register }) || null
|
|
1269
|
+
};
|
|
1270
|
+
} catch (e) {
|
|
1271
|
+
return { termid: job.termid, error: String(e && e.message || e) };
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
function createEmissionPool(register, uriBase) {
|
|
1275
|
+
const configured = Number(process.env.GCB_WORKERS);
|
|
1276
|
+
if (Number.isFinite(configured) && configured <= 0) return null;
|
|
1277
|
+
const size = Number.isFinite(configured) ? configured : Math.max(1, Math.min(4, (os.availableParallelism ? os.availableParallelism() : os.cpus().length) - 1));
|
|
1278
|
+
const workerPath = path3.join(__dirname3, "lib", "workers", "emit-concept-worker.mjs");
|
|
1279
|
+
let pool;
|
|
1280
|
+
try {
|
|
1281
|
+
pool = Array.from({ length: size }, () => new WorkerThread(workerPath, { workerData: { registerId: register, uriBase } }));
|
|
1282
|
+
} catch {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
const active = /* @__PURE__ */ new Map();
|
|
1286
|
+
const pending = [];
|
|
1287
|
+
const idle = /* @__PURE__ */ new Set();
|
|
1288
|
+
function dispatch() {
|
|
1289
|
+
while (pending.length > 0) {
|
|
1290
|
+
const worker = idle.values().next();
|
|
1291
|
+
if (worker.done) return;
|
|
1292
|
+
idle.delete(worker.value);
|
|
1293
|
+
const entry = pending.shift();
|
|
1294
|
+
active.set(worker.value, entry);
|
|
1295
|
+
worker.value.postMessage({ termid: entry.job.termid, sourceText: entry.job.sourceText });
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
for (const worker of pool) {
|
|
1299
|
+
idle.add(worker);
|
|
1300
|
+
worker.on("message", (msg) => {
|
|
1301
|
+
const entry = active.get(worker);
|
|
1302
|
+
active.delete(worker);
|
|
1303
|
+
idle.add(worker);
|
|
1304
|
+
entry?.resolve(msg);
|
|
1305
|
+
dispatch();
|
|
1306
|
+
});
|
|
1307
|
+
const fail = (err) => {
|
|
1308
|
+
const entry = active.get(worker);
|
|
1309
|
+
active.delete(worker);
|
|
1310
|
+
idle.delete(worker);
|
|
1311
|
+
pool = pool.filter((w) => w !== worker);
|
|
1312
|
+
entry?.reject(err);
|
|
1313
|
+
dispatch();
|
|
1314
|
+
};
|
|
1315
|
+
worker.on("error", fail);
|
|
1316
|
+
worker.on("exit", () => fail(new Error("emission worker exited")));
|
|
1317
|
+
}
|
|
1318
|
+
return {
|
|
1319
|
+
emit(job) {
|
|
1320
|
+
return new Promise((resolve2, reject) => {
|
|
1321
|
+
pending.push({ job, resolve: resolve2, reject });
|
|
1322
|
+
dispatch();
|
|
1323
|
+
});
|
|
1324
|
+
},
|
|
1325
|
+
async close() {
|
|
1326
|
+
await Promise.allSettled(pool.map((w) => w.terminate()));
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1255
1330
|
async function processDataset(dir, register, opts) {
|
|
1256
1331
|
const files = fs2.readdirSync(dir).filter((f) => f.endsWith(".yaml")).sort((a, b) => naturalSort(a.replace(".yaml", ""), b.replace(".yaml", "")));
|
|
1257
1332
|
console.log(`Processing ${register}: ${files.length} files`);
|
|
1258
1333
|
const conceptsDir = path3.join(DATA, register, "concepts");
|
|
1259
|
-
|
|
1260
|
-
|
|
1334
|
+
fs2.mkdirSync(conceptsDir, { recursive: true });
|
|
1335
|
+
const EMISSION_CACHE_VERSION = 1;
|
|
1336
|
+
const EMISSION_EXTENSIONS = ["json", "ttl", "jsonld", "tbx", "yaml"];
|
|
1337
|
+
const cacheDir = path3.join(ROOT, ".gcb-cache");
|
|
1338
|
+
const cachePath = path3.join(cacheDir, `${register}.json`);
|
|
1339
|
+
const emissionCache = {
|
|
1340
|
+
version: EMISSION_CACHE_VERSION,
|
|
1341
|
+
files: {}
|
|
1342
|
+
};
|
|
1343
|
+
try {
|
|
1344
|
+
const loaded = JSON.parse(fs2.readFileSync(cachePath, "utf8"));
|
|
1345
|
+
if (loaded?.version === EMISSION_CACHE_VERSION && loaded.files) emissionCache.files = loaded.files;
|
|
1346
|
+
} catch {
|
|
1261
1347
|
}
|
|
1262
1348
|
const concepts = [];
|
|
1263
1349
|
const conceptInstances = [];
|
|
1264
1350
|
const langTermCounts = {};
|
|
1265
1351
|
const langDefCounts = {};
|
|
1266
1352
|
const availableFormats = ["ttl", "jsonld", "yaml", "tbx", "csv"];
|
|
1353
|
+
const producedOutputs = /* @__PURE__ */ new Set();
|
|
1354
|
+
const emissionCacheFiles = {};
|
|
1267
1355
|
const designationLookup = /* @__PURE__ */ new Map();
|
|
1268
1356
|
for (const file of files) {
|
|
1269
1357
|
try {
|
|
@@ -1376,38 +1464,44 @@ async function processDataset(dir, register, opts) {
|
|
|
1376
1464
|
}
|
|
1377
1465
|
}
|
|
1378
1466
|
];
|
|
1467
|
+
const loopStart = Date.now();
|
|
1468
|
+
const emissionJobs = [];
|
|
1379
1469
|
for (let i = 0; i < files.length; i++) {
|
|
1380
1470
|
const file = files[i];
|
|
1471
|
+
if (i > 0 && i % 250 === 0) console.log(` ${i}/${files.length} concepts`);
|
|
1381
1472
|
try {
|
|
1382
|
-
const
|
|
1473
|
+
const filePath = path3.join(dir, file);
|
|
1474
|
+
const sourceText = fs2.readFileSync(filePath, "utf8");
|
|
1475
|
+
const sourceHash = createHash2("sha256").update(sourceText).digest("hex");
|
|
1476
|
+
const conceptYaml = loadConceptFile(filePath);
|
|
1383
1477
|
if (!conceptYaml || !conceptYaml.termid) continue;
|
|
1384
1478
|
const termid = String(conceptYaml.termid);
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1479
|
+
const cached = emissionCache.files[file];
|
|
1480
|
+
const unchanged = cached?.hash === sourceHash && EMISSION_EXTENSIONS.every((ext) => fs2.existsSync(path3.join(conceptsDir, `${termid}.${ext}`)));
|
|
1387
1481
|
let conceptInstance = null;
|
|
1388
1482
|
try {
|
|
1389
|
-
conceptInstance = parseConceptYaml(
|
|
1483
|
+
conceptInstance = parseConceptYaml(sourceText, termid);
|
|
1390
1484
|
} catch (e) {
|
|
1391
1485
|
console.warn(` ${termid}: canonical parse failed (${e.message}); skipping glossarist exports`);
|
|
1392
1486
|
}
|
|
1393
|
-
if (conceptInstance)
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
)
|
|
1399
|
-
|
|
1400
|
-
registerId: register,
|
|
1401
|
-
uriBase: refMaps.uriBase,
|
|
1402
|
-
formats: ["jsonld"]
|
|
1403
|
-
});
|
|
1404
|
-
fs2.writeFileSync(path3.join(conceptsDir, `${termid}.jsonld`), conceptJsonLd.jsonld);
|
|
1405
|
-
const tbxContent = conceptsToTbx([conceptInstance], { registerId: register });
|
|
1406
|
-
if (tbxContent) {
|
|
1407
|
-
fs2.writeFileSync(path3.join(conceptsDir, `${termid}.tbx`), tbxContent);
|
|
1487
|
+
if (conceptInstance) conceptInstances.push(conceptInstance);
|
|
1488
|
+
if (!unchanged) {
|
|
1489
|
+
const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
|
|
1490
|
+
writeJson(path3.join(conceptsDir, `${termid}.json`), jsonld);
|
|
1491
|
+
fs2.copyFileSync(filePath, path3.join(conceptsDir, `${termid}.yaml`));
|
|
1492
|
+
if (conceptInstance) {
|
|
1493
|
+
emissionJobs.push({ file, termid, sourceText, sourceHash, conceptInstance });
|
|
1408
1494
|
}
|
|
1495
|
+
} else {
|
|
1496
|
+
emissionCacheFiles[file] = { hash: sourceHash, termid };
|
|
1497
|
+
}
|
|
1498
|
+
producedOutputs.add(`${termid}.json`);
|
|
1499
|
+
producedOutputs.add(`${termid}.yaml`);
|
|
1500
|
+
if (conceptInstance) {
|
|
1501
|
+
producedOutputs.add(`${termid}.ttl`);
|
|
1502
|
+
producedOutputs.add(`${termid}.jsonld`);
|
|
1503
|
+
producedOutputs.add(`${termid}.tbx`);
|
|
1409
1504
|
}
|
|
1410
|
-
fs2.copyFileSync(path3.join(dir, file), path3.join(conceptsDir, `${termid}.yaml`));
|
|
1411
1505
|
concepts.push({
|
|
1412
1506
|
id: termid,
|
|
1413
1507
|
designations: getPrimaryDesignation(conceptYaml),
|
|
@@ -1421,12 +1515,49 @@ async function processDataset(dir, register, opts) {
|
|
|
1421
1515
|
console.warn(` Skipping ${file}: ${e.message}`);
|
|
1422
1516
|
}
|
|
1423
1517
|
}
|
|
1518
|
+
if (emissionJobs.length > 0) {
|
|
1519
|
+
const jobsByTermid = new Map(emissionJobs.map((j) => [j.termid, j]));
|
|
1520
|
+
const pool = createEmissionPool(register, refMaps.uriBase);
|
|
1521
|
+
const emissionStart = Date.now();
|
|
1522
|
+
try {
|
|
1523
|
+
const results = await Promise.all(emissionJobs.map((job) => {
|
|
1524
|
+
if (!pool) return inlineEmission(job, register, refMaps.uriBase);
|
|
1525
|
+
return pool.emit(job).catch(() => inlineEmission(job, register, refMaps.uriBase));
|
|
1526
|
+
}));
|
|
1527
|
+
for (const result of results) {
|
|
1528
|
+
if (result.error) {
|
|
1529
|
+
console.warn(` ${result.termid}: canonical parse failed (${result.error}); skipping glossarist exports`);
|
|
1530
|
+
const job2 = jobsByTermid.get(result.termid);
|
|
1531
|
+
if (job2) delete emissionCacheFiles[job2.file];
|
|
1532
|
+
for (const ext of ["ttl", "jsonld", "tbx"]) producedOutputs.delete(`${result.termid}.${ext}`);
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
fs2.writeFileSync(path3.join(conceptsDir, `${result.termid}.ttl`), result.ttl);
|
|
1536
|
+
fs2.writeFileSync(path3.join(conceptsDir, `${result.termid}.jsonld`), result.jsonld);
|
|
1537
|
+
if (result.tbx) fs2.writeFileSync(path3.join(conceptsDir, `${result.termid}.tbx`), result.tbx);
|
|
1538
|
+
const job = jobsByTermid.get(result.termid);
|
|
1539
|
+
if (job) emissionCacheFiles[job.file] = { hash: job.sourceHash, termid: job.termid };
|
|
1540
|
+
}
|
|
1541
|
+
console.log(` Interchange exports: ${((Date.now() - emissionStart) / 1e3).toFixed(1)}s (${emissionJobs.length} concepts, ${pool ? "parallel" : "inline"})`);
|
|
1542
|
+
} finally {
|
|
1543
|
+
if (pool) await pool.close();
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
for (const entry of fs2.readdirSync(conceptsDir)) {
|
|
1547
|
+
if (!producedOutputs.has(entry)) fs2.unlinkSync(path3.join(conceptsDir, entry));
|
|
1548
|
+
}
|
|
1549
|
+
fs2.mkdirSync(cacheDir, { recursive: true });
|
|
1550
|
+
fs2.writeFileSync(cachePath, JSON.stringify({ version: EMISSION_CACHE_VERSION, files: emissionCacheFiles }));
|
|
1551
|
+
console.log(` Per-concept emissions: ${((Date.now() - loopStart) / 1e3).toFixed(1)}s`);
|
|
1552
|
+
const aggregateStart = Date.now();
|
|
1424
1553
|
const aggregate = await emitOutputSet(conceptInstances, {
|
|
1425
1554
|
registerId: register,
|
|
1426
1555
|
uriBase: refMaps.uriBase,
|
|
1556
|
+
formats: ["csv", "jsonld"],
|
|
1427
1557
|
languageOrder: opts.languageOrder || opts.languages,
|
|
1428
1558
|
conceptUriPrefix: buildConceptUriPrefix(refMaps.siteOrigin, register)
|
|
1429
1559
|
});
|
|
1560
|
+
console.log(` Aggregate csv+jsonld: ${((Date.now() - aggregateStart) / 1e3).toFixed(1)}s`);
|
|
1430
1561
|
fs2.writeFileSync(path3.join(DATA, register, `${register}.csv`), aggregate.csv);
|
|
1431
1562
|
fs2.writeFileSync(path3.join(DATA, register, `${register}.jsonld`), aggregate.jsonld);
|
|
1432
1563
|
const CHUNK_SIZE = 500;
|
|
@@ -1580,7 +1711,9 @@ async function processDataset(dir, register, opts) {
|
|
|
1580
1711
|
if (opts.status) manifest.editionStatus = opts.status;
|
|
1581
1712
|
if (opts.ordering) manifest.ordering = opts.ordering;
|
|
1582
1713
|
if (opts.sections && opts.sections.length > 0) manifest.sections = opts.sections;
|
|
1714
|
+
const rdfStart = Date.now();
|
|
1583
1715
|
await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
|
|
1716
|
+
console.log(` Dataset RDF: ${((Date.now() - rdfStart) / 1e3).toFixed(1)}s`);
|
|
1584
1717
|
addBulkFormat(`${register}.ttl`);
|
|
1585
1718
|
manifest.bulkFormats = opts.bulkExports?.length ? bulkFormats.filter((f) => opts.bulkExports.includes(f.format)) : bulkFormats;
|
|
1586
1719
|
writeJson(path3.join(DATA, register, "manifest.json"), manifest);
|
package/scripts/generate-data.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
2
3
|
import path from 'path';
|
|
4
|
+
import { Worker as WorkerThread } from 'node:worker_threads';
|
|
3
5
|
import { createHash } from 'node:crypto';
|
|
4
6
|
import yaml from 'js-yaml';
|
|
5
7
|
import {
|
|
@@ -690,21 +692,129 @@ function getPrimaryDesignation(conceptYaml) {
|
|
|
690
692
|
return descs;
|
|
691
693
|
}
|
|
692
694
|
|
|
695
|
+
// Worker pool for glossarist-native per-concept emissions (ttl/jsonld/tbx).
|
|
696
|
+
// Set GCB_WORKERS=0 to force inline emission (same outputs, one thread).
|
|
697
|
+
type EmissionJob = { file: string; termid: string; sourceText: string; sourceHash: string; conceptInstance: Concept };
|
|
698
|
+
type EmissionResult = { termid: string; ttl?: string; jsonld?: string; tbx?: string; error?: string };
|
|
699
|
+
|
|
700
|
+
async function inlineEmission(job: EmissionJob, register: string, uriBase: string): Promise<EmissionResult> {
|
|
701
|
+
try {
|
|
702
|
+
const outputs = await emitOutputSet([job.conceptInstance], {
|
|
703
|
+
registerId: register,
|
|
704
|
+
uriBase,
|
|
705
|
+
formats: ['jsonld'],
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
termid: job.termid,
|
|
709
|
+
ttl: conceptToTurtle(job.conceptInstance, { uriBase, registerId: register }),
|
|
710
|
+
jsonld: outputs.jsonld,
|
|
711
|
+
tbx: conceptsToTbx([job.conceptInstance], { registerId: register }) || null,
|
|
712
|
+
};
|
|
713
|
+
} catch (e) {
|
|
714
|
+
return { termid: job.termid, error: String((e && e.message) || e) };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function createEmissionPool(register: string, uriBase: string): {
|
|
719
|
+
emit(job: EmissionJob): Promise<EmissionResult>;
|
|
720
|
+
close(): Promise<void>;
|
|
721
|
+
} | null {
|
|
722
|
+
const configured = Number(process.env.GCB_WORKERS);
|
|
723
|
+
if (Number.isFinite(configured) && configured <= 0) return null;
|
|
724
|
+
const size = Number.isFinite(configured)
|
|
725
|
+
? configured
|
|
726
|
+
: Math.max(1, Math.min(4, (os.availableParallelism ? os.availableParallelism() : os.cpus().length) - 1));
|
|
727
|
+
const workerPath = path.join(__dirname, 'lib', 'workers', 'emit-concept-worker.mjs');
|
|
728
|
+
|
|
729
|
+
let pool: WorkerThread[];
|
|
730
|
+
try {
|
|
731
|
+
pool = Array.from({ length: size }, () => new WorkerThread(workerPath, { workerData: { registerId: register, uriBase } }));
|
|
732
|
+
} catch {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const active = new Map<WorkerThread, { resolve: (r: EmissionResult) => void; reject: (e: Error) => void }>();
|
|
737
|
+
const pending: { job: EmissionJob; resolve: (r: EmissionResult) => void; reject: (e: Error) => void }[] = [];
|
|
738
|
+
const idle = new Set<WorkerThread>();
|
|
739
|
+
|
|
740
|
+
function dispatch() {
|
|
741
|
+
while (pending.length > 0) {
|
|
742
|
+
const worker = idle.values().next();
|
|
743
|
+
if (worker.done) return;
|
|
744
|
+
idle.delete(worker.value);
|
|
745
|
+
const entry = pending.shift()!;
|
|
746
|
+
active.set(worker.value, entry);
|
|
747
|
+
worker.value.postMessage({ termid: entry.job.termid, sourceText: entry.job.sourceText });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
for (const worker of pool) {
|
|
752
|
+
idle.add(worker);
|
|
753
|
+
worker.on('message', (msg: EmissionResult) => {
|
|
754
|
+
const entry = active.get(worker);
|
|
755
|
+
active.delete(worker);
|
|
756
|
+
idle.add(worker);
|
|
757
|
+
entry?.resolve(msg);
|
|
758
|
+
dispatch();
|
|
759
|
+
});
|
|
760
|
+
const fail = (err: Error) => {
|
|
761
|
+
const entry = active.get(worker);
|
|
762
|
+
active.delete(worker);
|
|
763
|
+
idle.delete(worker);
|
|
764
|
+
pool = pool.filter(w => w !== worker);
|
|
765
|
+
entry?.reject(err);
|
|
766
|
+
dispatch();
|
|
767
|
+
};
|
|
768
|
+
worker.on('error', fail);
|
|
769
|
+
worker.on('exit', () => fail(new Error('emission worker exited')));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
emit(job: EmissionJob): Promise<EmissionResult> {
|
|
774
|
+
return new Promise((resolve, reject) => {
|
|
775
|
+
pending.push({ job, resolve, reject });
|
|
776
|
+
dispatch();
|
|
777
|
+
});
|
|
778
|
+
},
|
|
779
|
+
async close() {
|
|
780
|
+
await Promise.allSettled(pool.map(w => w.terminate()));
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
693
785
|
async function processDataset(dir, register, opts) {
|
|
694
786
|
const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort((a, b) => naturalSort(a.replace('.yaml', ''), b.replace('.yaml', '')));
|
|
695
787
|
|
|
696
788
|
console.log(`Processing ${register}: ${files.length} files`);
|
|
697
789
|
|
|
698
790
|
const conceptsDir = path.join(DATA, register, 'concepts');
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
791
|
+
fs.mkdirSync(conceptsDir, { recursive: true });
|
|
792
|
+
|
|
793
|
+
// Incremental emission cache: per-source-file content hash → last
|
|
794
|
+
// emitted termid. Unchanged sources skip per-concept file emission;
|
|
795
|
+
// orphans (source removed, renamed termid, older cache version) are
|
|
796
|
+
// swept below. Bump EMISSION_CACHE_VERSION whenever the per-concept
|
|
797
|
+
// emission logic changes its output shape.
|
|
798
|
+
const EMISSION_CACHE_VERSION = 1;
|
|
799
|
+
const EMISSION_EXTENSIONS = ['json', 'ttl', 'jsonld', 'tbx', 'yaml'];
|
|
800
|
+
const cacheDir = path.join(ROOT, '.gcb-cache');
|
|
801
|
+
const cachePath = path.join(cacheDir, `${register}.json`);
|
|
802
|
+
const emissionCache: { version: number; files: Record<string, { hash: string; termid: string }> } = {
|
|
803
|
+
version: EMISSION_CACHE_VERSION,
|
|
804
|
+
files: {},
|
|
805
|
+
};
|
|
806
|
+
try {
|
|
807
|
+
const loaded = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
808
|
+
if (loaded?.version === EMISSION_CACHE_VERSION && loaded.files) emissionCache.files = loaded.files;
|
|
809
|
+
} catch {}
|
|
810
|
+
|
|
703
811
|
const concepts = [];
|
|
704
812
|
const conceptInstances: Concept[] = [];
|
|
705
813
|
const langTermCounts = {};
|
|
706
814
|
const langDefCounts = {};
|
|
707
815
|
const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx', 'csv'];
|
|
816
|
+
const producedOutputs = new Set<string>();
|
|
817
|
+
const emissionCacheFiles: Record<string, { hash: string; termid: string }> = {};
|
|
708
818
|
|
|
709
819
|
// Pre-scan: build designation → concept ID lookup for same-dataset designation refs
|
|
710
820
|
const designationLookup = new Map();
|
|
@@ -830,46 +940,54 @@ async function processDataset(dir, register, opts) {
|
|
|
830
940
|
},
|
|
831
941
|
];
|
|
832
942
|
|
|
943
|
+
const loopStart = Date.now();
|
|
944
|
+
const emissionJobs: EmissionJob[] = [];
|
|
945
|
+
|
|
833
946
|
for (let i = 0; i < files.length; i++) {
|
|
834
947
|
const file = files[i];
|
|
948
|
+
if (i > 0 && i % 250 === 0) console.log(` ${i}/${files.length} concepts`);
|
|
835
949
|
try {
|
|
836
|
-
const
|
|
950
|
+
const filePath = path.join(dir, file);
|
|
951
|
+
const sourceText = fs.readFileSync(filePath, 'utf8');
|
|
952
|
+
const sourceHash = createHash('sha256').update(sourceText).digest('hex');
|
|
953
|
+
|
|
954
|
+
const conceptYaml = loadConceptFile(filePath);
|
|
837
955
|
if (!conceptYaml || !conceptYaml.termid) continue;
|
|
838
956
|
|
|
839
957
|
const termid = String(conceptYaml.termid);
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
// computed termEntries but never wrote them into the document.
|
|
958
|
+
|
|
959
|
+
const cached = emissionCache.files[file];
|
|
960
|
+
const unchanged = cached?.hash === sourceHash
|
|
961
|
+
&& EMISSION_EXTENSIONS.every(ext => fs.existsSync(path.join(conceptsDir, `${termid}.${ext}`)));
|
|
962
|
+
|
|
963
|
+
// Canonical model instance — required for the aggregate exports on
|
|
964
|
+
// every run, independent of per-file emission caching.
|
|
848
965
|
let conceptInstance: Concept | null = null;
|
|
849
966
|
try {
|
|
850
|
-
conceptInstance = parseConceptYaml(
|
|
967
|
+
conceptInstance = parseConceptYaml(sourceText, termid);
|
|
851
968
|
} catch (e) {
|
|
852
969
|
console.warn(` ${termid}: canonical parse failed (${e.message}); skipping glossarist exports`);
|
|
853
970
|
}
|
|
854
|
-
if (conceptInstance)
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
);
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
uriBase: refMaps.uriBase,
|
|
863
|
-
formats: ['jsonld'],
|
|
864
|
-
});
|
|
865
|
-
fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), conceptJsonLd.jsonld!);
|
|
866
|
-
const tbxContent = conceptsToTbx([conceptInstance], { registerId: register });
|
|
867
|
-
if (tbxContent) {
|
|
868
|
-
fs.writeFileSync(path.join(conceptsDir, `${termid}.tbx`), tbxContent);
|
|
971
|
+
if (conceptInstance) conceptInstances.push(conceptInstance);
|
|
972
|
+
|
|
973
|
+
if (!unchanged) {
|
|
974
|
+
const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
|
|
975
|
+
writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
|
|
976
|
+
fs.copyFileSync(filePath, path.join(conceptsDir, `${termid}.yaml`));
|
|
977
|
+
if (conceptInstance) {
|
|
978
|
+
emissionJobs.push({ file, termid, sourceText, sourceHash, conceptInstance });
|
|
869
979
|
}
|
|
980
|
+
} else {
|
|
981
|
+
emissionCacheFiles[file] = { hash: sourceHash, termid };
|
|
870
982
|
}
|
|
871
983
|
|
|
872
|
-
|
|
984
|
+
producedOutputs.add(`${termid}.json`);
|
|
985
|
+
producedOutputs.add(`${termid}.yaml`);
|
|
986
|
+
if (conceptInstance) {
|
|
987
|
+
producedOutputs.add(`${termid}.ttl`);
|
|
988
|
+
producedOutputs.add(`${termid}.jsonld`);
|
|
989
|
+
producedOutputs.add(`${termid}.tbx`);
|
|
990
|
+
}
|
|
873
991
|
|
|
874
992
|
concepts.push({
|
|
875
993
|
id: termid,
|
|
@@ -886,17 +1004,64 @@ async function processDataset(dir, register, opts) {
|
|
|
886
1004
|
}
|
|
887
1005
|
}
|
|
888
1006
|
|
|
1007
|
+
// Glossarist-native per-concept exports (ttl/jsonld/tbx), parallelized
|
|
1008
|
+
// on worker threads; falls back to inline emission when workers are
|
|
1009
|
+
// unavailable (GCB_WORKERS=0 or restricted environments).
|
|
1010
|
+
if (emissionJobs.length > 0) {
|
|
1011
|
+
const jobsByTermid = new Map(emissionJobs.map(j => [j.termid, j]));
|
|
1012
|
+
const pool = createEmissionPool(register, refMaps.uriBase);
|
|
1013
|
+
const emissionStart = Date.now();
|
|
1014
|
+
try {
|
|
1015
|
+
const results = await Promise.all(emissionJobs.map(job => {
|
|
1016
|
+
if (!pool) return inlineEmission(job, register, refMaps.uriBase);
|
|
1017
|
+
return pool.emit(job).catch(() => inlineEmission(job, register, refMaps.uriBase));
|
|
1018
|
+
}));
|
|
1019
|
+
for (const result of results) {
|
|
1020
|
+
if (result.error) {
|
|
1021
|
+
console.warn(` ${result.termid}: canonical parse failed (${result.error}); skipping glossarist exports`);
|
|
1022
|
+
const job = jobsByTermid.get(result.termid);
|
|
1023
|
+
if (job) delete emissionCacheFiles[job.file];
|
|
1024
|
+
for (const ext of ['ttl', 'jsonld', 'tbx']) producedOutputs.delete(`${result.termid}.${ext}`);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
fs.writeFileSync(path.join(conceptsDir, `${result.termid}.ttl`), result.ttl);
|
|
1028
|
+
fs.writeFileSync(path.join(conceptsDir, `${result.termid}.jsonld`), result.jsonld);
|
|
1029
|
+
if (result.tbx) fs.writeFileSync(path.join(conceptsDir, `${result.termid}.tbx`), result.tbx);
|
|
1030
|
+
const job = jobsByTermid.get(result.termid);
|
|
1031
|
+
if (job) emissionCacheFiles[job.file] = { hash: job.sourceHash, termid: job.termid };
|
|
1032
|
+
}
|
|
1033
|
+
console.log(` Interchange exports: ${((Date.now() - emissionStart) / 1000).toFixed(1)}s (${emissionJobs.length} concepts, ${pool ? 'parallel' : 'inline'})`);
|
|
1034
|
+
} finally {
|
|
1035
|
+
if (pool) await pool.close();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Sweep per-concept outputs this run no longer produces (removed or
|
|
1040
|
+
// renamed sources) — replaces the wholesale pre-loop clean, which the
|
|
1041
|
+
// incremental cache needs gone.
|
|
1042
|
+
for (const entry of fs.readdirSync(conceptsDir)) {
|
|
1043
|
+
if (!producedOutputs.has(entry)) fs.unlinkSync(path.join(conceptsDir, entry));
|
|
1044
|
+
}
|
|
1045
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1046
|
+
fs.writeFileSync(cachePath, JSON.stringify({ version: EMISSION_CACHE_VERSION, files: emissionCacheFiles }));
|
|
1047
|
+
|
|
1048
|
+
console.log(` Per-concept emissions: ${((Date.now() - loopStart) / 1000).toFixed(1)}s`);
|
|
1049
|
+
|
|
889
1050
|
// Aggregate distributions: single-file exports of the whole register,
|
|
890
1051
|
// emitted by glossarist's output-set API from the canonical model walk.
|
|
891
1052
|
// The CSV uri column is a link a human opens — it carries the SPA route
|
|
892
1053
|
// shape on the deployment's configured host (siteOrigin), not the
|
|
893
|
-
// RDF-canonical IRI.
|
|
1054
|
+
// RDF-canonical IRI. Only the two formats consumed downstream are
|
|
1055
|
+
// requested — the output-set API computes all six by default.
|
|
1056
|
+
const aggregateStart = Date.now();
|
|
894
1057
|
const aggregate = await emitOutputSet(conceptInstances, {
|
|
895
1058
|
registerId: register,
|
|
896
1059
|
uriBase: refMaps.uriBase,
|
|
1060
|
+
formats: ['csv', 'jsonld'],
|
|
897
1061
|
languageOrder: opts.languageOrder || opts.languages,
|
|
898
1062
|
conceptUriPrefix: buildConceptUriPrefix(refMaps.siteOrigin, register),
|
|
899
1063
|
});
|
|
1064
|
+
console.log(` Aggregate csv+jsonld: ${((Date.now() - aggregateStart) / 1000).toFixed(1)}s`);
|
|
900
1065
|
fs.writeFileSync(path.join(DATA, register, `${register}.csv`), aggregate.csv!);
|
|
901
1066
|
fs.writeFileSync(path.join(DATA, register, `${register}.jsonld`), aggregate.jsonld!);
|
|
902
1067
|
|
|
@@ -1074,7 +1239,9 @@ async function processDataset(dir, register, opts) {
|
|
|
1074
1239
|
// Dataset-level RDF (WS J2/J5): dcat:Dataset + skos:ConceptScheme + skos:Collection per section.
|
|
1075
1240
|
// Runs before the manifest write so the ttl distribution is on disk when
|
|
1076
1241
|
// bulkFormats is assembled.
|
|
1242
|
+
const rdfStart = Date.now();
|
|
1077
1243
|
await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
|
|
1244
|
+
console.log(` Dataset RDF: ${((Date.now() - rdfStart) / 1000).toFixed(1)}s`);
|
|
1078
1245
|
addBulkFormat(`${register}.ttl`);
|
|
1079
1246
|
manifest.bulkFormats = opts.bulkExports?.length
|
|
1080
1247
|
? bulkFormats.filter(f => opts.bulkExports.includes(f.format))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Worker thread: glossarist-native per-concept interchange emissions
|
|
2
|
+
// (ttl / jsonld / tbx). Plain .mjs — ESM loader hooks like tsx do not
|
|
3
|
+
// propagate into worker threads, so this file must stay dependency-free
|
|
4
|
+
// of workspace TS; everything resolves from node_modules.
|
|
5
|
+
//
|
|
6
|
+
// Concept model instances are class objects and cannot cross the
|
|
7
|
+
// worker boundary, so the worker re-parses the exact source text the
|
|
8
|
+
// main thread read.
|
|
9
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
10
|
+
import { parseConceptYaml, conceptToTurtle, emitOutputSet, conceptsToTbx } from 'glossarist';
|
|
11
|
+
|
|
12
|
+
const { registerId, uriBase } = workerData;
|
|
13
|
+
|
|
14
|
+
parentPort.on('message', async ({ termid, sourceText }) => {
|
|
15
|
+
try {
|
|
16
|
+
const concept = parseConceptYaml(sourceText, termid);
|
|
17
|
+
const outputs = await emitOutputSet([concept], {
|
|
18
|
+
registerId,
|
|
19
|
+
uriBase,
|
|
20
|
+
formats: ['jsonld'],
|
|
21
|
+
});
|
|
22
|
+
parentPort.postMessage({
|
|
23
|
+
termid,
|
|
24
|
+
ttl: conceptToTurtle(concept, { uriBase, registerId }),
|
|
25
|
+
jsonld: outputs.jsonld,
|
|
26
|
+
tbx: conceptsToTbx([concept], { registerId }) || null,
|
|
27
|
+
});
|
|
28
|
+
} catch (e) {
|
|
29
|
+
parentPort.postMessage({ termid, error: String((e && e.message) || e) });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
@@ -32,7 +32,11 @@ describe('generate-data chunk hashes', () => {
|
|
|
32
32
|
cwd: root,
|
|
33
33
|
env: { ...process.env, SITE_CONFIG: configPath },
|
|
34
34
|
stdio: 'pipe',
|
|
35
|
-
|
|
35
|
+
// Three sequential full generations run in this test; each
|
|
36
|
+
// subprocess pays tsx startup, worker-pool spin-up, and aggregate
|
|
37
|
+
// emission. The suite runs tests in parallel, so leave generous
|
|
38
|
+
// headroom for CPU contention.
|
|
39
|
+
timeout: 120_000,
|
|
36
40
|
});
|
|
37
41
|
const indexes = ['index.json', 'index-meta.json'].map(file =>
|
|
38
42
|
JSON.parse(readFileSync(join(outputDir, file), 'utf8')),
|
|
@@ -70,5 +74,5 @@ describe('generate-data chunk hashes', () => {
|
|
|
70
74
|
} finally {
|
|
71
75
|
rmSync(root, { recursive: true, force: true });
|
|
72
76
|
}
|
|
73
|
-
},
|
|
77
|
+
}, 300_000);
|
|
74
78
|
});
|