glossarist 0.4.18 → 0.4.20
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/data/concept-model/shapes/glossarist.shacl.ttl +151 -1
- package/package.json +1 -1
- package/src/concept-diff.js +13 -0
- package/src/concept-parser.js +52 -2
- package/src/concept-serializer.js +11 -0
- package/src/diff/canonical-json.js +31 -0
- package/src/diff/change.js +28 -0
- package/src/diff/collection-diff.js +57 -25
- package/src/diff/concept-diff.js +100 -60
- package/src/diff/diff-patch.js +49 -48
- package/src/diff/diff-renderer.js +34 -0
- package/src/diff/identity.js +22 -0
- package/src/diff/index.d.ts +64 -1
- package/src/diff/index.js +2 -0
- package/src/diff/similarity.js +24 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/migration/partitive-relation-migrator.js +125 -0
- package/src/models/completeness.js +24 -0
- package/src/models/concept-date.js +8 -0
- package/src/models/concept-ref.js +4 -0
- package/src/models/concept-source.js +21 -2
- package/src/models/concept.js +55 -0
- package/src/models/designation.js +15 -0
- package/src/models/detailed-definition.js +11 -0
- package/src/models/enum.js +42 -0
- package/src/models/hyperedge-colors.js +71 -0
- package/src/models/index.d.ts +61 -0
- package/src/models/index.js +39 -0
- package/src/models/localized-concept.js +29 -0
- package/src/models/member-certainty.js +21 -0
- package/src/models/partitive-enumeration.js +23 -0
- package/src/models/partitive-hyperedge.js +175 -0
- package/src/models/partitive-member.js +79 -0
- package/src/models/partitive-relation.js +158 -0
- package/src/models/plurality-marker.js +24 -0
- package/src/models/related-concept.js +38 -1
- package/src/models/relation-categories.js +6 -0
- package/src/models/type-shared-plurality.js +71 -0
- package/src/rdf/gloss-concept.js +26 -0
- package/src/rdf/gloss-hyperedge.js +90 -0
- package/src/rdf/gloss-partitive-relation.js +109 -0
- package/src/rdf/gloss-source.js +1 -1
- package/src/rdf/index.d.ts +37 -0
- package/src/rdf/index.js +2 -0
- package/src/rdf/predicates.d.ts +54 -0
- package/src/rdf/predicates.js +27 -0
- package/src/reference-resolver.js +49 -0
- package/src/validators/binary-has-part-redundancy-rule.js +74 -0
- package/src/validators/external-concept-shape-rule.js +60 -0
- package/src/validators/index.js +19 -1
- package/src/validators/partitive-drift-rule.js +51 -0
- package/src/validators/partitive-hyperedge-rule.js +75 -0
- package/src/validators/partitive-relation-coherence-rule.js +107 -0
- package/src/validators/sourced-from-locality-rule.js +53 -0
package/src/diff/concept-diff.js
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
import { GlossaristModel } from '../models/base.js';
|
|
2
2
|
import { Added, Removed, Changed } from './change.js';
|
|
3
3
|
import { ListDiff, diffList, diffSet } from './list-diff.js';
|
|
4
|
-
|
|
4
|
+
import { identityOf } from './identity.js';
|
|
5
|
+
import { canonicalJson } from './canonical-json.js';
|
|
6
|
+
import { computeSimilarity } from './similarity.js';
|
|
7
|
+
|
|
8
|
+
// Metadata field lists for the diff layer.
|
|
9
|
+
//
|
|
10
|
+
// Concept and LocalizedConcept also expose static DIFF_FIELDS that
|
|
11
|
+
// mirror these arrays. The duplication is intentional: concept-diff.js
|
|
12
|
+
// cannot import Concept (Concept imports diffConcepts from this module,
|
|
13
|
+
// creating a cycle). Instead, a spec (test/diff/field-sync.test.js)
|
|
14
|
+
// asserts the two stay in sync. Adding a new scalar metadata field
|
|
15
|
+
// requires editing both sites; forgetting one fails the test loudly.
|
|
16
|
+
//
|
|
17
|
+
// (Invariant N2 — TODO.hyperedges-v2/07.)
|
|
5
18
|
const CONCEPT_METADATA_FIELDS = Object.freeze([
|
|
6
19
|
'status', 'term', 'uri', 'schemaVersion',
|
|
7
20
|
]);
|
|
8
21
|
|
|
9
22
|
const LOCALIZATION_METADATA_FIELDS = Object.freeze([
|
|
10
|
-
'entryStatus',
|
|
11
|
-
'
|
|
12
|
-
'
|
|
13
|
-
'
|
|
14
|
-
'release',
|
|
15
|
-
'lineageSourceSimilarity',
|
|
16
|
-
'script',
|
|
17
|
-
'system',
|
|
18
|
-
'reviewDate',
|
|
19
|
-
'reviewDecisionDate',
|
|
20
|
-
'reviewDecisionEvent',
|
|
21
|
-
'reviewStatus',
|
|
22
|
-
'reviewDecision',
|
|
23
|
-
'reviewDecisionNotes',
|
|
23
|
+
'entryStatus', 'classification', 'reviewType', 'domain', 'release',
|
|
24
|
+
'lineageSourceSimilarity', 'script', 'system',
|
|
25
|
+
'reviewDate', 'reviewDecisionDate', 'reviewDecisionEvent',
|
|
26
|
+
'reviewStatus', 'reviewDecision', 'reviewDecisionNotes',
|
|
24
27
|
]);
|
|
25
28
|
|
|
29
|
+
// Metadata field lists are sourced from the models per invariant N2.
|
|
30
|
+
// (See file-top comment about why they're duplicated rather than imported.)
|
|
31
|
+
|
|
26
32
|
export class DiffStats extends GlossaristModel {
|
|
27
33
|
constructor(data = {}) {
|
|
28
34
|
super();
|
|
@@ -93,6 +99,15 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
93
99
|
this._sources = wrapListDiff(data.sources);
|
|
94
100
|
this._dates = wrapListDiff(data.dates);
|
|
95
101
|
this._relatedConcepts = wrapListDiff(data.relatedConcepts ?? data.related_concepts);
|
|
102
|
+
// v2 canonical name is partitiveRelations. Accept v1 names
|
|
103
|
+
// (partitiveHyperedges / partitive_hyperedges) for backward compat
|
|
104
|
+
// with diffs serialized by older glossarist-js versions.
|
|
105
|
+
this._partitiveRelations = wrapListDiff(
|
|
106
|
+
data.partitiveRelations
|
|
107
|
+
?? data.partitive_relations
|
|
108
|
+
?? data.partitiveHyperedges
|
|
109
|
+
?? data.partitive_hyperedges,
|
|
110
|
+
);
|
|
96
111
|
this._groups = wrapListDiff(data.groups);
|
|
97
112
|
this._sections = wrapListDiff(data.sections);
|
|
98
113
|
this._tags = wrapListDiff(data.tags);
|
|
@@ -104,6 +119,8 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
104
119
|
get sources() { return this._sources; }
|
|
105
120
|
get dates() { return this._dates; }
|
|
106
121
|
get relatedConcepts() { return this._relatedConcepts; }
|
|
122
|
+
get partitiveRelations() { return this._partitiveRelations; }
|
|
123
|
+
/** @deprecated use partitiveRelations */ get partitiveHyperedges() { return this._partitiveRelations; }
|
|
107
124
|
get groups() { return this._groups; }
|
|
108
125
|
get sections() { return this._sections; }
|
|
109
126
|
get tags() { return this._tags; }
|
|
@@ -113,6 +130,7 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
113
130
|
return this._sources.hasChanges
|
|
114
131
|
|| this._dates.hasChanges
|
|
115
132
|
|| this._relatedConcepts.hasChanges
|
|
133
|
+
|| this._partitiveRelations.hasChanges
|
|
116
134
|
|| this._groups.hasChanges
|
|
117
135
|
|| this._sections.hasChanges
|
|
118
136
|
|| this._tags.hasChanges
|
|
@@ -123,6 +141,7 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
123
141
|
yield* walkList('concept.sources', this._sources);
|
|
124
142
|
yield* walkList('concept.dates', this._dates);
|
|
125
143
|
yield* walkList('concept.relatedConcepts', this._relatedConcepts);
|
|
144
|
+
yield* walkList('concept.partitiveRelations', this._partitiveRelations);
|
|
126
145
|
yield* walkList('concept.groups', this._groups);
|
|
127
146
|
yield* walkList('concept.sections', this._sections);
|
|
128
147
|
yield* walkList('concept.tags', this._tags);
|
|
@@ -134,6 +153,7 @@ export class ConceptLevelDiff extends GlossaristModel {
|
|
|
134
153
|
sources: this._sources.toJSON(),
|
|
135
154
|
dates: this._dates.toJSON(),
|
|
136
155
|
related_concepts: this._relatedConcepts.toJSON(),
|
|
156
|
+
partitive_relations: this._partitiveRelations.toJSON(),
|
|
137
157
|
groups: this._groups.toJSON(),
|
|
138
158
|
sections: this._sections.toJSON(),
|
|
139
159
|
tags: this._tags.toJSON(),
|
|
@@ -161,6 +181,8 @@ export class LocalizedConceptDiff extends GlossaristModel {
|
|
|
161
181
|
? data.metadata
|
|
162
182
|
: MetadataDiff.fromJSON(data.metadata ?? {});
|
|
163
183
|
this._totalItems = data.totalItems ?? data.total_items ?? 0;
|
|
184
|
+
this._statsCache = undefined;
|
|
185
|
+
this._similarityCache = undefined;
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
get designations() { return this._designations; }
|
|
@@ -171,6 +193,7 @@ export class LocalizedConceptDiff extends GlossaristModel {
|
|
|
171
193
|
get dates() { return this._dates; }
|
|
172
194
|
get related() { return this._related; }
|
|
173
195
|
get metadata() { return this._metadata; }
|
|
196
|
+
get totalItems() { return this._totalItems; }
|
|
174
197
|
|
|
175
198
|
get hasChanges() {
|
|
176
199
|
return this._designations.hasChanges
|
|
@@ -184,11 +207,17 @@ export class LocalizedConceptDiff extends GlossaristModel {
|
|
|
184
207
|
}
|
|
185
208
|
|
|
186
209
|
get stats() {
|
|
187
|
-
|
|
210
|
+
if (this._statsCache === undefined) {
|
|
211
|
+
this._statsCache = collectStats(this.walk());
|
|
212
|
+
}
|
|
213
|
+
return this._statsCache;
|
|
188
214
|
}
|
|
189
215
|
|
|
190
216
|
get similarity() {
|
|
191
|
-
|
|
217
|
+
if (this._similarityCache === undefined) {
|
|
218
|
+
this._similarityCache = computeSimilarity(this.stats.total, this._totalItems);
|
|
219
|
+
}
|
|
220
|
+
return this._similarityCache;
|
|
192
221
|
}
|
|
193
222
|
|
|
194
223
|
*walk(prefix) {
|
|
@@ -226,8 +255,8 @@ export class LocalizedConceptDiff extends GlossaristModel {
|
|
|
226
255
|
export class ConceptDiff extends GlossaristModel {
|
|
227
256
|
constructor(data = {}) {
|
|
228
257
|
super();
|
|
229
|
-
this.
|
|
230
|
-
this.
|
|
258
|
+
this._oldId = data.oldId ?? data.old_id ?? null;
|
|
259
|
+
this._newId = data.newId ?? data.new_id ?? null;
|
|
231
260
|
this._concept = data.concept instanceof ConceptLevelDiff
|
|
232
261
|
? data.concept
|
|
233
262
|
: ConceptLevelDiff.fromJSON(data.concept ?? {});
|
|
@@ -240,6 +269,8 @@ export class ConceptDiff extends GlossaristModel {
|
|
|
240
269
|
: LocalizedConceptDiff.fromJSON(lcDiff);
|
|
241
270
|
}
|
|
242
271
|
this._totalItems = data.totalItems ?? data.total_items ?? 0;
|
|
272
|
+
this._statsCache = undefined;
|
|
273
|
+
this._similarityCache = undefined;
|
|
243
274
|
}
|
|
244
275
|
|
|
245
276
|
get oldId() { return this._oldId; }
|
|
@@ -247,9 +278,7 @@ export class ConceptDiff extends GlossaristModel {
|
|
|
247
278
|
get concept() { return this._concept; }
|
|
248
279
|
get languages() { return this._languages; }
|
|
249
280
|
get localizations() { return this._localizations; }
|
|
250
|
-
|
|
251
|
-
set oldId(v) { this._oldId = v; }
|
|
252
|
-
set newId(v) { this._newId = v; }
|
|
281
|
+
get totalItems() { return this._totalItems; }
|
|
253
282
|
|
|
254
283
|
get localizationLanguages() {
|
|
255
284
|
return Object.keys(this._localizations);
|
|
@@ -266,11 +295,17 @@ export class ConceptDiff extends GlossaristModel {
|
|
|
266
295
|
}
|
|
267
296
|
|
|
268
297
|
get stats() {
|
|
269
|
-
|
|
298
|
+
if (this._statsCache === undefined) {
|
|
299
|
+
this._statsCache = collectStats(this.walk());
|
|
300
|
+
}
|
|
301
|
+
return this._statsCache;
|
|
270
302
|
}
|
|
271
303
|
|
|
272
304
|
get similarity() {
|
|
273
|
-
|
|
305
|
+
if (this._similarityCache === undefined) {
|
|
306
|
+
this._similarityCache = computeSimilarity(this.stats.total, this._totalItems);
|
|
307
|
+
}
|
|
308
|
+
return this._similarityCache;
|
|
274
309
|
}
|
|
275
310
|
|
|
276
311
|
*walk() {
|
|
@@ -302,7 +337,7 @@ export class ConceptDiff extends GlossaristModel {
|
|
|
302
337
|
}
|
|
303
338
|
}
|
|
304
339
|
|
|
305
|
-
export function diffConcepts(oldConcept, newConcept, language
|
|
340
|
+
export function diffConcepts(oldConcept, newConcept, language) {
|
|
306
341
|
if (!oldConcept && !newConcept) {
|
|
307
342
|
return new ConceptDiff({});
|
|
308
343
|
}
|
|
@@ -315,12 +350,19 @@ export function diffConcepts(oldConcept, newConcept, language = 'eng') {
|
|
|
315
350
|
let langs;
|
|
316
351
|
if (language === 'all') {
|
|
317
352
|
langs = union(oldLangs, newLangs);
|
|
318
|
-
} else if (
|
|
353
|
+
} else if (language) {
|
|
354
|
+
if (!oldLangs.includes(language) && !newLangs.includes(language)) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`diffConcepts: language '${language}' not present in either concept ` +
|
|
357
|
+
`(available: ${union(oldLangs, newLangs).join(', ') || 'none'})`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
319
360
|
langs = [language];
|
|
320
|
-
} else if (oldLangs.length === 0 && newLangs.length === 0) {
|
|
321
|
-
langs = [];
|
|
322
361
|
} else {
|
|
323
|
-
|
|
362
|
+
// No language specified: diff everything that exists. Avoids the
|
|
363
|
+
// silent 'eng' fallback that masked changes in concepts without an
|
|
364
|
+
// English localization.
|
|
365
|
+
langs = union(oldLangs, newLangs);
|
|
324
366
|
}
|
|
325
367
|
|
|
326
368
|
const conceptDiff = diffConceptLevel(oldConcept ?? null, newConcept ?? null);
|
|
@@ -330,7 +372,6 @@ export function diffConcepts(oldConcept, newConcept, language = 'eng') {
|
|
|
330
372
|
const oldLoc = oldConcept?.localization(lang) ?? null;
|
|
331
373
|
const newLoc = newConcept?.localization(lang) ?? null;
|
|
332
374
|
const lcDiff = diffLocalizedConcepts(oldLoc, newLoc);
|
|
333
|
-
lcDiff._totalItems = countLocalizedItems(oldLoc, newLoc);
|
|
334
375
|
localizations[lang] = lcDiff;
|
|
335
376
|
}
|
|
336
377
|
|
|
@@ -370,6 +411,7 @@ export function diffLocalizedConcepts(oldLoc, newLoc) {
|
|
|
370
411
|
dates: diffDates(oldLoc.dates ?? [], newLoc.dates ?? []),
|
|
371
412
|
related: diffRelated(oldLoc.related ?? [], newLoc.related ?? []),
|
|
372
413
|
metadata: diffMetadata(oldLoc, newLoc, LOCALIZATION_METADATA_FIELDS),
|
|
414
|
+
totalItems: countLocalizedItems(oldLoc, newLoc),
|
|
373
415
|
});
|
|
374
416
|
}
|
|
375
417
|
|
|
@@ -386,6 +428,7 @@ function diffConceptLevel(oldConcept, newConcept) {
|
|
|
386
428
|
sources: fullListDiff(c.sources ?? [], Direction),
|
|
387
429
|
dates: fullListDiff(c.dates ?? [], Direction),
|
|
388
430
|
relatedConcepts: fullListDiff(c.relatedConcepts ?? [], Direction),
|
|
431
|
+
partitiveRelations: fullListDiff(c.partitiveRelations ?? c.partitiveHyperedges ?? [], Direction),
|
|
389
432
|
groups: fullListDiff(c.groups ?? [], Direction),
|
|
390
433
|
sections: fullListDiff(c.sections ?? [], Direction),
|
|
391
434
|
tags: fullListDiff(c.tags ?? [], Direction),
|
|
@@ -397,6 +440,10 @@ function diffConceptLevel(oldConcept, newConcept) {
|
|
|
397
440
|
sources: diffSources(oldConcept.sources ?? [], newConcept.sources ?? []),
|
|
398
441
|
dates: diffDates(oldConcept.dates ?? [], newConcept.dates ?? []),
|
|
399
442
|
relatedConcepts: diffRelatedConcepts(oldConcept.relatedConcepts ?? [], newConcept.relatedConcepts ?? []),
|
|
443
|
+
partitiveRelations: diffPartitiveRelations(
|
|
444
|
+
oldConcept.partitiveRelations ?? oldConcept.partitiveHyperedges ?? [],
|
|
445
|
+
newConcept.partitiveRelations ?? newConcept.partitiveHyperedges ?? [],
|
|
446
|
+
),
|
|
400
447
|
groups: diffStringSet(oldConcept.groups ?? [], newConcept.groups ?? []),
|
|
401
448
|
sections: diffStringSet(oldConcept.sections ?? [], newConcept.sections ?? []),
|
|
402
449
|
tags: diffStringSet(oldConcept.tags ?? [], newConcept.tags ?? []),
|
|
@@ -421,7 +468,7 @@ function diffLanguageSets(oldLangs, newLangs) {
|
|
|
421
468
|
function fullyDiff(loc, lang, direction) {
|
|
422
469
|
const ChangeClass = direction === 'added' ? Added : Removed;
|
|
423
470
|
const key = direction;
|
|
424
|
-
|
|
471
|
+
const lc = new LocalizedConceptDiff({
|
|
425
472
|
languageCode: lang,
|
|
426
473
|
designations: new ListDiff({ [key]: (loc.terms ?? []).map(v => new ChangeClass({ value: v })) }),
|
|
427
474
|
definitions: new ListDiff({ [key]: (loc.definitions ?? []).map(v => new ChangeClass({ value: v })) }),
|
|
@@ -431,7 +478,9 @@ function fullyDiff(loc, lang, direction) {
|
|
|
431
478
|
dates: new ListDiff({ [key]: (loc.dates ?? []).map(v => new ChangeClass({ value: v })) }),
|
|
432
479
|
related: new ListDiff({ [key]: (loc.related ?? []).map(v => new ChangeClass({ value: v })) }),
|
|
433
480
|
metadata: fullMetadataDiff(loc, LOCALIZATION_METADATA_FIELDS, direction),
|
|
481
|
+
totalItems: countLocalizedItems(direction === 'added' ? null : loc, direction === 'added' ? loc : null),
|
|
434
482
|
});
|
|
483
|
+
return lc;
|
|
435
484
|
}
|
|
436
485
|
|
|
437
486
|
function fullListDiff(items, direction) {
|
|
@@ -455,17 +504,11 @@ function fullMetadataDiff(obj, fields, direction) {
|
|
|
455
504
|
|
|
456
505
|
function diffDesignations(oldTerms, newTerms) {
|
|
457
506
|
return diffSet(oldTerms, newTerms, {
|
|
458
|
-
identityKey:
|
|
507
|
+
identityKey: identityOf,
|
|
459
508
|
textKey: d => d?.designation ?? '',
|
|
460
509
|
});
|
|
461
510
|
}
|
|
462
511
|
|
|
463
|
-
function designationIdentity(d) {
|
|
464
|
-
const type = d?.type ?? 'expression';
|
|
465
|
-
const text = String(d?.designation ?? '').toLowerCase().trim();
|
|
466
|
-
return `${type}|${text}`;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
512
|
function diffTextList(oldItems, newItems) {
|
|
470
513
|
return diffList(oldItems, newItems, {
|
|
471
514
|
textKey: d => d?.content ?? '',
|
|
@@ -474,45 +517,44 @@ function diffTextList(oldItems, newItems) {
|
|
|
474
517
|
|
|
475
518
|
function diffSources(oldSources, newSources) {
|
|
476
519
|
return diffSet(oldSources, newSources, {
|
|
477
|
-
identityKey:
|
|
520
|
+
identityKey: identityOf,
|
|
478
521
|
});
|
|
479
522
|
}
|
|
480
523
|
|
|
481
|
-
function sourceIdentity(s) {
|
|
482
|
-
const ref = s?.origin?.ref;
|
|
483
|
-
const source = ref?.source ?? '';
|
|
484
|
-
const id = ref?.id ?? '';
|
|
485
|
-
const type = s?.type ?? '';
|
|
486
|
-
return `${type}|${source}|${id}`;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
524
|
function diffDates(oldDates, newDates) {
|
|
490
525
|
return diffSet(oldDates, newDates, {
|
|
491
|
-
identityKey:
|
|
526
|
+
identityKey: identityOf,
|
|
492
527
|
textKey: d => d?.date ?? '',
|
|
493
528
|
});
|
|
494
529
|
}
|
|
495
530
|
|
|
496
531
|
function diffRelated(oldRelated, newRelated) {
|
|
497
532
|
return diffSet(oldRelated, newRelated, {
|
|
498
|
-
identityKey:
|
|
533
|
+
identityKey: identityOf,
|
|
499
534
|
});
|
|
500
535
|
}
|
|
501
536
|
|
|
502
537
|
function diffRelatedConcepts(oldRC, newRC) {
|
|
503
538
|
return diffSet(oldRC, newRC, {
|
|
504
|
-
identityKey:
|
|
539
|
+
identityKey: identityOf,
|
|
505
540
|
});
|
|
506
541
|
}
|
|
507
542
|
|
|
508
|
-
function
|
|
509
|
-
|
|
510
|
-
|
|
543
|
+
function diffPartitiveRelations(oldR, newR) {
|
|
544
|
+
return diffSet(oldR, newR, {
|
|
545
|
+
identityKey: identityOf,
|
|
546
|
+
textKey: relationText,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function relationText(r) {
|
|
551
|
+
if (!r) return '';
|
|
552
|
+
return canonicalJson(typeof r.toJSON === 'function' ? r.toJSON() : r);
|
|
511
553
|
}
|
|
512
554
|
|
|
513
555
|
function diffStringSet(oldStrings, newStrings) {
|
|
514
556
|
return diffSet(oldStrings, newStrings, {
|
|
515
|
-
identityKey:
|
|
557
|
+
identityKey: identityOf,
|
|
516
558
|
});
|
|
517
559
|
}
|
|
518
560
|
|
|
@@ -545,12 +587,6 @@ function collectStats(walker) {
|
|
|
545
587
|
return new DiffStats({ added, removed, changed });
|
|
546
588
|
}
|
|
547
589
|
|
|
548
|
-
function computeSimilarity(changeCount, totalItems) {
|
|
549
|
-
if (totalItems <= 0) return 1.0;
|
|
550
|
-
const ratio = Math.min(changeCount / totalItems, 1);
|
|
551
|
-
return Math.round((1 - ratio) * 10000) / 10000;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
590
|
function maxOf(a, b) {
|
|
555
591
|
return Math.max(a ?? 0, b ?? 0);
|
|
556
592
|
}
|
|
@@ -561,6 +597,10 @@ function countConceptItems(oldConcept, newConcept, langs) {
|
|
|
561
597
|
count += maxOf(oldConcept?.sources?.length, newConcept?.sources?.length);
|
|
562
598
|
count += maxOf(oldConcept?.dates?.length, newConcept?.dates?.length);
|
|
563
599
|
count += maxOf(oldConcept?.relatedConcepts?.length, newConcept?.relatedConcepts?.length);
|
|
600
|
+
count += maxOf(
|
|
601
|
+
oldConcept?.partitiveRelations?.length ?? oldConcept?.partitiveHyperedges?.length,
|
|
602
|
+
newConcept?.partitiveRelations?.length ?? newConcept?.partitiveHyperedges?.length,
|
|
603
|
+
);
|
|
564
604
|
count += maxOf(oldConcept?.groups?.length, newConcept?.groups?.length);
|
|
565
605
|
count += maxOf(oldConcept?.sections?.length, newConcept?.sections?.length);
|
|
566
606
|
count += maxOf(oldConcept?.tags?.length, newConcept?.tags?.length);
|
package/src/diff/diff-patch.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Concept } from '../models/concept.js';
|
|
2
|
+
import { ConceptSource } from '../models/concept-source.js';
|
|
3
|
+
import { ConceptDate } from '../models/concept-date.js';
|
|
4
|
+
import { RelatedConcept } from '../models/related-concept.js';
|
|
5
|
+
import { Designation } from '../models/designation.js';
|
|
6
|
+
import { DetailedDefinition } from '../models/detailed-definition.js';
|
|
7
|
+
import { PartitiveRelation } from '../models/partitive-relation.js';
|
|
2
8
|
import { Added, Removed, Changed } from './change.js';
|
|
3
9
|
import { ListDiff } from './list-diff.js';
|
|
4
10
|
import { TextDiff, TextHunk } from './text-diff.js';
|
|
11
|
+
import { identityOf } from './identity.js';
|
|
5
12
|
import {
|
|
6
13
|
ConceptDiff,
|
|
7
14
|
ConceptLevelDiff,
|
|
@@ -9,14 +16,23 @@ import {
|
|
|
9
16
|
MetadataDiff,
|
|
10
17
|
} from './concept-diff.js';
|
|
11
18
|
|
|
12
|
-
|
|
19
|
+
// Wire-name (camelCase JS field → snake_case JSON key) for metadata
|
|
20
|
+
// fields patched onto Concept and LocalizedConcept.
|
|
21
|
+
//
|
|
22
|
+
// These mirror `Concept.wireNameFor` / `LocalizedConcept.wireNameFor`.
|
|
23
|
+
// The duplication is intentional: diff-patch.js cannot import those
|
|
24
|
+
// models lazily without making applyDiff async (a breaking API change),
|
|
25
|
+
// and the field set is small and stable. A spec
|
|
26
|
+
// (test/diff/field-sync.test.js) asserts the two stay in sync.
|
|
27
|
+
|
|
28
|
+
const CONCEPT_METADATA_JSON_KEYS = Object.freeze({
|
|
13
29
|
status: 'status',
|
|
14
30
|
term: 'term',
|
|
15
31
|
uri: 'uri',
|
|
16
32
|
schemaVersion: 'schema_version',
|
|
17
|
-
};
|
|
33
|
+
});
|
|
18
34
|
|
|
19
|
-
const LOC_METADATA_JSON_KEYS = {
|
|
35
|
+
const LOC_METADATA_JSON_KEYS = Object.freeze({
|
|
20
36
|
entryStatus: 'entry_status',
|
|
21
37
|
classification: 'classification',
|
|
22
38
|
reviewType: 'review_type',
|
|
@@ -31,7 +47,7 @@ const LOC_METADATA_JSON_KEYS = {
|
|
|
31
47
|
reviewStatus: 'review_status',
|
|
32
48
|
reviewDecision: 'review_decision',
|
|
33
49
|
reviewDecisionNotes: 'review_decision_notes',
|
|
34
|
-
};
|
|
50
|
+
});
|
|
35
51
|
|
|
36
52
|
export function applyDiff(oldConcept, diff) {
|
|
37
53
|
const json = oldConcept.toJSON();
|
|
@@ -51,6 +67,9 @@ export function reverseDiff(diff) {
|
|
|
51
67
|
sources: reverseListDiff(diff.concept.sources),
|
|
52
68
|
dates: reverseListDiff(diff.concept.dates),
|
|
53
69
|
relatedConcepts: reverseListDiff(diff.concept.relatedConcepts),
|
|
70
|
+
partitiveRelations: reverseListDiff(
|
|
71
|
+
diff.concept.partitiveRelations ?? diff.concept.partitiveHyperedges,
|
|
72
|
+
),
|
|
54
73
|
groups: reverseListDiff(diff.concept.groups),
|
|
55
74
|
sections: reverseListDiff(diff.concept.sections),
|
|
56
75
|
tags: reverseListDiff(diff.concept.tags),
|
|
@@ -86,11 +105,21 @@ export function reverseDiff(diff) {
|
|
|
86
105
|
}
|
|
87
106
|
|
|
88
107
|
function applyConceptLevelPatch(json, conceptDiff) {
|
|
89
|
-
json.sources = applyListPatch(json.sources ?? [], conceptDiff.sources,
|
|
90
|
-
json.dates = applyListPatch(json.dates ?? [], conceptDiff.dates,
|
|
91
|
-
json.related = applyListPatch(json.related ?? [], conceptDiff.relatedConcepts,
|
|
108
|
+
json.sources = applyListPatch(json.sources ?? [], conceptDiff.sources, ConceptSource.identityOf);
|
|
109
|
+
json.dates = applyListPatch(json.dates ?? [], conceptDiff.dates, ConceptDate.identityOf);
|
|
110
|
+
json.related = applyListPatch(json.related ?? [], conceptDiff.relatedConcepts, RelatedConcept.identityOf);
|
|
111
|
+
json.partitive_relations = applyListPatch(
|
|
112
|
+
json.partitive_relations ?? json.partitive_hyperedges ?? [],
|
|
113
|
+
conceptDiff.partitiveRelations ?? conceptDiff.partitiveHyperedges,
|
|
114
|
+
PartitiveRelation.identityOf,
|
|
115
|
+
);
|
|
116
|
+
// v1 wire key is removed once v2 has replaced it; otherwise leave
|
|
117
|
+
// v1 data in place for backward-compat readers.
|
|
118
|
+
if (json.partitive_relations != null && json.partitive_hyperedges != null) {
|
|
119
|
+
delete json.partitive_hyperedges;
|
|
120
|
+
}
|
|
92
121
|
|
|
93
|
-
json.tags = applyListPatch(json.tags ?? [], conceptDiff.tags
|
|
122
|
+
json.tags = applyListPatch(json.tags ?? [], conceptDiff.tags);
|
|
94
123
|
|
|
95
124
|
applyMetadataPatch(json, conceptDiff.metadata, CONCEPT_METADATA_JSON_KEYS);
|
|
96
125
|
}
|
|
@@ -114,18 +143,19 @@ function applyLocalizedPatch(json, lang, lcDiff) {
|
|
|
114
143
|
}
|
|
115
144
|
const loc = json.localizations[lang];
|
|
116
145
|
|
|
117
|
-
loc.terms = applyListPatch(loc.terms ?? [], lcDiff.designations,
|
|
118
|
-
loc.definition = applyListPatch(loc.definition ?? [], lcDiff.definitions,
|
|
119
|
-
loc.notes = applyListPatch(loc.notes ?? [], lcDiff.notes,
|
|
120
|
-
loc.examples = applyListPatch(loc.examples ?? [], lcDiff.examples,
|
|
121
|
-
loc.sources = applyListPatch(loc.sources ?? [], lcDiff.sources,
|
|
122
|
-
loc.dates = applyListPatch(loc.dates ?? [], lcDiff.dates,
|
|
123
|
-
loc.related = applyListPatch(loc.related ?? [], lcDiff.related,
|
|
146
|
+
loc.terms = applyListPatch(loc.terms ?? [], lcDiff.designations, Designation.identityOf);
|
|
147
|
+
loc.definition = applyListPatch(loc.definition ?? [], lcDiff.definitions, DetailedDefinition.identityOf);
|
|
148
|
+
loc.notes = applyListPatch(loc.notes ?? [], lcDiff.notes, DetailedDefinition.identityOf);
|
|
149
|
+
loc.examples = applyListPatch(loc.examples ?? [], lcDiff.examples, DetailedDefinition.identityOf);
|
|
150
|
+
loc.sources = applyListPatch(loc.sources ?? [], lcDiff.sources, ConceptSource.identityOf);
|
|
151
|
+
loc.dates = applyListPatch(loc.dates ?? [], lcDiff.dates, ConceptDate.identityOf);
|
|
152
|
+
loc.related = applyListPatch(loc.related ?? [], lcDiff.related, RelatedConcept.identityOf);
|
|
124
153
|
|
|
125
154
|
applyMetadataPatch(loc, lcDiff.metadata, LOC_METADATA_JSON_KEYS);
|
|
126
155
|
}
|
|
127
156
|
|
|
128
157
|
function applyListPatch(existingItems, listDiff, identityFn) {
|
|
158
|
+
const fn = identityFn ?? identityOf;
|
|
129
159
|
let result = [...existingItems];
|
|
130
160
|
|
|
131
161
|
for (const entry of listDiff.added) {
|
|
@@ -133,14 +163,14 @@ function applyListPatch(existingItems, listDiff, identityFn) {
|
|
|
133
163
|
}
|
|
134
164
|
|
|
135
165
|
for (const entry of listDiff.removed) {
|
|
136
|
-
const targetKey =
|
|
137
|
-
result = result.filter(item =>
|
|
166
|
+
const targetKey = fn(entry.value);
|
|
167
|
+
result = result.filter(item => fn(item) !== targetKey);
|
|
138
168
|
}
|
|
139
169
|
|
|
140
170
|
for (const entry of listDiff.changed) {
|
|
141
|
-
const oldKey =
|
|
171
|
+
const oldKey = fn(entry.oldValue);
|
|
142
172
|
result = result.map(item =>
|
|
143
|
-
|
|
173
|
+
fn(item) === oldKey ? toJsonValue(entry.newValue) : item,
|
|
144
174
|
);
|
|
145
175
|
}
|
|
146
176
|
|
|
@@ -203,32 +233,3 @@ function toJsonValue(value) {
|
|
|
203
233
|
if (typeof value.toJSON === 'function') return value.toJSON();
|
|
204
234
|
return value;
|
|
205
235
|
}
|
|
206
|
-
|
|
207
|
-
function designationIdentity(d) {
|
|
208
|
-
const data = typeof d?.toJSON === 'function' ? d.toJSON() : d ?? {};
|
|
209
|
-
const type = data.type ?? 'expression';
|
|
210
|
-
const text = String(data.designation ?? '').toLowerCase().trim();
|
|
211
|
-
return `${type}|${text}`;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function definitionIdentity(d) {
|
|
215
|
-
const data = typeof d?.toJSON === 'function' ? d.toJSON() : d ?? {};
|
|
216
|
-
return JSON.stringify(data);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function sourceIdentity(s) {
|
|
220
|
-
const data = typeof s?.toJSON === 'function' ? s.toJSON() : s ?? {};
|
|
221
|
-
const ref = data.origin?.ref;
|
|
222
|
-
return `${data.type ?? ''}|${ref?.source ?? ''}|${ref?.id ?? ''}`;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function dateIdentity(d) {
|
|
226
|
-
const data = typeof d?.toJSON === 'function' ? d.toJSON() : d ?? {};
|
|
227
|
-
return data.type ?? '';
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function relatedIdentity(r) {
|
|
231
|
-
const data = typeof r?.toJSON === 'function' ? r.toJSON() : r ?? {};
|
|
232
|
-
const ref = data.ref;
|
|
233
|
-
return `${data.type ?? ''}|${ref?.source ?? ''}|${ref?.id ?? ''}`;
|
|
234
|
-
}
|
|
@@ -124,6 +124,7 @@ function renderConceptLevel(diff, colors) {
|
|
|
124
124
|
lines.push(...renderListDiff('Sources', diff.sources, colors, itemLabel));
|
|
125
125
|
lines.push(...renderListDiff('Dates', diff.dates, colors, itemLabel));
|
|
126
126
|
lines.push(...renderListDiff('Related', diff.relatedConcepts, colors, itemLabel));
|
|
127
|
+
lines.push(...renderListDiff('Partitive relations', diff.partitiveRelations ?? diff.partitiveHyperedges, colors, relationLabel));
|
|
127
128
|
lines.push(...renderListDiff('Groups', diff.groups, colors, itemLabel));
|
|
128
129
|
lines.push(...renderListDiff('Sections', diff.sections, colors, itemLabel));
|
|
129
130
|
lines.push(...renderListDiff('Tags', diff.tags, colors, itemLabel));
|
|
@@ -197,6 +198,39 @@ function definitionLabel(d) {
|
|
|
197
198
|
return d.content ?? '?';
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
function relationLabel(r) {
|
|
202
|
+
if (!r) return '?';
|
|
203
|
+
const c = r.comprehensive;
|
|
204
|
+
const head = c?.id ?? c?.source ?? c?.text ?? '?';
|
|
205
|
+
const partitives = Array.isArray(r.partitives)
|
|
206
|
+
? r.partitives.map(m => {
|
|
207
|
+
const ref = m?.ref ?? m ?? {};
|
|
208
|
+
const tail = m?.certainty === 'possible' ? '?' : '';
|
|
209
|
+
return `${ref.id ?? ref.source ?? ref.text ?? '?'}${tail}`;
|
|
210
|
+
}).join(', ')
|
|
211
|
+
: '';
|
|
212
|
+
const completeness = r.completeness ? ` (${r.completeness})` : '';
|
|
213
|
+
const criterion = r.criterion
|
|
214
|
+
? ` / ${Object.values(r.criterion)[0] ?? ''}`
|
|
215
|
+
: '';
|
|
216
|
+
const plurality = r.plurality
|
|
217
|
+
? ` [${_pluralityLabel(r.plurality)}]`
|
|
218
|
+
: '';
|
|
219
|
+
return `${head} → {${partitives}}${completeness}${criterion}${plurality}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function _pluralityLabel(p) {
|
|
223
|
+
if (!p) return '';
|
|
224
|
+
const flags = [];
|
|
225
|
+
if (p.is_shared) flags.push('shared');
|
|
226
|
+
if (p.is_uncertain) flags.push('uncertain');
|
|
227
|
+
if (p.shared_type) {
|
|
228
|
+
const t = p.shared_type;
|
|
229
|
+
flags.push(`type=${t.id ?? t.source ?? '?'}`);
|
|
230
|
+
}
|
|
231
|
+
return flags.join(',');
|
|
232
|
+
}
|
|
233
|
+
|
|
200
234
|
function itemLabel(item) {
|
|
201
235
|
if (item == null) return '?';
|
|
202
236
|
if (typeof item === 'string') return item;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Shared identity helper for the diff layer.
|
|
2
|
+
//
|
|
3
|
+
// Per invariant N1 (TODO.hyperedges-v2/00-master-plan.md), identity is a
|
|
4
|
+
// model concern. Models expose `identity()` returning a stable string.
|
|
5
|
+
// This helper dispatches: if the value is a model with `identity()`,
|
|
6
|
+
// call it; otherwise fall back to canonical JSON of `toJSON()`.
|
|
7
|
+
//
|
|
8
|
+
// Both `concept-diff.js` (compute side) and `diff-patch.js` (apply side)
|
|
9
|
+
// import this — closing the duplication that previously allowed the two
|
|
10
|
+
// sides to drift.
|
|
11
|
+
|
|
12
|
+
import { canonicalJson } from './canonical-json.js';
|
|
13
|
+
|
|
14
|
+
export function identityOf(item) {
|
|
15
|
+
if (item == null) return '';
|
|
16
|
+
if (typeof item.identity === 'function') return item.identity();
|
|
17
|
+
if (typeof item === 'string') return item;
|
|
18
|
+
if (typeof item === 'number') return String(item);
|
|
19
|
+
if (typeof item === 'boolean') return String(item);
|
|
20
|
+
if (typeof item.toJSON === 'function') return canonicalJson(item.toJSON());
|
|
21
|
+
return canonicalJson(item);
|
|
22
|
+
}
|