@glossarist/concept-browser 0.7.133 → 0.7.135
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 +20 -0
- package/package.json +1 -1
- package/scripts/fetch-datasets.js +1 -0
- package/scripts/generate-data.js +134 -28
- package/scripts/generate-data.ts +48 -21
- package/scripts/lib/concept-formats.ts +128 -8
- package/scripts/load-site-config.js +1 -0
- package/scripts/load-site-config.ts +1 -0
- package/scripts/process-about-pages.js +1 -0
- package/src/__tests__/components/pipe-bundles.test.ts +114 -0
- package/src/__tests__/scripts/concept-csv.test.ts +142 -0
- package/src/__tests__/wire-keys.test.ts +1 -1
- package/src/adapters/jsonld-types.ts +2 -0
- package/src/adapters/model-bridge.ts +3 -1
- package/src/adapters/types.ts +8 -0
- package/src/adapters/wire-keys.ts +6 -0
- package/src/components/relation-sphere/pipe-bundles.ts +137 -5
- package/src/composables/use-concept-edges.ts +20 -1
- package/src/composables/use-format-registry.ts +1 -0
- package/src/config/types.ts +1 -0
package/README.md
CHANGED
|
@@ -425,6 +425,26 @@ data:
|
|
|
425
425
|
|
|
426
426
|
Or use a Glossarist GCR package — set `gcr_package` in your dataset config and the CLI downloads it automatically.
|
|
427
427
|
|
|
428
|
+
## Bulk exports
|
|
429
|
+
|
|
430
|
+
The build emits single-file aggregate distributions per dataset into `public/data/{id}/`:
|
|
431
|
+
|
|
432
|
+
| File | Format | Contents |
|
|
433
|
+
|------|--------|----------|
|
|
434
|
+
| `{id}.csv` | CSV (UTF-8 BOM + CRLF, RFC 4180) | One row per concept × language — termid, uri, status, section, language, term, alt terms, definition, notes, examples, sources, source links. Opens directly in Excel. |
|
|
435
|
+
| `{id}.jsonld` | SKOS JSON-LD | All concepts in one `@graph` document |
|
|
436
|
+
| `{id}.ttl` | Turtle | dcat:Dataset + ConceptScheme metadata |
|
|
437
|
+
|
|
438
|
+
Files from the dataset's GCR `compiled/` directory are also copied and listed. The dataset page's **Downloads** card offers everything found, unless you restrict it with `bulk_exports` in `site-config.yml`:
|
|
439
|
+
|
|
440
|
+
```yaml
|
|
441
|
+
bulk_exports:
|
|
442
|
+
- csv
|
|
443
|
+
- ttl
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Values: `ttl`, `jsonld`, `csv`, `tbx`, `jsonl`, `yaml`. Absent or empty = offer all available.
|
|
447
|
+
|
|
428
448
|
## Deployment
|
|
429
449
|
|
|
430
450
|
The build outputs a static site to `dist/`. Deploy it anywhere:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.135",
|
|
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/generate-data.js
CHANGED
|
@@ -26,6 +26,7 @@ var SNAKE_TO_CAMEL = {
|
|
|
26
26
|
footer_nav: "footerNav",
|
|
27
27
|
footer_logo: "footerLogo",
|
|
28
28
|
news_dir: "newsDir",
|
|
29
|
+
bulk_exports: "bulkExports",
|
|
29
30
|
// Dataset-level
|
|
30
31
|
gcr_package: "gcrPackage",
|
|
31
32
|
source_repo: "sourceRepo",
|
|
@@ -838,15 +839,15 @@ function conceptJsonToTurtle(concept) {
|
|
|
838
839
|
lines.push(" .");
|
|
839
840
|
return lines.join("\n");
|
|
840
841
|
}
|
|
841
|
-
|
|
842
|
+
var SKOS_JSON_LD_CONTEXT = {
|
|
843
|
+
skos: "http://www.w3.org/2004/02/skos/core#",
|
|
844
|
+
dcterms: "http://purl.org/dc/terms/",
|
|
845
|
+
"@language": { "@container": "@language" }
|
|
846
|
+
};
|
|
847
|
+
function buildSkosJsonLdObject(concept) {
|
|
842
848
|
const uri = concept["@id"] || "";
|
|
843
849
|
const id = concept["gl:identifier"] || "";
|
|
844
850
|
const doc = {
|
|
845
|
-
"@context": {
|
|
846
|
-
skos: "http://www.w3.org/2004/02/skos/core#",
|
|
847
|
-
dcterms: "http://purl.org/dc/terms/",
|
|
848
|
-
"@language": { "@container": "@language" }
|
|
849
|
-
},
|
|
850
851
|
"@id": uri,
|
|
851
852
|
"@type": "skos:Concept",
|
|
852
853
|
"skos:notation": id
|
|
@@ -870,7 +871,101 @@ function conceptJsonToSkosJsonLd(concept) {
|
|
|
870
871
|
if (Object.keys(altLabels).length) doc["skos:altLabel"] = altLabels;
|
|
871
872
|
if (Object.keys(definitions).length) doc["skos:definition"] = definitions;
|
|
872
873
|
if (Object.keys(scopeNotes).length) doc["skos:scopeNote"] = scopeNotes;
|
|
873
|
-
return
|
|
874
|
+
return doc;
|
|
875
|
+
}
|
|
876
|
+
function conceptJsonToSkosJsonLd(concept) {
|
|
877
|
+
return JSON.stringify({
|
|
878
|
+
"@context": SKOS_JSON_LD_CONTEXT,
|
|
879
|
+
...buildSkosJsonLdObject(concept)
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
function conceptsToSkosJsonLdGraph(concepts) {
|
|
883
|
+
return JSON.stringify(
|
|
884
|
+
{
|
|
885
|
+
"@context": SKOS_JSON_LD_CONTEXT,
|
|
886
|
+
"@graph": concepts.map(buildSkosJsonLdObject)
|
|
887
|
+
},
|
|
888
|
+
null,
|
|
889
|
+
2
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
var CSV_COLUMNS = [
|
|
893
|
+
"termid",
|
|
894
|
+
"uri",
|
|
895
|
+
"status",
|
|
896
|
+
"section",
|
|
897
|
+
"language",
|
|
898
|
+
"term",
|
|
899
|
+
"alt_terms",
|
|
900
|
+
"definition",
|
|
901
|
+
"notes",
|
|
902
|
+
"examples",
|
|
903
|
+
"sources",
|
|
904
|
+
"source_links"
|
|
905
|
+
];
|
|
906
|
+
function csvEscape(value) {
|
|
907
|
+
if (/[",\r\n]/.test(value)) {
|
|
908
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
909
|
+
}
|
|
910
|
+
return value;
|
|
911
|
+
}
|
|
912
|
+
function joinLines(items) {
|
|
913
|
+
return items.filter(Boolean).join("\n");
|
|
914
|
+
}
|
|
915
|
+
function localizedLanguages(localized, languageOrder) {
|
|
916
|
+
const present = Object.keys(localized);
|
|
917
|
+
if (!languageOrder?.length) return present;
|
|
918
|
+
const ordered = languageOrder.filter((lang) => present.includes(lang));
|
|
919
|
+
return ordered.concat(present.filter((lang) => !ordered.includes(lang)));
|
|
920
|
+
}
|
|
921
|
+
function sourceRefString(src) {
|
|
922
|
+
const ref = src?.["gl:origin"]?.["gl:ref"];
|
|
923
|
+
if (!ref) return "";
|
|
924
|
+
return [ref["gl:source"], ref["gl:id"]].filter(Boolean).join(": ");
|
|
925
|
+
}
|
|
926
|
+
function sourceLinkString(src) {
|
|
927
|
+
return src?.["gl:origin"]?.["gl:link"] || "";
|
|
928
|
+
}
|
|
929
|
+
function conceptsToCsv(concepts, opts = {}) {
|
|
930
|
+
const rows = [CSV_COLUMNS.join(",")];
|
|
931
|
+
for (const concept of concepts) {
|
|
932
|
+
const localized = concept["gl:localizedConcept"] || {};
|
|
933
|
+
const domains = (concept["gl:domain"] || []).map((d) => d["gl:conceptId"]).filter(Boolean).join("; ");
|
|
934
|
+
const managedSources = concept["gl:source"] || [];
|
|
935
|
+
const shared = {
|
|
936
|
+
termid: concept["gl:identifier"] || "",
|
|
937
|
+
uri: concept["@id"] || "",
|
|
938
|
+
status: concept["gl:status"] || "",
|
|
939
|
+
section: domains
|
|
940
|
+
};
|
|
941
|
+
const languages = localizedLanguages(localized, opts.languageOrder);
|
|
942
|
+
if (languages.length === 0) {
|
|
943
|
+
rows.push([shared.termid, shared.uri, shared.status, shared.section, "", "", "", "", "", "", "", ""].map(csvEscape).join(","));
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
for (const lang of languages) {
|
|
947
|
+
const lc = localized[lang] || {};
|
|
948
|
+
const designations = (lc["gl:designation"] || []).filter((d) => d["gl:term"]);
|
|
949
|
+
const preferred = designations.find((d) => d["gl:normativeStatus"] === "preferred") || designations[0];
|
|
950
|
+
const altTerms = designations.filter((d) => d !== preferred).map((d) => d["gl:term"]);
|
|
951
|
+
const localizedSources = lc["gl:source"]?.length ? lc["gl:source"] : managedSources;
|
|
952
|
+
rows.push([
|
|
953
|
+
shared.termid,
|
|
954
|
+
shared.uri,
|
|
955
|
+
shared.status,
|
|
956
|
+
shared.section,
|
|
957
|
+
lang,
|
|
958
|
+
preferred?.["gl:term"] || "",
|
|
959
|
+
altTerms.join("; "),
|
|
960
|
+
joinLines((lc["gl:definition"] || []).map((d) => d["gl:content"])),
|
|
961
|
+
joinLines((lc["gl:notes"] || []).map((d) => d["gl:content"])),
|
|
962
|
+
joinLines((lc["gl:examples"] || []).map((d) => d["gl:content"])),
|
|
963
|
+
localizedSources.map(sourceRefString).filter(Boolean).join("; "),
|
|
964
|
+
localizedSources.map(sourceLinkString).filter(Boolean).join("; ")
|
|
965
|
+
].map(csvEscape).join(","));
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return "\uFEFF" + rows.join("\r\n") + "\r\n";
|
|
874
969
|
}
|
|
875
970
|
function conceptJsonToTbx(concept) {
|
|
876
971
|
const id = concept["gl:identifier"] || "";
|
|
@@ -1417,9 +1512,10 @@ async function processDataset(dir, register, opts) {
|
|
|
1417
1512
|
for (const f of fs2.readdirSync(conceptsDir)) fs2.unlinkSync(path3.join(conceptsDir, f));
|
|
1418
1513
|
}
|
|
1419
1514
|
const concepts = [];
|
|
1515
|
+
const jsonlds = [];
|
|
1420
1516
|
const langTermCounts = {};
|
|
1421
1517
|
const langDefCounts = {};
|
|
1422
|
-
const availableFormats = ["ttl", "jsonld", "yaml", "tbx"];
|
|
1518
|
+
const availableFormats = ["ttl", "jsonld", "yaml", "tbx", "csv"];
|
|
1423
1519
|
const designationLookup = /* @__PURE__ */ new Map();
|
|
1424
1520
|
for (const file of files) {
|
|
1425
1521
|
try {
|
|
@@ -1521,6 +1617,7 @@ async function processDataset(dir, register, opts) {
|
|
|
1521
1617
|
const termid = String(conceptYaml.termid);
|
|
1522
1618
|
const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
|
|
1523
1619
|
writeJson(path3.join(conceptsDir, `${termid}.json`), jsonld);
|
|
1620
|
+
jsonlds.push(jsonld);
|
|
1524
1621
|
const ttlContent = conceptJsonToTurtle(jsonld);
|
|
1525
1622
|
fs2.writeFileSync(path3.join(conceptsDir, `${termid}.ttl`), ttlContent);
|
|
1526
1623
|
const skosJsonLd = conceptJsonToSkosJsonLd(jsonld);
|
|
@@ -1543,6 +1640,8 @@ async function processDataset(dir, register, opts) {
|
|
|
1543
1640
|
console.warn(` Skipping ${file}: ${e.message}`);
|
|
1544
1641
|
}
|
|
1545
1642
|
}
|
|
1643
|
+
fs2.writeFileSync(path3.join(DATA, register, `${register}.csv`), conceptsToCsv(jsonlds, { languageOrder: opts.languageOrder || opts.languages }));
|
|
1644
|
+
fs2.writeFileSync(path3.join(DATA, register, `${register}.jsonld`), conceptsToSkosJsonLdGraph(jsonlds));
|
|
1546
1645
|
const CHUNK_SIZE = 500;
|
|
1547
1646
|
const chunks = [];
|
|
1548
1647
|
for (let i = 0; i < concepts.length; i += CHUNK_SIZE) {
|
|
@@ -1612,28 +1711,32 @@ async function processDataset(dir, register, opts) {
|
|
|
1612
1711
|
definitions: langDefCounts[lang] || 0
|
|
1613
1712
|
};
|
|
1614
1713
|
}
|
|
1714
|
+
const BULK_FORMAT_BY_EXT = {
|
|
1715
|
+
".ttl": "turtle",
|
|
1716
|
+
".jsonld": "jsonld",
|
|
1717
|
+
".xml": "tbx",
|
|
1718
|
+
".jsonl": "jsonl",
|
|
1719
|
+
".yaml": "yaml",
|
|
1720
|
+
".csv": "csv"
|
|
1721
|
+
};
|
|
1722
|
+
const bulkFormats = [];
|
|
1723
|
+
const addBulkFormat = (file) => {
|
|
1724
|
+
const dest = path3.join(DATA, register, file);
|
|
1725
|
+
if (!fs2.existsSync(dest) || bulkFormats.some((f) => f.file === file)) return;
|
|
1726
|
+
const formatName = BULK_FORMAT_BY_EXT[path3.extname(file)] || path3.extname(file).slice(1);
|
|
1727
|
+
bulkFormats.push({ file, format: formatName, size: fs2.statSync(dest).size });
|
|
1728
|
+
};
|
|
1729
|
+
for (const file of [`${register}.csv`, `${register}.jsonld`]) addBulkFormat(file);
|
|
1615
1730
|
const sourceRoot = path3.dirname(dir);
|
|
1616
1731
|
const compiledDir = path3.join(sourceRoot, "compiled");
|
|
1617
|
-
const bulkFormats = [];
|
|
1618
1732
|
if (fs2.existsSync(compiledDir)) {
|
|
1733
|
+
let copied = 0;
|
|
1619
1734
|
for (const file of fs2.readdirSync(compiledDir)) {
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
const ext = path3.extname(file);
|
|
1624
|
-
const formatMap = {
|
|
1625
|
-
".ttl": "turtle",
|
|
1626
|
-
".jsonld": "jsonld",
|
|
1627
|
-
".xml": "tbx",
|
|
1628
|
-
".jsonl": "jsonl",
|
|
1629
|
-
".yaml": "yaml"
|
|
1630
|
-
};
|
|
1631
|
-
const formatName = formatMap[ext] || ext.slice(1);
|
|
1632
|
-
bulkFormats.push({ file, format: formatName, size: fs2.statSync(src).size });
|
|
1633
|
-
}
|
|
1634
|
-
if (bulkFormats.length) {
|
|
1635
|
-
console.log(` Copied ${bulkFormats.length} bulk format files`);
|
|
1735
|
+
fs2.copyFileSync(path3.join(compiledDir, file), path3.join(DATA, register, file));
|
|
1736
|
+
addBulkFormat(file);
|
|
1737
|
+
copied += 1;
|
|
1636
1738
|
}
|
|
1739
|
+
if (copied) console.log(` Copied ${copied} bulk format files from compiled/`);
|
|
1637
1740
|
}
|
|
1638
1741
|
const sourceStats = Array.from(stats.sourceMap.values()).map((s) => ({
|
|
1639
1742
|
ref: s.ref,
|
|
@@ -1675,7 +1778,6 @@ async function processDataset(dir, register, opts) {
|
|
|
1675
1778
|
year: opts.year ?? void 0,
|
|
1676
1779
|
languageStats: langStats,
|
|
1677
1780
|
availableFormats,
|
|
1678
|
-
bulkFormats,
|
|
1679
1781
|
hasBibliography: opts.hasBibliography,
|
|
1680
1782
|
hasImages: opts.hasImages
|
|
1681
1783
|
};
|
|
@@ -1685,8 +1787,10 @@ async function processDataset(dir, register, opts) {
|
|
|
1685
1787
|
if (opts.status) manifest.editionStatus = opts.status;
|
|
1686
1788
|
if (opts.ordering) manifest.ordering = opts.ordering;
|
|
1687
1789
|
if (opts.sections && opts.sections.length > 0) manifest.sections = opts.sections;
|
|
1688
|
-
writeJson(path3.join(DATA, register, "manifest.json"), manifest);
|
|
1689
1790
|
await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
|
|
1791
|
+
addBulkFormat(`${register}.ttl`);
|
|
1792
|
+
manifest.bulkFormats = opts.bulkExports?.length ? bulkFormats.filter((f) => opts.bulkExports.includes(f.format)) : bulkFormats;
|
|
1793
|
+
writeJson(path3.join(DATA, register, "manifest.json"), manifest);
|
|
1690
1794
|
const bibPath = path3.join(sourceRoot, "bibliography.yaml");
|
|
1691
1795
|
if (fs2.existsSync(bibPath)) {
|
|
1692
1796
|
const rawBib = readYaml(bibPath);
|
|
@@ -1736,6 +1840,7 @@ for (const ds of config.datasets) {
|
|
|
1736
1840
|
}
|
|
1737
1841
|
}
|
|
1738
1842
|
var refMaps = buildRefMaps(config, registerCache);
|
|
1843
|
+
var bulkExports = (config.bulkExports || []).map((f) => f === "ttl" ? "turtle" : f);
|
|
1739
1844
|
for (let i = 0; i < config.datasets.length; i++) {
|
|
1740
1845
|
const ds = config.datasets[i];
|
|
1741
1846
|
const dir = path3.join(datasetDir(ds), "concepts");
|
|
@@ -1769,7 +1874,8 @@ for (let i = 0; i < config.datasets.length; i++) {
|
|
|
1769
1874
|
ordering: reg?.ordering || null,
|
|
1770
1875
|
sections: reg?.sections ? reg.sections.map((s) => s.toJSON()) : [],
|
|
1771
1876
|
hasBibliography: fs2.existsSync(path3.join(datasetDir(ds), "bibliography.yaml")),
|
|
1772
|
-
hasImages: fs2.existsSync(path3.join(datasetDir(ds), "images"))
|
|
1877
|
+
hasImages: fs2.existsSync(path3.join(datasetDir(ds), "images")),
|
|
1878
|
+
bulkExports
|
|
1773
1879
|
});
|
|
1774
1880
|
const resolvedOwner = firstNonEmpty(reg?.owner, ds.owner) ?? "";
|
|
1775
1881
|
const resolvedTags = firstNonEmpty(reg?.tags, ds.tags) ?? [];
|
package/scripts/generate-data.ts
CHANGED
|
@@ -47,6 +47,8 @@ import {
|
|
|
47
47
|
conceptJsonToTurtle,
|
|
48
48
|
conceptJsonToSkosJsonLd,
|
|
49
49
|
conceptJsonToTbx,
|
|
50
|
+
conceptsToCsv,
|
|
51
|
+
conceptsToSkosJsonLdGraph,
|
|
50
52
|
} from './lib/concept-formats';
|
|
51
53
|
|
|
52
54
|
// MECE partitive multiplicity: 2 independent axes (ISO 704:2022).
|
|
@@ -689,9 +691,10 @@ async function processDataset(dir, register, opts) {
|
|
|
689
691
|
for (const f of fs.readdirSync(conceptsDir)) fs.unlinkSync(path.join(conceptsDir, f));
|
|
690
692
|
}
|
|
691
693
|
const concepts = [];
|
|
694
|
+
const jsonlds = [];
|
|
692
695
|
const langTermCounts = {};
|
|
693
696
|
const langDefCounts = {};
|
|
694
|
-
const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx'];
|
|
697
|
+
const availableFormats = ['ttl', 'jsonld', 'yaml', 'tbx', 'csv'];
|
|
695
698
|
|
|
696
699
|
// Pre-scan: build designation → concept ID lookup for same-dataset designation refs
|
|
697
700
|
const designationLookup = new Map();
|
|
@@ -802,6 +805,7 @@ async function processDataset(dir, register, opts) {
|
|
|
802
805
|
const termid = String(conceptYaml.termid);
|
|
803
806
|
const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
|
|
804
807
|
writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
|
|
808
|
+
jsonlds.push(jsonld);
|
|
805
809
|
|
|
806
810
|
const ttlContent = conceptJsonToTurtle(jsonld);
|
|
807
811
|
fs.writeFileSync(path.join(conceptsDir, `${termid}.ttl`), ttlContent);
|
|
@@ -831,6 +835,10 @@ async function processDataset(dir, register, opts) {
|
|
|
831
835
|
}
|
|
832
836
|
}
|
|
833
837
|
|
|
838
|
+
// Aggregate distributions: single-file exports of the whole register
|
|
839
|
+
fs.writeFileSync(path.join(DATA, register, `${register}.csv`), conceptsToCsv(jsonlds, { languageOrder: opts.languageOrder || opts.languages }));
|
|
840
|
+
fs.writeFileSync(path.join(DATA, register, `${register}.jsonld`), conceptsToSkosJsonLdGraph(jsonlds));
|
|
841
|
+
|
|
834
842
|
const CHUNK_SIZE = 500;
|
|
835
843
|
const chunks = [];
|
|
836
844
|
for (let i = 0; i < concepts.length; i += CHUNK_SIZE) {
|
|
@@ -908,29 +916,37 @@ async function processDataset(dir, register, opts) {
|
|
|
908
916
|
};
|
|
909
917
|
}
|
|
910
918
|
|
|
911
|
-
//
|
|
919
|
+
// Aggregate bulk exports offered on the dataset page: single-file
|
|
920
|
+
// distributions generated by this build plus any files shipped in the
|
|
921
|
+
// dataset's compiled/ directory. site-config `bulk_exports` restricts
|
|
922
|
+
// which formats are offered; absent/empty config offers all available.
|
|
923
|
+
const BULK_FORMAT_BY_EXT = {
|
|
924
|
+
'.ttl': 'turtle',
|
|
925
|
+
'.jsonld': 'jsonld',
|
|
926
|
+
'.xml': 'tbx',
|
|
927
|
+
'.jsonl': 'jsonl',
|
|
928
|
+
'.yaml': 'yaml',
|
|
929
|
+
'.csv': 'csv',
|
|
930
|
+
};
|
|
931
|
+
const bulkFormats = [];
|
|
932
|
+
const addBulkFormat = file => {
|
|
933
|
+
const dest = path.join(DATA, register, file);
|
|
934
|
+
if (!fs.existsSync(dest) || bulkFormats.some(f => f.file === file)) return;
|
|
935
|
+
const formatName = BULK_FORMAT_BY_EXT[path.extname(file)] || path.extname(file).slice(1);
|
|
936
|
+
bulkFormats.push({ file, format: formatName, size: fs.statSync(dest).size });
|
|
937
|
+
};
|
|
938
|
+
for (const file of [`${register}.csv`, `${register}.jsonld`]) addBulkFormat(file);
|
|
939
|
+
|
|
912
940
|
const sourceRoot = path.dirname(dir);
|
|
913
941
|
const compiledDir = path.join(sourceRoot, 'compiled');
|
|
914
|
-
const bulkFormats = [];
|
|
915
942
|
if (fs.existsSync(compiledDir)) {
|
|
943
|
+
let copied = 0;
|
|
916
944
|
for (const file of fs.readdirSync(compiledDir)) {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const ext = path.extname(file);
|
|
921
|
-
const formatMap = {
|
|
922
|
-
'.ttl': 'turtle',
|
|
923
|
-
'.jsonld': 'jsonld',
|
|
924
|
-
'.xml': 'tbx',
|
|
925
|
-
'.jsonl': 'jsonl',
|
|
926
|
-
'.yaml': 'yaml',
|
|
927
|
-
};
|
|
928
|
-
const formatName = formatMap[ext] || ext.slice(1);
|
|
929
|
-
bulkFormats.push({ file, format: formatName, size: fs.statSync(src).size });
|
|
930
|
-
}
|
|
931
|
-
if (bulkFormats.length) {
|
|
932
|
-
console.log(` Copied ${bulkFormats.length} bulk format files`);
|
|
945
|
+
fs.copyFileSync(path.join(compiledDir, file), path.join(DATA, register, file));
|
|
946
|
+
addBulkFormat(file);
|
|
947
|
+
copied += 1;
|
|
933
948
|
}
|
|
949
|
+
if (copied) console.log(` Copied ${copied} bulk format files from compiled/`);
|
|
934
950
|
}
|
|
935
951
|
|
|
936
952
|
const sourceStats = Array.from(stats.sourceMap.values())
|
|
@@ -977,7 +993,6 @@ async function processDataset(dir, register, opts) {
|
|
|
977
993
|
year: opts.year ?? undefined,
|
|
978
994
|
languageStats: langStats,
|
|
979
995
|
availableFormats,
|
|
980
|
-
bulkFormats,
|
|
981
996
|
hasBibliography: opts.hasBibliography,
|
|
982
997
|
hasImages: opts.hasImages,
|
|
983
998
|
};
|
|
@@ -987,10 +1002,16 @@ async function processDataset(dir, register, opts) {
|
|
|
987
1002
|
if (opts.status) manifest.editionStatus = opts.status;
|
|
988
1003
|
if (opts.ordering) manifest.ordering = opts.ordering;
|
|
989
1004
|
if (opts.sections && opts.sections.length > 0) manifest.sections = opts.sections;
|
|
990
|
-
writeJson(path.join(DATA, register, 'manifest.json'), manifest);
|
|
991
1005
|
|
|
992
1006
|
// Dataset-level RDF (WS J2/J5): dcat:Dataset + skos:ConceptScheme + skos:Collection per section.
|
|
1007
|
+
// Runs before the manifest write so the ttl distribution is on disk when
|
|
1008
|
+
// bulkFormats is assembled.
|
|
993
1009
|
await writeDatasetRdf(register, manifest, concepts, refMaps, opts);
|
|
1010
|
+
addBulkFormat(`${register}.ttl`);
|
|
1011
|
+
manifest.bulkFormats = opts.bulkExports?.length
|
|
1012
|
+
? bulkFormats.filter(f => opts.bulkExports.includes(f.format))
|
|
1013
|
+
: bulkFormats;
|
|
1014
|
+
writeJson(path.join(DATA, register, 'manifest.json'), manifest);
|
|
994
1015
|
|
|
995
1016
|
// Copy bibliography.yaml → bibliography.json
|
|
996
1017
|
// ALWAYS emit bibliography.json — empty stub `{ bibliography: [] }` when no
|
|
@@ -1058,6 +1079,11 @@ for (const ds of config.datasets) {
|
|
|
1058
1079
|
|
|
1059
1080
|
const refMaps = buildRefMaps(config, registerCache);
|
|
1060
1081
|
|
|
1082
|
+
// site-config `bulk_exports`: which aggregate formats the dataset pages
|
|
1083
|
+
// offer for download. 'ttl' is accepted as an alias of the canonical
|
|
1084
|
+
// format id 'turtle'. Absent/empty → offer all available formats.
|
|
1085
|
+
const bulkExports = (config.bulkExports || []).map(f => (f === 'ttl' ? 'turtle' : f));
|
|
1086
|
+
|
|
1061
1087
|
for (let i = 0; i < config.datasets.length; i++) {
|
|
1062
1088
|
const ds = config.datasets[i];
|
|
1063
1089
|
|
|
@@ -1110,6 +1136,7 @@ for (let i = 0; i < config.datasets.length; i++) {
|
|
|
1110
1136
|
sections: reg?.sections ? reg.sections.map(s => s.toJSON()) : [],
|
|
1111
1137
|
hasBibliography: fs.existsSync(path.join(datasetDir(ds), 'bibliography.yaml')),
|
|
1112
1138
|
hasImages: fs.existsSync(path.join(datasetDir(ds), 'images')),
|
|
1139
|
+
bulkExports,
|
|
1113
1140
|
});
|
|
1114
1141
|
const resolvedOwner = firstNonEmpty(reg?.owner, ds.owner) ?? '';
|
|
1115
1142
|
const resolvedTags = firstNonEmpty(reg?.tags, ds.tags) ?? [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Concept format emitters — Turtle, SKOS JSON-LD, TBX.
|
|
2
|
+
* Concept format emitters — Turtle, SKOS JSON-LD, TBX, aggregate CSV.
|
|
3
3
|
*
|
|
4
4
|
* Extracted from generate-data.ts. Each function takes a JSON-LD concept
|
|
5
5
|
* object and serializes it to a different interchange format.
|
|
@@ -55,16 +55,17 @@ export function conceptJsonToTurtle(concept: Record<string, any>): string {
|
|
|
55
55
|
return lines.join('\n');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
const SKOS_JSON_LD_CONTEXT = {
|
|
59
|
+
skos: 'http://www.w3.org/2004/02/skos/core#',
|
|
60
|
+
dcterms: 'http://purl.org/dc/terms/',
|
|
61
|
+
'@language': { '@container': '@language' },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function buildSkosJsonLdObject(concept: Record<string, any>): Record<string, any> {
|
|
59
65
|
const uri = concept['@id'] || '';
|
|
60
66
|
const id = concept['gl:identifier'] || '';
|
|
61
67
|
|
|
62
68
|
const doc: Record<string, any> = {
|
|
63
|
-
'@context': {
|
|
64
|
-
skos: 'http://www.w3.org/2004/02/skos/core#',
|
|
65
|
-
dcterms: 'http://purl.org/dc/terms/',
|
|
66
|
-
'@language': { '@container': '@language' },
|
|
67
|
-
},
|
|
68
69
|
'@id': uri,
|
|
69
70
|
'@type': 'skos:Concept',
|
|
70
71
|
'skos:notation': id,
|
|
@@ -91,7 +92,126 @@ export function conceptJsonToSkosJsonLd(concept: Record<string, any>): string {
|
|
|
91
92
|
if (Object.keys(definitions).length) doc['skos:definition'] = definitions;
|
|
92
93
|
if (Object.keys(scopeNotes).length) doc['skos:scopeNote'] = scopeNotes;
|
|
93
94
|
|
|
94
|
-
return
|
|
95
|
+
return doc;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function conceptJsonToSkosJsonLd(concept: Record<string, any>): string {
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
'@context': SKOS_JSON_LD_CONTEXT,
|
|
101
|
+
...buildSkosJsonLdObject(concept),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function conceptsToSkosJsonLdGraph(concepts: Record<string, any>[]): string {
|
|
106
|
+
return JSON.stringify(
|
|
107
|
+
{
|
|
108
|
+
'@context': SKOS_JSON_LD_CONTEXT,
|
|
109
|
+
'@graph': concepts.map(buildSkosJsonLdObject),
|
|
110
|
+
},
|
|
111
|
+
null,
|
|
112
|
+
2,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const CSV_COLUMNS = [
|
|
117
|
+
'termid',
|
|
118
|
+
'uri',
|
|
119
|
+
'status',
|
|
120
|
+
'section',
|
|
121
|
+
'language',
|
|
122
|
+
'term',
|
|
123
|
+
'alt_terms',
|
|
124
|
+
'definition',
|
|
125
|
+
'notes',
|
|
126
|
+
'examples',
|
|
127
|
+
'sources',
|
|
128
|
+
'source_links',
|
|
129
|
+
] as const;
|
|
130
|
+
|
|
131
|
+
function csvEscape(value: string): string {
|
|
132
|
+
if (/[",\r\n]/.test(value)) {
|
|
133
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function joinLines(items: string[]): string {
|
|
139
|
+
return items.filter(Boolean).join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function localizedLanguages(localized: Record<string, any>, languageOrder?: string[]): string[] {
|
|
143
|
+
const present = Object.keys(localized);
|
|
144
|
+
if (!languageOrder?.length) return present;
|
|
145
|
+
const ordered = languageOrder.filter(lang => present.includes(lang));
|
|
146
|
+
return ordered.concat(present.filter(lang => !ordered.includes(lang)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sourceRefString(src: Record<string, any>): string {
|
|
150
|
+
const ref = src?.['gl:origin']?.['gl:ref'];
|
|
151
|
+
if (!ref) return '';
|
|
152
|
+
return [ref['gl:source'], ref['gl:id']].filter(Boolean).join(': ');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sourceLinkString(src: Record<string, any>): string {
|
|
156
|
+
return src?.['gl:origin']?.['gl:link'] || '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Aggregate CSV export: one row per (concept × language). Columns follow the
|
|
161
|
+
* Glossarist concept-model fields. UTF-8 BOM + CRLF so Excel opens the file
|
|
162
|
+
* with correct encoding without an import wizard.
|
|
163
|
+
*/
|
|
164
|
+
export function conceptsToCsv(
|
|
165
|
+
concepts: Record<string, any>[],
|
|
166
|
+
opts: { languageOrder?: string[] } = {},
|
|
167
|
+
): string {
|
|
168
|
+
const rows: string[] = [CSV_COLUMNS.join(',')];
|
|
169
|
+
|
|
170
|
+
for (const concept of concepts) {
|
|
171
|
+
const localized = concept['gl:localizedConcept'] || {};
|
|
172
|
+
const domains = (concept['gl:domain'] || [])
|
|
173
|
+
.map((d: any) => d['gl:conceptId'])
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join('; ');
|
|
176
|
+
const managedSources = concept['gl:source'] || [];
|
|
177
|
+
const shared = {
|
|
178
|
+
termid: concept['gl:identifier'] || '',
|
|
179
|
+
uri: concept['@id'] || '',
|
|
180
|
+
status: concept['gl:status'] || '',
|
|
181
|
+
section: domains,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const languages = localizedLanguages(localized, opts.languageOrder);
|
|
185
|
+
if (languages.length === 0) {
|
|
186
|
+
rows.push([shared.termid, shared.uri, shared.status, shared.section, '', '', '', '', '', '', '', ''].map(csvEscape).join(','));
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const lang of languages) {
|
|
191
|
+
const lc = localized[lang] || {};
|
|
192
|
+
const designations = (lc['gl:designation'] || []).filter((d: any) => d['gl:term']);
|
|
193
|
+
const preferred = designations.find((d: any) => d['gl:normativeStatus'] === 'preferred') || designations[0];
|
|
194
|
+
const altTerms = designations.filter((d: any) => d !== preferred).map((d: any) => d['gl:term']);
|
|
195
|
+
const localizedSources = lc['gl:source']?.length ? lc['gl:source'] : managedSources;
|
|
196
|
+
|
|
197
|
+
rows.push([
|
|
198
|
+
shared.termid,
|
|
199
|
+
shared.uri,
|
|
200
|
+
shared.status,
|
|
201
|
+
shared.section,
|
|
202
|
+
lang,
|
|
203
|
+
preferred?.['gl:term'] || '',
|
|
204
|
+
altTerms.join('; '),
|
|
205
|
+
joinLines((lc['gl:definition'] || []).map((d: any) => d['gl:content'])),
|
|
206
|
+
joinLines((lc['gl:notes'] || []).map((d: any) => d['gl:content'])),
|
|
207
|
+
joinLines((lc['gl:examples'] || []).map((d: any) => d['gl:content'])),
|
|
208
|
+
localizedSources.map(sourceRefString).filter(Boolean).join('; '),
|
|
209
|
+
localizedSources.map(sourceLinkString).filter(Boolean).join('; '),
|
|
210
|
+
].map(csvEscape).join(','));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return '\uFEFF' + rows.join('\r\n') + '\r\n';
|
|
95
215
|
}
|
|
96
216
|
|
|
97
217
|
export function conceptJsonToTbx(concept: Record<string, any>): string {
|
|
@@ -167,3 +167,117 @@ describe('drawGenericPipes — pipe-and-thread contract', () => {
|
|
|
167
167
|
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
|
+
|
|
171
|
+
describe('drawGenericPipes — external + ellipsis ghosts (concept-model PR #91)', () => {
|
|
172
|
+
const pos = new Map<string, { x: number; y: number }>([
|
|
173
|
+
['vim-2012/1.9', { x: 0, y: 0 }],
|
|
174
|
+
['vim-2012/1.15', { x: 100, y: -50 }],
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
function makeGhostRelation(): GenericRelationWire {
|
|
178
|
+
return {
|
|
179
|
+
source: 'https://example.org/vim-2012/concept/1.9',
|
|
180
|
+
comprehensive: 'https://example.org/vim-2012/concept/1.9',
|
|
181
|
+
completeness: 'complete',
|
|
182
|
+
criterion: { eng: 'by system membership' },
|
|
183
|
+
register: 'vim-2012',
|
|
184
|
+
members: [
|
|
185
|
+
{ uri: 'https://example.org/vim-2012/concept/1.15', presence: 'required', count: 'at_least_one',
|
|
186
|
+
delimitingCharacteristic: { eng: 'off-system measurement unit' } },
|
|
187
|
+
{ uri: 'urn:gloss:ext:in-system measurement unit', presence: 'optional', count: 'any',
|
|
188
|
+
external: true, text: 'in-system measurement unit',
|
|
189
|
+
delimitingCharacteristic: { eng: 'in-system measurement unit' } },
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function makeEllipsisRelation(): GenericRelationWire {
|
|
195
|
+
return {
|
|
196
|
+
source: 'https://example.org/vim-2012/concept/1.9',
|
|
197
|
+
comprehensive: 'https://example.org/vim-2012/concept/1.9',
|
|
198
|
+
completeness: 'partial',
|
|
199
|
+
criterion: { eng: 'by epistemic status' },
|
|
200
|
+
register: 'vim-2012',
|
|
201
|
+
members: [
|
|
202
|
+
{ uri: 'https://example.org/vim-2012/concept/1.15', presence: 'required', count: 'at_least_one',
|
|
203
|
+
delimitingCharacteristic: { eng: 'x' } },
|
|
204
|
+
{ uri: 'urn:gloss:ellipsis', presence: 'required', count: 'exactly_one', ellipsis: true },
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
it('renders an external member as a dashed pill with parenthesized text', () => {
|
|
210
|
+
const svg = makeSvg();
|
|
211
|
+
drawGenericPipes(svg, pos, {
|
|
212
|
+
isDark: false,
|
|
213
|
+
color: '#b45309',
|
|
214
|
+
isMuted: false,
|
|
215
|
+
relations: [makeGhostRelation()],
|
|
216
|
+
locale: 'eng',
|
|
217
|
+
});
|
|
218
|
+
const pill = svg.querySelector('rect.pipe-ghost-external');
|
|
219
|
+
expect(pill).toBeDefined();
|
|
220
|
+
expect(pill!.getAttribute('stroke-dasharray')).toBe('3 2');
|
|
221
|
+
const label = svg.querySelector('text.pipe-ghost-external-label');
|
|
222
|
+
expect(label).toBeDefined();
|
|
223
|
+
expect(label!.textContent).toBe('(in-system measurement unit)');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('renders an ellipsis member as a muted ... ghost', () => {
|
|
227
|
+
const svg = makeSvg();
|
|
228
|
+
drawGenericPipes(svg, pos, {
|
|
229
|
+
isDark: false,
|
|
230
|
+
color: '#b45309',
|
|
231
|
+
isMuted: false,
|
|
232
|
+
relations: [makeEllipsisRelation()],
|
|
233
|
+
locale: 'eng',
|
|
234
|
+
});
|
|
235
|
+
const ell = svg.querySelector('text.pipe-ghost-ellipsis');
|
|
236
|
+
expect(ell).toBeDefined();
|
|
237
|
+
expect(ell!.textContent).toBe('...');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('keeps a 1-resolved + 1-ghost rake alive (ghosts count as members)', () => {
|
|
241
|
+
const svg = makeSvg();
|
|
242
|
+
drawGenericPipes(svg, pos, {
|
|
243
|
+
isDark: false,
|
|
244
|
+
color: '#b45309',
|
|
245
|
+
isMuted: false,
|
|
246
|
+
relations: [makeGhostRelation()],
|
|
247
|
+
locale: 'eng',
|
|
248
|
+
});
|
|
249
|
+
/* 1 resolved member + 1 ghost = 2 layout members → 1 pipe + 2 threads. */
|
|
250
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(3);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('draws ghost threads dashed so they read as non-node placeholders', () => {
|
|
254
|
+
const svg = makeSvg();
|
|
255
|
+
drawGenericPipes(svg, pos, {
|
|
256
|
+
isDark: false,
|
|
257
|
+
color: '#b45309',
|
|
258
|
+
isMuted: false,
|
|
259
|
+
relations: [makeEllipsisRelation()],
|
|
260
|
+
locale: 'eng',
|
|
261
|
+
});
|
|
262
|
+
const dashed = Array.from(svg.querySelectorAll('line.pipe-seg'))
|
|
263
|
+
.filter(l => l.getAttribute('stroke-dasharray') === '3 3');
|
|
264
|
+
expect(dashed.length).toBe(1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('drops a rake whose members are ALL ghosts with no positioned anchor', () => {
|
|
268
|
+
const svg = makeSvg();
|
|
269
|
+
const emptyPos = new Map<string, { x: number; y: number }>([
|
|
270
|
+
['vim-2012/1.9', { x: 0, y: 0 }],
|
|
271
|
+
]);
|
|
272
|
+
drawGenericPipes(svg, emptyPos, {
|
|
273
|
+
isDark: false,
|
|
274
|
+
color: '#b45309',
|
|
275
|
+
isMuted: false,
|
|
276
|
+
relations: [makeGhostRelation()],
|
|
277
|
+
locale: 'eng',
|
|
278
|
+
});
|
|
279
|
+
/* 1.15 not in pos → zero positioned members → layout collapses onto
|
|
280
|
+
comp; ghosts have no anchor distinct from comp, so nothing draws. */
|
|
281
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { conceptsToCsv, conceptsToSkosJsonLdGraph, CSV_COLUMNS } from '../../../scripts/lib/concept-formats';
|
|
3
|
+
|
|
4
|
+
function parseCsv(csv: string): string[][] {
|
|
5
|
+
const body = csv.replace(/^\uFEFF/, '');
|
|
6
|
+
const rows: string[][] = [];
|
|
7
|
+
let row: string[] = [];
|
|
8
|
+
let field = '';
|
|
9
|
+
let quoted = false;
|
|
10
|
+
for (let i = 0; i < body.length; i++) {
|
|
11
|
+
const ch = body[i];
|
|
12
|
+
if (quoted) {
|
|
13
|
+
if (ch === '"') {
|
|
14
|
+
if (body[i + 1] === '"') { field += '"'; i++; }
|
|
15
|
+
else quoted = false;
|
|
16
|
+
} else {
|
|
17
|
+
field += ch;
|
|
18
|
+
}
|
|
19
|
+
} else if (ch === '"') {
|
|
20
|
+
quoted = true;
|
|
21
|
+
} else if (ch === ',') {
|
|
22
|
+
row.push(field); field = '';
|
|
23
|
+
} else if (ch === '\r' && body[i + 1] === '\n') {
|
|
24
|
+
row.push(field); rows.push(row); row = []; field = ''; i++;
|
|
25
|
+
} else {
|
|
26
|
+
field += ch;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return rows;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const baseConcept = {
|
|
33
|
+
'@id': 'https://example.com/vocab/concept/1-1-010',
|
|
34
|
+
'gl:identifier': '1-1-010',
|
|
35
|
+
'gl:status': 'valid',
|
|
36
|
+
'gl:domain': [{ 'gl:conceptId': 'section-1' }],
|
|
37
|
+
'gl:source': [{
|
|
38
|
+
'gl:sourceType': 'authoritative',
|
|
39
|
+
'gl:origin': { 'gl:ref': { 'gl:source': 'IALA Dictionary' }, 'gl:link': 'https://example.com/source' },
|
|
40
|
+
}],
|
|
41
|
+
'gl:localizedConcept': {
|
|
42
|
+
eng: {
|
|
43
|
+
'gl:designation': [
|
|
44
|
+
{ 'gl:normativeStatus': 'preferred', 'gl:term': 'Buoyage and beaconage' },
|
|
45
|
+
{ 'gl:normativeStatus': 'admitted', 'gl:term': 'Sea-marking (G.B.)' },
|
|
46
|
+
],
|
|
47
|
+
'gl:definition': [{ 'gl:content': 'Ensemble of beacons, buoys, seamarks and small lights.' }],
|
|
48
|
+
'gl:notes': [{ 'gl:content': 'Note one.' }, { 'gl:content': 'Note two.' }],
|
|
49
|
+
},
|
|
50
|
+
fra: {
|
|
51
|
+
'gl:designation': [{ 'gl:normativeStatus': 'preferred', 'gl:term': 'Balisage' }],
|
|
52
|
+
'gl:definition': [{ 'gl:content': 'Ensemble de balises.' }],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('conceptsToCsv', () => {
|
|
58
|
+
it('emits UTF-8 BOM, CRLF line endings, and the model-driven header', () => {
|
|
59
|
+
const csv = conceptsToCsv([baseConcept]);
|
|
60
|
+
expect(csv.startsWith('\uFEFF')).toBe(true);
|
|
61
|
+
expect(csv.endsWith('\r\n')).toBe(true);
|
|
62
|
+
expect(csv).toContain('\r\n');
|
|
63
|
+
const rows = parseCsv(csv);
|
|
64
|
+
expect(rows[0]).toEqual([...CSV_COLUMNS]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('emits one row per concept and language', () => {
|
|
68
|
+
const rows = parseCsv(conceptsToCsv([baseConcept]));
|
|
69
|
+
expect(rows).toHaveLength(3);
|
|
70
|
+
expect(rows[1][CSV_COLUMNS.indexOf('termid')]).toBe('1-1-010');
|
|
71
|
+
expect(rows[1][CSV_COLUMNS.indexOf('language')]).toBe('eng');
|
|
72
|
+
expect(rows[2][CSV_COLUMNS.indexOf('language')]).toBe('fra');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('honors languageOrder from the deployment config', () => {
|
|
76
|
+
const rows = parseCsv(conceptsToCsv([baseConcept], { languageOrder: ['fra', 'eng'] }));
|
|
77
|
+
expect(rows[1][CSV_COLUMNS.indexOf('language')]).toBe('fra');
|
|
78
|
+
expect(rows[2][CSV_COLUMNS.indexOf('language')]).toBe('eng');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps languages missing from languageOrder at the end', () => {
|
|
82
|
+
const rows = parseCsv(conceptsToCsv([baseConcept], { languageOrder: ['deu', 'eng'] }));
|
|
83
|
+
expect(rows.slice(1).map(r => r[CSV_COLUMNS.indexOf('language')])).toEqual(['eng', 'fra']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('separates the preferred term from alt terms', () => {
|
|
87
|
+
const rows = parseCsv(conceptsToCsv([baseConcept]));
|
|
88
|
+
const eng = rows[1];
|
|
89
|
+
expect(eng[CSV_COLUMNS.indexOf('term')]).toBe('Buoyage and beaconage');
|
|
90
|
+
expect(eng[CSV_COLUMNS.indexOf('alt_terms')]).toBe('Sea-marking (G.B.)');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('joins multi-entry notes with a newline inside a quoted cell', () => {
|
|
94
|
+
const rows = parseCsv(conceptsToCsv([baseConcept]));
|
|
95
|
+
expect(rows[1][CSV_COLUMNS.indexOf('notes')]).toBe('Note one.\nNote two.');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('escapes quotes and commas per RFC 4180', () => {
|
|
99
|
+
const concept = JSON.parse(JSON.stringify(baseConcept));
|
|
100
|
+
concept['gl:localizedConcept'].eng['gl:definition'] = [{ 'gl:content': 'Says "hello", loudly' }];
|
|
101
|
+
const csv = conceptsToCsv([concept]);
|
|
102
|
+
expect(csv).toContain('"Says ""hello"", loudly"');
|
|
103
|
+
expect(parseCsv(csv)[1][CSV_COLUMNS.indexOf('definition')]).toBe('Says "hello", loudly');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('falls back to managed sources when the localization has none', () => {
|
|
107
|
+
const rows = parseCsv(conceptsToCsv([baseConcept]));
|
|
108
|
+
for (const row of rows.slice(1)) {
|
|
109
|
+
expect(row[CSV_COLUMNS.indexOf('sources')]).toBe('IALA Dictionary');
|
|
110
|
+
expect(row[CSV_COLUMNS.indexOf('source_links')]).toBe('https://example.com/source');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('still emits a row for concepts with no localizations', () => {
|
|
115
|
+
const bare = {
|
|
116
|
+
'@id': 'https://example.com/vocab/concept/9-9-999',
|
|
117
|
+
'gl:identifier': '9-9-999',
|
|
118
|
+
'gl:status': 'retired',
|
|
119
|
+
};
|
|
120
|
+
const rows = parseCsv(conceptsToCsv([bare]));
|
|
121
|
+
expect(rows).toHaveLength(2);
|
|
122
|
+
expect(rows[1][CSV_COLUMNS.indexOf('termid')]).toBe('9-9-999');
|
|
123
|
+
expect(rows[1][CSV_COLUMNS.indexOf('language')]).toBe('');
|
|
124
|
+
expect(rows[1][CSV_COLUMNS.indexOf('term')]).toBe('');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('carries section domains and status columns', () => {
|
|
128
|
+
const rows = parseCsv(conceptsToCsv([baseConcept]));
|
|
129
|
+
expect(rows[1][CSV_COLUMNS.indexOf('section')]).toBe('section-1');
|
|
130
|
+
expect(rows[1][CSV_COLUMNS.indexOf('status')]).toBe('valid');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('conceptsToSkosJsonLdGraph', () => {
|
|
135
|
+
it('wraps concepts in a shared @context and @graph', () => {
|
|
136
|
+
const parsed = JSON.parse(conceptsToSkosJsonLdGraph([baseConcept]));
|
|
137
|
+
expect(parsed['@context']['skos']).toBe('http://www.w3.org/2004/02/skos/core#');
|
|
138
|
+
expect(parsed['@graph']).toHaveLength(1);
|
|
139
|
+
expect(parsed['@graph'][0]['@id']).toBe(baseConcept['@id']);
|
|
140
|
+
expect(parsed['@graph'][0]['skos:prefLabel']['eng']).toBe('Buoyage and beaconage');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -60,6 +60,6 @@ describe('GL wire-key SSOT', () => {
|
|
|
60
60
|
// The point of this test: when adding a key, you remember to update
|
|
61
61
|
// the count; when removing, you notice the test fails. Catches
|
|
62
62
|
// accidental drift in either direction.
|
|
63
|
-
expect(Object.keys(GL).length).toBe(
|
|
63
|
+
expect(Object.keys(GL).length).toBe(93);
|
|
64
64
|
});
|
|
65
65
|
});
|
|
@@ -298,7 +298,9 @@ function mapRefFromJsonLd(rawRef: JsonLdRef | string | undefined): Record<string
|
|
|
298
298
|
refObj.id = rawRef[GL.LOCAL_ID] ?? rawRef.id;
|
|
299
299
|
refObj.version = rawRef[GL.VERSION] ?? rawRef.version;
|
|
300
300
|
if (rawRef[GL.TEXT]) refObj.text = rawRef[GL.TEXT];
|
|
301
|
-
|
|
301
|
+
if (rawRef[GL.EXTERNAL] === true) refObj.external = true;
|
|
302
|
+
if (rawRef[GL.ELLIPSIS] === true) refObj.ellipsis = true;
|
|
303
|
+
return (refObj.source ?? refObj.id ?? refObj.version ?? refObj.text ?? refObj.external ?? refObj.ellipsis) != null
|
|
302
304
|
? refObj : null;
|
|
303
305
|
}
|
|
304
306
|
|
package/src/adapters/types.ts
CHANGED
|
@@ -204,6 +204,14 @@ export interface GenericMemberWire {
|
|
|
204
204
|
* distinguishes this specific concept from its coordinate concepts.
|
|
205
205
|
* Required on every GenericMember. LocalizedString shape. */
|
|
206
206
|
delimitingCharacteristic: Record<string, string>;
|
|
207
|
+
/** Inline external concept (concept-model PR #91). No dataset node —
|
|
208
|
+
* rendered as a dashed `(text)` ghost, never resolved to a URI. */
|
|
209
|
+
external?: boolean;
|
|
210
|
+
/** Author-acknowledged gap (concept-model PR #91). Rendered as a
|
|
211
|
+
* muted `...` ghost. */
|
|
212
|
+
ellipsis?: boolean;
|
|
213
|
+
/** Display text for external members (the parenthetical label). */
|
|
214
|
+
text?: string;
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
export interface GraphNode {
|
|
@@ -108,6 +108,12 @@ export const GL = {
|
|
|
108
108
|
VERSION: 'gl:version',
|
|
109
109
|
TEXT: 'gl:text',
|
|
110
110
|
|
|
111
|
+
// ConceptRef external/ellipsis flags (concept-model PR #91).
|
|
112
|
+
// external = parenthetical (ISO 704:2022 §5.5.4.3.1); renders as `(text)`.
|
|
113
|
+
// ellipsis = author-acknowledged gap; renders as `...`.
|
|
114
|
+
EXTERNAL: 'gl:external',
|
|
115
|
+
ELLIPSIS: 'gl:ellipsis',
|
|
116
|
+
|
|
111
117
|
// Locally-scoped id within source/origin/ref (NOT JSON-LD's @id)
|
|
112
118
|
LOCAL_ID: 'gl:id',
|
|
113
119
|
|
|
@@ -80,7 +80,16 @@ export function drawGenericPipes(
|
|
|
80
80
|
if (!compPos) continue;
|
|
81
81
|
|
|
82
82
|
const memberPositions: PipeLayoutMember[] = [];
|
|
83
|
+
const ghostMembers: Array<{ member: GenericRelationWire['members'][number]; pos: NodePos }> = [];
|
|
83
84
|
for (const m of rel.members) {
|
|
85
|
+
/* Ghost members (external / ellipsis, concept-model PR #91) carry
|
|
86
|
+
synthetic urn:gloss:* URIs that never resolve to a node — they
|
|
87
|
+
get virtual positions on an arc beyond the middle node and are
|
|
88
|
+
drawn as dashed ghosts after the layout. */
|
|
89
|
+
if (m.ellipsis === true || m.external === true) {
|
|
90
|
+
ghostMembers.push({ member: m, pos: { x: 0, y: 0 } });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
84
93
|
const parsed = UriRouter.parseUri(m.uri);
|
|
85
94
|
if (!parsed) continue;
|
|
86
95
|
const nid = `${parsed.registerId}/${parsed.conceptId}`;
|
|
@@ -89,7 +98,31 @@ export function drawGenericPipes(
|
|
|
89
98
|
memberPositions.push({ pos: p, delimitingCharacteristic: m.delimitingCharacteristic });
|
|
90
99
|
}
|
|
91
100
|
|
|
92
|
-
|
|
101
|
+
/* Ghosts need a middle-node estimate to fan around. With ≥2
|
|
102
|
+
positioned members the layout gives it directly; with exactly 1
|
|
103
|
+
use the comp→member midpoint (computePipeLayout requires ≥2 and
|
|
104
|
+
would return null). Zero positioned members means the ghosts have
|
|
105
|
+
no anchor — drop the rake. */
|
|
106
|
+
if (memberPositions.length === 0 && ghostMembers.length > 0) continue;
|
|
107
|
+
let provisionalMiddle: NodePos | null = null;
|
|
108
|
+
if (memberPositions.length >= 2) {
|
|
109
|
+
const l = computePipeLayout(compPos, memberPositions);
|
|
110
|
+
provisionalMiddle = l ? l.middleNode : null;
|
|
111
|
+
} else if (memberPositions.length === 1) {
|
|
112
|
+
provisionalMiddle = {
|
|
113
|
+
x: (compPos.x + memberPositions[0].pos.x) / 2,
|
|
114
|
+
y: (compPos.y + memberPositions[0].pos.y) / 2,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!provisionalMiddle) continue;
|
|
118
|
+
assignGhostPositions(provisionalMiddle, compPos, memberPositions, ghostMembers);
|
|
119
|
+
|
|
120
|
+
const allPositions = [
|
|
121
|
+
...memberPositions,
|
|
122
|
+
...ghostMembers.map(g => ({ pos: g.pos, delimitingCharacteristic: g.member.delimitingCharacteristic })),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const layout = computePipeLayout(compPos, allPositions);
|
|
93
126
|
if (!layout) continue;
|
|
94
127
|
|
|
95
128
|
/* 1. Thick parent pipe: comp → middle node. The characteristic
|
|
@@ -98,9 +131,25 @@ export function drawGenericPipes(
|
|
|
98
131
|
|
|
99
132
|
/* 2. Thin threads: middle → each member. No per-thread labels —
|
|
100
133
|
the characteristic is a single hyperedge-level field shared
|
|
101
|
-
by all members, rendered once on the pipe body.
|
|
102
|
-
|
|
134
|
+
by all members, rendered once on the pipe body.
|
|
135
|
+
Ghost members are excluded here (step 2b draws their dashed
|
|
136
|
+
threads separately) — layout.threads preserves the
|
|
137
|
+
allPositions order: positioned members first, ghosts last. */
|
|
138
|
+
layout.threads.forEach((thread, i) => {
|
|
139
|
+
if (i >= memberPositions.length) return;
|
|
103
140
|
drawSegment(svg, opts.color, thread.start, thread.end, THREAD_WIDTH);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/* 2b. Ghost members — external concepts and ellipsis gaps. Dashed
|
|
144
|
+
threads + dashed pills so they read as "present but not a
|
|
145
|
+
node of this dataset" (ISO 704:2022 §5.5.4.3.1). */
|
|
146
|
+
for (const ghost of ghostMembers) {
|
|
147
|
+
drawSegment(svg, opts.color, layout.middleNode, ghost.pos, THREAD_WIDTH, { dashed: true, opacity: 0.55 });
|
|
148
|
+
if (ghost.member.ellipsis === true) {
|
|
149
|
+
drawEllipsisGhost(svg, ghost.pos, opts.color);
|
|
150
|
+
} else {
|
|
151
|
+
drawExternalGhost(svg, ghost.pos, ghost.member.text ?? '', opts.color, opts.isDark);
|
|
152
|
+
}
|
|
104
153
|
}
|
|
105
154
|
|
|
106
155
|
/* 3. Square junction at the middle node (distinct from the rake's
|
|
@@ -129,7 +178,7 @@ export function drawGenericPipes(
|
|
|
129
178
|
}
|
|
130
179
|
}
|
|
131
180
|
|
|
132
|
-
function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodePos, width: number): void {
|
|
181
|
+
function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodePos, width: number, style?: { dashed?: boolean; opacity?: number }): void {
|
|
133
182
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
134
183
|
line.setAttribute('class', 'pipe-seg');
|
|
135
184
|
line.setAttribute('x1', String(from.x));
|
|
@@ -139,10 +188,93 @@ function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodeP
|
|
|
139
188
|
line.setAttribute('stroke', color);
|
|
140
189
|
line.setAttribute('stroke-width', String(width));
|
|
141
190
|
line.setAttribute('stroke-linecap', 'round');
|
|
142
|
-
line.setAttribute('opacity',
|
|
191
|
+
line.setAttribute('opacity', String(style?.opacity ?? 0.9));
|
|
192
|
+
if (style?.dashed) line.setAttribute('stroke-dasharray', '3 3');
|
|
143
193
|
svg.appendChild(line);
|
|
144
194
|
}
|
|
145
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Place ghost members (external / ellipsis) on an arc beyond the middle
|
|
198
|
+
* node, on the side away from the comprehensive, fanned to match the
|
|
199
|
+
* spread of the positioned members. Pure 2D — ghosts never enter the
|
|
200
|
+
* 3D force layout.
|
|
201
|
+
*/
|
|
202
|
+
function assignGhostPositions(
|
|
203
|
+
middleNode: NodePos,
|
|
204
|
+
compPos: NodePos,
|
|
205
|
+
positioned: readonly { pos: NodePos }[],
|
|
206
|
+
ghosts: Array<{ pos: NodePos }>,
|
|
207
|
+
): void {
|
|
208
|
+
if (ghosts.length === 0) return;
|
|
209
|
+
/* Fan radius: a bit beyond the average positioned-member distance
|
|
210
|
+
from the middle node so ghosts sit outside the resolved set. */
|
|
211
|
+
const avgDist = positioned.length > 0
|
|
212
|
+
? positioned.reduce((s, m) => s + Math.hypot(m.pos.x - middleNode.x, m.pos.y - middleNode.y), 0) / positioned.length
|
|
213
|
+
: 40;
|
|
214
|
+
const radius = Math.max(avgDist, 40);
|
|
215
|
+
/* Base direction: away from the comprehensive. */
|
|
216
|
+
const awayX = middleNode.x - compPos.x;
|
|
217
|
+
const awayY = middleNode.y - compPos.y;
|
|
218
|
+
const awayLen = Math.hypot(awayX, awayY) || 1;
|
|
219
|
+
const baseAngle = Math.atan2(awayY / awayLen, awayX / awayLen);
|
|
220
|
+
const spread = 0.5; /* radians between consecutive ghosts */
|
|
221
|
+
ghosts.forEach((g, i) => {
|
|
222
|
+
const offset = ghosts.length === 1 ? 0 : (i - (ghosts.length - 1) / 2) * spread;
|
|
223
|
+
const a = baseAngle + offset;
|
|
224
|
+
g.pos.x = middleNode.x + Math.cos(a) * radius;
|
|
225
|
+
g.pos.y = middleNode.y + Math.sin(a) * radius;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Ellipsis ghost: a muted "..." — the author knew more members exist. */
|
|
230
|
+
function drawEllipsisGhost(svg: SVGSVGElement, at: NodePos, color: string): void {
|
|
231
|
+
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
232
|
+
text.setAttribute('class', 'pipe-ghost-ellipsis');
|
|
233
|
+
text.setAttribute('x', String(at.x));
|
|
234
|
+
text.setAttribute('y', String(at.y + 3));
|
|
235
|
+
text.setAttribute('text-anchor', 'middle');
|
|
236
|
+
text.setAttribute('fill', color);
|
|
237
|
+
text.setAttribute('font-size', '11');
|
|
238
|
+
text.setAttribute('font-family', 'JetBrains Mono, monospace');
|
|
239
|
+
text.setAttribute('font-weight', '600');
|
|
240
|
+
text.setAttribute('opacity', '0.8');
|
|
241
|
+
text.textContent = '...';
|
|
242
|
+
svg.appendChild(text);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** External ghost: dashed-border pill with the parenthetical label. */
|
|
246
|
+
function drawExternalGhost(svg: SVGSVGElement, at: NodePos, label: string, color: string, isDark: boolean): void {
|
|
247
|
+
const fontSize = 9;
|
|
248
|
+
const text = label ? `(${label})` : '(external)';
|
|
249
|
+
const w = Math.max(text.length * (fontSize * 0.55) + 10, 34);
|
|
250
|
+
const h = 15;
|
|
251
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
252
|
+
rect.setAttribute('class', 'pipe-ghost-external');
|
|
253
|
+
rect.setAttribute('x', String(at.x - w / 2));
|
|
254
|
+
rect.setAttribute('y', String(at.y - h / 2));
|
|
255
|
+
rect.setAttribute('width', String(w));
|
|
256
|
+
rect.setAttribute('height', String(h));
|
|
257
|
+
rect.setAttribute('rx', '4');
|
|
258
|
+
rect.setAttribute('fill', isDark ? 'rgba(26,18,8,0.85)' : 'rgba(255,255,255,0.92)');
|
|
259
|
+
rect.setAttribute('stroke', color);
|
|
260
|
+
rect.setAttribute('stroke-width', '1');
|
|
261
|
+
rect.setAttribute('stroke-dasharray', '3 2');
|
|
262
|
+
rect.setAttribute('opacity', '0.92');
|
|
263
|
+
svg.appendChild(rect);
|
|
264
|
+
|
|
265
|
+
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
266
|
+
t.setAttribute('class', 'pipe-ghost-external-label');
|
|
267
|
+
t.setAttribute('x', String(at.x));
|
|
268
|
+
t.setAttribute('y', String(at.y + 3));
|
|
269
|
+
t.setAttribute('text-anchor', 'middle');
|
|
270
|
+
t.setAttribute('fill', color);
|
|
271
|
+
t.setAttribute('font-size', String(fontSize));
|
|
272
|
+
t.setAttribute('font-family', 'JetBrains Mono, monospace');
|
|
273
|
+
t.setAttribute('font-style', 'italic');
|
|
274
|
+
t.textContent = text;
|
|
275
|
+
svg.appendChild(t);
|
|
276
|
+
}
|
|
277
|
+
|
|
146
278
|
function drawSquare(svg: SVGSVGElement, cx: number, cy: number, half: number, fill: string, stroke: string): void {
|
|
147
279
|
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
148
280
|
rect.setAttribute('class', 'pipe-junction');
|
|
@@ -190,7 +190,26 @@ export function useConceptEdges(
|
|
|
190
190
|
const isGeneric = rel instanceof GenericHyperedge;
|
|
191
191
|
const members: any[] = (rel?.members ?? rel?.partitives ?? [])
|
|
192
192
|
.map((m: any): any | null => {
|
|
193
|
-
const
|
|
193
|
+
const ref = m?.ref;
|
|
194
|
+
/* Ghost members (concept-model PR #91): external parenthetical
|
|
195
|
+
concepts and ellipsis gaps have no dataset node, so they get a
|
|
196
|
+
synthetic urn: URI plus flags. Renderers draw them as ghosts
|
|
197
|
+
without resolving the URI. */
|
|
198
|
+
if (ref?.ellipsis === true) {
|
|
199
|
+
return { uri: 'urn:gloss:ellipsis', presence: m?.presence, count: m?.count, ellipsis: true };
|
|
200
|
+
}
|
|
201
|
+
if (ref?.external === true || ref?.isExternal === true) {
|
|
202
|
+
const text = ref?.text ?? '';
|
|
203
|
+
return {
|
|
204
|
+
uri: `urn:gloss:ext:${text}`,
|
|
205
|
+
presence: m?.presence,
|
|
206
|
+
count: m?.count,
|
|
207
|
+
external: true,
|
|
208
|
+
text,
|
|
209
|
+
...(isGeneric ? { delimitingCharacteristic: m?.delimitingCharacteristic } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const uri = resolveConceptRefUri(ref);
|
|
194
213
|
if (!uri || uri === source) return null;
|
|
195
214
|
const base: any = {
|
|
196
215
|
uri,
|
|
@@ -40,3 +40,4 @@ registerFormat({ id: 'jsonld', extension: 'jsonld', mediaType: 'application/ld+
|
|
|
40
40
|
registerFormat({ id: 'yaml', extension: 'yaml', mediaType: 'application/yaml', label: 'YAML', available: 'per-concept', serialize: 'build' });
|
|
41
41
|
registerFormat({ id: 'tbx', extension: 'tbx.xml', mediaType: 'application/x-tbx', label: 'TBX', available: 'aggregate', serialize: 'build' });
|
|
42
42
|
registerFormat({ id: 'jsonl', extension: 'jsonl', mediaType: 'application/jsonl+json', label: 'JSON-Lines', available: 'aggregate', serialize: 'build' });
|
|
43
|
+
registerFormat({ id: 'csv', extension: 'csv', mediaType: 'text/csv', label: 'CSV', available: 'aggregate', serialize: 'build' });
|