@glossarist/concept-browser 0.7.78 → 0.7.80

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.
Files changed (55) hide show
  1. package/package.json +2 -2
  2. package/scripts/build-edges.js +109 -39
  3. package/scripts/generate-data.mjs +63 -27
  4. package/src/__tests__/build-edges-redundancy.test.ts +101 -0
  5. package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
  6. package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
  7. package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
  8. package/src/__tests__/config/page-types.test.ts +82 -0
  9. package/src/__tests__/model-bridge-bridges.test.ts +3 -2
  10. package/src/__tests__/model-bridge-citation.test.ts +1 -1
  11. package/src/__tests__/ontology-registry.test.ts +2 -2
  12. package/src/__tests__/partitive-relation-styling.test.ts +82 -0
  13. package/src/__tests__/partitive-relation.test.ts +285 -0
  14. package/src/adapters/GraphDataSource.ts +16 -6
  15. package/src/adapters/dataset-stats.ts +13 -0
  16. package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
  17. package/src/adapters/types.ts +27 -17
  18. package/src/components/AppSidebar.vue +14 -57
  19. package/src/components/ConceptDetail.vue +29 -0
  20. package/src/components/ConceptDiffModal.vue +120 -0
  21. package/src/components/ConceptDiffView.vue +192 -0
  22. package/src/components/ConceptEditionRail.vue +61 -36
  23. package/src/components/PartitiveRelationList.vue +139 -0
  24. package/src/components/groups/DatasetGroupRenderer.vue +14 -4
  25. package/src/components/groups/DefaultGroupSidebar.vue +28 -11
  26. package/src/components/groups/LineageGroupSidebar.vue +17 -29
  27. package/src/composables/use-concept-diff.ts +231 -0
  28. package/src/composables/use-concept-edges.ts +66 -45
  29. package/src/composables/use-dataset-stats.ts +52 -0
  30. package/src/config/page-types.ts +67 -0
  31. package/src/config/types.ts +6 -1
  32. package/src/config/use-site-config.ts +13 -29
  33. package/src/data/taxonomies.json +24 -1
  34. package/src/i18n/locales/eng.yml +6 -0
  35. package/src/utils/partitive-relation-styling.ts +62 -0
  36. package/src/views/DatasetView.vue +5 -3
  37. package/src/views/StatsView.vue +9 -22
  38. package/src/__tests__/hyperedge-styling.test.ts +0 -57
  39. package/src/__tests__/partitive-hyperedge.test.ts +0 -199
  40. package/src/components/PartitiveHyperedgeList.vue +0 -112
  41. package/src/islands/BibliographyIsland.vue +0 -26
  42. package/src/islands/ConceptDetailIsland.vue +0 -27
  43. package/src/islands/ConceptDiffIsland.vue +0 -214
  44. package/src/islands/ConceptIsland.vue +0 -102
  45. package/src/islands/GraphIsland.vue +0 -15
  46. package/src/islands/GroupRendererIsland.vue +0 -15
  47. package/src/islands/HeaderIsland.vue +0 -10
  48. package/src/islands/NonVerbalIsland.vue +0 -75
  49. package/src/islands/RelationSphereIsland.vue +0 -20
  50. package/src/islands/SearchIsland.vue +0 -23
  51. package/src/islands/SeriesCardIsland.vue +0 -12
  52. package/src/islands/ShortcutsIsland.vue +0 -13
  53. package/src/islands/SidebarIsland.vue +0 -15
  54. package/src/islands/SupersessionDiffIsland.vue +0 -267
  55. package/src/utils/hyperedge-styling.ts +0 -48
