glossarist 0.3.4 → 0.3.6
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 +1 -1
- package/src/concept-collection.js +25 -25
- package/src/gcr-reader.js +32 -2
- package/src/index.d.ts +10 -0
- package/src/index.js +2 -0
- package/src/models/concept-source.js +2 -0
- package/src/models/concept.js +44 -0
- package/src/reference-mention.js +82 -0
- package/src/reference-resolver.js +290 -26
- package/src/render-classification.js +106 -0
- package/src/validators/concept-validator.js +51 -44
- package/src/validators/gcr-validator.js +6 -6
- package/src/validators/index.js +4 -1
- package/src/validators/register-validator.js +7 -11
- package/src/validators/relationship-type-rule.js +15 -16
- package/src/validators/v3-rules.js +171 -107
- package/src/validators/validation-error.js +4 -0
- package/src/validators/validation-result.js +29 -11
- package/src/validators/validation-rule.js +7 -5
package/package.json
CHANGED
|
@@ -5,7 +5,6 @@ const _items = Symbol('items');
|
|
|
5
5
|
export class ConceptCollection {
|
|
6
6
|
constructor(concepts = []) {
|
|
7
7
|
this[_items] = Array.from(concepts);
|
|
8
|
-
return new Proxy(this, _handler);
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
get length() { return this[_items].length; }
|
|
@@ -23,10 +22,35 @@ export class ConceptCollection {
|
|
|
23
22
|
splice(...args) { return this[_items].splice(...args); }
|
|
24
23
|
set(index, item) { this[_items][index] = item; }
|
|
25
24
|
|
|
25
|
+
toArray() { return [...this[_items]]; }
|
|
26
|
+
|
|
26
27
|
byId(id) {
|
|
27
28
|
return this[_items].find(c => c.id === id || c.termid === id);
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Find a concept by id and version.
|
|
33
|
+
*
|
|
34
|
+
* The version is matched against a top-level `version` field on
|
|
35
|
+
* the concept instance (e.g. `concept.version`). The deployment
|
|
36
|
+
* is responsible for setting this field on bibliographic records;
|
|
37
|
+
* the data model does not enforce its presence or type.
|
|
38
|
+
*
|
|
39
|
+
* If `version` is null, the method falls back to `byId(id)`. This
|
|
40
|
+
* supports the "version-agnostic" lookup for datasets where
|
|
41
|
+
* version is not tracked.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} id
|
|
44
|
+
* @param {string | null} version
|
|
45
|
+
* @returns {Concept | null}
|
|
46
|
+
*/
|
|
47
|
+
byIdAnd(id, version) {
|
|
48
|
+
if (version == null) return this.byId(id);
|
|
49
|
+
return this[_items].find(c =>
|
|
50
|
+
(c.id === id || c.termid === id) && c.version === version
|
|
51
|
+
) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
30
54
|
byPrefix(prefix) {
|
|
31
55
|
return new ConceptCollection(this[_items].filter(c => c.id.startsWith(prefix)));
|
|
32
56
|
}
|
|
@@ -90,27 +114,3 @@ export class ConceptCollection {
|
|
|
90
114
|
slice(...args) { return new ConceptCollection(this[_items].slice(...args)); }
|
|
91
115
|
concat(...args) { return new ConceptCollection(this[_items].concat(...args)); }
|
|
92
116
|
}
|
|
93
|
-
|
|
94
|
-
const _handler = {
|
|
95
|
-
get(target, prop, receiver) {
|
|
96
|
-
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
|
|
97
|
-
return target[_items][Number(prop)];
|
|
98
|
-
}
|
|
99
|
-
if (prop === 'length') return target[_items].length;
|
|
100
|
-
const value = Reflect.get(target, prop, receiver);
|
|
101
|
-
return typeof value === 'function' ? value.bind(target) : value;
|
|
102
|
-
},
|
|
103
|
-
set(target, prop, value) {
|
|
104
|
-
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
|
|
105
|
-
target[_items][Number(prop)] = value;
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
return Reflect.set(target, prop, value);
|
|
109
|
-
},
|
|
110
|
-
has(target, prop) {
|
|
111
|
-
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
|
|
112
|
-
return Number(prop) in target[_items];
|
|
113
|
-
}
|
|
114
|
-
return Reflect.has(target, prop);
|
|
115
|
-
},
|
|
116
|
-
};
|
package/src/gcr-reader.js
CHANGED
|
@@ -367,12 +367,42 @@ export class GcrPackage {
|
|
|
367
367
|
return map;
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
-
/**
|
|
371
|
-
|
|
370
|
+
/**
|
|
371
|
+
* Read a text file from the package.
|
|
372
|
+
* @param {string} filePath
|
|
373
|
+
* @returns {Promise<string | null>} null if the file doesn't exist
|
|
374
|
+
*/
|
|
375
|
+
async readText(filePath) {
|
|
372
376
|
const entry = this._zip.file(filePath);
|
|
373
377
|
if (!entry) return null;
|
|
374
378
|
return entry.async('text');
|
|
375
379
|
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* List all entry paths in the package.
|
|
383
|
+
* @returns {Array<{ path: string, dir: boolean }>}
|
|
384
|
+
*/
|
|
385
|
+
entryPaths() {
|
|
386
|
+
const entries = [];
|
|
387
|
+
this._zip.forEach((relativePath, entry) => {
|
|
388
|
+
entries.push({ path: relativePath, dir: !!entry.dir });
|
|
389
|
+
});
|
|
390
|
+
return entries;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Check whether a specific entry exists in the package.
|
|
395
|
+
* @param {string} filePath
|
|
396
|
+
* @returns {boolean}
|
|
397
|
+
*/
|
|
398
|
+
hasEntry(filePath) {
|
|
399
|
+
return this._zip.file(filePath) != null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** @private @deprecated Use readText instead */
|
|
403
|
+
async _readText(filePath) {
|
|
404
|
+
return this.readText(filePath);
|
|
405
|
+
}
|
|
376
406
|
}
|
|
377
407
|
|
|
378
408
|
// --- Concept YAML parsing ---
|
package/src/index.d.ts
CHANGED
|
@@ -34,6 +34,16 @@ export { conceptUuid, localizedConceptUuid, uuidV5 } from './uuid';
|
|
|
34
34
|
// Reference resolution
|
|
35
35
|
export { ReferenceResolver, Reference, referenceResolver } from './reference-resolver';
|
|
36
36
|
|
|
37
|
+
export type MentionParseResult = {
|
|
38
|
+
kind: 'cite-ref' | 'numeric' | 'designation' | 'unresolved';
|
|
39
|
+
key?: string;
|
|
40
|
+
label?: string | null;
|
|
41
|
+
id?: string;
|
|
42
|
+
raw: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function parseMention(raw: string): MentionParseResult;
|
|
46
|
+
|
|
37
47
|
// V1 support
|
|
38
48
|
export { V1Reader, migrateV1ToV2 } from './v1-reader';
|
|
39
49
|
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,8 @@ export { ManagedConceptCollection } from './managed-concept-collection.js';
|
|
|
8
8
|
export { validateConcept, validateRegister, validateGcrPackage, createConceptValidator, ValidationError, ValidationRule, ValidationResult, RegisterValidator, GcrValidator } from './validators/index.js';
|
|
9
9
|
export { conceptUuid, localizedConceptUuid, uuidV5 } from './uuid.js';
|
|
10
10
|
export { ReferenceResolver, Reference, referenceResolver } from './reference-resolver.js';
|
|
11
|
+
export { parseMention } from './reference-mention.js';
|
|
12
|
+
export { ReferenceClassifier } from './render-classification.js';
|
|
11
13
|
export { V1Reader, migrateV1ToV2 } from './v1-reader.js';
|
|
12
14
|
export { GlossaristError, InvalidInputError, YamlParseError } from './errors.js';
|
|
13
15
|
|
|
@@ -4,6 +4,7 @@ import { Citation } from './citation.js';
|
|
|
4
4
|
export class ConceptSource extends GlossaristModel {
|
|
5
5
|
constructor(data = {}) {
|
|
6
6
|
super();
|
|
7
|
+
this.id = data.id ?? null;
|
|
7
8
|
this.status = data.status ?? null;
|
|
8
9
|
this.type = data.type ?? null;
|
|
9
10
|
this.origin = data.origin
|
|
@@ -14,6 +15,7 @@ export class ConceptSource extends GlossaristModel {
|
|
|
14
15
|
|
|
15
16
|
toJSON() {
|
|
16
17
|
const obj = {};
|
|
18
|
+
if (this.id != null) obj.id = this.id;
|
|
17
19
|
if (this.status != null) obj.status = this.status;
|
|
18
20
|
if (this.type != null) obj.type = this.type;
|
|
19
21
|
if (this.origin != null) obj.origin = this.origin.toJSON();
|
package/src/models/concept.js
CHANGED
|
@@ -30,6 +30,11 @@ export class Concept extends GlossaristModel {
|
|
|
30
30
|
return Object.keys(this._rawLocalizations);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
get languageCodes() {
|
|
34
|
+
return this.languages;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @deprecated Use localization(lang) for model access, or toJSON().localizations for raw data */
|
|
33
38
|
get localizations() {
|
|
34
39
|
return this._rawLocalizations;
|
|
35
40
|
}
|
|
@@ -68,6 +73,45 @@ export class Concept extends GlossaristModel {
|
|
|
68
73
|
return lang in this._rawLocalizations;
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Find a source by its local id within this concept.
|
|
78
|
+
*
|
|
79
|
+
* The lookup walks concept-level, localization-level, and
|
|
80
|
+
* designation-level sources in that order, returning the first
|
|
81
|
+
* source whose `id` matches. Sources without an `id` are
|
|
82
|
+
* skipped. The id is local to the concept; uniqueness is
|
|
83
|
+
* enforced by the validator (see CiteRefIntegrityRule).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} id
|
|
86
|
+
* @returns {ConceptSource | null}
|
|
87
|
+
*/
|
|
88
|
+
findSourceById(id) {
|
|
89
|
+
if (typeof id !== 'string' || id.length === 0) return null;
|
|
90
|
+
|
|
91
|
+
// 1. Concept-level sources.
|
|
92
|
+
for (const source of this.sources) {
|
|
93
|
+
if (source.id === id) return source;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Localization-level sources.
|
|
97
|
+
for (const lang of this.languages) {
|
|
98
|
+
const lc = this.localization(lang);
|
|
99
|
+
if (!lc) continue;
|
|
100
|
+
for (const source of lc.sources) {
|
|
101
|
+
if (source.id === id) return source;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Designation-level sources.
|
|
105
|
+
for (const designation of lc.terms) {
|
|
106
|
+
for (const source of designation.sources) {
|
|
107
|
+
if (source.id === id) return source;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
71
115
|
toJSON() {
|
|
72
116
|
const obj = { id: this.id };
|
|
73
117
|
if (this.term != null) obj.term = this.term;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention parser for {{...}} inline references in concept text.
|
|
3
|
+
*
|
|
4
|
+
* Pure function: takes a raw mention body (the text inside
|
|
5
|
+
* {{...}}) and returns a structured MentionParseResult.
|
|
6
|
+
*
|
|
7
|
+
* Convention: the ID always comes first, the display (render) text
|
|
8
|
+
* always comes last. Every comma-separated form follows this:
|
|
9
|
+
*
|
|
10
|
+
* {{id}} bare ID
|
|
11
|
+
* {{id, display text}} ID + display text
|
|
12
|
+
* {{cite:key}} cite-key (source id)
|
|
13
|
+
* {{cite:key, display text}} cite-key + display text
|
|
14
|
+
*
|
|
15
|
+
* @typedef {Object} MentionParseResult
|
|
16
|
+
* @property {'cite-ref' | 'numeric' | 'designation' | 'unresolved'} kind
|
|
17
|
+
* @property {string} [key] — for 'cite-ref': the local key
|
|
18
|
+
* @property {string} [label] — display text (always last)
|
|
19
|
+
* @property {string} [id] — for 'numeric' / 'designation': the id
|
|
20
|
+
* @property {string} raw — the original mention body
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const NUMERIC_RE = /^\d+(?:[.-]\d+)+$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse the body of a {{...}} mention (without the braces).
|
|
27
|
+
*
|
|
28
|
+
* The function is pure: no I/O, no model lookups, no state.
|
|
29
|
+
* Resolution of the parsed result is the extractor's job.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} raw — the trimmed text inside {{...}}
|
|
32
|
+
* @returns {MentionParseResult}
|
|
33
|
+
*/
|
|
34
|
+
export function parseMention(raw) {
|
|
35
|
+
const body = raw.trim();
|
|
36
|
+
|
|
37
|
+
// 1. cite:<key>[,display] — explicit citation reference.
|
|
38
|
+
// Key is the source id; display text is optional.
|
|
39
|
+
const citeMatch = body.match(/^cite:([^,}]+)(?:,(.*))?$/);
|
|
40
|
+
if (citeMatch) {
|
|
41
|
+
const label = citeMatch[2] !== undefined ? unquoteLabel(citeMatch[2].trim()) : null;
|
|
42
|
+
return {
|
|
43
|
+
kind: 'cite-ref',
|
|
44
|
+
key: citeMatch[1].trim(),
|
|
45
|
+
label,
|
|
46
|
+
raw: body,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. Comma-separated form: {{id, display}}.
|
|
51
|
+
// ID always comes first, display text always comes last.
|
|
52
|
+
const commaIdx = body.indexOf(',');
|
|
53
|
+
if (commaIdx !== -1) {
|
|
54
|
+
const id = body.slice(0, commaIdx).trim();
|
|
55
|
+
const label = unquoteLabel(body.slice(commaIdx + 1).trim());
|
|
56
|
+
|
|
57
|
+
if (NUMERIC_RE.test(id)) {
|
|
58
|
+
return { kind: 'numeric', id, label, raw: body };
|
|
59
|
+
}
|
|
60
|
+
return { kind: 'designation', id, label, raw: body };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Bare numeric id.
|
|
64
|
+
if (NUMERIC_RE.test(body)) {
|
|
65
|
+
return { kind: 'numeric', id: body, label: null, raw: body };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 4. Anything else is unresolved at the parse layer.
|
|
69
|
+
return { kind: 'unresolved', raw: body };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip surrounding double quotes from a label, unescaping
|
|
74
|
+
* CSV-style "" to a single ". If the input is not quoted,
|
|
75
|
+
* return it unchanged.
|
|
76
|
+
*/
|
|
77
|
+
function unquoteLabel(label) {
|
|
78
|
+
if (label.length >= 2 && label.startsWith('"') && label.endsWith('"')) {
|
|
79
|
+
return label.slice(1, -1).replace(/""/g, '"');
|
|
80
|
+
}
|
|
81
|
+
return label;
|
|
82
|
+
}
|
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import { ConceptRef } from './models/concept-ref.js';
|
|
2
|
+
import { parseMention } from './reference-mention.js';
|
|
2
3
|
|
|
3
4
|
export class Reference {
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} type — the structural kind of the reference
|
|
7
|
+
* ('concept', 'dataset', 'bibliography', 'typed-ref',
|
|
8
|
+
* 'standard').
|
|
9
|
+
* @param {string | null} target — the legacy flat display
|
|
10
|
+
* string. Kept for backward compat with callers that only
|
|
11
|
+
* read `r.target`.
|
|
12
|
+
* @param {string | null} [relationship] — the type of the
|
|
13
|
+
* relationship that produced this reference (e.g. 'see',
|
|
14
|
+
* 'supersedes', 'source').
|
|
15
|
+
* @param {string | null} [source] — a JSON-pointer-ish path
|
|
16
|
+
* indicating where in the concept the reference was
|
|
17
|
+
* extracted from.
|
|
18
|
+
* @param {object} [extras] — additional fields (v8+):
|
|
19
|
+
* `citation`, `sourceId`, `resolution`, `lookupKey`,
|
|
20
|
+
* `label`, `quoted`, `uri`. All optional.
|
|
21
|
+
*/
|
|
22
|
+
constructor(type, target, relationship, source, extras = {}) {
|
|
5
23
|
this.type = type;
|
|
6
24
|
this.target = target;
|
|
7
25
|
this.relationship = relationship ?? null;
|
|
8
26
|
this.source = source ?? null;
|
|
27
|
+
|
|
28
|
+
this.uri = extras.uri ?? null;
|
|
29
|
+
this.citation = extras.citation ?? null;
|
|
30
|
+
this.sourceId = extras.sourceId ?? null;
|
|
31
|
+
this.resolution = extras.resolution ?? null;
|
|
32
|
+
this.lookupKey = extras.lookupKey ?? null;
|
|
9
33
|
}
|
|
10
34
|
}
|
|
11
35
|
|
|
@@ -18,6 +42,21 @@ function refTarget(rc) {
|
|
|
18
42
|
}
|
|
19
43
|
|
|
20
44
|
export class ReferenceResolver {
|
|
45
|
+
/**
|
|
46
|
+
* Extract all embedded references from a concept's localizations.
|
|
47
|
+
*
|
|
48
|
+
* Walks definitions, notes, examples, and annotations text.
|
|
49
|
+
* For each `{{...}}` mention, runs `parseMention` to
|
|
50
|
+
* classify the form, then dispatches:
|
|
51
|
+
* - 'cite-ref' → look up the key in concept.sources; emit
|
|
52
|
+
* Bibliography Reference with the Citation.
|
|
53
|
+
* - 'numeric' → emit Concept Reference with the bare id
|
|
54
|
+
* (existing behavior).
|
|
55
|
+
* - 'unresolved' → do not emit a Reference.
|
|
56
|
+
*
|
|
57
|
+
* @param {Concept} concept
|
|
58
|
+
* @returns {Reference[]}
|
|
59
|
+
*/
|
|
21
60
|
extractReferences(concept) {
|
|
22
61
|
const refs = [];
|
|
23
62
|
|
|
@@ -42,47 +81,272 @@ export class ReferenceResolver {
|
|
|
42
81
|
}
|
|
43
82
|
}
|
|
44
83
|
|
|
45
|
-
const texts =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
refs.push(..._extractEmbedded(text));
|
|
84
|
+
const texts = this._collectTexts(lc, lang);
|
|
85
|
+
for (const { text, source } of texts) {
|
|
86
|
+
for (const ref of this._extractFromText(text, source, concept)) {
|
|
87
|
+
refs.push(ref);
|
|
88
|
+
}
|
|
51
89
|
}
|
|
52
90
|
}
|
|
53
91
|
|
|
54
92
|
return refs;
|
|
55
93
|
}
|
|
56
94
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Collect all text fields from a localized concept, paired
|
|
97
|
+
* with diagnostic source paths.
|
|
98
|
+
*
|
|
99
|
+
* @param {LocalizedConcept} lc
|
|
100
|
+
* @param {string} lang
|
|
101
|
+
* @returns {{text: string, source: string}[]}
|
|
102
|
+
*/
|
|
103
|
+
_collectTexts(lc, lang) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (let i = 0; (lc.definitions ?? [])[i]; i++) {
|
|
106
|
+
const content = lc.definitions[i]?.content;
|
|
107
|
+
if (typeof content === 'string') {
|
|
108
|
+
out.push({ text: content, source: `localizations.${lang}.definitions[${i}].content` });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (let i = 0; (lc.notes ?? [])[i]; i++) {
|
|
112
|
+
const content = typeof lc.notes[i] === 'object'
|
|
113
|
+
? (lc.notes[i]?.content ?? '')
|
|
114
|
+
: String(lc.notes[i] ?? '');
|
|
115
|
+
if (content) {
|
|
116
|
+
out.push({ text: content, source: `localizations.${lang}.notes[${i}].content` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (let i = 0; (lc.examples ?? [])[i]; i++) {
|
|
120
|
+
const content = lc.examples[i]?.content;
|
|
121
|
+
if (typeof content === 'string') {
|
|
122
|
+
out.push({ text: content, source: `localizations.${lang}.examples[${i}].content` });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (let i = 0; (lc.annotations ?? [])[i]; i++) {
|
|
126
|
+
const content = lc.annotations[i]?.content;
|
|
127
|
+
if (typeof content === 'string') {
|
|
128
|
+
out.push({ text: content, source: `localizations.${lang}.annotations[${i}].content` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Walk a single text string and emit References for each
|
|
136
|
+
* `{{...}}` mention.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} text
|
|
139
|
+
* @param {string} source — diagnostic path
|
|
140
|
+
* @param {Concept} concept — the owning concept (for cite-ref lookup)
|
|
141
|
+
* @returns {Reference[]}
|
|
142
|
+
*/
|
|
143
|
+
_extractFromText(text, source, concept) {
|
|
144
|
+
const refs = [];
|
|
145
|
+
const re = /\{\{([^{}]*?)\}\}/g;
|
|
146
|
+
let m;
|
|
147
|
+
while ((m = re.exec(text)) !== null) {
|
|
148
|
+
const parsed = parseMention(m[1]);
|
|
149
|
+
switch (parsed.kind) {
|
|
150
|
+
case 'cite-ref':
|
|
151
|
+
refs.push(this._resolveCiteRef(parsed, source, concept));
|
|
152
|
+
break;
|
|
153
|
+
case 'numeric':
|
|
154
|
+
refs.push(new Reference('concept', parsed.label ?? parsed.id, 'embedded', source, {
|
|
155
|
+
lookupKey: { id: parsed.id },
|
|
156
|
+
}));
|
|
157
|
+
break;
|
|
158
|
+
case 'designation':
|
|
159
|
+
refs.push(new Reference('concept', parsed.label ?? parsed.id, 'embedded', source, {
|
|
160
|
+
lookupKey: { designation: parsed.id },
|
|
161
|
+
}));
|
|
162
|
+
break;
|
|
163
|
+
case 'unresolved':
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return refs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve a `cite-ref` parser result against the concept's
|
|
172
|
+
* sources list. Emits a Bibliography Reference with the
|
|
173
|
+
* resolved Citation (if found) or an unresolved Reference
|
|
174
|
+
* (if not).
|
|
175
|
+
*
|
|
176
|
+
* @param {MentionParseResult} parsed
|
|
177
|
+
* @param {string} source — diagnostic path
|
|
178
|
+
* @param {Concept} concept — the owning concept
|
|
179
|
+
* @returns {Reference}
|
|
180
|
+
*/
|
|
181
|
+
_resolveCiteRef(parsed, source, concept) {
|
|
182
|
+
const sourceEntry = concept?.findSourceById(parsed.key) ?? null;
|
|
183
|
+
if (!sourceEntry) {
|
|
184
|
+
return new Reference(
|
|
185
|
+
'bibliography',
|
|
186
|
+
parsed.label ?? parsed.key,
|
|
187
|
+
null,
|
|
188
|
+
source,
|
|
189
|
+
{
|
|
190
|
+
sourceId: parsed.key,
|
|
191
|
+
citation: null,
|
|
192
|
+
resolution: { kind: 'unresolved', reason: 'no-source' },
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const displayTarget = parsed.label
|
|
197
|
+
?? sourceEntry.origin?.toString()
|
|
198
|
+
?? sourceEntry.id;
|
|
199
|
+
return new Reference(
|
|
200
|
+
'bibliography',
|
|
201
|
+
displayTarget,
|
|
202
|
+
null,
|
|
203
|
+
source,
|
|
204
|
+
{
|
|
205
|
+
sourceId: sourceEntry.id,
|
|
206
|
+
citation: sourceEntry.origin,
|
|
207
|
+
resolution: { kind: 'resolved', sourceId: sourceEntry.id },
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve a single reference against a registry (a map of
|
|
214
|
+
* datasetId → { concepts, register? }). The registry may also
|
|
215
|
+
* include 'bibliography:<source>' keys for bibliographic
|
|
216
|
+
* datasets.
|
|
217
|
+
*
|
|
218
|
+
* For a `type: 'bibliography'` Reference with an inline
|
|
219
|
+
* `citation`, the resolver first tries the bibliography
|
|
220
|
+
* registry (matching `citation.ref` by source/id/version);
|
|
221
|
+
* if not found, returns the inline Citation as a
|
|
222
|
+
* self-contained fallback.
|
|
223
|
+
*
|
|
224
|
+
* For a `type: 'bibliography'` Reference with a `uri` and
|
|
225
|
+
* `resolution.kind === 'bibliography-namespace'`, the
|
|
226
|
+
* resolver tries the bibliography registry by
|
|
227
|
+
* `resolution.source/id/version`.
|
|
228
|
+
*
|
|
229
|
+
* For `type: 'concept'` References with a `lookupKey.id`
|
|
230
|
+
* (id-match, short-id, or numeric), the resolver looks up
|
|
231
|
+
* the id in `lookupKey.dataset`'s ConceptCollection.
|
|
232
|
+
*
|
|
233
|
+
* Backward compat: when the second argument is a
|
|
234
|
+
* ConceptCollection (has `byId` but no `concepts` field), it
|
|
235
|
+
* is treated as a one-key registry of one default dataset.
|
|
236
|
+
*/
|
|
237
|
+
resolveReference(ref, registry) {
|
|
238
|
+
if (ref == null) return null;
|
|
239
|
+
|
|
240
|
+
// Backward-compat: single ConceptCollection becomes a
|
|
241
|
+
// one-key registry.
|
|
242
|
+
if (isConceptCollection(registry)) {
|
|
243
|
+
registry = { _default: { concepts: registry } };
|
|
244
|
+
}
|
|
245
|
+
if (registry == null) return null;
|
|
246
|
+
|
|
247
|
+
// 1. cite:key form (Bibliography with inline Citation).
|
|
248
|
+
if (ref.type === 'bibliography' && ref.citation) {
|
|
249
|
+
const bioRecord = this._resolveBibliographyRecord(
|
|
250
|
+
ref.citation.ref,
|
|
251
|
+
registry,
|
|
252
|
+
);
|
|
253
|
+
if (bioRecord) return bioRecord;
|
|
254
|
+
return ref.citation;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 2. URI form (urn:... or https:...) with
|
|
258
|
+
// bibliography-namespace resolution.
|
|
259
|
+
if (ref.uri) {
|
|
260
|
+
if (ref.resolution?.kind === 'bibliography-namespace'
|
|
261
|
+
|| (ref.resolution?.source && !ref.resolution?.datasetId)) {
|
|
262
|
+
const bioRecord = this._resolveBibliographyRecord(
|
|
263
|
+
ref.resolution,
|
|
264
|
+
registry,
|
|
265
|
+
);
|
|
266
|
+
if (bioRecord) return bioRecord;
|
|
267
|
+
}
|
|
268
|
+
// Concept URI lookup (for non-bibliography URIs).
|
|
269
|
+
if (ref.resolution?.datasetId) {
|
|
270
|
+
const coll = registry[ref.resolution.datasetId]?.concepts;
|
|
271
|
+
if (coll) {
|
|
272
|
+
return coll.byId(ref.resolution.conceptId);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 3. Same-dataset concept id (numeric mention, id-match, etc.).
|
|
279
|
+
if (ref.lookupKey?.id) {
|
|
280
|
+
const coll = registry[ref.lookupKey.dataset]?.concepts;
|
|
281
|
+
if (coll) return coll.byId(ref.lookupKey.id);
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 3b. Backward-compat: a concept ref with a `target` (id)
|
|
286
|
+
// but no `lookupKey` is looked up in the single
|
|
287
|
+
// collection (backward-compat one-key registry).
|
|
288
|
+
if (ref.type === 'concept' && ref.target) {
|
|
289
|
+
const defaultColl = registry._default?.concepts;
|
|
290
|
+
if (defaultColl) return defaultColl.byId(ref.target);
|
|
291
|
+
// Try every dataset in the registry as a fallback.
|
|
292
|
+
for (const entry of Object.values(registry)) {
|
|
293
|
+
if (entry?.concepts?.byId(ref.target)) {
|
|
294
|
+
return entry.concepts.byId(ref.target);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 4. Unanchored designation — search is a separate concern
|
|
301
|
+
// (plan 06). For v8, return null.
|
|
302
|
+
if (ref.lookupKey?.designation) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Try to resolve a Citation::Ref against the bibliography
|
|
311
|
+
* registry. The ref has shape { source, id, version? }.
|
|
312
|
+
*
|
|
313
|
+
* Returns the matching bibliographic record (a Concept), or
|
|
314
|
+
* null if no match.
|
|
315
|
+
*/
|
|
316
|
+
_resolveBibliographyRecord(citationRef, registry) {
|
|
317
|
+
if (!citationRef?.source || !citationRef?.id) return null;
|
|
318
|
+
const bioColl = registry[`bibliography:${citationRef.source}`]?.concepts;
|
|
319
|
+
if (!bioColl) return null;
|
|
320
|
+
if (citationRef.version) {
|
|
321
|
+
return bioColl.byIdAnd(citationRef.id, citationRef.version);
|
|
322
|
+
}
|
|
323
|
+
return bioColl.byId(citationRef.id);
|
|
60
324
|
}
|
|
61
325
|
|
|
62
|
-
resolveAll(concept,
|
|
326
|
+
resolveAll(concept, registry) {
|
|
63
327
|
const resolved = new Map();
|
|
64
328
|
for (const ref of this.extractReferences(concept)) {
|
|
65
|
-
if (ref.type === 'concept') {
|
|
66
|
-
|
|
329
|
+
if (ref.type === 'concept' || ref.type === 'bibliography') {
|
|
330
|
+
const target = this.resolveReference(ref, registry);
|
|
331
|
+
if (target != null) {
|
|
332
|
+
const key = ref.target ?? ref.uri ?? ref.sourceId;
|
|
333
|
+
if (key != null) resolved.set(key, target);
|
|
334
|
+
}
|
|
67
335
|
}
|
|
68
336
|
}
|
|
69
337
|
return resolved;
|
|
70
338
|
}
|
|
71
339
|
}
|
|
72
340
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
refs.push(new Reference('concept', target, 'embedded', null));
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return refs;
|
|
341
|
+
/**
|
|
342
|
+
* Type-guard for the single-collection case (backward compat).
|
|
343
|
+
* A ConceptCollection has `byId` but no `concepts` field.
|
|
344
|
+
*/
|
|
345
|
+
function isConceptCollection(x) {
|
|
346
|
+
return x != null
|
|
347
|
+
&& typeof x === 'object'
|
|
348
|
+
&& typeof x.byId === 'function'
|
|
349
|
+
&& !('concepts' in x);
|
|
86
350
|
}
|
|
87
351
|
|
|
88
352
|
export const referenceResolver = new ReferenceResolver();
|