@kgdiff/formats 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/detect.d.ts +47 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +425 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/dist/iri.d.ts +13 -0
- package/dist/iri.d.ts.map +1 -0
- package/dist/iri.js +91 -0
- package/dist/iri.js.map +1 -0
- package/dist/json.d.ts +31 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +787 -0
- package/dist/json.js.map +1 -0
- package/dist/jsonld.d.ts +16 -0
- package/dist/jsonld.d.ts.map +1 -0
- package/dist/jsonld.js +1249 -0
- package/dist/jsonld.js.map +1 -0
- package/dist/obo.d.ts +25 -0
- package/dist/obo.d.ts.map +1 -0
- package/dist/obo.js +478 -0
- package/dist/obo.js.map +1 -0
- package/dist/prefixes.d.ts +49 -0
- package/dist/prefixes.d.ts.map +1 -0
- package/dist/prefixes.js +177 -0
- package/dist/prefixes.js.map +1 -0
- package/dist/rdf.d.ts +46 -0
- package/dist/rdf.d.ts.map +1 -0
- package/dist/rdf.js +1047 -0
- package/dist/rdf.js.map +1 -0
- package/dist/rdfxml.d.ts +8 -0
- package/dist/rdfxml.d.ts.map +1 -0
- package/dist/rdfxml.js +529 -0
- package/dist/rdfxml.js.map +1 -0
- package/dist/shared.d.ts +117 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +355 -0
- package/dist/shared.js.map +1 -0
- package/dist/xml.d.ts +50 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +508 -0
- package/dist/xml.js.map +1 -0
- package/package.json +55 -3
- package/src/detect.ts +435 -0
- package/src/index.ts +296 -0
- package/src/iri.ts +86 -0
- package/src/json.ts +889 -0
- package/src/jsonld.ts +1376 -0
- package/src/obo.ts +557 -0
- package/src/prefixes.ts +195 -0
- package/src/rdf.ts +1181 -0
- package/src/rdfxml.ts +633 -0
- package/src/shared.ts +408 -0
- package/src/xml.ts +550 -0
package/src/obo.ts
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OBO 1.4 flat files → `GraphInput`.
|
|
3
|
+
*
|
|
4
|
+
* `[Term]` and `[Instance]` stanzas become nodes typed `Term` / `Instance`; `[Typedef]` stanzas
|
|
5
|
+
* are not nodes, their `name` labels the edges of that relation. Tags map as follows:
|
|
6
|
+
*
|
|
7
|
+
* id → id · name → label · synonym → aliases + attr `synonyms` (`"text (SCOPE)"`)
|
|
8
|
+
* def → attrs `def` + `def_xrefs` · is_obsolete → deprecated · replaced_by → replacedBy
|
|
9
|
+
* alt_id → replaces + attr `alt_id` · is_a / relationship / intersection_of / union_of /
|
|
10
|
+
* disjoint_from / equivalent_to / instance_of → edges · property_value → attr keyed by property
|
|
11
|
+
* every other tag (namespace, xref, subset, consider, comment, created_by, …) → attr
|
|
12
|
+
*
|
|
13
|
+
* Trailing `! comments` and `{qualifier}` blocks are stripped; OBO escapes are decoded.
|
|
14
|
+
*/
|
|
15
|
+
import type { AttrValue, EdgeInput, GraphInput, NodeInput } from '@kgdiff/core';
|
|
16
|
+
import { PrefixTable, readableName, WELL_KNOWN_PREFIXES } from './prefixes.js';
|
|
17
|
+
import {
|
|
18
|
+
compareStrings,
|
|
19
|
+
type EdgeFields,
|
|
20
|
+
getOwn,
|
|
21
|
+
invalidInput,
|
|
22
|
+
makeEdge,
|
|
23
|
+
makeMeta,
|
|
24
|
+
NodeBuilder,
|
|
25
|
+
setOwn,
|
|
26
|
+
sortEdges,
|
|
27
|
+
sortNodes,
|
|
28
|
+
stripBom,
|
|
29
|
+
typedValue,
|
|
30
|
+
type Warnings,
|
|
31
|
+
} from './shared.js';
|
|
32
|
+
|
|
33
|
+
export interface OboParseOutput {
|
|
34
|
+
readonly graph: GraphInput;
|
|
35
|
+
readonly prefixes: Readonly<Record<string, string>>;
|
|
36
|
+
/** `[Typedef]` names by relation id (`part_of` → “part of”). */
|
|
37
|
+
readonly predicateLabels: Readonly<Record<string, string>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Clause {
|
|
41
|
+
readonly tag: string;
|
|
42
|
+
readonly value: string;
|
|
43
|
+
readonly line: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Stanza {
|
|
47
|
+
readonly kind: string;
|
|
48
|
+
readonly line: number;
|
|
49
|
+
readonly clauses: Clause[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Attributes that are lists by nature: always arrays, even with one value. */
|
|
53
|
+
const LIST_ATTRS: ReadonlySet<string> = new Set([
|
|
54
|
+
'alt_id',
|
|
55
|
+
'consider',
|
|
56
|
+
'def_xrefs',
|
|
57
|
+
'subset',
|
|
58
|
+
'synonyms',
|
|
59
|
+
'xref',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Tags whose single value is the target of an edge with the tag as predicate. */
|
|
63
|
+
const EDGE_TAGS: ReadonlySet<string> = new Set([
|
|
64
|
+
'is_a',
|
|
65
|
+
'union_of',
|
|
66
|
+
'disjoint_from',
|
|
67
|
+
'equivalent_to',
|
|
68
|
+
'instance_of',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const SYNONYM_SCOPES: ReadonlySet<string> = new Set(['EXACT', 'BROAD', 'NARROW', 'RELATED']);
|
|
72
|
+
|
|
73
|
+
/** OBO 1.2 scoped synonym tags. */
|
|
74
|
+
const LEGACY_SYNONYMS: Readonly<Record<string, string>> = {
|
|
75
|
+
exact_synonym: 'EXACT',
|
|
76
|
+
broad_synonym: 'BROAD',
|
|
77
|
+
narrow_synonym: 'NARROW',
|
|
78
|
+
related_synonym: 'RELATED',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Parses OBO text. Throws `KgdiffError('invalid-input')` with the line on malformed input. */
|
|
82
|
+
export function parseObo(text: string, warnings: Warnings): OboParseOutput {
|
|
83
|
+
const { header, stanzas } = readFrames(text);
|
|
84
|
+
|
|
85
|
+
const meta: Record<string, unknown> = {};
|
|
86
|
+
const idSpaces: Record<string, string> = {};
|
|
87
|
+
let defaultNamespace: string | undefined;
|
|
88
|
+
for (const clause of header) {
|
|
89
|
+
const value = splitValue(clause.value, clause.tag).text;
|
|
90
|
+
switch (clause.tag) {
|
|
91
|
+
case 'data-version':
|
|
92
|
+
meta.version = decodeEscapes(value);
|
|
93
|
+
break;
|
|
94
|
+
case 'ontology':
|
|
95
|
+
meta.name = decodeEscapes(value);
|
|
96
|
+
break;
|
|
97
|
+
case 'default-namespace':
|
|
98
|
+
defaultNamespace = decodeEscapes(value);
|
|
99
|
+
break;
|
|
100
|
+
case 'idspace': {
|
|
101
|
+
const [prefix, iri] = tokens(value);
|
|
102
|
+
if (prefix !== undefined && iri !== undefined) setOwn(idSpaces, prefix, iri);
|
|
103
|
+
else
|
|
104
|
+
warnings.add('obo-invalid-idspace', '`idspace` needs a prefix and an IRI.', clause.line);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Typedef names are reported for display; edge labels derive from the relation id alone,
|
|
113
|
+
// so adding or removing a Typedef does not relabel every edge that uses it.
|
|
114
|
+
const relationNames = new Map<string, string>();
|
|
115
|
+
for (const stanza of stanzas) {
|
|
116
|
+
if (stanza.kind !== 'Typedef') continue;
|
|
117
|
+
const id = stanzaId(stanza);
|
|
118
|
+
const name = stanza.clauses.find((clause) => clause.tag === 'name');
|
|
119
|
+
if (name && !relationNames.has(id))
|
|
120
|
+
relationNames.set(id, decodeEscapes(splitValue(name.value, name.tag).text));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const builder = new OboGraphBuilder(warnings);
|
|
124
|
+
for (const stanza of stanzas) {
|
|
125
|
+
switch (stanza.kind) {
|
|
126
|
+
case 'Term':
|
|
127
|
+
case 'Instance':
|
|
128
|
+
builder.addStanza(stanza);
|
|
129
|
+
break;
|
|
130
|
+
case 'Typedef':
|
|
131
|
+
break;
|
|
132
|
+
default:
|
|
133
|
+
warnings.add(
|
|
134
|
+
'obo-unknown-stanza',
|
|
135
|
+
`Skipped unsupported [${stanza.kind}] stanza.`,
|
|
136
|
+
stanza.line,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const prefixes = new PrefixTable([
|
|
142
|
+
['well-known', WELL_KNOWN_PREFIXES],
|
|
143
|
+
['declared', idSpaces],
|
|
144
|
+
]).record;
|
|
145
|
+
const graphMeta = makeMeta(meta);
|
|
146
|
+
const graph: GraphInput = {
|
|
147
|
+
nodes: builder.nodes(defaultNamespace),
|
|
148
|
+
edges: builder.edges(),
|
|
149
|
+
...(graphMeta ? { meta: graphMeta } : {}),
|
|
150
|
+
};
|
|
151
|
+
const predicateLabels: Record<string, string> = {};
|
|
152
|
+
for (const id of [...relationNames.keys()].sort(compareStrings)) {
|
|
153
|
+
setOwn(predicateLabels, id, relationNames.get(id) ?? id);
|
|
154
|
+
}
|
|
155
|
+
return { graph, prefixes, predicateLabels };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Frames ─────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
function readFrames(text: string): { header: Clause[]; stanzas: Stanza[] } {
|
|
161
|
+
const header: Clause[] = [];
|
|
162
|
+
const stanzas: Stanza[] = [];
|
|
163
|
+
let current: Stanza | undefined;
|
|
164
|
+
const lines = stripBom(text).split(/\r\n|\r|\n/);
|
|
165
|
+
for (let index = 0; index < lines.length; index++) {
|
|
166
|
+
const line = (lines[index] ?? '').trim();
|
|
167
|
+
const lineNumber = index + 1;
|
|
168
|
+
if (line === '' || line.startsWith('!')) continue;
|
|
169
|
+
if (line.startsWith('[')) {
|
|
170
|
+
const match = /^\[([^\]]*)\]\s*(?:!.*)?$/.exec(line);
|
|
171
|
+
if (!match) {
|
|
172
|
+
throw invalidInput(
|
|
173
|
+
`Invalid OBO on line ${lineNumber}: malformed stanza header "${clip(line)}".`,
|
|
174
|
+
{
|
|
175
|
+
line: lineNumber,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
current = { kind: (match[1] ?? '').trim(), line: lineNumber, clauses: [] };
|
|
180
|
+
stanzas.push(current);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const match = /^([^\s:]+):[ \t]*(.*)$/.exec(line);
|
|
184
|
+
if (!match) {
|
|
185
|
+
throw invalidInput(
|
|
186
|
+
`Invalid OBO on line ${lineNumber}: expected "tag: value", got "${clip(line)}".`,
|
|
187
|
+
{ line: lineNumber },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const clause = { tag: match[1] ?? '', value: match[2] ?? '', line: lineNumber };
|
|
191
|
+
(current ? current.clauses : header).push(clause);
|
|
192
|
+
}
|
|
193
|
+
return { header, stanzas };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function stanzaId(stanza: Stanza): string {
|
|
197
|
+
const clause = stanza.clauses.find((candidate) => candidate.tag === 'id');
|
|
198
|
+
const id = clause ? tokens(splitValue(clause.value, clause.tag).text)[0] : undefined;
|
|
199
|
+
if (id === undefined) {
|
|
200
|
+
throw invalidInput(`Invalid OBO: [${stanza.kind}] stanza on line ${stanza.line} has no id.`, {
|
|
201
|
+
line: stanza.line,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return id;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 57)}…` : text);
|
|
208
|
+
|
|
209
|
+
// ─── Values ─────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const ESCAPES: Readonly<Record<string, string>> = { n: '\n', t: '\t', W: ' ' };
|
|
212
|
+
|
|
213
|
+
/** Decodes OBO escapes: `\n`, `\t`, `\W` (space); any other `\x` is `x` (`\"`, `\\`, `\!`, …). */
|
|
214
|
+
function decodeEscapes(text: string): string {
|
|
215
|
+
return text.includes('\\')
|
|
216
|
+
? text.replace(/\\([\s\S])/g, (_, c: string) => ESCAPES[c] ?? c)
|
|
217
|
+
: text;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Tags whose values contain quoted strings (`def: "…" […]`, `synonym: "…" SCOPE`,
|
|
222
|
+
* `property_value: p "…" xsd:string`, `xref: X "description"`, header descriptions).
|
|
223
|
+
* Elsewhere a `"` is an ordinary character: `name: 5" pipe ! comment` names a 5" pipe.
|
|
224
|
+
*/
|
|
225
|
+
const QUOTED_TAGS: ReadonlySet<string> = new Set([
|
|
226
|
+
'def',
|
|
227
|
+
'synonym',
|
|
228
|
+
...Object.keys(LEGACY_SYNONYMS),
|
|
229
|
+
'property_value',
|
|
230
|
+
'xref',
|
|
231
|
+
'idspace',
|
|
232
|
+
'subsetdef',
|
|
233
|
+
'synonymtypedef',
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Splits a raw tag value into its text and `{qualifiers}`, dropping a trailing `! comment`.
|
|
238
|
+
* Escaped characters never split; quoted strings protect `!` and `{` only in tags that have
|
|
239
|
+
* them (`QUOTED_TAGS`) and inside qualifier blocks (`{source="a!b"}`).
|
|
240
|
+
*/
|
|
241
|
+
function splitValue(raw: string, tag: string): { text: string; qualifiers: string | undefined } {
|
|
242
|
+
const quotedTag = QUOTED_TAGS.has(tag);
|
|
243
|
+
let quoted = false;
|
|
244
|
+
let inQualifier = false;
|
|
245
|
+
let end = raw.length;
|
|
246
|
+
let lastBrace = -1;
|
|
247
|
+
for (let i = 0; i < raw.length; i++) {
|
|
248
|
+
const c = raw[i];
|
|
249
|
+
if (c === '\\') i++;
|
|
250
|
+
else if (c === '"' && (quotedTag || inQualifier)) quoted = !quoted;
|
|
251
|
+
else if (quoted) continue;
|
|
252
|
+
else if (c === '!') {
|
|
253
|
+
end = i;
|
|
254
|
+
break;
|
|
255
|
+
} else if (c === '{') {
|
|
256
|
+
lastBrace = i;
|
|
257
|
+
inQualifier = true;
|
|
258
|
+
} else if (c === '}') inQualifier = false;
|
|
259
|
+
}
|
|
260
|
+
let text = raw.slice(0, end).trimEnd();
|
|
261
|
+
let qualifiers: string | undefined;
|
|
262
|
+
if (lastBrace >= 0 && lastBrace < end && text.endsWith('}') && !text.endsWith('\\}')) {
|
|
263
|
+
qualifiers = text.slice(lastBrace + 1, -1);
|
|
264
|
+
text = text.slice(0, lastBrace).trimEnd();
|
|
265
|
+
}
|
|
266
|
+
return { text, qualifiers };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Whitespace-separated tokens (escapes respected and decoded). */
|
|
270
|
+
function tokens(text: string): string[] {
|
|
271
|
+
const out: string[] = [];
|
|
272
|
+
let current = '';
|
|
273
|
+
let started = false;
|
|
274
|
+
for (let i = 0; i < text.length; i++) {
|
|
275
|
+
const c = text[i] ?? '';
|
|
276
|
+
if (c === '\\' && i + 1 < text.length) {
|
|
277
|
+
current += c + (text[i + 1] ?? '');
|
|
278
|
+
started = true;
|
|
279
|
+
i++;
|
|
280
|
+
} else if (c === ' ' || c === '\t') {
|
|
281
|
+
if (started) out.push(decodeEscapes(current));
|
|
282
|
+
current = '';
|
|
283
|
+
started = false;
|
|
284
|
+
} else {
|
|
285
|
+
current += c;
|
|
286
|
+
started = true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (started) out.push(decodeEscapes(current));
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Reads a leading quoted string; `undefined` when `text` does not start with a quote. */
|
|
294
|
+
function readQuoted(text: string, clause: Clause): { value: string; rest: string } | undefined {
|
|
295
|
+
if (!text.startsWith('"')) return undefined;
|
|
296
|
+
for (let i = 1; i < text.length; i++) {
|
|
297
|
+
const c = text[i];
|
|
298
|
+
if (c === '\\') i++;
|
|
299
|
+
else if (c === '"')
|
|
300
|
+
return { value: decodeEscapes(text.slice(1, i)), rest: text.slice(i + 1).trim() };
|
|
301
|
+
}
|
|
302
|
+
throw invalidInput(
|
|
303
|
+
`Invalid OBO on line ${clause.line}: unterminated quoted string in \`${clause.tag}\`.`,
|
|
304
|
+
{ line: clause.line },
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Reads a leading `[xref, xref "description" {qualifiers}, …]` list. */
|
|
309
|
+
function readXrefs(text: string, clause: Clause): { xrefs: string[]; rest: string } {
|
|
310
|
+
const trimmed = text.trimStart();
|
|
311
|
+
if (!trimmed.startsWith('[')) return { xrefs: [], rest: trimmed };
|
|
312
|
+
const items: string[] = [];
|
|
313
|
+
let quoted = false;
|
|
314
|
+
let start = 1;
|
|
315
|
+
for (let i = 1; i < trimmed.length; i++) {
|
|
316
|
+
const c = trimmed[i];
|
|
317
|
+
if (c === '\\') i++;
|
|
318
|
+
else if (c === '"') quoted = !quoted;
|
|
319
|
+
else if (!quoted && (c === ',' || c === ']')) {
|
|
320
|
+
items.push(trimmed.slice(start, i));
|
|
321
|
+
start = i + 1;
|
|
322
|
+
if (c === ']') {
|
|
323
|
+
const xrefs = items.map(xrefId).filter((id) => id !== '');
|
|
324
|
+
return { xrefs, rest: trimmed.slice(i + 1).trim() };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
throw invalidInput(
|
|
329
|
+
`Invalid OBO on line ${clause.line}: unterminated xref list in \`${clause.tag}\`.`,
|
|
330
|
+
{
|
|
331
|
+
line: clause.line,
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The id of one xref, without its quoted description or qualifiers. */
|
|
337
|
+
function xrefId(item: string): string {
|
|
338
|
+
const text = item.trim();
|
|
339
|
+
let end = text.length;
|
|
340
|
+
for (let i = 0; i < text.length; i++) {
|
|
341
|
+
const c = text[i];
|
|
342
|
+
if (c === '\\') i++;
|
|
343
|
+
else if (c === ' ' || c === '\t' || c === '"' || c === '{') {
|
|
344
|
+
end = i;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return decodeEscapes(text.slice(0, end));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function xsdLocalName(datatype: string | undefined): string | undefined {
|
|
352
|
+
if (datatype === undefined) return undefined;
|
|
353
|
+
if (datatype.startsWith('xsd:')) return datatype.slice(4);
|
|
354
|
+
const hash = datatype.lastIndexOf('#');
|
|
355
|
+
return datatype.startsWith('http://www.w3.org/2001/XMLSchema#')
|
|
356
|
+
? datatype.slice(hash + 1)
|
|
357
|
+
: undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Graph building ─────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
class OboGraphBuilder {
|
|
363
|
+
private readonly builders = new Map<string, NodeBuilder>();
|
|
364
|
+
private readonly edgeMap = new Map<string, EdgeFields>();
|
|
365
|
+
|
|
366
|
+
constructor(private readonly warnings: Warnings) {}
|
|
367
|
+
|
|
368
|
+
addStanza(stanza: Stanza): void {
|
|
369
|
+
const id = stanzaId(stanza);
|
|
370
|
+
let node = this.builders.get(id);
|
|
371
|
+
if (node) {
|
|
372
|
+
this.warnings.add(
|
|
373
|
+
'obo-duplicate-id',
|
|
374
|
+
`[${stanza.kind}] ${id} is defined more than once; the stanzas were merged.`,
|
|
375
|
+
stanza.line,
|
|
376
|
+
);
|
|
377
|
+
} else {
|
|
378
|
+
node = new NodeBuilder(id);
|
|
379
|
+
this.builders.set(id, node);
|
|
380
|
+
}
|
|
381
|
+
node.types.add(stanza.kind);
|
|
382
|
+
for (const clause of stanza.clauses) this.addClause(node, clause);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
nodes(defaultNamespace: string | undefined): NodeInput[] {
|
|
386
|
+
const nodes: NodeInput[] = [];
|
|
387
|
+
for (const node of this.builders.values()) {
|
|
388
|
+
if (defaultNamespace !== undefined && !node.attrs.has('namespace')) {
|
|
389
|
+
node.attrs.add('namespace', defaultNamespace);
|
|
390
|
+
}
|
|
391
|
+
nodes.push(node.build(LIST_ATTRS));
|
|
392
|
+
}
|
|
393
|
+
return sortNodes(nodes);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
edges(): EdgeInput[] {
|
|
397
|
+
return sortEdges([...this.edgeMap.values()].map(makeEdge));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private addEdge(source: string, predicate: string, target: string, relation?: string): void {
|
|
401
|
+
const key = JSON.stringify([source, predicate, target, relation ?? null]);
|
|
402
|
+
if (this.edgeMap.has(key)) return;
|
|
403
|
+
const label =
|
|
404
|
+
relation === undefined
|
|
405
|
+
? readableName(predicate)
|
|
406
|
+
: `${readableName(predicate)} (${readableName(relation)})`;
|
|
407
|
+
this.edgeMap.set(key, {
|
|
408
|
+
source,
|
|
409
|
+
target,
|
|
410
|
+
predicate,
|
|
411
|
+
label,
|
|
412
|
+
...(relation !== undefined ? { attrs: { relation } } : {}),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private addClause(node: NodeBuilder, clause: Clause): void {
|
|
417
|
+
const { tag } = clause;
|
|
418
|
+
const { text } = splitValue(clause.value, tag);
|
|
419
|
+
const [first, second] = tokens(text);
|
|
420
|
+
switch (tag) {
|
|
421
|
+
case 'id':
|
|
422
|
+
return;
|
|
423
|
+
case 'name': {
|
|
424
|
+
const name = decodeEscapes(text);
|
|
425
|
+
if (node.label === undefined) node.label = name;
|
|
426
|
+
else if (node.label !== name) {
|
|
427
|
+
node.aliases.add(name);
|
|
428
|
+
this.warnings.add(
|
|
429
|
+
'obo-multiple-names',
|
|
430
|
+
`${node.id} has more than one name.`,
|
|
431
|
+
clause.line,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
case 'def': {
|
|
437
|
+
const quoted = readQuoted(text, clause);
|
|
438
|
+
if (!quoted) {
|
|
439
|
+
node.attrs.add('def', decodeEscapes(text));
|
|
440
|
+
this.warnings.add(
|
|
441
|
+
'obo-unquoted-def',
|
|
442
|
+
`Definition of ${node.id} is not quoted.`,
|
|
443
|
+
clause.line,
|
|
444
|
+
);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
node.attrs.add('def', quoted.value);
|
|
448
|
+
for (const xref of readXrefs(quoted.rest, clause).xrefs) node.attrs.add('def_xrefs', xref);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
case 'synonym':
|
|
452
|
+
case 'exact_synonym':
|
|
453
|
+
case 'broad_synonym':
|
|
454
|
+
case 'narrow_synonym':
|
|
455
|
+
case 'related_synonym': {
|
|
456
|
+
const quoted = readQuoted(text, clause);
|
|
457
|
+
const synonym = quoted ? quoted.value : decodeEscapes(text);
|
|
458
|
+
const scopeToken = quoted ? tokens(quoted.rest.replace(/\[[\s\S]*$/, ''))[0] : undefined;
|
|
459
|
+
const scope =
|
|
460
|
+
getOwn(LEGACY_SYNONYMS, tag) ??
|
|
461
|
+
(scopeToken !== undefined && SYNONYM_SCOPES.has(scopeToken) ? scopeToken : 'RELATED');
|
|
462
|
+
if (synonym === '') return;
|
|
463
|
+
node.aliases.add(synonym);
|
|
464
|
+
node.attrs.add('synonyms', `${synonym} (${scope})`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
case 'relationship':
|
|
468
|
+
if (first === undefined || second === undefined) {
|
|
469
|
+
this.warnings.add(
|
|
470
|
+
'obo-invalid-relationship',
|
|
471
|
+
'relationship needs a relation and a target.',
|
|
472
|
+
clause.line,
|
|
473
|
+
);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.addEdge(node.id, first, second);
|
|
477
|
+
return;
|
|
478
|
+
case 'intersection_of':
|
|
479
|
+
if (first === undefined) {
|
|
480
|
+
this.missingValue(clause);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (second === undefined) this.addEdge(node.id, tag, first);
|
|
484
|
+
else this.addEdge(node.id, tag, second, first);
|
|
485
|
+
return;
|
|
486
|
+
case 'is_obsolete':
|
|
487
|
+
if (first === 'true') node.deprecated = true;
|
|
488
|
+
return;
|
|
489
|
+
case 'replaced_by':
|
|
490
|
+
if (first === undefined) {
|
|
491
|
+
this.missingValue(clause);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
node.replacedBy.add(first);
|
|
495
|
+
return;
|
|
496
|
+
case 'alt_id':
|
|
497
|
+
if (first === undefined) {
|
|
498
|
+
this.missingValue(clause);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
node.replaces.add(first);
|
|
502
|
+
node.attrs.add('alt_id', first);
|
|
503
|
+
return;
|
|
504
|
+
case 'consider':
|
|
505
|
+
case 'subset':
|
|
506
|
+
if (first === undefined) {
|
|
507
|
+
this.missingValue(clause);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
node.attrs.add(tag, first);
|
|
511
|
+
return;
|
|
512
|
+
case 'xref': {
|
|
513
|
+
const id = xrefId(text);
|
|
514
|
+
if (id === '') {
|
|
515
|
+
this.missingValue(clause);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
node.attrs.add('xref', id);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
case 'property_value':
|
|
522
|
+
this.addPropertyValue(node, text, clause);
|
|
523
|
+
return;
|
|
524
|
+
default:
|
|
525
|
+
if (EDGE_TAGS.has(tag)) {
|
|
526
|
+
if (first === undefined) {
|
|
527
|
+
this.missingValue(clause);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
this.addEdge(node.id, tag, first);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// namespace, comment, created_by, creation_date and any other tag: kept verbatim.
|
|
534
|
+
node.attrs.add(tag, decodeEscapes(text));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** `property_value: prop "text" xsd:type` or `property_value: prop target-id`. */
|
|
539
|
+
private addPropertyValue(node: NodeBuilder, text: string, clause: Clause): void {
|
|
540
|
+
const space = text.search(/[ \t]/);
|
|
541
|
+
const property = space < 0 ? undefined : decodeEscapes(text.slice(0, space));
|
|
542
|
+
const rest = space < 0 ? '' : text.slice(space).trim();
|
|
543
|
+
let value: AttrValue | undefined;
|
|
544
|
+
const quoted = readQuoted(rest, clause);
|
|
545
|
+
if (quoted) value = typedValue(quoted.value, xsdLocalName(tokens(quoted.rest)[0]));
|
|
546
|
+
else value = tokens(rest)[0];
|
|
547
|
+
if (property === undefined || value === undefined) {
|
|
548
|
+
this.missingValue(clause);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
node.attrs.add(property, value);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private missingValue(clause: Clause): void {
|
|
555
|
+
this.warnings.add('obo-missing-value', `\`${clause.tag}\` has no value.`, clause.line);
|
|
556
|
+
}
|
|
557
|
+
}
|