@@ -0,0 +1,139 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PartitiveRelationList — renders one-to-many partitive decompositions
4
+ * as grouped cards. Each relation shows:
5
+ * - the comprehensive (whole) concept as the card header
6
+ * - each partitive member as an indented child with a certainty badge
7
+ * - completeness badge (complete / partial)
8
+ * - plurality badge (shared / uncertain / sharedType) when present
9
+ * - criterion as italic text under the title
10
+ *
11
+ * Independent of RelationshipList.vue, which renders binary edges.
12
+ * Both consume the same stores but render in separate sections.
13
+ *
14
+ * v2 shape per concept-model/TODO.partitive-relation-v2.
15
+ */
16
+ import type { PartitiveRelationWire, Manifest } from '../adapters/types';
17
+ import { useRouter } from 'vue-router';
18
+ import { useVocabularyStore } from '../stores/vocabulary';
19
+ import { getFactory } from '../adapters/factory';
20
+ import { useI18n } from '../i18n';
21
+ import {
22
+ partitiveRelationStyle,
23
+ completenessLabel,
24
+ certaintyLabel,
25
+ } from '../utils/partitive-relation-styling';
26
+
27
+ const props = defineProps<{
28
+ relations: PartitiveRelationWire[];
29
+ manifest: Manifest;
30
+ registerId: string;
31
+ }>();
32
+
33
+ const emit = defineEmits<{
34
+ (e: 'navigate', registerId: string, conceptId: string): void;
35
+ }>();
36
+
37
+ const router = useRouter();
38
+ const store = useVocabularyStore();
39
+ const factory = getFactory();
40
+ const { t } = useI18n();
41
+
42
+ function labelFor(uri: string): string {
43
+ const parsed = factory.resolve(uri);
44
+ if (parsed.type === 'internal') {
45
+ return parsed.conceptId || uri;
46
+ }
47
+ return uri;
48
+ }
49
+
50
+ function navigate(uri: string) {
51
+ const parsed = factory.resolve(uri);
52
+ if (parsed.type === 'internal') {
53
+ store.viewConcept(parsed.registerId, parsed.conceptId);
54
+ router.push({
55
+ name: 'concept',
56
+ params: { registerId: parsed.registerId, conceptId: parsed.conceptId },
57
+ });
58
+ }
59
+ }
60
+
61
+ function criterionText(criterion?: Record<string, string>): string | null {
62
+ if (!criterion) return null;
63
+ return criterion[props.manifest.id]
64
+ ?? criterion.default
65
+ ?? criterion.eng
66
+ ?? Object.values(criterion)[0]
67
+ ?? null;
68
+ }
69
+
70
+ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality']>): string {
71
+ if (!plurality.isShared) return '';
72
+ let label = t('partitive.plurality.shared') || 'shared type';
73
+ if (plurality.isUncertain) {
74
+ label = `${label} (${t('partitive.plurality.uncertain') || 'uncertain'})`;
75
+ }
76
+ if (plurality.sharedType) {
77
+ label = `${label}: ${plurality.sharedType}`;
78
+ }
79
+ return label;
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <div v-if="relations.length > 0" class="space-y-3">
85
+ <div class="section-label flex items-center gap-1.5 mb-2">
86
+ <span class="inline-block w-2 h-2 rounded-full bg-emerald-600"></span>
87
+ {{ t('partitive.relations') || 'Partitive relations' }}
88
+ </div>
89
+ <div
90
+ v-for="(rel, i) in relations"
91
+ :key="i"
92
+ class="rounded-lg border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 overflow-hidden"
93
+ >
94
+ <div class="px-3 py-2 flex items-center gap-2 border-b border-emerald-200 dark:border-emerald-800">
95
+ <span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-200 dark:bg-emerald-800 text-emerald-900 dark:text-emerald-100 uppercase">
96
+ {{ completenessLabel(rel.completeness) }}
97
+ </span>
98
+ <span
99
+ v-if="rel.plurality && rel.plurality.isShared"
100
+ class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100 uppercase"
101
+ :title="pluralityBadge(rel.plurality)"
102
+ >
103
+ {{ pluralityBadge(rel.plurality) }}
104
+ </span>
105
+ <button
106
+ @click="navigate(rel.comprehensive)"
107
+ class="ml-auto text-sm font-medium text-emerald-900 dark:text-emerald-100 hover:underline truncate"
108
+ >
109
+ {{ labelFor(rel.comprehensive) }}
110
+ </button>
111
+ </div>
112
+ <div
113
+ v-if="criterionText(rel.criterion)"
114
+ class="px-3 py-1 text-xs text-emerald-800 dark:text-emerald-200 italic border-b border-emerald-200 dark:border-emerald-800"
115
+ >
116
+ {{ criterionText(rel.criterion) }}
117
+ </div>
118
+ <ul class="divide-y divide-emerald-100 dark:divide-emerald-900/30">
119
+ <li v-for="(member, j) in rel.partitives" :key="j">
120
+ <button
121
+ @click="navigate(member.uri)"
122
+ class="block w-full text-left px-3 py-1.5 text-sm hover:bg-emerald-100 dark:hover:bg-emerald-900/30 text-emerald-900 dark:text-emerald-100"
123
+ :class="{ 'opacity-60': member.certainty === 'possible' }"
124
+ >
125
+ <span class="text-emerald-600 dark:text-emerald-400 mr-2">└</span>
126
+ {{ labelFor(member.uri) }}
127
+ <span
128
+ v-if="member.certainty === 'possible'"
129
+ class="ml-2 text-[10px] font-mono px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-800 dark:text-amber-200 uppercase"
130
+ :title="'plurality uncertain (ISO 704 broken line)'"
131
+ >
132
+ {{ certaintyLabel(member.certainty) }}
133
+ </span>
134
+ </button>
135
+ </li>
136
+ </ul>
137
+ </div>
138
+ </div>
139
+ </template>
@@ -1,8 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * DatasetGroupRenderer — OCP dispatcher. Maps group.kind to the
4
- * appropriate sidebar renderer component. New kinds: add entry to
5
- * GROUP_RENDERERS + new component. Zero edits to existing code.
4
+ * appropriate sidebar renderer component.
5
+ *
6
+ * Adding a new kind: add an entry to GROUP_RENDERERS + create the new
7
+ * sidebar component. Zero edits to existing components or to
8
+ * AppSidebar.
9
+ *
10
+ * Forwards the `expanded` slot scoped per-entry so callers can render
11
+ * shared per-entry content (sub-pages, sections, etc.) regardless of kind.
6
12
  */
