@glossarist/concept-browser 0.7.42 → 0.7.44
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/cli/index.mjs +12 -13
- package/package.json +2 -1
- package/scripts/__tests__/concept-groups.test.mjs +51 -0
- package/scripts/__tests__/fetch-datasets.test.mjs +105 -0
- package/scripts/fetch-datasets.mjs +53 -51
- package/scripts/generate-data.mjs +22 -27
- package/scripts/lib/concept-groups.mjs +16 -0
- package/scripts/lib/local-path-safety.mjs +68 -0
- package/src/__tests__/dataset-view.test.ts +95 -1
- package/src/__tests__/section-display.test.ts +46 -0
- package/src/__tests__/section-tree.test.ts +128 -0
- package/src/adapters/GraphDataSource.ts +9 -25
- package/src/components/AppSidebar.vue +5 -14
- package/src/utils/section-display.ts +21 -0
- package/src/utils/section-tree.ts +53 -0
- package/src/views/DatasetView.vue +26 -16
package/cli/index.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { loadSiteConfig } from '../scripts/load-site-config.mjs';
|
|
23
|
-
import {
|
|
23
|
+
import { existsSync } from 'fs';
|
|
24
24
|
import { resolve, dirname } from 'path';
|
|
25
25
|
import { fileURLToPath } from 'url';
|
|
26
26
|
|
|
@@ -166,22 +166,21 @@ Environment:
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
// Run vite build using the package's vite.config.ts
|
|
169
|
+
// Run vite build using the package's vite.config.ts via programmatic API
|
|
170
170
|
console.log(`\n=== BUILD SPA ===\n`);
|
|
171
171
|
const viteConfig = resolve(pkgRoot, 'vite.config.ts');
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
env: { ...process.env },
|
|
172
|
+
const { build: viteBuild } = await import('vite');
|
|
173
|
+
await viteBuild({
|
|
174
|
+
configFile: viteConfig,
|
|
175
|
+
root: pkgRoot,
|
|
176
|
+
mode: 'production',
|
|
178
177
|
});
|
|
179
178
|
|
|
180
|
-
// Run postbuild (404 page)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
179
|
+
// Run postbuild (404 page) via dynamic import
|
|
180
|
+
const postbuild = resolve(pkgRoot, 'scripts', 'generate-404.js');
|
|
181
|
+
if (existsSync(postbuild)) {
|
|
182
|
+
await import(`file://${postbuild}`);
|
|
183
|
+
}
|
|
185
184
|
|
|
186
185
|
return;
|
|
187
186
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.44",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"favicons": "^7.2.0",
|
|
28
28
|
"glossarist": "^0.3.7",
|
|
29
29
|
"js-yaml": "^4.1.0",
|
|
30
|
+
"jszip": "^3.10.1",
|
|
30
31
|
"pinia": "^2.3.1",
|
|
31
32
|
"postcss": "^8.5.3",
|
|
32
33
|
"sharp": "^0.34.5",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getGroups } from '../lib/concept-groups.mjs';
|
|
3
|
+
|
|
4
|
+
describe('getGroups', () => {
|
|
5
|
+
it('returns explicit eng.groups when present', () => {
|
|
6
|
+
expect(getGroups({ eng: { groups: ['g1', 'g2'] }, termid: 1 })).toEqual(['g1', 'g2']);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('derives groups from _domains with ref_type=section', () => {
|
|
10
|
+
expect(getGroups({
|
|
11
|
+
termid: 1,
|
|
12
|
+
_domains: [
|
|
13
|
+
{ ref_type: 'section', concept_id: 'section-102-01' },
|
|
14
|
+
{ ref_type: 'section', concept_id: 'section-102-02' },
|
|
15
|
+
],
|
|
16
|
+
})).toEqual(['102-01', '102-02']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('ignores _domains entries without ref_type=section', () => {
|
|
20
|
+
expect(getGroups({
|
|
21
|
+
termid: 1,
|
|
22
|
+
_domains: [
|
|
23
|
+
{ ref_type: 'other', concept_id: 'x' },
|
|
24
|
+
{ ref_type: 'section', concept_id: 'section-103' },
|
|
25
|
+
],
|
|
26
|
+
})).toEqual(['103']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('falls through when _domains has no section entries', () => {
|
|
30
|
+
expect(getGroups({
|
|
31
|
+
termid: '103-01-02',
|
|
32
|
+
_domains: [{ ref_type: 'other', concept_id: 'x' }],
|
|
33
|
+
})).toEqual(['103']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('derives group from termid with NNN- prefix (e.g. IEV)', () => {
|
|
37
|
+
expect(getGroups({ termid: '103-01-02' })).toEqual(['103']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('derives group from dotted termid (e.g. VIM)', () => {
|
|
41
|
+
expect(getGroups({ termid: '1.2.3.4' })).toEqual(['1.2.3']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns empty array when no derivation matches', () => {
|
|
45
|
+
expect(getGroups({ termid: 'abc' })).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns empty array when termid is missing', () => {
|
|
49
|
+
expect(getGroups({})).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { assertLocalPathSafe } from '../lib/local-path-safety.mjs';
|
|
6
|
+
|
|
7
|
+
function makeTmpTree() {
|
|
8
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-fetch-'));
|
|
9
|
+
const datasetsDir = path.join(root, '.datasets');
|
|
10
|
+
const sourceDir = path.join(root, 'source-data');
|
|
11
|
+
fs.mkdirSync(datasetsDir);
|
|
12
|
+
fs.mkdirSync(path.join(sourceDir, 'concepts'), { recursive: true });
|
|
13
|
+
fs.writeFileSync(path.join(sourceDir, 'concepts', 'a.yaml'), 'termid: 1\n');
|
|
14
|
+
return { root, datasetsDir, sourceDir };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('assertLocalPathSafe', () => {
|
|
18
|
+
let tree;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => { tree = makeTmpTree(); });
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
fs.rmSync(tree.root, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns resolved path for a safe external location', () => {
|
|
26
|
+
const resolved = assertLocalPathSafe('foo', tree.sourceDir, {
|
|
27
|
+
root: tree.root,
|
|
28
|
+
datasetsDir: tree.datasetsDir,
|
|
29
|
+
});
|
|
30
|
+
// Returns the realpath (symlinks resolved); on macOS tmpdir resolves
|
|
31
|
+
// /var → /private/var, so compare against realpath, not path.resolve.
|
|
32
|
+
expect(resolved).toBe(fs.realpathSync(path.resolve(tree.root, tree.sourceDir)));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('throws when localPath does not exist', () => {
|
|
36
|
+
expect(() =>
|
|
37
|
+
assertLocalPathSafe('foo', path.join(tree.root, 'nope'), {
|
|
38
|
+
root: tree.root,
|
|
39
|
+
datasetsDir: tree.datasetsDir,
|
|
40
|
+
})
|
|
41
|
+
).toThrow(/does not exist/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('throws when localPath equals .datasets/<id>', () => {
|
|
45
|
+
const staged = path.join(tree.datasetsDir, 'foo');
|
|
46
|
+
fs.mkdirSync(staged, { recursive: true });
|
|
47
|
+
expect(() =>
|
|
48
|
+
assertLocalPathSafe('foo', staged, {
|
|
49
|
+
root: tree.root,
|
|
50
|
+
datasetsDir: tree.datasetsDir,
|
|
51
|
+
})
|
|
52
|
+
).toThrow(/same physical location/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws when localPath is nested inside .datasets/<id>', () => {
|
|
56
|
+
const staged = path.join(tree.datasetsDir, 'foo');
|
|
57
|
+
fs.mkdirSync(path.join(staged, 'subdir'), { recursive: true });
|
|
58
|
+
expect(() =>
|
|
59
|
+
assertLocalPathSafe('foo', path.join(staged, 'subdir'), {
|
|
60
|
+
root: tree.root,
|
|
61
|
+
datasetsDir: tree.datasetsDir,
|
|
62
|
+
})
|
|
63
|
+
).toThrow(/nested inside/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('throws when localPath contains .datasets/<id> (parent-of-staging hazard)', () => {
|
|
67
|
+
// localPath = root itself, datasetsDir = root/.datasets — staging ops
|
|
68
|
+
// (rm -rf .datasets/<id>) would touch files inside localPath.
|
|
69
|
+
expect(() =>
|
|
70
|
+
assertLocalPathSafe('foo', tree.root, {
|
|
71
|
+
root: tree.root,
|
|
72
|
+
datasetsDir: tree.datasetsDir,
|
|
73
|
+
})
|
|
74
|
+
).toThrow(/contains .datasets/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('throws when localPath is a symlink to .datasets/<id> (the reported bug)', () => {
|
|
78
|
+
const staged = path.join(tree.datasetsDir, 'foo');
|
|
79
|
+
fs.mkdirSync(staged, { recursive: true });
|
|
80
|
+
const symlinkPath = path.join(tree.root, 'evil-link');
|
|
81
|
+
fs.symlinkSync(staged, symlinkPath);
|
|
82
|
+
expect(() =>
|
|
83
|
+
assertLocalPathSafe('foo', symlinkPath, {
|
|
84
|
+
root: tree.root,
|
|
85
|
+
datasetsDir: tree.datasetsDir,
|
|
86
|
+
})
|
|
87
|
+
).toThrow(/same physical location/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does NOT modify the source directory (regression for data-loss bug)', () => {
|
|
91
|
+
const sentinel = path.join(tree.sourceDir, 'concepts', 'SENTINEL.yaml');
|
|
92
|
+
fs.writeFileSync(sentinel, 'termid: sentinel\n');
|
|
93
|
+
const beforeMtime = fs.statSync(sentinel).mtimeMs;
|
|
94
|
+
|
|
95
|
+
assertLocalPathSafe('foo', tree.sourceDir, {
|
|
96
|
+
root: tree.root,
|
|
97
|
+
datasetsDir: tree.datasetsDir,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Source directory must be completely untouched after the safety check.
|
|
101
|
+
expect(fs.existsSync(sentinel)).toBe(true);
|
|
102
|
+
expect(fs.statSync(sentinel).mtimeMs).toBe(beforeMtime);
|
|
103
|
+
expect(fs.readdirSync(path.join(tree.sourceDir, 'concepts'))).toContain('SENTINEL.yaml');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* fetch-datasets.mjs — Load datasets from .gcr files or
|
|
3
|
+
* fetch-datasets.mjs — Load datasets from .gcr files, local paths, or git repos.
|
|
4
4
|
*
|
|
5
5
|
* Reads site config (via load-site-config.mjs), for each dataset:
|
|
6
6
|
* 1. If .gcr/{id}.gcr exists, extract to .datasets/{id}/
|
|
7
7
|
* 2. Else download from gcrPackage URL and extract
|
|
8
|
-
* 3. Else
|
|
8
|
+
* 3. Else if localPath is set, use it in-place (NO copy, NO staging)
|
|
9
|
+
* 4. Else clone/update source repo into .datasets/{id}/
|
|
9
10
|
*
|
|
10
11
|
* After fetching, validates that all GCR dependencies are satisfiable
|
|
11
12
|
* (either provided locally or routed externally).
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* No shell commands. All file ops use Node fs; ZIP uses JSZip; git uses
|
|
15
|
+
* execFileSync with array args (no shell interpolation).
|
|
15
16
|
*/
|
|
16
17
|
import fs from 'fs';
|
|
17
18
|
import path from 'path';
|
|
19
|
+
import JSZip from 'jszip';
|
|
18
20
|
import { loadGcr } from 'glossarist';
|
|
19
|
-
import {
|
|
21
|
+
import { execFileSync } from 'child_process';
|
|
20
22
|
import { loadSiteConfig } from './load-site-config.mjs';
|
|
23
|
+
import { assertLocalPathSafe } from './lib/local-path-safety.mjs';
|
|
21
24
|
|
|
22
25
|
const ROOT = process.cwd();
|
|
23
26
|
const DATASETS_DIR = path.join(ROOT, '.datasets');
|
|
@@ -39,23 +42,29 @@ async function downloadGcr(url, destPath) {
|
|
|
39
42
|
console.log(` Saved to ${destPath} (${(buf.length / 1024).toFixed(0)} KB)`);
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
// --- GCR extraction ---
|
|
43
|
-
function extractGcr(gcrPath, targetDir) {
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
// --- GCR extraction (pure JSZip; no shell, cross-platform) ---
|
|
46
|
+
async function extractGcr(gcrPath, targetDir) {
|
|
47
|
+
const targetAbs = path.resolve(targetDir);
|
|
48
|
+
if (fs.existsSync(targetAbs)) {
|
|
49
|
+
fs.rmSync(targetAbs, { recursive: true, force: true });
|
|
46
50
|
}
|
|
47
|
-
fs.mkdirSync(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
fs.mkdirSync(targetAbs, { recursive: true });
|
|
52
|
+
|
|
53
|
+
const buf = fs.readFileSync(gcrPath);
|
|
54
|
+
const zip = await JSZip.loadAsync(buf);
|
|
55
|
+
const entries = Object.values(zip.files);
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (entry.dir) continue;
|
|
58
|
+
// zip-slip guard: refuse entries that escape targetDir
|
|
59
|
+
const dest = path.resolve(targetAbs, entry.name);
|
|
60
|
+
if (dest !== targetAbs && !dest.startsWith(targetAbs + path.sep)) {
|
|
61
|
+
throw new Error(`Refusing to extract entry outside target dir: ${entry.name}`);
|
|
56
62
|
}
|
|
63
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
64
|
+
const content = await entry.async('nodebuffer');
|
|
65
|
+
fs.writeFileSync(dest, content);
|
|
57
66
|
}
|
|
58
|
-
console.log(` Extracted to ${
|
|
67
|
+
console.log(` Extracted to ${targetAbs}`);
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
// --- Read GCR metadata from ZIP without extraction ---
|
|
@@ -96,7 +105,7 @@ function validateDependencies(config, gcrMetadata) {
|
|
|
96
105
|
return errors;
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
// --- Git operations ---
|
|
108
|
+
// --- Git operations (execFileSync with array args — no shell) ---
|
|
100
109
|
function cloneOrUpdate(sourceRepo, targetDir) {
|
|
101
110
|
const env = { ...process.env };
|
|
102
111
|
let repoUrl = sourceRepo;
|
|
@@ -104,24 +113,28 @@ function cloneOrUpdate(sourceRepo, targetDir) {
|
|
|
104
113
|
repoUrl = sourceRepo.replace('https://', `https://x-access-token:${env.GITHUB_TOKEN}@`);
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
|
|
116
|
+
const targetAbs = path.resolve(targetDir);
|
|
117
|
+
|
|
118
|
+
if (fs.existsSync(path.join(targetAbs, '.git'))) {
|
|
108
119
|
console.log(` Updating existing clone...`);
|
|
109
120
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
execFileSync('git', ['fetch', 'origin'], { cwd: targetAbs, stdio: 'pipe', env });
|
|
122
|
+
execFileSync('git', ['reset', '--hard', 'origin/HEAD'], { cwd: targetAbs, stdio: 'pipe', env });
|
|
123
|
+
execFileSync('git', ['clean', '-fd'], { cwd: targetAbs, stdio: 'pipe', env });
|
|
113
124
|
} catch {
|
|
114
125
|
console.warn(` git update failed, re-cloning`);
|
|
115
|
-
fs.rmSync(
|
|
116
|
-
|
|
126
|
+
fs.rmSync(targetAbs, { recursive: true, force: true });
|
|
127
|
+
execFileSync('git', ['clone', '--depth', '1', repoUrl, targetAbs], { stdio: 'pipe', env });
|
|
117
128
|
}
|
|
118
129
|
} else {
|
|
119
|
-
fs.mkdirSync(
|
|
130
|
+
fs.mkdirSync(targetAbs, { recursive: true });
|
|
120
131
|
console.log(` Cloning ${sourceRepo}...`);
|
|
121
|
-
|
|
132
|
+
execFileSync('git', ['clone', '--depth', '1', repoUrl, targetAbs], { stdio: 'pipe', env });
|
|
122
133
|
}
|
|
123
134
|
}
|
|
124
135
|
|
|
136
|
+
// --- localPath safety check: see scripts/lib/local-path-safety.mjs ---
|
|
137
|
+
|
|
125
138
|
// --- Main ---
|
|
126
139
|
console.log('Fetching glossarist datasets...\n');
|
|
127
140
|
|
|
@@ -137,7 +150,7 @@ for (const ds of config.datasets) {
|
|
|
137
150
|
try {
|
|
138
151
|
if (fs.existsSync(gcrPath)) {
|
|
139
152
|
console.log(` Using local .gcr/${ds.id}.gcr`);
|
|
140
|
-
extractGcr(gcrPath, targetDir);
|
|
153
|
+
await extractGcr(gcrPath, targetDir);
|
|
141
154
|
} else if (ds.gcrPackage) {
|
|
142
155
|
console.log(` Using GCR package: ${ds.gcrPackage}`);
|
|
143
156
|
try {
|
|
@@ -148,29 +161,18 @@ for (const ds of config.datasets) {
|
|
|
148
161
|
console.log();
|
|
149
162
|
continue;
|
|
150
163
|
}
|
|
151
|
-
extractGcr(gcrPath, targetDir);
|
|
164
|
+
await extractGcr(gcrPath, targetDir);
|
|
165
|
+
} else if (ds.localPath) {
|
|
166
|
+
// localPath means "data is here, use in-place." No copy, no staging.
|
|
167
|
+
// generate-data.mjs reads from localPath directly via datasetDir(ds).
|
|
168
|
+
const localResolved = assertLocalPathSafe(ds.id, ds.localPath);
|
|
169
|
+
console.log(` Using localPath in-place: ${localResolved}`);
|
|
170
|
+
} else if (ds.sourceRepo) {
|
|
171
|
+
cloneOrUpdate(ds.sourceRepo, targetDir);
|
|
152
172
|
} else {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
|
|
157
|
-
const localConcepts = path.join(envOverride, 'concepts');
|
|
158
|
-
const targetConcepts = path.join(targetDir, 'concepts');
|
|
159
|
-
if (fs.existsSync(localConcepts)) {
|
|
160
|
-
if (fs.existsSync(targetConcepts)) fs.rmSync(targetConcepts, { recursive: true, force: true });
|
|
161
|
-
execSync(`cp -r "${localConcepts}" "${targetConcepts}"`, { stdio: 'pipe' });
|
|
162
|
-
}
|
|
163
|
-
const registerYaml = path.join(envOverride, 'register.yaml');
|
|
164
|
-
if (fs.existsSync(registerYaml)) {
|
|
165
|
-
fs.copyFileSync(registerYaml, path.join(targetDir, 'register.yaml'));
|
|
166
|
-
}
|
|
167
|
-
} else if (ds.sourceRepo) {
|
|
168
|
-
cloneOrUpdate(ds.sourceRepo, targetDir);
|
|
169
|
-
} else {
|
|
170
|
-
console.warn(` No source configured, skipping`);
|
|
171
|
-
console.log();
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
173
|
+
console.warn(` No source configured, skipping`);
|
|
174
|
+
console.log();
|
|
175
|
+
continue;
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
// Read metadata for dependency validation (from GCR ZIP, not extracted dir)
|
|
@@ -3,12 +3,24 @@ import path from 'path';
|
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
import { naturalSort, Register, parseMention } from 'glossarist';
|
|
5
5
|
import { loadSiteConfig } from './load-site-config.mjs';
|
|
6
|
-
|
|
6
|
+
import { getGroups } from './lib/concept-groups.mjs';
|
|
7
7
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
8
8
|
const ROOT = process.cwd();
|
|
9
9
|
const PUBLIC = path.join(ROOT, 'public');
|
|
10
10
|
const DATA = path.join(PUBLIC, 'data');
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a dataset's source directory.
|
|
14
|
+
* - If `ds.localPath` is set, use it in-place (resolved against ROOT).
|
|
15
|
+
* No staging, no copy. fetch-datasets.mjs verifies the path is safe.
|
|
16
|
+
* - Otherwise fall back to the standard .datasets/<id>/ staging dir.
|
|
17
|
+
*/
|
|
18
|
+
function datasetDir(ds) {
|
|
19
|
+
return ds.localPath
|
|
20
|
+
? path.resolve(ROOT, ds.localPath)
|
|
21
|
+
: path.join(ROOT, '.datasets', ds.id);
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
const DS_PALETTE = [
|
|
13
25
|
'#3366ff', '#0d9488', '#d97706', '#8b5cf6',
|
|
14
26
|
'#ec4899', '#059669', '#dc2626', '#6366f1',
|
|
@@ -574,24 +586,6 @@ function getPrimaryDesignation(conceptYaml) {
|
|
|
574
586
|
return descs;
|
|
575
587
|
}
|
|
576
588
|
|
|
577
|
-
function getGroups(conceptYaml) {
|
|
578
|
-
if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
|
|
579
|
-
// Derive groups from domains (e.g. section-based grouping in G18)
|
|
580
|
-
if (conceptYaml._domains) {
|
|
581
|
-
const sectionIds = conceptYaml._domains
|
|
582
|
-
.filter(d => d.ref_type === 'section' && d.concept_id)
|
|
583
|
-
.map(d => d.concept_id.replace(/^section-/, ''));
|
|
584
|
-
if (sectionIds.length) return sectionIds;
|
|
585
|
-
}
|
|
586
|
-
const termid = String(conceptYaml.termid);
|
|
587
|
-
if (/^\d{3}-/.test(termid)) return [termid.substring(0, 3)];
|
|
588
|
-
if (/^\d+\.\d+\.\d+/.test(termid)) {
|
|
589
|
-
const parts = termid.split('.');
|
|
590
|
-
return [`${parts[0]}.${parts[1]}.${parts[2]}`];
|
|
591
|
-
}
|
|
592
|
-
return [];
|
|
593
|
-
}
|
|
594
|
-
|
|
595
589
|
function escapeTurtle(s) {
|
|
596
590
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
597
591
|
}
|
|
@@ -952,7 +946,8 @@ function processDataset(dir, register, opts) {
|
|
|
952
946
|
}
|
|
953
947
|
|
|
954
948
|
// Copy bulk format files from compiled/ directory (full GCR)
|
|
955
|
-
const
|
|
949
|
+
const sourceRoot = path.dirname(dir);
|
|
950
|
+
const compiledDir = path.join(sourceRoot, 'compiled');
|
|
956
951
|
const bulkFormats = [];
|
|
957
952
|
if (fs.existsSync(compiledDir)) {
|
|
958
953
|
for (const file of fs.readdirSync(compiledDir)) {
|
|
@@ -1011,7 +1006,7 @@ function processDataset(dir, register, opts) {
|
|
|
1011
1006
|
writeJson(path.join(DATA, register, 'manifest.json'), manifest);
|
|
1012
1007
|
|
|
1013
1008
|
// Copy bibliography.yaml → bibliography.json
|
|
1014
|
-
const bibPath = path.join(
|
|
1009
|
+
const bibPath = path.join(sourceRoot, 'bibliography.yaml');
|
|
1015
1010
|
if (fs.existsSync(bibPath)) {
|
|
1016
1011
|
const bibData = readYaml(bibPath);
|
|
1017
1012
|
writeJson(path.join(DATA, register, 'bibliography.json'), bibData);
|
|
@@ -1019,7 +1014,7 @@ function processDataset(dir, register, opts) {
|
|
|
1019
1014
|
}
|
|
1020
1015
|
|
|
1021
1016
|
// Copy images/
|
|
1022
|
-
const imagesSrcDir = path.join(
|
|
1017
|
+
const imagesSrcDir = path.join(sourceRoot, 'images');
|
|
1023
1018
|
if (fs.existsSync(imagesSrcDir) && fs.statSync(imagesSrcDir).isDirectory()) {
|
|
1024
1019
|
const imagesDestDir = path.join(DATA, register, 'images');
|
|
1025
1020
|
fs.mkdirSync(imagesDestDir, { recursive: true });
|
|
@@ -1048,8 +1043,8 @@ const registerCache = {};
|
|
|
1048
1043
|
|
|
1049
1044
|
// Pre-load all register.yaml files (needed before buildRefMaps for URI pattern indexing)
|
|
1050
1045
|
for (const ds of config.datasets) {
|
|
1051
|
-
const
|
|
1052
|
-
const registerYamlPath = path.join(
|
|
1046
|
+
const dsDir = datasetDir(ds);
|
|
1047
|
+
const registerYamlPath = path.join(dsDir, 'register.yaml');
|
|
1053
1048
|
if (fs.existsSync(registerYamlPath)) {
|
|
1054
1049
|
try {
|
|
1055
1050
|
const raw = yaml.load(fs.readFileSync(registerYamlPath, 'utf8'));
|
|
@@ -1065,7 +1060,7 @@ const refMaps = buildRefMaps(config, registerCache);
|
|
|
1065
1060
|
for (let i = 0; i < config.datasets.length; i++) {
|
|
1066
1061
|
const ds = config.datasets[i];
|
|
1067
1062
|
|
|
1068
|
-
const dir = path.join(
|
|
1063
|
+
const dir = path.join(datasetDir(ds), 'concepts');
|
|
1069
1064
|
if (!fs.existsSync(dir)) {
|
|
1070
1065
|
console.warn(`Skipping ${ds.id}: source directory not found (${dir})`);
|
|
1071
1066
|
console.warn(` Run: npm run fetch-datasets`);
|
|
@@ -1107,8 +1102,8 @@ for (let i = 0; i < config.datasets.length; i++) {
|
|
|
1107
1102
|
status: ds.editionStatus || reg?.status,
|
|
1108
1103
|
ordering: reg?.ordering || null,
|
|
1109
1104
|
sections: reg?.sections ? reg.sections.map(s => s.toJSON()) : [],
|
|
1110
|
-
hasBibliography: fs.existsSync(path.join(
|
|
1111
|
-
hasImages: fs.existsSync(path.join(
|
|
1105
|
+
hasBibliography: fs.existsSync(path.join(datasetDir(ds), 'bibliography.yaml')),
|
|
1106
|
+
hasImages: fs.existsSync(path.join(datasetDir(ds), 'images')),
|
|
1112
1107
|
});
|
|
1113
1108
|
registry.push({
|
|
1114
1109
|
id: ds.id,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function getGroups(conceptYaml) {
|
|
2
|
+
if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
|
|
3
|
+
if (conceptYaml._domains) {
|
|
4
|
+
const sectionIds = conceptYaml._domains
|
|
5
|
+
.filter(d => d.ref_type === 'section' && d.concept_id)
|
|
6
|
+
.map(d => d.concept_id.replace(/^section-/, ''));
|
|
7
|
+
if (sectionIds.length) return sectionIds;
|
|
8
|
+
}
|
|
9
|
+
const termid = String(conceptYaml.termid);
|
|
10
|
+
if (/^\d{3}-/.test(termid)) return [termid.substring(0, 3)];
|
|
11
|
+
if (/^\d+\.\d+\.\d+/.test(termid)) {
|
|
12
|
+
const parts = termid.split('.');
|
|
13
|
+
return [`${parts[0]}.${parts[1]}.${parts[2]}`];
|
|
14
|
+
}
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Assert that a dataset's `localPath` is safe to use as an in-place source.
|
|
6
|
+
*
|
|
7
|
+
* "Safe" means: the resolved physical location of `localPath` is disjoint
|
|
8
|
+
* from the staging directory (`.datasets/<id>`). If they overlap, staging
|
|
9
|
+
* operations (rm, extract, clone) would destroy the user's source data —
|
|
10
|
+
* the data-loss bug reported in v0.7.43.
|
|
11
|
+
*
|
|
12
|
+
* Returns the resolved absolute path on success; throws on any hazard.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} datasetId
|
|
15
|
+
* @param {string} localPath - relative to `root` (or absolute)
|
|
16
|
+
* @param {{ root?: string, datasetsDir?: string }} [opts]
|
|
17
|
+
* @returns {string} resolved absolute path
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Compute the canonical physical path of `p`, resolving symlinks on the
|
|
21
|
+
* existing prefix. If `p` itself exists, this is just `realpathSync(p)`.
|
|
22
|
+
* If not, we walk up to the nearest existing ancestor, realpath it, and
|
|
23
|
+
* re-append the non-existent tail. This is needed because macOS tmpdir
|
|
24
|
+
* (`/var/folders/...`) is a symlink to `/private/var/folders/...`; without
|
|
25
|
+
* this, prefix comparisons across the symlink boundary silently fail.
|
|
26
|
+
*/
|
|
27
|
+
function physicalPath(p) {
|
|
28
|
+
if (fs.existsSync(p)) return fs.realpathSync(p);
|
|
29
|
+
const parent = path.dirname(p);
|
|
30
|
+
const parentReal = fs.existsSync(parent) ? fs.realpathSync(parent) : physicalPath(parent);
|
|
31
|
+
return path.join(parentReal, path.basename(p));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertLocalPathSafe(datasetId, localPath, { root = process.cwd(), datasetsDir } = {}) {
|
|
35
|
+
const datasetsRoot = datasetsDir || path.join(root, '.datasets');
|
|
36
|
+
const localResolved = path.resolve(root, localPath);
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(localResolved)) {
|
|
39
|
+
throw new Error(`localPath for ${datasetId} does not exist: ${localResolved}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const localReal = fs.realpathSync(localResolved);
|
|
43
|
+
const stagedAbs = path.join(datasetsRoot, datasetId);
|
|
44
|
+
const stagedReal = physicalPath(stagedAbs);
|
|
45
|
+
|
|
46
|
+
if (localReal === stagedReal) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`localPath for ${datasetId} resolves to the same physical location as .datasets/${datasetId} ` +
|
|
49
|
+
`(${localReal}). Refusing to operate — source and staging would clobber. ` +
|
|
50
|
+
`Use a path outside .datasets/.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (localReal.startsWith(stagedReal + path.sep)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`localPath for ${datasetId} is nested inside .datasets/${datasetId}. ` +
|
|
56
|
+
`Refusing to operate — staging operations would destroy source data. ` +
|
|
57
|
+
`Use a path outside .datasets/.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (stagedReal.startsWith(localReal + path.sep)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`localPath for ${datasetId} contains .datasets/${datasetId}. ` +
|
|
63
|
+
`Refusing to operate — staging operations would destroy source data. ` +
|
|
64
|
+
`Use a path outside localPath.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return localReal;
|
|
68
|
+
}
|
|
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia } from 'pinia';
|
|
|
4
4
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
5
5
|
import DatasetView from '../views/DatasetView.vue';
|
|
6
6
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
|
-
import type { Manifest, ConceptSummary } from '../adapters/types';
|
|
7
|
+
import type { Manifest, ConceptSummary, SectionNode } from '../adapters/types';
|
|
8
8
|
|
|
9
9
|
function makeManifest(overrides: Partial<Manifest> = {}): Manifest {
|
|
10
10
|
return {
|
|
@@ -230,4 +230,98 @@ describe('DatasetView', () => {
|
|
|
230
230
|
expect(prevBtn).toBeDefined();
|
|
231
231
|
expect(prevBtn!.attributes('disabled')).toBeDefined();
|
|
232
232
|
});
|
|
233
|
+
|
|
234
|
+
describe('hierarchical section filter', () => {
|
|
235
|
+
const HIERARCHICAL_TREE: SectionNode[] = [
|
|
236
|
+
{
|
|
237
|
+
id: '102',
|
|
238
|
+
names: { eng: 'Mathematics' },
|
|
239
|
+
conceptCount: 0,
|
|
240
|
+
children: [
|
|
241
|
+
{ id: '102-01', names: { eng: 'Sets' }, conceptCount: 2 },
|
|
242
|
+
{
|
|
243
|
+
id: '102-02',
|
|
244
|
+
names: { eng: 'Numbers' },
|
|
245
|
+
conceptCount: 2,
|
|
246
|
+
children: [
|
|
247
|
+
{ id: '102-02-01', names: { eng: 'Reals' }, conceptCount: 1 },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
{ id: '103', names: { eng: 'Functions' }, conceptCount: 1 },
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
function makeHierarchicalAdapter(concepts: ConceptSummary[], sections: SectionNode[]) {
|
|
256
|
+
const dense = concepts.filter(Boolean);
|
|
257
|
+
return {
|
|
258
|
+
registerId: 'test',
|
|
259
|
+
index: dense,
|
|
260
|
+
manifest: null,
|
|
261
|
+
getConceptCount: () => dense.length,
|
|
262
|
+
getConcepts: () => dense,
|
|
263
|
+
isRangeLoaded: () => true,
|
|
264
|
+
ensureChunksForRange: async () => {},
|
|
265
|
+
ensureAllChunksLoaded: async () => {},
|
|
266
|
+
getAdjacentConcepts: () => ({ prev: null, next: null }),
|
|
267
|
+
getSectionTree: () => sections,
|
|
268
|
+
} as any;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function mountHierarchical(concepts: ConceptSummary[], sectionId: string) {
|
|
272
|
+
const store = useVocabularyStore();
|
|
273
|
+
store.manifests.set('test', makeManifest());
|
|
274
|
+
store.datasets.set('test', makeHierarchicalAdapter(concepts, HIERARCHICAL_TREE));
|
|
275
|
+
return mount(DatasetView, {
|
|
276
|
+
global: { plugins: [pinia, router] },
|
|
277
|
+
props: { registerId: 'test' },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
it('matches concepts in a child section when filtering by parent (closure)', async () => {
|
|
282
|
+
const concepts: ConceptSummary[] = [
|
|
283
|
+
{ id: 'a', designations: { eng: 'set A' }, eng: 'set A', status: 'valid', groups: ['102-01'] },
|
|
284
|
+
{ id: 'b', designations: { eng: 'unrelated' }, eng: 'unrelated', status: 'valid', groups: ['999'] },
|
|
285
|
+
];
|
|
286
|
+
const wrapper = mountHierarchical(concepts, 'section-102');
|
|
287
|
+
await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
|
|
288
|
+
await flushPromises();
|
|
289
|
+
expect(wrapper.text()).toContain('set A');
|
|
290
|
+
expect(wrapper.text()).not.toContain('unrelated');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('matches concepts at arbitrary depth (grandparent closure)', async () => {
|
|
294
|
+
const concepts: ConceptSummary[] = [
|
|
295
|
+
{ id: 'deep', designations: { eng: 'real number' }, eng: 'real number', status: 'valid', groups: ['102-02-01'] },
|
|
296
|
+
{ id: 'other', designations: { eng: 'other concept' }, eng: 'other concept', status: 'valid', groups: ['103'] },
|
|
297
|
+
];
|
|
298
|
+
const wrapper = mountHierarchical(concepts, 'section-102');
|
|
299
|
+
await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
|
|
300
|
+
await flushPromises();
|
|
301
|
+
expect(wrapper.text()).toContain('real number');
|
|
302
|
+
expect(wrapper.text()).not.toContain('other concept');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('still matches by concept ID prefix when no groups are set', async () => {
|
|
306
|
+
const concepts: ConceptSummary[] = [
|
|
307
|
+
{ id: '102.3.4', designations: { eng: 'numbered term' }, eng: 'numbered term', status: 'valid' },
|
|
308
|
+
{ id: '999.1', designations: { eng: 'different section' }, eng: 'different section', status: 'valid' },
|
|
309
|
+
];
|
|
310
|
+
const wrapper = mountHierarchical(concepts, 'section-102');
|
|
311
|
+
await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
|
|
312
|
+
await flushPromises();
|
|
313
|
+
expect(wrapper.text()).toContain('numbered term');
|
|
314
|
+
expect(wrapper.text()).not.toContain('different section');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('renders the section display name when filter is active', async () => {
|
|
318
|
+
const concepts: ConceptSummary[] = [
|
|
319
|
+
{ id: 'a', designations: { eng: 'set A' }, eng: 'set A', status: 'valid', groups: ['102-01'] },
|
|
320
|
+
];
|
|
321
|
+
const wrapper = mountHierarchical(concepts, 'section-102');
|
|
322
|
+
await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
|
|
323
|
+
await flushPromises();
|
|
324
|
+
expect(wrapper.text()).toContain('102 — Mathematics');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
233
327
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sectionName, formatSectionLabel } from '../utils/section-display';
|
|
3
|
+
|
|
4
|
+
describe('sectionName', () => {
|
|
5
|
+
it('prefers the requested language', () => {
|
|
6
|
+
expect(sectionName({ id: 'x', names: { eng: 'X', fra: 'X (fra)' } }, 'fra')).toBe('X (fra)');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('falls back to English when requested language missing', () => {
|
|
10
|
+
expect(sectionName({ id: 'x', names: { eng: 'X' } }, 'deu')).toBe('X');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('falls back to id when no names at all', () => {
|
|
14
|
+
expect(sectionName({ id: 'x', names: {} }, 'fra')).toBe('x');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('falls back to id when names object missing', () => {
|
|
18
|
+
expect(sectionName({ id: 'x' }, 'fra')).toBe('x');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('formatSectionLabel', () => {
|
|
23
|
+
it('returns just id when name is empty', () => {
|
|
24
|
+
expect(formatSectionLabel({ id: '102', names: {} }, 'eng')).toBe('102');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns just id when name equals id', () => {
|
|
28
|
+
expect(formatSectionLabel({ id: '102', names: { eng: '102' } }, 'eng')).toBe('102');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns just name when name equals id-with-spaces (EXPRESS schema convention)', () => {
|
|
32
|
+
expect(formatSectionLabel({ id: 'action_schema', names: { eng: 'action schema' } }, 'eng')).toBe('action schema');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns "id — name" when name is meaningfully different', () => {
|
|
36
|
+
expect(formatSectionLabel({ id: '102', names: { eng: 'Mathematics' } }, 'eng')).toBe('102 — Mathematics');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('localizes the name lookup', () => {
|
|
40
|
+
expect(formatSectionLabel({ id: '102', names: { eng: 'Math', fra: 'Maths' } }, 'fra')).toBe('102 — Maths');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('falls back through English when requested language has no name', () => {
|
|
44
|
+
expect(formatSectionLabel({ id: '102', names: { eng: 'Math' } }, 'fra')).toBe('102 — Math');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { SectionNode } from '../adapters/types';
|
|
3
|
+
import {
|
|
4
|
+
findSectionNode,
|
|
5
|
+
collectDescendantSectionIds,
|
|
6
|
+
toSectionNode,
|
|
7
|
+
toSectionTree,
|
|
8
|
+
} from '../utils/section-tree';
|
|
9
|
+
|
|
10
|
+
const TREE: SectionNode[] = [
|
|
11
|
+
{
|
|
12
|
+
id: '102',
|
|
13
|
+
names: { eng: 'Mathematics' },
|
|
14
|
+
conceptCount: 0,
|
|
15
|
+
children: [
|
|
16
|
+
{ id: '102-01', names: { eng: 'Sets' }, conceptCount: 5 },
|
|
17
|
+
{
|
|
18
|
+
id: '102-02',
|
|
19
|
+
names: { eng: 'Numbers' },
|
|
20
|
+
conceptCount: 3,
|
|
21
|
+
children: [
|
|
22
|
+
{ id: '102-02-01', names: { eng: 'Reals' }, conceptCount: 1 },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{ id: '103', names: { eng: 'Functions' }, conceptCount: 0 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe('findSectionNode', () => {
|
|
31
|
+
it('finds a root by id', () => {
|
|
32
|
+
expect(findSectionNode(TREE, '103')?.id).toBe('103');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('finds a descendant by id (recursive)', () => {
|
|
36
|
+
expect(findSectionNode(TREE, '102-02-01')?.id).toBe('102-02-01');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns null for unknown id', () => {
|
|
40
|
+
expect(findSectionNode(TREE, '999')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns null for empty tree', () => {
|
|
44
|
+
expect(findSectionNode([], '102')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('collectDescendantSectionIds', () => {
|
|
49
|
+
it('includes root and all descendants at arbitrary depth', () => {
|
|
50
|
+
const ids = collectDescendantSectionIds(TREE, '102');
|
|
51
|
+
expect([...ids].sort()).toEqual(['102', '102-01', '102-02', '102-02-01']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns single-element set for a leaf', () => {
|
|
55
|
+
expect([...collectDescendantSectionIds(TREE, '103')]).toEqual(['103']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns empty set for unknown root', () => {
|
|
59
|
+
expect(collectDescendantSectionIds(TREE, '999').size).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('toSectionNode', () => {
|
|
64
|
+
it('maps minimal input with id only', () => {
|
|
65
|
+
expect(toSectionNode({ id: 'x' })).toEqual({
|
|
66
|
+
id: 'x',
|
|
67
|
+
names: {},
|
|
68
|
+
conceptCount: 0,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('preserves names and conceptCount', () => {
|
|
73
|
+
expect(toSectionNode({ id: 'x', names: { eng: 'X' }, conceptCount: 7 })).toEqual({
|
|
74
|
+
id: 'x',
|
|
75
|
+
names: { eng: 'X' },
|
|
76
|
+
conceptCount: 7,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('defaults missing id to empty string', () => {
|
|
81
|
+
expect(toSectionNode({}).id).toBe('');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('defaults missing conceptCount to 0', () => {
|
|
85
|
+
expect(toSectionNode({ id: 'x' }).conceptCount).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('defaults missing names to empty object', () => {
|
|
89
|
+
expect(toSectionNode({ id: 'x' }).names).toEqual({});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('recursively maps children', () => {
|
|
93
|
+
const node = toSectionNode({
|
|
94
|
+
id: 'p',
|
|
95
|
+
names: { eng: 'Parent' },
|
|
96
|
+
children: [{ id: 'c', names: { eng: 'Child' } }],
|
|
97
|
+
});
|
|
98
|
+
expect(node.children?.[0]).toEqual({
|
|
99
|
+
id: 'c',
|
|
100
|
+
names: { eng: 'Child' },
|
|
101
|
+
conceptCount: 0,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('omits children array when source has none', () => {
|
|
106
|
+
expect(toSectionNode({ id: 'x' }).children).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('omits children array when source children is empty', () => {
|
|
110
|
+
expect(toSectionNode({ id: 'x', children: [] }).children).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('toSectionTree', () => {
|
|
115
|
+
it('maps a list of roots', () => {
|
|
116
|
+
const tree = toSectionTree([
|
|
117
|
+
{ id: 'a', names: { eng: 'A' } },
|
|
118
|
+
{ id: 'b' },
|
|
119
|
+
]);
|
|
120
|
+
expect(tree).toHaveLength(2);
|
|
121
|
+
expect(tree[0].id).toBe('a');
|
|
122
|
+
expect(tree[1].names).toEqual({});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns empty array for empty input', () => {
|
|
126
|
+
expect(toSectionTree([])).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { Concept, RelatedConcept } from 'glossarist';
|
|
|
3
3
|
import type { DatasetAdapter } from './DatasetAdapter';
|
|
4
4
|
import { UriRouter } from './UriRouter';
|
|
5
5
|
import { slugify } from '../utils/slugify';
|
|
6
|
+
import { toSectionNode, toSectionTree } from '../utils/section-tree';
|
|
6
7
|
|
|
7
8
|
interface DomainNodeJson {
|
|
8
9
|
uri?: string;
|
|
@@ -14,12 +15,6 @@ interface DomainNodeJson {
|
|
|
14
15
|
children?: DomainNodeJson[];
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
interface SectionJson {
|
|
18
|
-
id: string;
|
|
19
|
-
names?: Record<string, string>;
|
|
20
|
-
children?: SectionJson[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
|
|
24
19
|
if (!rc.ref) return '';
|
|
25
20
|
const ref = rc.ref;
|
|
@@ -136,7 +131,7 @@ export class GraphDataSource {
|
|
|
136
131
|
getSectionTree(): SectionNode[] {
|
|
137
132
|
const nodes = this.adapter.manifest?.sections;
|
|
138
133
|
if (!nodes || nodes.length === 0) return [];
|
|
139
|
-
return nodes
|
|
134
|
+
return toSectionTree(nodes);
|
|
140
135
|
}
|
|
141
136
|
|
|
142
137
|
private mapDomainNode(dn: DomainNodeJson): GraphNode {
|
|
@@ -151,28 +146,17 @@ export class GraphDataSource {
|
|
|
151
146
|
conceptCount: dn.conceptCount || 0,
|
|
152
147
|
};
|
|
153
148
|
if (dn.children && dn.children.length > 0) {
|
|
154
|
-
node.children = dn.children.map(
|
|
149
|
+
node.children = dn.children.map(c => this.domainNodeToSection(c));
|
|
155
150
|
}
|
|
156
151
|
return node;
|
|
157
152
|
}
|
|
158
153
|
|
|
159
|
-
private
|
|
160
|
-
|
|
161
|
-
id: dn.id
|
|
154
|
+
private domainNodeToSection(dn: DomainNodeJson): SectionNode {
|
|
155
|
+
return toSectionNode({
|
|
156
|
+
id: dn.id,
|
|
162
157
|
names: dn.names || (dn.label ? { eng: dn.label } : {}),
|
|
163
|
-
conceptCount: dn.conceptCount
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
node.children = dn.children.map((c) => this.mapSectionNode(c));
|
|
167
|
-
}
|
|
168
|
-
return node;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private mapManifestSection(s: SectionJson): SectionNode {
|
|
172
|
-
const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
|
|
173
|
-
if (s.children && s.children.length > 0) {
|
|
174
|
-
node.children = s.children.map(c => this.mapManifestSection(c));
|
|
175
|
-
}
|
|
176
|
-
return node;
|
|
158
|
+
conceptCount: dn.conceptCount,
|
|
159
|
+
children: dn.children,
|
|
160
|
+
});
|
|
177
161
|
}
|
|
178
162
|
}
|
|
@@ -8,6 +8,8 @@ import { useSiteConfig } from '../config/use-site-config';
|
|
|
8
8
|
import NavIcon from './NavIcon.vue';
|
|
9
9
|
import { useI18n, locale } from '../i18n';
|
|
10
10
|
import type { SectionNode } from '../adapters/types';
|
|
11
|
+
import { toSectionTree } from '../utils/section-tree';
|
|
12
|
+
import { formatSectionLabel, sectionName as sectionLocalized } from '../utils/section-display';
|
|
11
13
|
|
|
12
14
|
const OntologySidebarSection = defineAsyncComponent(() => import('./OntologySidebarSection.vue'));
|
|
13
15
|
|
|
@@ -174,26 +176,15 @@ function toggleSectionNode(id: string) {
|
|
|
174
176
|
function getDatasetSections(dsId: string): SectionNode[] {
|
|
175
177
|
const m = store.manifests.get(dsId);
|
|
176
178
|
if (!m?.sections?.length) return [];
|
|
177
|
-
return m.sections
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function enrichSectionNode(s: { id: string; names: Record<string, string>; children?: any[] }): SectionNode {
|
|
181
|
-
const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
|
|
182
|
-
if (s.children && s.children.length > 0) {
|
|
183
|
-
node.children = s.children.map(c => enrichSectionNode(c));
|
|
184
|
-
}
|
|
185
|
-
return node;
|
|
179
|
+
return toSectionTree(m.sections);
|
|
186
180
|
}
|
|
187
181
|
|
|
188
182
|
function sectionLabel(section: SectionNode): string {
|
|
189
|
-
|
|
190
|
-
return name || section.id;
|
|
183
|
+
return sectionLocalized(section, locale.value);
|
|
191
184
|
}
|
|
192
185
|
|
|
193
186
|
function sectionDisplay(section: SectionNode): string {
|
|
194
|
-
|
|
195
|
-
if (name && name !== section.id) return `${section.id} — ${name}`;
|
|
196
|
-
return name || section.id;
|
|
187
|
+
return formatSectionLabel(section, locale.value);
|
|
197
188
|
}
|
|
198
189
|
|
|
199
190
|
function goToSection(dsId: string, sectionId: string) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SectionNode } from '../adapters/types';
|
|
2
|
+
|
|
3
|
+
interface SectionLike {
|
|
4
|
+
id: string;
|
|
5
|
+
names?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sectionName(section: SectionLike, lang: string): string {
|
|
9
|
+
const names = section.names || {};
|
|
10
|
+
return names[lang] || names.eng || section.id;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatSectionLabel(section: SectionLike, lang: string): string {
|
|
14
|
+
const names = section.names || {};
|
|
15
|
+
const name = names[lang] || names.eng || '';
|
|
16
|
+
const bare = section.id;
|
|
17
|
+
if (!name) return bare;
|
|
18
|
+
if (name === bare) return name;
|
|
19
|
+
if (name === bare.replace(/_/g, ' ')) return name;
|
|
20
|
+
return `${bare} — ${name}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SectionNode } from '../adapters/types';
|
|
2
|
+
|
|
3
|
+
interface SectionLike {
|
|
4
|
+
id?: string;
|
|
5
|
+
names?: Record<string, string>;
|
|
6
|
+
conceptCount?: number;
|
|
7
|
+
children?: SectionLike[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function findSectionNode(
|
|
11
|
+
tree: readonly SectionNode[],
|
|
12
|
+
id: string,
|
|
13
|
+
): SectionNode | null {
|
|
14
|
+
for (const node of tree) {
|
|
15
|
+
if (node.id === id) return node;
|
|
16
|
+
if (node.children) {
|
|
17
|
+
const found = findSectionNode(node.children, id);
|
|
18
|
+
if (found) return found;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function collectDescendantSectionIds(
|
|
25
|
+
tree: readonly SectionNode[],
|
|
26
|
+
rootId: string,
|
|
27
|
+
): Set<string> {
|
|
28
|
+
const root = findSectionNode(tree, rootId);
|
|
29
|
+
if (!root) return new Set();
|
|
30
|
+
const ids = new Set<string>();
|
|
31
|
+
const walk = (n: SectionNode) => {
|
|
32
|
+
ids.add(n.id);
|
|
33
|
+
n.children?.forEach(walk);
|
|
34
|
+
};
|
|
35
|
+
walk(root);
|
|
36
|
+
return ids;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function toSectionNode(s: SectionLike): SectionNode {
|
|
40
|
+
const node: SectionNode = {
|
|
41
|
+
id: s.id ?? '',
|
|
42
|
+
names: s.names || {},
|
|
43
|
+
conceptCount: s.conceptCount ?? 0,
|
|
44
|
+
};
|
|
45
|
+
if (s.children && s.children.length > 0) {
|
|
46
|
+
node.children = s.children.map(toSectionNode);
|
|
47
|
+
}
|
|
48
|
+
return node;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function toSectionTree(items: readonly SectionLike[]): SectionNode[] {
|
|
52
|
+
return items.map(toSectionNode);
|
|
53
|
+
}
|
|
@@ -9,7 +9,9 @@ import { langName, langLabel, sortLanguages } from '../utils/lang';
|
|
|
9
9
|
import ConceptCard from '../components/ConceptCard.vue';
|
|
10
10
|
import { useI18n, locale } from '../i18n';
|
|
11
11
|
import { useSiteConfig } from '../config/use-site-config';
|
|
12
|
-
import type { SectionNode } from '../adapters/types';
|
|
12
|
+
import type { SectionNode, ConceptSummary } from '../adapters/types';
|
|
13
|
+
import { collectDescendantSectionIds, findSectionNode } from '../utils/section-tree';
|
|
14
|
+
import { formatSectionLabel } from '../utils/section-display';
|
|
13
15
|
|
|
14
16
|
const props = defineProps<{ registerId: string }>();
|
|
15
17
|
|
|
@@ -167,20 +169,34 @@ const filtered = computed(() => {
|
|
|
167
169
|
const q = filter.value.trim().toLowerCase();
|
|
168
170
|
const lang = selectedLang.value;
|
|
169
171
|
const sec = sectionQuery.value;
|
|
172
|
+
const closure = sectionClosure.value;
|
|
170
173
|
return loadedConcepts.value.filter(c => {
|
|
171
174
|
if (lang && !(lang in (c.designations ?? {}))) return false;
|
|
172
|
-
if (sec && !conceptMatchesSection(c, sec)) return false;
|
|
175
|
+
if (sec && !conceptMatchesSection(c, sec.replace(/^section-/, ''), closure)) return false;
|
|
173
176
|
if (!q) return true;
|
|
174
177
|
return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
|
|
175
178
|
});
|
|
176
179
|
});
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
const sectionClosure = computed<Set<string> | null>(() => {
|
|
182
|
+
const q = sectionQuery.value;
|
|
183
|
+
if (!q) return null;
|
|
184
|
+
const prefix = q.replace(/^section-/, '');
|
|
185
|
+
const tree = getSections();
|
|
186
|
+
const closure = collectDescendantSectionIds(tree, prefix);
|
|
187
|
+
return closure.size > 0 ? closure : null;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function conceptMatchesSection(concept: ConceptSummary, sectionId: string, closure: Set<string> | null): boolean {
|
|
191
|
+
if (closure) {
|
|
192
|
+
if (concept.groups?.some(g => closure.has(g))) return true;
|
|
193
|
+
if (closure.has(sectionId)) {
|
|
194
|
+
return concept.id.startsWith(sectionId + '.') || concept.id.startsWith(sectionId + '-');
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (concept.groups?.length && concept.groups.includes(sectionId)) return true;
|
|
199
|
+
return concept.id.startsWith(sectionId + '.') || concept.id.startsWith(sectionId + '-');
|
|
184
200
|
}
|
|
185
201
|
|
|
186
202
|
function getSections(): SectionNode[] {
|
|
@@ -188,18 +204,12 @@ function getSections(): SectionNode[] {
|
|
|
188
204
|
return adapter.value.getSectionTree();
|
|
189
205
|
}
|
|
190
206
|
|
|
191
|
-
function sectionName(section: SectionNode): string {
|
|
192
|
-
return section.names[locale.value] || section.names.eng || section.id;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
207
|
const sectionDisplayName = computed(() => {
|
|
196
208
|
if (!sectionQuery.value) return '';
|
|
197
209
|
const prefix = sectionQuery.value.replace(/^section-/, '');
|
|
198
|
-
const
|
|
199
|
-
const found = sections.find(s => s.id === prefix);
|
|
210
|
+
const found = findSectionNode(getSections(), prefix);
|
|
200
211
|
if (!found) return prefix;
|
|
201
|
-
|
|
202
|
-
return name !== found.id ? `${found.id} — ${name}` : name;
|
|
212
|
+
return formatSectionLabel(found, locale.value);
|
|
203
213
|
});
|
|
204
214
|
|
|
205
215
|
// Alphabetical grouping
|