7
13
  import { computed } from 'vue';
8
14
  import { groupRendererFor } from '../../config/group-renderers';
@@ -28,5 +34,9 @@ const renderer = computed(() => groupRendererFor(resolveGroupKind({ kind: props.
28
34
  </script>
29
35
 
30
36
  <template>
31
- <component :is="renderer" :entries="entries" :current-dataset="currentDataset" />
32
- </template>
37
+ <component :is="renderer" :entries="entries" :current-dataset="currentDataset">
38
+ <template #expanded="slotProps">
39
+ <slot name="expanded" v-bind="slotProps" />
40
+ </template>
41
+ </component>
42
+ </template>
@@ -1,13 +1,19 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * DefaultGroupSidebar — flat list of dataset entries with expansion.
3
+ * DefaultGroupSidebar — flat list of dataset entries with expansion slot.
4
4
  * Used for topic, family, collection, and default group kinds.
5
- * Replaces the inline v-else template in AppSidebar.
5
+ * Invoked via DatasetGroupRenderer dispatch (OCP).
6
+ *
7
+ * Slot `expanded` receives `{ entry, isCurrent }` per entry so callers
8
+ * can render shared per-entry content (sub-pages, sections, etc.)
9
+ * without touching this component.
6
10
  */
7
11
  import { useRouter } from 'vue-router';
8
12
  import { useI18n } from '../../i18n';
13
+ import { useDsStyle } from '../../utils/dataset-style';
14
+ import { useSiteConfig } from '../../config/use-site-config';
9
15
 
10
- const props = defineProps<{
16
+ defineProps<{
11
17
  entries: Array<{
12
18
  id: string;
13
19
  title: string;
@@ -19,9 +25,11 @@ const props = defineProps<{
19
25
 
20
26
  const router = useRouter();
21
27
  const { t } = useI18n();
28
+ const { getColor } = useDsStyle();
29
+ const { localizedDatasetField } = useSiteConfig();
22
30
 
23
- function navigate(id: string) {
24
- if (id === props.currentDataset) return;
31
+ function navigate(id: string, current: string) {
32
+ if (id === current) return;
25
33
  router.push({ name: 'dataset', params: { registerId: id } });
26
34
  }
27
35
  </script>
@@ -35,16 +43,25 @@ function navigate(id: string) {
35
43
  >
36
44
  <button
37
45
  type="button"
38
- class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
46
+ class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2 flex items-start gap-2"
39
47
  :class="currentDataset === ds.id
40
48
  ? 'text-ink-800 dark:text-ink-50'
41
49
  : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700 hover:text-ink-800 dark:hover:text-ink-50'"
42
- @click="navigate(ds.id)"
50
+ :style="{ borderLeftColor: currentDataset === ds.id ? getColor(ds.id) : 'transparent' }"
51
+ @click="navigate(ds.id, currentDataset)"
43
52
  >
44
- <div class="font-medium truncate leading-snug">{{ ds.title }}</div>
45
- <div v-if="ds.loaded" class="text-xs mt-0.5 text-ink-300 dark:text-ink-400">
46
- {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
53
+ <span class="w-2 h-2 rounded-full flex-shrink-0 mt-1.5" :style="{ backgroundColor: getColor(ds.id) }"></span>
54
+ <div class="min-w-0 flex-1">
55
+ <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
56
+ <div
57
+ v-if="ds.loaded"
58
+ class="text-xs mt-0.5"
59
+ :class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'"
60
+ >
61
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
62
+ </div>
47
63
  </div>
48
64
  </button>
65
+ <slot name="expanded" :entry="ds" :is-current="currentDataset === ds.id" />
49
66
  </div>
50
- </template>
67
+ </template>
@@ -1,12 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * LineageGroupSidebar — timeline-style entries for edition series.
4
- * Replaces the inline v-if="group.kind === 'lineage'" template in
5
- * AppSidebar. Open/closed: new group kinds get their own component.
4
+ * Invoked via DatasetGroupRenderer dispatch when group.kind === 'lineage'.
5
+ *
6
+ * Slot `expanded` receives `{ entry, isCurrent }` per entry so callers
7
+ * can render shared per-entry content (sub-pages, sections, etc.)
8
+ * without touching this component.
6
9
  */
7
10
  import { useRouter } from 'vue-router';
8
11
 
9
- const props = defineProps<{
12
+ defineProps<{
10
13
  entries: Array<{
11
14
  id: string;
12
15
  title: string;
@@ -22,24 +25,27 @@ const props = defineProps<{
22
25
 
23
26
  const router = useRouter();
24
27
 
25
- function navigate(id: string) {
26
- if (id === props.currentDataset) return;
28
+ function navigate(id: string, current: string) {
29
+ if (id === current) return;
27
30
  router.push({ name: 'dataset', params: { registerId: id } });
28
31
  }
29
32
  </script>
30
33
 
31
34
  <template>
32
- <div class="series-timeline">
35
+ <div
36
+ v-for="ds in entries"
37
+ :key="ds.id"
38
+ class="rounded-lg transition-all duration-150"
39
+ :class="currentDataset === ds.id ? 'bg-surface' : ''"
40
+ >
33
41
  <button
34
- v-for="ds in entries"
35
- :key="ds.id"
36
42
  type="button"
37
43
  class="series-entry w-full text-left flex items-center gap-2 pl-6 pr-3 py-1.5 rounded-md text-sm border-l-2 transition-all duration-150"
38
44
  :class="currentDataset === ds.id
39
45
  ? 'bg-amber-50/70 dark:bg-amber-400/10 border-l-[3px] text-ink-900 dark:text-ink-50 font-semibold'
40
46
  : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700/40 hover:text-ink-900 dark:hover:text-ink-50'"
41
- :style="currentDataset === ds.id ? { borderLeftColor: 'var(--group-kind-lineage-light, #B8935A)' } : {}"
42
- @click="navigate(ds.id)"
47
+ :style="currentDataset === ds.id ? { borderLeftColor: 'var(--gold-accent, #B8935A)' } : {}"
48
+ @click="navigate(ds.id, currentDataset)"
43
49
  >
44
50
  <span class="flex-1 truncate text-[13.5px] font-medium leading-snug">{{ ds.ref || ds.title || ds.id }}</span>
45
51
  <span
@@ -52,24 +58,6 @@ function navigate(id: string) {
52
58
  title="Current edition"
53
59
  >✦</span>
54
60
  </button>
61
+ <slot name="expanded" :entry="ds" :is-current="currentDataset === ds.id" />
55
62
  </div>
56
63
  </template>
57
-
58
- <style scoped>
59
- .series-entry {
60
- position: relative;
61
- }
62
- .current-star {
63
- display: inline-flex;
64
- align-items: center;
65
- justify-content: center;
66
- font-size: 16px;
67
- line-height: 1;
68
- color: var(--group-kind-lineage-light, #B8935A);
69
- filter: drop-shadow(0 0 4px rgba(184, 147, 90, 0.45));
70
- }
71
- :global(.dark) .current-star {
72
- color: var(--group-kind-lineage-dark, #D4AF6E);
73
- filter: drop-shadow(0 0 4px rgba(212, 175, 110, 0.55));
74
- }
75
- </style>
@@ -0,0 +1,231 @@
1
+ /**
2
+ * useConceptDiff — diff two concepts and derive render-ready sections.
3
+ *
4
+ * Consolidates logic that was previously duplicated across
5
+ * ConceptDiffIsland.vue and SupersessionDiffIsland.vue (DRY).
6
+ *
7
+ * Pipeline:
8
+ * raw wire data → DiffableConcept → diffConcepts → ConceptDiff
9
+ * ↓
10
+ * deriveDiffSections
11
+ * computeSimilarity
12
+ *
13
+ * `similarity` is not a field on ConceptDiff (it doesn't exist in
14
+ * glossarist-js's type). It is computed from stats:
15
+ * similarity = 1 - (added + removed + changed) / total
16
+ */
17
+
18
+ import { ref } from 'vue';
19
+ import { diffConcepts } from 'glossarist/diff';
20
+ import type { ConceptDiff } from 'glossarist/diff';
21
+
22
+ // ── Inputs ────────────────────────────────────────────────────────────────
23
+
24
+ /** Wire shape of a concept as stored in fetched JSON or content collections. */
25
+ export interface ConceptLikeData {
26
+ conceptId?: string;
27
+ id?: string;
28
+ uri?: string;
29
+ status?: string;
30
+ languages?: string[];
31
+ localizations?: Record<string, unknown>;
32
+ }
33
+
34
+ /** Normalized concept shape consumed by diffConcepts. */
35
+ export interface DiffableConcept {
36
+ readonly id: string;
37
+ readonly termid: string;
38
+ readonly status?: string;
39
+ readonly uri?: string;
40
+ readonly languages: readonly string[];
41
+ localization(lang: string): {
42
+ languageCode: string;
43
+ terms: unknown[];
44
+ definitions: unknown[];
45
+ notes: unknown[];
46
+ examples: unknown[];
47
+ } | null;
48
+ }
49
+
50
+ // ── Outputs ───────────────────────────────────────────────────────────────
51
+
52
+ export type DiffHunk = { type: 'added' | 'removed' | 'equal'; text: string };
53
+
54
+ export interface DefinitionDiff {
55
+ hunks?: readonly DiffHunk[];
56
+ type?: 'added' | 'removed';
57
+ value?: string;
58
+ }
59
+
60
+ export interface DesignationsDiff {
61
+ items: Array<{ type?: string; text: string }>;
62
+ }
63
+
64
+ export interface SimpleChangesDiff {
65
+ items: Array<{ type?: string; text: string }>;
66
+ }
67
+
68
+ export interface DiffSections {
69
+ definition?: DefinitionDiff;
70
+ designations?: DesignationsDiff;
71
+ terms?: { hunks?: readonly DiffHunk[]; oldValue?: string; newValue?: string };
72
+ notes?: SimpleChangesDiff;
73
+ examples?: SimpleChangesDiff;
74
+ }
75
+
76
+ // ── Adapters ──────────────────────────────────────────────────────────────
77
+
78
+ export function toDiffableConcept(data: ConceptLikeData): DiffableConcept {
79
+ const langs = data.languages
80
+ ?? (data.localizations ? Object.keys(data.localizations) : []);
81
+ const id = String(data.conceptId ?? data.id ?? '');
82
+ return {
83
+ id,
84
+ termid: id,
85
+ status: data.status,
86
+ uri: data.uri,
87
+ languages: langs,
88
+ localization(lang: string) {
89
+ const locs = (data.localizations ?? {}) as Record<string, any>;
90
+ const loc = locs[lang];
91
+ if (!loc) return null;
92
+ return {
93
+ languageCode: loc.languageCode ?? lang,
94
+ terms: loc.terms ?? [],
95
+ definitions: loc.definitions ?? [],
96
+ notes: loc.notes ?? [],
97
+ examples: loc.examples ?? [],
98
+ };
99
+ },
100
+ };
101
+ }
102
+
103
+ // ── Pure derivations ──────────────────────────────────────────────────────
104
+
105
+ interface ReadableConceptDiff {
106
+ localization?(lang: string): any;
107
+ localizations?: Record<string, any>;
108
+ stats?: { added?: number; removed?: number; changed?: number; total?: number };
109
+ }
110
+
111
+ function asReadable(diff: ConceptDiff): ReadableConceptDiff {
112
+ return diff as unknown as ReadableConceptDiff;
113
+ }
114
+
115
+ function extractDesignationText(value: any): string {
116
+ if (value == null) return '';
117
+ if (typeof value === 'string') return value;
118
+ return value.designation ?? value.text ?? String(value);
119
+ }
120
+
121
+ function extractContentText(value: any): string {
122
+ if (value == null) return '';
123
+ if (typeof value === 'string') return value;
124
+ return value.content ?? value.text ?? String(value);
125
+ }
126
+
127
+ /**
128
+ * Derive render-ready DiffSections from a ConceptDiff for a single language.
129
+ * Pure function — same diff + lang always yields the same sections.
130
+ */
131
+ export function deriveDiffSections(diff: ConceptDiff | null, lang: string): DiffSections {
132
+ if (!diff) return {};
133
+ const d = asReadable(diff);
134
+ const loc = d.localization?.(lang) ?? d.localizations?.[lang];
135
+ if (!loc) return {};
136
+ const out: DiffSections = {};
137
+
138
+ const defChanged = loc.definitions?.changed?.[0];
139
+ if (defChanged?.textDiff?.hunks?.length) {
140
+ out.definition = { hunks: defChanged.textDiff.hunks };
141
+ } else if (loc.definitions?.added?.length) {
142
+ out.definition = { type: 'added', value: extractContentText(loc.definitions.added[0].value) };
143
+ } else if (loc.definitions?.removed?.length) {
144
+ out.definition = { type: 'removed', value: extractContentText(loc.definitions.removed[0].value) };
145
+ }
146
+
147
+ const hasDesignationChanges =
148
+ loc.designations?.added?.length
149
+ || loc.designations?.removed?.length
150
+ || loc.designations?.changed?.length;
151
+ if (hasDesignationChanges) {
152
+ const items: Array<{ type?: string; text: string }> = [];
153
+ for (const d of loc.designations.added ?? []) {
154
+ items.push({ type: 'added', text: extractDesignationText(d.value) });
155
+ }
156
+ for (const d of loc.designations.removed ?? []) {
157
+ items.push({ type: 'removed', text: extractDesignationText(d.value) });
158
+ }
159
+ for (const d of loc.designations.changed ?? []) {
160
+ items.push({ type: 'removed', text: extractDesignationText(d.oldValue) });
161
+ items.push({ type: 'added', text: extractDesignationText(d.newValue) });
162
+ }
163
+ out.designations = { items };
164
+ }
165
+
166
+ if (loc.notes?.added?.length || loc.notes?.removed?.length) {
167
+ out.notes = {
168
+ items: [
169
+ ...(loc.notes.added ?? []).map((n: any) => ({ type: 'added', text: extractContentText(n.value) })),
170
+ ...(loc.notes.removed ?? []).map((n: any) => ({ type: 'removed', text: extractContentText(n.value) })),
171
+ ],
172
+ };
173
+ }
174
+
175
+ if (loc.examples?.added?.length || loc.examples?.removed?.length) {
176
+ out.examples = {
177
+ items: [
178
+ ...(loc.examples.added ?? []).map((e: any) => ({ type: 'added', text: extractContentText(e.value) })),
179
+ ...(loc.examples.removed ?? []).map((e: any) => ({ type: 'removed', text: extractContentText(e.value) })),
180
+ ],
181
+ };
182
+ }
183
+
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * Similarity score in [0, 1]. Returns null when no comparable stats.
189
+ *
190
+ * Computed as 1 − (added + removed + changed) / total — i.e. the
191
+ * fraction of the concept that did NOT change. `added` counts as a
192
+ * change because it represents content present in only one version.
193
+ */
194
+ export function computeSimilarity(diff: ConceptDiff | null): number | null {
195
+ if (!diff) return null;
196
+ const stats = asReadable(diff).stats;
197
+ if (!stats || !stats.total) return null;
198
+ const changed = (stats.added ?? 0) + (stats.removed ?? 0) + (stats.changed ?? 0);
199
+ return Math.max(0, Math.min(1, 1 - changed / stats.total));
200
+ }
201
+
202
+ // ── Composable ────────────────────────────────────────────────────────────
203
+
204
+ export function useConceptDiff() {
205
+ const diffResult = ref<ConceptDiff | null>(null);
206
+ const loading = ref(false);
207
+ const error = ref<string | null>(null);
208
+
209
+ function diff(oldData: ConceptLikeData | null, newData: ConceptLikeData | null): void {
210
+ loading.value = true;
211
+ error.value = null;
212
+ try {
213
+ diffResult.value = diffConcepts(
214
+ oldData ? toDiffableConcept(oldData) : null,
215
+ newData ? toDiffableConcept(newData) : null,
216
+ );
217
+ } catch (e: unknown) {
218
+ error.value = e instanceof Error ? e.message : String(e);
219
+ diffResult.value = null;
220
+ } finally {
221
+ loading.value = false;
222
+ }
223
+ }
224
+
225
+ function clear(): void {
226
+ diffResult.value = null;
227
+ error.value = null;
228
+ }
229
+
230
+ return { diffResult, loading, error, diff, clear };
231
+ }