fhirsmith 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/configurations/projector.json +21 -0
- package/configurations/readme.md +5 -0
- package/library/package-manager.js +0 -2
- package/library/version-utilities.js +85 -0
- package/package.json +1 -1
- package/packages/package-crawler.js +44 -9
- package/packages/packages.js +1 -0
- package/registry/crawler.js +35 -14
- package/registry/registry.js +3 -0
- package/server.js +4 -0
- package/tx/README.md +4 -4
- package/tx/cs/cs-loinc.js +5 -2
- package/tx/cs/cs-provider-api.js +25 -1
- package/tx/cs/cs-provider-list.js +2 -2
- package/tx/library/canonical-resource.js +6 -1
- package/tx/library.js +127 -10
- package/tx/ocl/README.md +236 -0
- package/tx/ocl/cache/cache-paths.cjs +32 -0
- package/tx/ocl/cache/cache-paths.js +2 -0
- package/tx/ocl/cache/cache-utils.cjs +43 -0
- package/tx/ocl/cache/cache-utils.js +2 -0
- package/tx/ocl/cm-ocl.cjs +531 -0
- package/tx/ocl/cm-ocl.js +1 -105
- package/tx/ocl/cs-ocl.cjs +1779 -0
- package/tx/ocl/cs-ocl.js +1 -38
- package/tx/ocl/fingerprint/fingerprint.cjs +67 -0
- package/tx/ocl/fingerprint/fingerprint.js +2 -0
- package/tx/ocl/http/client.cjs +31 -0
- package/tx/ocl/http/client.js +2 -0
- package/tx/ocl/http/pagination.cjs +98 -0
- package/tx/ocl/http/pagination.js +2 -0
- package/tx/ocl/jobs/background-queue.cjs +200 -0
- package/tx/ocl/jobs/background-queue.js +2 -0
- package/tx/ocl/mappers/concept-mapper.cjs +66 -0
- package/tx/ocl/mappers/concept-mapper.js +2 -0
- package/tx/ocl/model/concept-filter-context.cjs +51 -0
- package/tx/ocl/model/concept-filter-context.js +2 -0
- package/tx/ocl/shared/constants.cjs +15 -0
- package/tx/ocl/shared/constants.js +2 -0
- package/tx/ocl/shared/patches.cjs +224 -0
- package/tx/ocl/shared/patches.js +2 -0
- package/tx/ocl/vs-ocl.cjs +1848 -0
- package/tx/ocl/vs-ocl.js +1 -104
- package/tx/operation-context.js +8 -1
- package/tx/params.js +24 -3
- package/tx/provider.js +47 -0
- package/tx/tx-html.js +1 -1
- package/tx/tx.js +8 -0
- package/tx/vs/vs-vsac.js +4 -3
- package/tx/workers/batch-validate.js +3 -2
- package/tx/workers/batch.js +3 -2
- package/tx/workers/expand.js +64 -9
- package/tx/workers/lookup.js +5 -4
- package/tx/workers/read.js +2 -1
- package/tx/workers/related.js +3 -2
- package/tx/workers/search.js +4 -9
- package/tx/workers/subsumes.js +3 -2
- package/tx/workers/translate.js +4 -3
- package/tx/workers/validate.js +132 -40
- package/tx/workers/worker.js +1 -7
|
@@ -0,0 +1,1848 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { AbstractValueSetProvider } = require('../vs/vs-api');
|
|
5
|
+
const { VersionUtilities } = require('../../library/version-utilities');
|
|
6
|
+
const ValueSet = require('../library/valueset');
|
|
7
|
+
const { SearchFilterText } = require('../library/designations');
|
|
8
|
+
const { TxParameters } = require('../params');
|
|
9
|
+
const { OCLSourceCodeSystemFactory, OCLBackgroundJobQueue } = require('./cs-ocl');
|
|
10
|
+
const { PAGE_SIZE, CONCEPT_PAGE_SIZE, FILTERED_CONCEPT_PAGE_SIZE, COLD_CACHE_FRESHNESS_MS } = require('./shared/constants');
|
|
11
|
+
const { createOclHttpClient } = require('./http/client');
|
|
12
|
+
const { CACHE_VS_DIR, getCacheFilePath } = require('./cache/cache-paths');
|
|
13
|
+
const { ensureCacheDirectories, getColdCacheAgeMs, formatCacheAgeMinutes } = require('./cache/cache-utils');
|
|
14
|
+
const { computeValueSetExpansionFingerprint } = require('./fingerprint/fingerprint');
|
|
15
|
+
const { ensureTxParametersHashIncludesFilter, patchValueSetExpandWholeSystemForOcl } = require('./shared/patches');
|
|
16
|
+
|
|
17
|
+
ensureTxParametersHashIncludesFilter(TxParameters);
|
|
18
|
+
patchValueSetExpandWholeSystemForOcl();
|
|
19
|
+
|
|
20
|
+
function normalizeCanonicalSystem(system) {
|
|
21
|
+
if (typeof system !== 'string') {
|
|
22
|
+
return system;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const trimmed = system.trim();
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
return trimmed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Treat canonical URLs with and without trailing slash as equivalent.
|
|
31
|
+
return trimmed.replace(/\/+$/, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class OCLValueSetProvider extends AbstractValueSetProvider {
|
|
35
|
+
constructor(config = {}) {
|
|
36
|
+
super();
|
|
37
|
+
const options = typeof config === 'string' ? { baseUrl: config } : (config || {});
|
|
38
|
+
|
|
39
|
+
this.org = options.org || null;
|
|
40
|
+
const http = createOclHttpClient(options);
|
|
41
|
+
this.baseUrl = http.baseUrl;
|
|
42
|
+
this.httpClient = http.client;
|
|
43
|
+
|
|
44
|
+
this.valueSetMap = new Map();
|
|
45
|
+
this._idMap = new Map();
|
|
46
|
+
this.collectionMeta = new Map();
|
|
47
|
+
this.sourceCanonicalCache = new Map();
|
|
48
|
+
this.collectionConceptPageCache = new Map();
|
|
49
|
+
this.pendingCollectionConceptPageRequests = new Map();
|
|
50
|
+
this.collectionSourcesCache = new Map();
|
|
51
|
+
this.pendingCollectionSourcesRequests = new Map();
|
|
52
|
+
this.pendingSourceCanonicalRequests = new Map();
|
|
53
|
+
this.collectionByCanonicalCache = new Map();
|
|
54
|
+
this.pendingCollectionByCanonicalRequests = new Map();
|
|
55
|
+
this._composePromises = new Map();
|
|
56
|
+
this.backgroundExpansionCache = new Map();
|
|
57
|
+
this.backgroundExpansionProgress = new Map();
|
|
58
|
+
this.valueSetFingerprints = new Map();
|
|
59
|
+
this._initialized = false;
|
|
60
|
+
this._initializePromise = null;
|
|
61
|
+
this.sourcePackageCode = this.org
|
|
62
|
+
? `ocl:${this.baseUrl}|org=${this.org}`
|
|
63
|
+
: `ocl:${this.baseUrl}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async #loadColdCacheForValueSets() {
|
|
67
|
+
try {
|
|
68
|
+
const files = await fs.readdir(CACHE_VS_DIR);
|
|
69
|
+
let loadedCount = 0;
|
|
70
|
+
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
if (!file.endsWith('.json')) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const filePath = path.join(CACHE_VS_DIR, file);
|
|
78
|
+
const data = await fs.readFile(filePath, 'utf-8');
|
|
79
|
+
const cached = JSON.parse(data);
|
|
80
|
+
|
|
81
|
+
if (!cached || !cached.canonicalUrl || !cached.expansion) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const paramsKey = cached.paramsKey || 'default';
|
|
86
|
+
const cacheKey = this.#expansionCacheKey(
|
|
87
|
+
{ url: cached.canonicalUrl, version: cached.version || null },
|
|
88
|
+
paramsKey
|
|
89
|
+
);
|
|
90
|
+
const createdAt = cached.timestamp ? new Date(cached.timestamp).getTime() : null;
|
|
91
|
+
this.backgroundExpansionCache.set(cacheKey, {
|
|
92
|
+
expansion: cached.expansion,
|
|
93
|
+
metadataSignature: cached.metadataSignature || null,
|
|
94
|
+
dependencyChecksums: cached.dependencyChecksums || {},
|
|
95
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : null
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.valueSetFingerprints.set(cacheKey, cached.fingerprint || null);
|
|
99
|
+
loadedCount++;
|
|
100
|
+
console.log(`[OCL-ValueSet] Loaded ValueSet from cold cache: ${cached.canonicalUrl}`);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error(`[OCL-ValueSet] Failed to load cold cache file ${file}:`, error.message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (loadedCount > 0) {
|
|
107
|
+
console.log(`[OCL-ValueSet] Loaded ${loadedCount} ValueSet expansions from cold cache`);
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== 'ENOENT') {
|
|
111
|
+
console.error('[OCL-ValueSet] Failed to load cold cache:', error.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async #saveColdCacheForValueSet(vs, expansion, metadataSignature, dependencyChecksums, paramsKey = 'default') {
|
|
117
|
+
const canonicalUrl = vs?.url;
|
|
118
|
+
const version = vs?.version || null;
|
|
119
|
+
if (!canonicalUrl || !expansion) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cacheFilePath = getCacheFilePath(CACHE_VS_DIR, canonicalUrl, version, paramsKey);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await ensureCacheDirectories(CACHE_VS_DIR);
|
|
127
|
+
|
|
128
|
+
const fingerprint = computeValueSetExpansionFingerprint(expansion);
|
|
129
|
+
const cacheData = {
|
|
130
|
+
canonicalUrl,
|
|
131
|
+
version,
|
|
132
|
+
paramsKey,
|
|
133
|
+
fingerprint,
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
conceptCount: expansion.contains?.length || 0,
|
|
136
|
+
expansion,
|
|
137
|
+
metadataSignature,
|
|
138
|
+
dependencyChecksums
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
await fs.writeFile(cacheFilePath, JSON.stringify(cacheData, null, 2), 'utf-8');
|
|
142
|
+
console.log(`[OCL-ValueSet] Saved ValueSet expansion to cold cache: ${canonicalUrl} (${expansion.contains?.length || 0} concepts, fingerprint=${fingerprint?.substring(0, 8)})`);
|
|
143
|
+
|
|
144
|
+
return fingerprint;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error(`[OCL-ValueSet] Failed to save cold cache for ValueSet ${canonicalUrl}:`, error.message);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
sourcePackage() {
|
|
152
|
+
return this.sourcePackageCode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async initialize() {
|
|
156
|
+
if (this._initialized) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (this._initializePromise) {
|
|
161
|
+
await this._initializePromise;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this._initializePromise = (async () => {
|
|
166
|
+
try {
|
|
167
|
+
// Load cold cache first
|
|
168
|
+
await this.#loadColdCacheForValueSets();
|
|
169
|
+
|
|
170
|
+
const collections = await this.#fetchCollectionsForDiscovery();
|
|
171
|
+
console.log(`[OCL-ValueSet] Fetched ${collections.length} collections`);
|
|
172
|
+
|
|
173
|
+
for (const collection of collections) {
|
|
174
|
+
const valueSet = this.#toValueSet(collection);
|
|
175
|
+
if (!valueSet) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
this.#indexValueSet(valueSet);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`[OCL-ValueSet] Loaded ${this.valueSetMap.size} value sets`);
|
|
182
|
+
this._initialized = true;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error(`[OCL-ValueSet] Initialization failed:`, error.message);
|
|
185
|
+
if (error.response) {
|
|
186
|
+
console.error(`[OCL-ValueSet] HTTP ${error.response.status}: ${error.response.statusText}`);
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await this._initializePromise;
|
|
194
|
+
} finally {
|
|
195
|
+
this._initializePromise = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
assignIds(ids) {
|
|
200
|
+
if (!this.spaceId) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const unique = new Set(this.valueSetMap.values());
|
|
205
|
+
this._idMap.clear();
|
|
206
|
+
|
|
207
|
+
for (const vs of unique) {
|
|
208
|
+
if (!vs.id.startsWith(`${this.spaceId}-`)) {
|
|
209
|
+
const nextId = `${this.spaceId}-${vs.id}`;
|
|
210
|
+
vs.id = nextId;
|
|
211
|
+
vs.jsonObj.id = nextId;
|
|
212
|
+
}
|
|
213
|
+
this._idMap.set(vs.id, vs);
|
|
214
|
+
ids.add(`ValueSet/${vs.id}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async fetchValueSet(url, version) {
|
|
219
|
+
this._validateFetchParams(url, version);
|
|
220
|
+
|
|
221
|
+
let key = `${url}|${version}`;
|
|
222
|
+
if (this.valueSetMap.has(key)) {
|
|
223
|
+
const vs = this.valueSetMap.get(key);
|
|
224
|
+
await this.#ensureComposeIncludes(vs);
|
|
225
|
+
this.#clearInlineExpansion(vs);
|
|
226
|
+
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset' });
|
|
227
|
+
return vs;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (version && VersionUtilities.isSemVer(version)) {
|
|
231
|
+
const majorMinor = VersionUtilities.getMajMin(version);
|
|
232
|
+
if (majorMinor) {
|
|
233
|
+
key = `${url}|${majorMinor}`;
|
|
234
|
+
if (this.valueSetMap.has(key)) {
|
|
235
|
+
const vs = this.valueSetMap.get(key);
|
|
236
|
+
await this.#ensureComposeIncludes(vs);
|
|
237
|
+
this.#clearInlineExpansion(vs);
|
|
238
|
+
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-mm' });
|
|
239
|
+
return vs;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (this.valueSetMap.has(url)) {
|
|
245
|
+
const vs = this.valueSetMap.get(url);
|
|
246
|
+
await this.#ensureComposeIncludes(vs);
|
|
247
|
+
this.#clearInlineExpansion(vs);
|
|
248
|
+
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-url' });
|
|
249
|
+
return vs;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const resolved = await this.#resolveValueSetByCanonical(url, version);
|
|
253
|
+
if (resolved) {
|
|
254
|
+
await this.#ensureComposeIncludes(resolved);
|
|
255
|
+
this.#clearInlineExpansion(resolved);
|
|
256
|
+
this.#scheduleBackgroundExpansion(resolved, { reason: 'fetch-valueset-resolved' });
|
|
257
|
+
return resolved;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await this.initialize();
|
|
261
|
+
|
|
262
|
+
key = `${url}|${version}`;
|
|
263
|
+
if (this.valueSetMap.has(key)) {
|
|
264
|
+
const vs = this.valueSetMap.get(key);
|
|
265
|
+
await this.#ensureComposeIncludes(vs);
|
|
266
|
+
this.#clearInlineExpansion(vs);
|
|
267
|
+
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init' });
|
|
268
|
+
return vs;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (version && VersionUtilities.isSemVer(version)) {
|
|
272
|
+
const majorMinor = VersionUtilities.getMajMin(version);
|
|
273
|
+
if (majorMinor) {
|
|
274
|
+
key = `${url}|${majorMinor}`;
|
|
275
|
+
if (this.valueSetMap.has(key)) {
|
|
276
|
+
const vs = this.valueSetMap.get(key);
|
|
277
|
+
await this.#ensureComposeIncludes(vs);
|
|
278
|
+
this.#clearInlineExpansion(vs);
|
|
279
|
+
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init-mm' });
|
|
280
|
+
return vs;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (this.valueSetMap.has(url)) {
|
|
286
|
+
const vs = this.valueSetMap.get(url);
|
|
287
|
+
await this.#ensureComposeIncludes(vs);
|
|
288
|
+
this.#clearInlineExpansion(vs);
|
|
289
|
+
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-init-url' });
|
|
290
|
+
return vs;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async fetchValueSetById(id) {
|
|
297
|
+
const local = this.#getLocalValueSetById(id);
|
|
298
|
+
if (local) {
|
|
299
|
+
await this.#ensureComposeIncludes(local);
|
|
300
|
+
this.#clearInlineExpansion(local);
|
|
301
|
+
this.#scheduleBackgroundExpansion(local, { reason: 'fetch-valueset-by-id' });
|
|
302
|
+
return local;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await this.initialize();
|
|
306
|
+
|
|
307
|
+
const vs = this.#getLocalValueSetById(id);
|
|
308
|
+
await this.#ensureComposeIncludes(vs);
|
|
309
|
+
this.#clearInlineExpansion(vs);
|
|
310
|
+
this.#scheduleBackgroundExpansion(vs, { reason: 'fetch-valueset-by-id-init' });
|
|
311
|
+
return vs;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#clearInlineExpansion(vs) {
|
|
315
|
+
if (!vs || !vs.jsonObj || !vs.jsonObj.expansion) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
delete vs.jsonObj.expansion;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#getLocalValueSetById(id) {
|
|
322
|
+
if (this._idMap.has(id)) {
|
|
323
|
+
return this._idMap.get(id);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (this.spaceId && id.startsWith(`${this.spaceId}-`)) {
|
|
327
|
+
const unprefixed = id.substring(this.spaceId.length + 1);
|
|
328
|
+
return this._idMap.get(id) || this._idMap.get(unprefixed) || this.valueSetMap.get(unprefixed) || null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return this._idMap.get(id) || this.valueSetMap.get(id) || null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// eslint-disable-next-line no-unused-vars
|
|
335
|
+
async searchValueSets(searchParams, _elements) {
|
|
336
|
+
await this.initialize();
|
|
337
|
+
this._validateSearchParams(searchParams);
|
|
338
|
+
|
|
339
|
+
const params = Object.fromEntries(searchParams.map(({ name, value }) => [name, String(value).toLowerCase()]));
|
|
340
|
+
const values = Array.from(new Set(this.valueSetMap.values()));
|
|
341
|
+
|
|
342
|
+
if (Object.keys(params).length === 0) {
|
|
343
|
+
return values;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return values.filter(vs => this.#matches(vs.jsonObj, params));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
vsCount() {
|
|
350
|
+
return new Set(this.valueSetMap.values()).size;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async listAllValueSets() {
|
|
354
|
+
await this.initialize();
|
|
355
|
+
const urls = new Set();
|
|
356
|
+
for (const vs of this.valueSetMap.values()) {
|
|
357
|
+
if (vs && vs.url) {
|
|
358
|
+
urls.add(vs.url);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return Array.from(urls);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async close() {
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#indexValueSet(vs) {
|
|
368
|
+
const existing = this.valueSetMap.get(vs.url)
|
|
369
|
+
|| (vs.version ? this.valueSetMap.get(`${vs.url}|${vs.version}`) : null)
|
|
370
|
+
|| this._idMap.get(vs.id)
|
|
371
|
+
|| null;
|
|
372
|
+
|
|
373
|
+
// Preserve hydrated cold-cache expansions on first index; invalidate only on replacement.
|
|
374
|
+
if (existing && existing !== vs) {
|
|
375
|
+
this.#invalidateExpansionCache(vs);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
this.valueSetMap.set(vs.url, vs);
|
|
379
|
+
if (vs.version) {
|
|
380
|
+
this.valueSetMap.set(`${vs.url}|${vs.version}`, vs);
|
|
381
|
+
}
|
|
382
|
+
this.valueSetMap.set(vs.id, vs);
|
|
383
|
+
this._idMap.set(vs.id, vs);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#toValueSet(collection) {
|
|
387
|
+
if (!collection || typeof collection !== 'object') {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const canonicalUrl = collection.canonical_url || collection.canonicalUrl || collection.url;
|
|
392
|
+
const id = collection.id;
|
|
393
|
+
if (!canonicalUrl || !id) {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const preferredSource = normalizeCanonicalSystem(collection.preferred_source || collection.preferredSource || null);
|
|
398
|
+
const json = {
|
|
399
|
+
resourceType: 'ValueSet',
|
|
400
|
+
id,
|
|
401
|
+
url: canonicalUrl,
|
|
402
|
+
version: collection.version || null,
|
|
403
|
+
name: collection.name || id,
|
|
404
|
+
title: collection.full_name || collection.fullName || collection.name || id,
|
|
405
|
+
status: 'active',
|
|
406
|
+
experimental: collection.experimental === true,
|
|
407
|
+
immutable: collection.immutable === true,
|
|
408
|
+
description: collection.description || null,
|
|
409
|
+
publisher: collection.publisher || collection.owner || null,
|
|
410
|
+
language: collection.default_locale || collection.defaultLocale || null
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const lastUpdated = this.#toIsoDate(collection.updated_on || collection.updatedOn || collection.updated_at || collection.updatedAt);
|
|
414
|
+
if (lastUpdated) {
|
|
415
|
+
json.meta = { lastUpdated };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (preferredSource) {
|
|
419
|
+
json.compose = {
|
|
420
|
+
include: [{ system: preferredSource }]
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const conceptsUrl = this.#normalizePath(
|
|
425
|
+
collection.concepts_url || collection.conceptsUrl || this.#buildCollectionConceptsPath(collection)
|
|
426
|
+
);
|
|
427
|
+
const expansionUrl = this.#normalizePath(
|
|
428
|
+
collection.expansion_url || collection.expansionUrl || this.#buildCollectionExpansionPath(collection)
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const meta = {
|
|
432
|
+
collectionId: collection.id || collection.short_code || collection.shortCode || null,
|
|
433
|
+
conceptsUrl,
|
|
434
|
+
expansionUrl,
|
|
435
|
+
preferredSource,
|
|
436
|
+
owner: collection.owner || null,
|
|
437
|
+
ownerType: collection.owner_type || collection.ownerType || null
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
this.#storeCollectionMeta(id, canonicalUrl, meta);
|
|
441
|
+
|
|
442
|
+
const valueSet = new ValueSet(json, 'R5');
|
|
443
|
+
this.#attachOclHelpers(valueSet, meta);
|
|
444
|
+
return valueSet;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#attachOclHelpers(valueSet, meta) {
|
|
448
|
+
if (!valueSet || !meta) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
valueSet.oclMeta = meta;
|
|
453
|
+
valueSet.oclFetchConcepts = async ({ count, offset, activeOnly, filter, languageCodes }) => {
|
|
454
|
+
return this.#fetchCollectionConcepts(meta, {
|
|
455
|
+
count,
|
|
456
|
+
offset,
|
|
457
|
+
activeOnly,
|
|
458
|
+
filter: typeof filter === 'string' ? filter : null,
|
|
459
|
+
languageCodes: Array.isArray(languageCodes) ? languageCodes : [],
|
|
460
|
+
fallbackSystem: meta.preferredSource || valueSet.url
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#storeCollectionMeta(id, url, meta) {
|
|
466
|
+
if (!meta || (!meta.conceptsUrl && !meta.expansionUrl)) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (id) {
|
|
470
|
+
this.collectionMeta.set(id, meta);
|
|
471
|
+
}
|
|
472
|
+
if (url) {
|
|
473
|
+
this.collectionMeta.set(url, meta);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#normalizePath(pathValue) {
|
|
478
|
+
if (!pathValue) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
if (typeof pathValue !== 'string') {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
if (pathValue.startsWith('http://') || pathValue.startsWith('https://')) {
|
|
485
|
+
return pathValue;
|
|
486
|
+
}
|
|
487
|
+
return `${this.baseUrl}${pathValue.startsWith('/') ? '' : '/'}${pathValue}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#buildCollectionConceptsPath(collection) {
|
|
491
|
+
if (!collection || typeof collection !== 'object') {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const owner = collection.owner || null;
|
|
495
|
+
const ownerType = collection.owner_type || collection.ownerType || null;
|
|
496
|
+
const id = collection.id || collection.short_code || collection.shortCode || null;
|
|
497
|
+
if (!owner || !id || ownerType !== 'Organization') {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
return `/orgs/${encodeURIComponent(owner)}/collections/${encodeURIComponent(id)}/concepts/`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#buildCollectionExpansionPath(collection) {
|
|
504
|
+
if (!collection || typeof collection !== 'object') {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
const owner = collection.owner || null;
|
|
508
|
+
const ownerType = collection.owner_type || collection.ownerType || null;
|
|
509
|
+
const id = collection.id || collection.short_code || collection.shortCode || null;
|
|
510
|
+
if (!owner || !id || ownerType !== 'Organization') {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
return `/orgs/${encodeURIComponent(owner)}/collections/${encodeURIComponent(id)}/HEAD/expansions/autoexpand-HEAD/`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#getCollectionMeta(vs) {
|
|
517
|
+
if (!vs) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
return this.collectionMeta.get(vs.id) || this.collectionMeta.get(vs.url) || null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async #ensureComposeIncludes(vs) {
|
|
524
|
+
if (!vs || !vs.jsonObj) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const meta = this.#getCollectionMeta(vs);
|
|
529
|
+
|
|
530
|
+
const composeKey = vs.id || vs.url;
|
|
531
|
+
if (this._composePromises.has(composeKey)) {
|
|
532
|
+
await this._composePromises.get(composeKey);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const promise = (async () => {
|
|
537
|
+
const existingInclude = Array.isArray(vs?.jsonObj?.compose?.include)
|
|
538
|
+
? vs.jsonObj.compose.include
|
|
539
|
+
: [];
|
|
540
|
+
|
|
541
|
+
// Always normalize existing compose entries first because discovery metadata
|
|
542
|
+
// can carry non-canonical preferred_source values.
|
|
543
|
+
const include = this.#normalizeComposeIncludes(existingInclude);
|
|
544
|
+
|
|
545
|
+
// Reconcile with collection-resolved sources whenever available so $expand
|
|
546
|
+
// and direct CodeSystem lookups share the same canonical registry keys.
|
|
547
|
+
if (meta && (meta.conceptsUrl || meta.expansionUrl)) {
|
|
548
|
+
const sources = await this.#fetchCollectionSources(meta);
|
|
549
|
+
if (Array.isArray(sources) && sources.length > 0) {
|
|
550
|
+
include.push(...this.#normalizeComposeIncludes(sources));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Preferred source is a fallback only when no resolvable include was found.
|
|
555
|
+
if (include.length === 0 && meta?.preferredSource) {
|
|
556
|
+
include.push(...this.#normalizeComposeIncludes([{ system: meta.preferredSource }]));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const deduped = this.#dedupeComposeIncludes(include);
|
|
560
|
+
if (deduped.length > 0) {
|
|
561
|
+
vs.jsonObj.compose = { include: deduped };
|
|
562
|
+
}
|
|
563
|
+
})();
|
|
564
|
+
|
|
565
|
+
this._composePromises.set(composeKey, promise);
|
|
566
|
+
try {
|
|
567
|
+
await promise;
|
|
568
|
+
} finally {
|
|
569
|
+
this._composePromises.delete(composeKey);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
#normalizeComposeIncludes(includeEntries) {
|
|
574
|
+
if (!Array.isArray(includeEntries) || includeEntries.length === 0) {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const normalized = [];
|
|
579
|
+
for (const entry of includeEntries) {
|
|
580
|
+
if (!entry || typeof entry !== 'object') {
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const system = normalizeCanonicalSystem(entry.system);
|
|
585
|
+
if (!system) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let version = entry.version || null;
|
|
590
|
+
const hasAnyFactory = OCLSourceCodeSystemFactory.hasFactory(system, null);
|
|
591
|
+
const hasExactFactory = OCLSourceCodeSystemFactory.hasExactFactory(system, version);
|
|
592
|
+
|
|
593
|
+
// If include.version does not match the registered OCL factory key,
|
|
594
|
+
// omit it so Provider lookup can reuse the already loaded canonical factory.
|
|
595
|
+
if (version && hasAnyFactory && !hasExactFactory) {
|
|
596
|
+
version = null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
normalized.push({
|
|
600
|
+
system,
|
|
601
|
+
version: version || undefined
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return normalized;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#dedupeComposeIncludes(includeEntries) {
|
|
609
|
+
const deduped = [];
|
|
610
|
+
const seen = new Set();
|
|
611
|
+
|
|
612
|
+
for (const include of includeEntries || []) {
|
|
613
|
+
const system = normalizeCanonicalSystem(include?.system);
|
|
614
|
+
if (!system) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const version = include?.version || '';
|
|
619
|
+
const key = `${system}|${version}`;
|
|
620
|
+
if (seen.has(key)) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
seen.add(key);
|
|
625
|
+
deduped.push({
|
|
626
|
+
system,
|
|
627
|
+
version: version || undefined
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return deduped;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async #fetchCollectionSources(meta) {
|
|
635
|
+
const sourcesCacheKey = `${meta.owner || ''}|${meta.collectionId || ''}|${meta.conceptsUrl || ''}|${meta.expansionUrl || ''}`;
|
|
636
|
+
if (this.collectionSourcesCache.has(sourcesCacheKey)) {
|
|
637
|
+
return this.collectionSourcesCache.get(sourcesCacheKey);
|
|
638
|
+
}
|
|
639
|
+
if (this.pendingCollectionSourcesRequests.has(sourcesCacheKey)) {
|
|
640
|
+
return this.pendingCollectionSourcesRequests.get(sourcesCacheKey);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const pending = (async () => {
|
|
644
|
+
const sources = [];
|
|
645
|
+
const seen = new Set();
|
|
646
|
+
|
|
647
|
+
if (meta.expansionUrl) {
|
|
648
|
+
try {
|
|
649
|
+
const response = await this.httpClient.get(meta.expansionUrl);
|
|
650
|
+
const resolved = Array.isArray(response.data?.resolved_source_versions)
|
|
651
|
+
? response.data.resolved_source_versions
|
|
652
|
+
: [];
|
|
653
|
+
|
|
654
|
+
for (const entry of resolved) {
|
|
655
|
+
const system = entry.canonical_url || entry.canonicalUrl || null;
|
|
656
|
+
const owner = entry.owner || meta.owner || null;
|
|
657
|
+
const shortCode = entry.short_code || entry.shortCode || entry.id || null;
|
|
658
|
+
const version = entry.version || null;
|
|
659
|
+
|
|
660
|
+
const systemUrl = normalizeCanonicalSystem(system || (owner && shortCode ? await this.#getSourceCanonicalUrl(owner, shortCode) : null));
|
|
661
|
+
if (systemUrl && !seen.has(systemUrl)) {
|
|
662
|
+
seen.add(systemUrl);
|
|
663
|
+
sources.push({ system: systemUrl, version });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
} catch (error) {
|
|
667
|
+
// fall through to concepts listing
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (sources.length > 0) {
|
|
672
|
+
return sources;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (!meta.conceptsUrl) {
|
|
676
|
+
return sources;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const sourceKeys = await this.#fetchCollectionSourceKeys(meta.conceptsUrl, meta.owner || null);
|
|
680
|
+
for (const { owner, source } of sourceKeys) {
|
|
681
|
+
const systemUrl = normalizeCanonicalSystem(await this.#getSourceCanonicalUrl(owner, source));
|
|
682
|
+
if (systemUrl && !seen.has(systemUrl)) {
|
|
683
|
+
seen.add(systemUrl);
|
|
684
|
+
sources.push({ system: systemUrl });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (sources.length === 0 && meta.preferredSource) {
|
|
689
|
+
const preferredSource = normalizeCanonicalSystem(meta.preferredSource);
|
|
690
|
+
if (preferredSource) {
|
|
691
|
+
sources.push({ system: preferredSource });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
this.collectionSourcesCache.set(sourcesCacheKey, sources);
|
|
696
|
+
return sources;
|
|
697
|
+
})();
|
|
698
|
+
|
|
699
|
+
this.pendingCollectionSourcesRequests.set(sourcesCacheKey, pending);
|
|
700
|
+
try {
|
|
701
|
+
return await pending;
|
|
702
|
+
} finally {
|
|
703
|
+
this.pendingCollectionSourcesRequests.delete(sourcesCacheKey);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async #fetchCollectionConcepts(meta, options) {
|
|
708
|
+
if (!meta || !meta.conceptsUrl) {
|
|
709
|
+
return { contains: [], total: 0 };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const count = Number.isInteger(options?.count) ? options.count : CONCEPT_PAGE_SIZE;
|
|
713
|
+
const offset = Number.isInteger(options?.offset) ? options.offset : 0;
|
|
714
|
+
const activeOnly = options?.activeOnly === true;
|
|
715
|
+
const filter = this.#normalizeFilter(options?.filter);
|
|
716
|
+
const filterMatcher = filter ? new SearchFilterText(filter) : null;
|
|
717
|
+
const remoteQuery = this.#buildRemoteQuery(filter);
|
|
718
|
+
const fallbackSystem = options?.fallbackSystem || null;
|
|
719
|
+
const preferredLanguageCodes = this.#normalizeLanguageCodes(options?.languageCodes);
|
|
720
|
+
const effectiveLanguageCodes = preferredLanguageCodes.length > 0 ? preferredLanguageCodes : ['en'];
|
|
721
|
+
|
|
722
|
+
if (count <= 0) {
|
|
723
|
+
return { contains: [], total: 0 };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const hasFilter = !!filter;
|
|
727
|
+
const limit = hasFilter
|
|
728
|
+
? Math.min(FILTERED_CONCEPT_PAGE_SIZE, CONCEPT_PAGE_SIZE)
|
|
729
|
+
: CONCEPT_PAGE_SIZE;
|
|
730
|
+
let page = Math.floor(Math.max(0, offset) / limit) + 1;
|
|
731
|
+
let skip = Math.max(0, offset) % limit;
|
|
732
|
+
let remaining = count;
|
|
733
|
+
const contains = [];
|
|
734
|
+
let reportedTotal = null;
|
|
735
|
+
|
|
736
|
+
while (remaining > 0) {
|
|
737
|
+
const pageData = await this.#fetchConceptPage(meta.conceptsUrl, page, limit, remoteQuery);
|
|
738
|
+
const pageItems = Array.isArray(pageData?.items) ? pageData.items : [];
|
|
739
|
+
if (
|
|
740
|
+
reportedTotal == null &&
|
|
741
|
+
typeof pageData?.reportedTotal === 'number' &&
|
|
742
|
+
Number.isFinite(pageData.reportedTotal) &&
|
|
743
|
+
pageData.reportedTotal >= 0
|
|
744
|
+
) {
|
|
745
|
+
reportedTotal = pageData.reportedTotal;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (!pageItems || pageItems.length === 0) {
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const slice = pageItems.slice(skip);
|
|
753
|
+
skip = 0;
|
|
754
|
+
|
|
755
|
+
for (const concept of slice) {
|
|
756
|
+
if (remaining <= 0) {
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
if (activeOnly && concept.retired === true) {
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const localizedNames = this.#extractLocalizedNames(concept, effectiveLanguageCodes);
|
|
764
|
+
const localizedDefinitions = this.#extractLocalizedDefinitions(concept, effectiveLanguageCodes);
|
|
765
|
+
|
|
766
|
+
const display = localizedNames.display || concept.display_name || concept.display || concept.name || null;
|
|
767
|
+
const definition = localizedDefinitions.definition || concept.definition || concept.description || concept.concept_class || null;
|
|
768
|
+
const code = concept.code || concept.id || null;
|
|
769
|
+
const searchableText = [
|
|
770
|
+
code,
|
|
771
|
+
display,
|
|
772
|
+
definition,
|
|
773
|
+
...localizedNames.designation.map(d => d.value),
|
|
774
|
+
...localizedDefinitions.definitions.map(d => d.value)
|
|
775
|
+
].filter(Boolean).join(' ');
|
|
776
|
+
if (!this.#conceptMatchesFilter(searchableText, code, display, definition, filter, filterMatcher)) {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (!code) {
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const owner = concept.owner || meta.owner || null;
|
|
785
|
+
const source = concept.source || null;
|
|
786
|
+
const conceptCanonical = concept.source_canonical_url || concept.sourceCanonicalUrl || null;
|
|
787
|
+
const system = conceptCanonical || (owner && source
|
|
788
|
+
? await this.#getSourceCanonicalUrl(owner, source)
|
|
789
|
+
: fallbackSystem);
|
|
790
|
+
|
|
791
|
+
contains.push({
|
|
792
|
+
system: system || fallbackSystem,
|
|
793
|
+
code,
|
|
794
|
+
display,
|
|
795
|
+
definition: definition || undefined,
|
|
796
|
+
designation: localizedNames.designation,
|
|
797
|
+
definitions: localizedDefinitions.definitions,
|
|
798
|
+
inactive: concept.retired === true ? true : undefined
|
|
799
|
+
});
|
|
800
|
+
remaining -= 1;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (pageItems.length < limit) {
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
page += 1;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return { contains, total: contains.length, reportedTotal };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async #resolveValueSetByCanonical(url, version) {
|
|
814
|
+
const canonicalUrl = typeof url === 'string' ? url.trim() : '';
|
|
815
|
+
if (!canonicalUrl) {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const collection = await this.#findCollectionByCanonical(canonicalUrl, version);
|
|
820
|
+
if (!collection) {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const valueSet = this.#toValueSet(collection);
|
|
825
|
+
if (!valueSet) {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
this.#indexValueSet(valueSet);
|
|
830
|
+
return valueSet;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#valueSetBaseKey(vs) {
|
|
834
|
+
if (!vs || !vs.url) {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
return `${vs.url}|${vs.version || ''}`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
#expansionParamsKey(params) {
|
|
841
|
+
if (!params || typeof params !== 'object') {
|
|
842
|
+
return 'default';
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
const normalized = Object.keys(params)
|
|
847
|
+
.sort()
|
|
848
|
+
.reduce((acc, key) => {
|
|
849
|
+
if (key === 'tx-resource' || key === 'valueSet') {
|
|
850
|
+
return acc;
|
|
851
|
+
}
|
|
852
|
+
acc[key] = params[key];
|
|
853
|
+
return acc;
|
|
854
|
+
}, {});
|
|
855
|
+
|
|
856
|
+
const json = JSON.stringify(normalized);
|
|
857
|
+
if (!json || json === '{}') {
|
|
858
|
+
return 'default';
|
|
859
|
+
}
|
|
860
|
+
return crypto.createHash('sha256').update(json).digest('hex').substring(0, 16);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
return 'default';
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
#expansionCacheKey(vs, paramsKey) {
|
|
867
|
+
const base = this.#valueSetBaseKey(vs);
|
|
868
|
+
if (!base) {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
return `${base}|${paramsKey || 'default'}`;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
#invalidateExpansionCache(vs) {
|
|
875
|
+
const base = this.#valueSetBaseKey(vs);
|
|
876
|
+
if (!base) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
for (const key of this.backgroundExpansionCache.keys()) {
|
|
881
|
+
if (key.startsWith(`${base}|`)) {
|
|
882
|
+
this.backgroundExpansionCache.delete(key);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
#applyCachedExpansion(vs, paramsKey) {
|
|
888
|
+
if (!vs || !vs.jsonObj) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const cacheKey = this.#expansionCacheKey(vs, paramsKey);
|
|
893
|
+
if (!cacheKey) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const cached = this.backgroundExpansionCache.get(cacheKey);
|
|
898
|
+
if (!cached || !cached.expansion) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (!this.#isCachedExpansionValid(vs, cached)) {
|
|
903
|
+
this.backgroundExpansionCache.delete(cacheKey);
|
|
904
|
+
if (vs.jsonObj.expansion) {
|
|
905
|
+
delete vs.jsonObj.expansion;
|
|
906
|
+
}
|
|
907
|
+
console.log(`[OCL-ValueSet] Cached ValueSet expansion invalidated: ${cacheKey}`);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (vs.jsonObj.expansion) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
vs.jsonObj.expansion = structuredClone(cached.expansion);
|
|
916
|
+
console.log(`[OCL-ValueSet] ValueSet expansion restored from cache: ${cacheKey}`);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
#scheduleBackgroundExpansion(vs, options = {}) {
|
|
920
|
+
if (!vs || !vs.jsonObj) {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const paramsKey = this.#expansionParamsKey(options.params || null);
|
|
925
|
+
const cacheKey = this.#expansionCacheKey(vs, paramsKey);
|
|
926
|
+
if (!cacheKey) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const cached = this.backgroundExpansionCache.get(cacheKey);
|
|
931
|
+
if (cached && !this.#isCachedExpansionValid(vs, cached)) {
|
|
932
|
+
this.backgroundExpansionCache.delete(cacheKey);
|
|
933
|
+
if (vs.jsonObj.expansion) {
|
|
934
|
+
delete vs.jsonObj.expansion;
|
|
935
|
+
}
|
|
936
|
+
console.log(`[OCL-ValueSet] Cached ValueSet expansion invalidated: ${cacheKey}`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (vs.jsonObj.expansion) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const cacheFilePath = getCacheFilePath(CACHE_VS_DIR, vs.url, vs.version || null, paramsKey);
|
|
944
|
+
const cacheAgeFromFileMs = getColdCacheAgeMs(cacheFilePath);
|
|
945
|
+
const persistedCache = this.backgroundExpansionCache.get(cacheKey);
|
|
946
|
+
const cacheAgeFromMetadataMs = Number.isFinite(persistedCache?.createdAt)
|
|
947
|
+
? Math.max(0, Date.now() - persistedCache.createdAt)
|
|
948
|
+
: null;
|
|
949
|
+
|
|
950
|
+
// Treat cache as fresh when either file mtime or persisted timestamp is recent.
|
|
951
|
+
const freshnessCandidates = [cacheAgeFromFileMs, cacheAgeFromMetadataMs].filter(age => age != null);
|
|
952
|
+
const freshestCacheAgeMs = freshnessCandidates.length > 0 ? Math.min(...freshnessCandidates) : null;
|
|
953
|
+
if (freshestCacheAgeMs != null && freshestCacheAgeMs <= COLD_CACHE_FRESHNESS_MS) {
|
|
954
|
+
const freshnessSource = cacheAgeFromFileMs != null && cacheAgeFromMetadataMs != null
|
|
955
|
+
? 'file+metadata'
|
|
956
|
+
: cacheAgeFromFileMs != null
|
|
957
|
+
? 'file'
|
|
958
|
+
: 'metadata';
|
|
959
|
+
console.log(`[OCL-ValueSet] Skipping warm-up for ValueSet ${vs.url} (cold cache age: ${formatCacheAgeMinutes(freshestCacheAgeMs)})`);
|
|
960
|
+
console.log(`[OCL-ValueSet] ValueSet cold cache is fresh, not enqueueing warm-up job (${cacheKey}, source=${freshnessSource})`);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const jobKey = `vs:${cacheKey}`;
|
|
965
|
+
if (OCLBackgroundJobQueue.isQueuedOrRunning(jobKey)) {
|
|
966
|
+
console.log(`[OCL-ValueSet] ValueSet expansion already queued or running: ${cacheKey}`);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
let queuedJobSize = null;
|
|
971
|
+
const warmupAgeText = freshestCacheAgeMs != null
|
|
972
|
+
? formatCacheAgeMinutes(freshestCacheAgeMs)
|
|
973
|
+
: 'no cold cache';
|
|
974
|
+
console.log(`[OCL-ValueSet] Enqueueing warm-up for ValueSet ${vs.url} (cold cache age: ${warmupAgeText})`);
|
|
975
|
+
console.log(`[OCL-ValueSet] ValueSet expansion enqueued: ${cacheKey}`);
|
|
976
|
+
OCLBackgroundJobQueue.enqueue(
|
|
977
|
+
jobKey,
|
|
978
|
+
'ValueSet expansion',
|
|
979
|
+
async () => {
|
|
980
|
+
await this.#runBackgroundExpansion(vs, cacheKey, paramsKey, queuedJobSize);
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
jobId: vs.url || cacheKey,
|
|
984
|
+
getProgress: () => this.#backgroundExpansionProgressSnapshot(cacheKey),
|
|
985
|
+
resolveJobSize: async () => {
|
|
986
|
+
const meta = this.#getCollectionMeta(vs);
|
|
987
|
+
queuedJobSize = await this.#fetchConceptCountFromHeaders(meta?.conceptsUrl || null);
|
|
988
|
+
return queuedJobSize;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async #runBackgroundExpansion(vs, cacheKey, paramsKey = 'default', knownConceptCount = null) {
|
|
995
|
+
console.log(`[OCL-ValueSet] ValueSet expansion started: ${cacheKey}`);
|
|
996
|
+
const progressState = { processed: 0, total: null };
|
|
997
|
+
this.backgroundExpansionProgress.set(cacheKey, progressState);
|
|
998
|
+
try {
|
|
999
|
+
await this.#ensureComposeIncludes(vs);
|
|
1000
|
+
|
|
1001
|
+
const meta = this.#getCollectionMeta(vs);
|
|
1002
|
+
const resolvedTotal = Number.isFinite(knownConceptCount) && knownConceptCount >= 0
|
|
1003
|
+
? knownConceptCount
|
|
1004
|
+
: await this.#fetchConceptCountFromHeaders(meta?.conceptsUrl || null);
|
|
1005
|
+
progressState.total = resolvedTotal;
|
|
1006
|
+
const sources = meta ? await this.#fetchCollectionSources(meta) : [];
|
|
1007
|
+
for (const source of sources || []) {
|
|
1008
|
+
OCLSourceCodeSystemFactory.scheduleBackgroundLoadByKey(
|
|
1009
|
+
source.system,
|
|
1010
|
+
source.version || null,
|
|
1011
|
+
'valueset-expansion'
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const expansion = await this.#buildBackgroundExpansion(vs, progressState);
|
|
1016
|
+
if (!expansion) {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
progressState.processed = expansion.total || progressState.processed;
|
|
1021
|
+
if (typeof progressState.total !== 'number' || !Number.isFinite(progressState.total) || progressState.total <= 0) {
|
|
1022
|
+
progressState.total = expansion.total || 0;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const metadataSignature = this.#valueSetMetadataSignature(vs);
|
|
1026
|
+
const dependencyChecksums = this.#valueSetDependencyChecksums(vs);
|
|
1027
|
+
|
|
1028
|
+
// Compute custom fingerprint and compare with cold cache
|
|
1029
|
+
const newFingerprint = computeValueSetExpansionFingerprint(expansion);
|
|
1030
|
+
const oldFingerprint = this.valueSetFingerprints.get(cacheKey);
|
|
1031
|
+
|
|
1032
|
+
if (oldFingerprint && newFingerprint === oldFingerprint) {
|
|
1033
|
+
console.log(`[OCL-ValueSet] ValueSet expansion fingerprint unchanged: ${cacheKey} (fingerprint=${newFingerprint?.substring(0, 8)})`);
|
|
1034
|
+
} else {
|
|
1035
|
+
if (oldFingerprint) {
|
|
1036
|
+
console.log(`[OCL-ValueSet] ValueSet expansion fingerprint changed: ${cacheKey} (${oldFingerprint?.substring(0, 8)} -> ${newFingerprint?.substring(0, 8)})`);
|
|
1037
|
+
console.log(`[OCL-ValueSet] Replacing cold cache with new hot cache: ${cacheKey}`);
|
|
1038
|
+
} else {
|
|
1039
|
+
console.log(`[OCL-ValueSet] Computed fingerprint for ValueSet expansion: ${cacheKey} (fingerprint=${newFingerprint?.substring(0, 8)})`);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Save to cold cache
|
|
1043
|
+
const savedFingerprint = await this.#saveColdCacheForValueSet(vs, expansion, metadataSignature, dependencyChecksums, paramsKey);
|
|
1044
|
+
if (savedFingerprint) {
|
|
1045
|
+
this.valueSetFingerprints.set(cacheKey, savedFingerprint);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
this.backgroundExpansionCache.set(cacheKey, {
|
|
1050
|
+
expansion,
|
|
1051
|
+
metadataSignature,
|
|
1052
|
+
dependencyChecksums,
|
|
1053
|
+
createdAt: Date.now()
|
|
1054
|
+
});
|
|
1055
|
+
// Keep expansions in provider-managed cache only.
|
|
1056
|
+
// Inline expansion on ValueSet bypasses $expand filtering in worker pipeline.
|
|
1057
|
+
|
|
1058
|
+
console.log(`[OCL-ValueSet] ValueSet expansion completed and cached: ${cacheKey}`);
|
|
1059
|
+
console.log(`[OCL-ValueSet] ValueSet now available in cache: ${cacheKey}`);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
console.error(`[OCL-ValueSet] ValueSet background expansion failed: ${cacheKey}: ${error.message}`);
|
|
1062
|
+
} finally {
|
|
1063
|
+
this.backgroundExpansionProgress.delete(cacheKey);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async #buildBackgroundExpansion(vs, progressState = null) {
|
|
1068
|
+
const meta = this.#getCollectionMeta(vs);
|
|
1069
|
+
if (!meta || !meta.conceptsUrl) {
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const contains = [];
|
|
1074
|
+
let offset = 0;
|
|
1075
|
+
|
|
1076
|
+
// Pull all concepts in fixed-size pages until exhausted.
|
|
1077
|
+
// eslint-disable-next-line no-constant-condition
|
|
1078
|
+
while (true) {
|
|
1079
|
+
const batch = await this.#fetchCollectionConcepts(meta, {
|
|
1080
|
+
count: CONCEPT_PAGE_SIZE,
|
|
1081
|
+
offset,
|
|
1082
|
+
activeOnly: false,
|
|
1083
|
+
filter: null,
|
|
1084
|
+
languageCodes: []
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
const entries = Array.isArray(batch?.contains) ? batch.contains : [];
|
|
1088
|
+
if (entries.length === 0) {
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
for (const entry of entries) {
|
|
1093
|
+
if (!entry?.system || !entry?.code) {
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const out = {
|
|
1098
|
+
system: entry.system,
|
|
1099
|
+
code: entry.code
|
|
1100
|
+
};
|
|
1101
|
+
if (entry.display) {
|
|
1102
|
+
out.display = entry.display;
|
|
1103
|
+
}
|
|
1104
|
+
if (entry.definition) {
|
|
1105
|
+
out.definition = entry.definition;
|
|
1106
|
+
}
|
|
1107
|
+
if (entry.inactive === true) {
|
|
1108
|
+
out.inactive = true;
|
|
1109
|
+
}
|
|
1110
|
+
if (Array.isArray(entry.designation) && entry.designation.length > 0) {
|
|
1111
|
+
out.designation = entry.designation
|
|
1112
|
+
.filter(d => d && d.value)
|
|
1113
|
+
.map(d => ({
|
|
1114
|
+
language: d.language,
|
|
1115
|
+
value: d.value
|
|
1116
|
+
}));
|
|
1117
|
+
}
|
|
1118
|
+
contains.push(out);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (progressState) {
|
|
1122
|
+
progressState.processed = contains.length;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (entries.length < CONCEPT_PAGE_SIZE) {
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
offset += entries.length;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return {
|
|
1132
|
+
timestamp: new Date().toISOString(),
|
|
1133
|
+
identifier: `urn:uuid:${crypto.randomUUID()}`,
|
|
1134
|
+
total: contains.length,
|
|
1135
|
+
contains
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async #findCollectionByCanonical(canonicalUrl, version) {
|
|
1140
|
+
const lookupKey = `${this.org || '*'}|${canonicalUrl}|${version || ''}`;
|
|
1141
|
+
if (this.collectionByCanonicalCache.has(lookupKey)) {
|
|
1142
|
+
return this.collectionByCanonicalCache.get(lookupKey);
|
|
1143
|
+
}
|
|
1144
|
+
if (this.pendingCollectionByCanonicalRequests.has(lookupKey)) {
|
|
1145
|
+
return this.pendingCollectionByCanonicalRequests.get(lookupKey);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const token = this.#canonicalToken(canonicalUrl);
|
|
1149
|
+
if (!token) {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const pending = (async () => {
|
|
1154
|
+
const organizations = await this.#fetchOrganizationIds();
|
|
1155
|
+
const endpoints = organizations.length > 0
|
|
1156
|
+
? organizations.map(orgId => `/orgs/${encodeURIComponent(orgId)}/collections/`)
|
|
1157
|
+
: ['/collections/'];
|
|
1158
|
+
|
|
1159
|
+
const exactMatches = [];
|
|
1160
|
+
for (const endpoint of endpoints) {
|
|
1161
|
+
const response = await this.httpClient.get(endpoint, {
|
|
1162
|
+
params: {
|
|
1163
|
+
q: token,
|
|
1164
|
+
page: 1,
|
|
1165
|
+
limit: PAGE_SIZE
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
const payload = response.data;
|
|
1170
|
+
const items = Array.isArray(payload)
|
|
1171
|
+
? payload
|
|
1172
|
+
: Array.isArray(payload?.results)
|
|
1173
|
+
? payload.results
|
|
1174
|
+
: Array.isArray(payload?.items)
|
|
1175
|
+
? payload.items
|
|
1176
|
+
: Array.isArray(payload?.data)
|
|
1177
|
+
? payload.data
|
|
1178
|
+
: [];
|
|
1179
|
+
|
|
1180
|
+
for (const item of items) {
|
|
1181
|
+
const itemCanonical = item?.canonical_url || item?.canonicalUrl || item?.url || null;
|
|
1182
|
+
if (itemCanonical === canonicalUrl) {
|
|
1183
|
+
exactMatches.push(item);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
let match = null;
|
|
1189
|
+
if (exactMatches.length > 0) {
|
|
1190
|
+
if (!version) {
|
|
1191
|
+
match = exactMatches[0];
|
|
1192
|
+
} else {
|
|
1193
|
+
const exactVersion = exactMatches.find(item => (item.version || null) === version);
|
|
1194
|
+
if (exactVersion) {
|
|
1195
|
+
match = exactVersion;
|
|
1196
|
+
} else if (VersionUtilities.isSemVer(version)) {
|
|
1197
|
+
const majorMinor = VersionUtilities.getMajMin(version);
|
|
1198
|
+
if (majorMinor) {
|
|
1199
|
+
const majorMinorMatch = exactMatches.find(item => (item.version || null) === majorMinor);
|
|
1200
|
+
if (majorMinorMatch) {
|
|
1201
|
+
match = majorMinorMatch;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
this.collectionByCanonicalCache.set(lookupKey, match);
|
|
1209
|
+
return match;
|
|
1210
|
+
})();
|
|
1211
|
+
|
|
1212
|
+
this.pendingCollectionByCanonicalRequests.set(lookupKey, pending);
|
|
1213
|
+
try {
|
|
1214
|
+
return await pending;
|
|
1215
|
+
} finally {
|
|
1216
|
+
this.pendingCollectionByCanonicalRequests.delete(lookupKey);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
#valueSetMetadataSignature(vs) {
|
|
1221
|
+
const meta = this.#getCollectionMeta(vs);
|
|
1222
|
+
const payload = {
|
|
1223
|
+
url: vs?.url || null,
|
|
1224
|
+
version: vs?.version || null,
|
|
1225
|
+
lastUpdated: vs?.jsonObj?.meta?.lastUpdated || null,
|
|
1226
|
+
collectionId: meta?.collectionId || null,
|
|
1227
|
+
conceptsUrl: meta?.conceptsUrl || null,
|
|
1228
|
+
expansionUrl: meta?.expansionUrl || null,
|
|
1229
|
+
preferredSource: meta?.preferredSource || null
|
|
1230
|
+
};
|
|
1231
|
+
return JSON.stringify(payload);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
#valueSetDependencyChecksums(vs) {
|
|
1235
|
+
const include = Array.isArray(vs?.jsonObj?.compose?.include) ? vs.jsonObj.compose.include : [];
|
|
1236
|
+
const checksums = {};
|
|
1237
|
+
for (const item of include) {
|
|
1238
|
+
const system = normalizeCanonicalSystem(item?.system || null);
|
|
1239
|
+
if (!system) {
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
const version = item?.version || null;
|
|
1243
|
+
const key = `${system}|${version || ''}`;
|
|
1244
|
+
checksums[key] = OCLSourceCodeSystemFactory.checksumForResource(system, version);
|
|
1245
|
+
}
|
|
1246
|
+
return checksums;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
#isCachedExpansionValid(vs, cached) {
|
|
1250
|
+
if (!cached || typeof cached !== 'object') {
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (cached.metadataSignature !== this.#valueSetMetadataSignature(vs)) {
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const currentDeps = this.#valueSetDependencyChecksums(vs);
|
|
1259
|
+
const cachedDeps = cached.dependencyChecksums || {};
|
|
1260
|
+
const currentKeys = Object.keys(currentDeps).sort();
|
|
1261
|
+
const cachedKeys = Object.keys(cachedDeps).sort();
|
|
1262
|
+
|
|
1263
|
+
if (currentKeys.length !== cachedKeys.length) {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
for (let i = 0; i < currentKeys.length; i++) {
|
|
1268
|
+
if (currentKeys[i] !== cachedKeys[i]) {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
if ((currentDeps[currentKeys[i]] || null) !== (cachedDeps[cachedKeys[i]] || null)) {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
return true;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async #fetchCollectionsForDiscovery() {
|
|
1280
|
+
const organizations = await this.#fetchOrganizationIds();
|
|
1281
|
+
if (organizations.length === 0) {
|
|
1282
|
+
// Fallback for OCL instances that expose global listing but not org listing.
|
|
1283
|
+
return await this.#fetchAllPages('/collections/');
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const allCollections = [];
|
|
1287
|
+
const seen = new Set();
|
|
1288
|
+
|
|
1289
|
+
for (const orgId of organizations) {
|
|
1290
|
+
const endpoint = `/orgs/${encodeURIComponent(orgId)}/collections/`;
|
|
1291
|
+
const collections = await this.#fetchAllPages(endpoint);
|
|
1292
|
+
for (const collection of collections) {
|
|
1293
|
+
if (!collection || typeof collection !== 'object') {
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
const key = this.#collectionIdentity(collection);
|
|
1297
|
+
if (seen.has(key)) {
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
seen.add(key);
|
|
1301
|
+
allCollections.push(collection);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
return allCollections;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async #fetchOrganizationIds() {
|
|
1309
|
+
const endpoint = '/orgs/';
|
|
1310
|
+
console.log(`[OCL-ValueSet] Loading organizations from: ${this.baseUrl}${endpoint}`);
|
|
1311
|
+
const orgs = await this.#fetchAllPages(endpoint);
|
|
1312
|
+
|
|
1313
|
+
const ids = [];
|
|
1314
|
+
const seen = new Set();
|
|
1315
|
+
for (const org of orgs || []) {
|
|
1316
|
+
if (!org || typeof org !== 'object') {
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const id = org.id || org.mnemonic || org.short_code || org.shortCode || org.name || null;
|
|
1321
|
+
if (!id) {
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const normalized = String(id).trim();
|
|
1326
|
+
if (!normalized || seen.has(normalized)) {
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
seen.add(normalized);
|
|
1331
|
+
ids.push(normalized);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (ids.length === 0 && this.org) {
|
|
1335
|
+
ids.push(this.org);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
return ids;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
#collectionIdentity(collection) {
|
|
1342
|
+
if (!collection || typeof collection !== 'object') {
|
|
1343
|
+
return '__invalid__';
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const owner = collection.owner || '';
|
|
1347
|
+
const canonical = collection.canonical_url || collection.canonicalUrl || '';
|
|
1348
|
+
const id = collection.id || collection.short_code || collection.shortCode || collection.name || '';
|
|
1349
|
+
return `${owner}|${canonical}|${id}`;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
#canonicalToken(canonicalUrl) {
|
|
1353
|
+
if (!canonicalUrl || typeof canonicalUrl !== 'string') {
|
|
1354
|
+
return null;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const trimmed = canonicalUrl.trim();
|
|
1358
|
+
if (!trimmed) {
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const parts = trimmed.replace(/\/+$/, '').split('/').filter(Boolean);
|
|
1363
|
+
if (parts.length === 0) {
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
return parts[parts.length - 1];
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
async #fetchConceptPage(conceptsUrl, page, limit, filter = null) {
|
|
1371
|
+
try {
|
|
1372
|
+
const cacheKey = `${conceptsUrl}|p=${page}|l=${limit}|q=${filter || ''}|verbose=1`;
|
|
1373
|
+
if (this.collectionConceptPageCache.has(cacheKey)) {
|
|
1374
|
+
const cached = this.collectionConceptPageCache.get(cacheKey);
|
|
1375
|
+
const items = Array.isArray(cached)
|
|
1376
|
+
? cached
|
|
1377
|
+
: Array.isArray(cached?.items)
|
|
1378
|
+
? cached.items
|
|
1379
|
+
: [];
|
|
1380
|
+
return {
|
|
1381
|
+
items,
|
|
1382
|
+
reportedTotal: this.#extractTotalFromPayload(cached?.payload || null)
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
if (this.pendingCollectionConceptPageRequests.has(cacheKey)) {
|
|
1386
|
+
return this.pendingCollectionConceptPageRequests.get(cacheKey);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const pending = (async () => {
|
|
1390
|
+
const params = { page, limit, verbose: true };
|
|
1391
|
+
if (filter) {
|
|
1392
|
+
params.q = filter;
|
|
1393
|
+
}
|
|
1394
|
+
const response = await this.httpClient.get(conceptsUrl, { params });
|
|
1395
|
+
const payload = response.data;
|
|
1396
|
+
const items = Array.isArray(payload)
|
|
1397
|
+
? payload
|
|
1398
|
+
: Array.isArray(payload?.results)
|
|
1399
|
+
? payload.results
|
|
1400
|
+
: Array.isArray(payload?.items)
|
|
1401
|
+
? payload.items
|
|
1402
|
+
: Array.isArray(payload?.data)
|
|
1403
|
+
? payload.data
|
|
1404
|
+
: [];
|
|
1405
|
+
|
|
1406
|
+
this.collectionConceptPageCache.set(cacheKey, { items, payload });
|
|
1407
|
+
return {
|
|
1408
|
+
items,
|
|
1409
|
+
reportedTotal: this.#extractTotalFromPayload(payload)
|
|
1410
|
+
};
|
|
1411
|
+
})();
|
|
1412
|
+
|
|
1413
|
+
this.pendingCollectionConceptPageRequests.set(cacheKey, pending);
|
|
1414
|
+
try {
|
|
1415
|
+
return await pending;
|
|
1416
|
+
} finally {
|
|
1417
|
+
this.pendingCollectionConceptPageRequests.delete(cacheKey);
|
|
1418
|
+
}
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
console.error(`[OCL-ValueSet] #fetchConceptPage ERROR: ${error.message}`);
|
|
1421
|
+
throw error;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
#buildRemoteQuery(filter) {
|
|
1426
|
+
if (!filter || typeof filter !== 'string') {
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const tokens = filter
|
|
1431
|
+
.split(/\s+or\s+|\||&|\s+/i)
|
|
1432
|
+
.map(t => t.trim())
|
|
1433
|
+
.filter(Boolean)
|
|
1434
|
+
.map(t => (t.startsWith('-') || t.startsWith('!')) ? t.substring(1) : t)
|
|
1435
|
+
.map(t => t.replace(/[%*?]/g, ''))
|
|
1436
|
+
.map(t => t.replace(/[^\p{L}\p{N}]+/gu, ''))
|
|
1437
|
+
.filter(t => t.length >= 3);
|
|
1438
|
+
|
|
1439
|
+
if (tokens.length === 0) {
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
tokens.sort((a, b) => b.length - a.length);
|
|
1444
|
+
return tokens[0];
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
#normalizeLanguageCodes(languageCodes) {
|
|
1448
|
+
if (!Array.isArray(languageCodes)) {
|
|
1449
|
+
return [];
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const normalized = [];
|
|
1453
|
+
for (const code of languageCodes) {
|
|
1454
|
+
if (!code || typeof code !== 'string') {
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
normalized.push(code.toLowerCase());
|
|
1458
|
+
}
|
|
1459
|
+
return normalized;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
#backgroundExpansionProgressSnapshot(cacheKey) {
|
|
1463
|
+
const progress = this.backgroundExpansionProgress.get(cacheKey);
|
|
1464
|
+
if (!progress) {
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const processed = progress.processed;
|
|
1469
|
+
const total = progress.total;
|
|
1470
|
+
if (
|
|
1471
|
+
typeof processed === 'number' &&
|
|
1472
|
+
Number.isFinite(processed) &&
|
|
1473
|
+
typeof total === 'number' &&
|
|
1474
|
+
Number.isFinite(total) &&
|
|
1475
|
+
total > 0
|
|
1476
|
+
) {
|
|
1477
|
+
return { processed, total };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
return null;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
#extractTotalFromPayload(payload) {
|
|
1484
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const candidates = [
|
|
1489
|
+
payload.total,
|
|
1490
|
+
payload.total_count,
|
|
1491
|
+
payload.totalCount,
|
|
1492
|
+
payload.num_found,
|
|
1493
|
+
payload.numFound,
|
|
1494
|
+
payload.count
|
|
1495
|
+
];
|
|
1496
|
+
|
|
1497
|
+
for (const candidate of candidates) {
|
|
1498
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) {
|
|
1499
|
+
return candidate;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
async #fetchConceptCountFromHeaders(conceptsUrl) {
|
|
1507
|
+
if (!conceptsUrl) {
|
|
1508
|
+
return null;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
try {
|
|
1512
|
+
const response = await this.httpClient.get(conceptsUrl, {
|
|
1513
|
+
params: {
|
|
1514
|
+
limit: 1
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
return this.#extractNumFoundFromHeaders(response?.headers);
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
#extractNumFoundFromHeaders(headers) {
|
|
1524
|
+
if (!headers || typeof headers !== 'object') {
|
|
1525
|
+
return null;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const raw = headers.num_found ?? headers['num-found'] ?? headers.Num_Found ?? null;
|
|
1529
|
+
const parsed = typeof raw === 'number' ? raw : Number.parseInt(raw, 10);
|
|
1530
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
return parsed;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
#languageRank(languageCode, preferredLanguageCodes) {
|
|
1537
|
+
if (!languageCode) {
|
|
1538
|
+
return 1000;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const normalized = String(languageCode).toLowerCase();
|
|
1542
|
+
for (let i = 0; i < preferredLanguageCodes.length; i++) {
|
|
1543
|
+
const preferred = preferredLanguageCodes[i];
|
|
1544
|
+
if (normalized === preferred || normalized.startsWith(`${preferred}-`) || preferred.startsWith(`${normalized}-`)) {
|
|
1545
|
+
return i;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (normalized === 'en' || normalized.startsWith('en-')) {
|
|
1550
|
+
return preferredLanguageCodes.length;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
return preferredLanguageCodes.length + 1;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
#extractLocalizedNames(concept, preferredLanguageCodes) {
|
|
1557
|
+
const names = Array.isArray(concept?.names) ? concept.names : [];
|
|
1558
|
+
const unique = new Map();
|
|
1559
|
+
|
|
1560
|
+
for (const item of names) {
|
|
1561
|
+
const value = item?.name;
|
|
1562
|
+
if (!value || typeof value !== 'string') {
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const language = typeof item?.locale === 'string' && item.locale.trim() ? item.locale.trim().toLowerCase() : null;
|
|
1567
|
+
const key = `${language || ''}|${value}`;
|
|
1568
|
+
if (unique.has(key)) {
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
unique.set(key, {
|
|
1573
|
+
language: language || undefined,
|
|
1574
|
+
value,
|
|
1575
|
+
localePreferred: item?.locale_preferred === true,
|
|
1576
|
+
nameType: item?.name_type || ''
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const designation = Array.from(unique.values())
|
|
1581
|
+
.sort((a, b) => {
|
|
1582
|
+
const rankDiff = this.#languageRank(a.language, preferredLanguageCodes) - this.#languageRank(b.language, preferredLanguageCodes);
|
|
1583
|
+
if (rankDiff !== 0) {
|
|
1584
|
+
return rankDiff;
|
|
1585
|
+
}
|
|
1586
|
+
if (a.localePreferred !== b.localePreferred) {
|
|
1587
|
+
return a.localePreferred ? -1 : 1;
|
|
1588
|
+
}
|
|
1589
|
+
const aFs = /fully\s*-?\s*specified/i.test(a.nameType);
|
|
1590
|
+
const bFs = /fully\s*-?\s*specified/i.test(b.nameType);
|
|
1591
|
+
if (aFs !== bFs) {
|
|
1592
|
+
return aFs ? -1 : 1;
|
|
1593
|
+
}
|
|
1594
|
+
return a.value.localeCompare(b.value);
|
|
1595
|
+
})
|
|
1596
|
+
.map(({ language, value }) => ({
|
|
1597
|
+
language,
|
|
1598
|
+
value
|
|
1599
|
+
}));
|
|
1600
|
+
|
|
1601
|
+
return {
|
|
1602
|
+
display: designation.length > 0 ? designation[0].value : null,
|
|
1603
|
+
designation
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
#extractLocalizedDefinitions(concept, preferredLanguageCodes) {
|
|
1608
|
+
const descriptions = Array.isArray(concept?.descriptions) ? concept.descriptions : [];
|
|
1609
|
+
const unique = new Map();
|
|
1610
|
+
|
|
1611
|
+
for (const item of descriptions) {
|
|
1612
|
+
const value = item?.description;
|
|
1613
|
+
if (!value || typeof value !== 'string') {
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const language = typeof item?.locale === 'string' && item.locale.trim() ? item.locale.trim().toLowerCase() : null;
|
|
1618
|
+
const key = `${language || ''}|${value}`;
|
|
1619
|
+
if (unique.has(key)) {
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
unique.set(key, {
|
|
1624
|
+
language: language || undefined,
|
|
1625
|
+
value,
|
|
1626
|
+
localePreferred: item?.locale_preferred === true,
|
|
1627
|
+
descriptionType: item?.description_type || ''
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const definitions = Array.from(unique.values())
|
|
1632
|
+
.sort((a, b) => {
|
|
1633
|
+
const rankDiff = this.#languageRank(a.language, preferredLanguageCodes) - this.#languageRank(b.language, preferredLanguageCodes);
|
|
1634
|
+
if (rankDiff !== 0) {
|
|
1635
|
+
return rankDiff;
|
|
1636
|
+
}
|
|
1637
|
+
if (a.localePreferred !== b.localePreferred) {
|
|
1638
|
+
return a.localePreferred ? -1 : 1;
|
|
1639
|
+
}
|
|
1640
|
+
const aDef = /definition/i.test(a.descriptionType);
|
|
1641
|
+
const bDef = /definition/i.test(b.descriptionType);
|
|
1642
|
+
if (aDef !== bDef) {
|
|
1643
|
+
return aDef ? -1 : 1;
|
|
1644
|
+
}
|
|
1645
|
+
return a.value.localeCompare(b.value);
|
|
1646
|
+
})
|
|
1647
|
+
.map(({ language, value }) => ({
|
|
1648
|
+
language,
|
|
1649
|
+
value
|
|
1650
|
+
}));
|
|
1651
|
+
|
|
1652
|
+
return {
|
|
1653
|
+
definition: definitions.length > 0 ? definitions[0].value : null,
|
|
1654
|
+
definitions
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
async #fetchCollectionSourceKeys(conceptsUrl, defaultOwner) {
|
|
1659
|
+
const keys = new Map();
|
|
1660
|
+
let page = 1;
|
|
1661
|
+
|
|
1662
|
+
// eslint-disable-next-line no-constant-condition
|
|
1663
|
+
while (true) {
|
|
1664
|
+
const response = await this.httpClient.get(conceptsUrl, { params: { page, limit: CONCEPT_PAGE_SIZE } });
|
|
1665
|
+
const payload = response.data;
|
|
1666
|
+
|
|
1667
|
+
let items = [];
|
|
1668
|
+
if (Array.isArray(payload)) {
|
|
1669
|
+
items = payload;
|
|
1670
|
+
} else if (payload && typeof payload === 'object') {
|
|
1671
|
+
items = Array.isArray(payload.results)
|
|
1672
|
+
? payload.results
|
|
1673
|
+
: Array.isArray(payload.items)
|
|
1674
|
+
? payload.items
|
|
1675
|
+
: Array.isArray(payload.data)
|
|
1676
|
+
? payload.data
|
|
1677
|
+
: [];
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
if (!items || items.length === 0) {
|
|
1681
|
+
break;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
for (const concept of items) {
|
|
1685
|
+
const owner = concept.owner || defaultOwner || null;
|
|
1686
|
+
const source = concept.source || null;
|
|
1687
|
+
if (owner && source) {
|
|
1688
|
+
keys.set(`${owner}|${source}`, { owner, source });
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
if (items.length < CONCEPT_PAGE_SIZE) {
|
|
1693
|
+
break;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
page += 1;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
return Array.from(keys.values());
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
async #getSourceCanonicalUrl(owner, source) {
|
|
1703
|
+
const key = `${owner}|${source}`;
|
|
1704
|
+
if (this.sourceCanonicalCache.has(key)) {
|
|
1705
|
+
return this.sourceCanonicalCache.get(key);
|
|
1706
|
+
}
|
|
1707
|
+
if (this.pendingSourceCanonicalRequests.has(key)) {
|
|
1708
|
+
return this.pendingSourceCanonicalRequests.get(key);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const path = `/orgs/${encodeURIComponent(owner)}/sources/${encodeURIComponent(source)}/`;
|
|
1712
|
+
const pending = (async () => {
|
|
1713
|
+
try {
|
|
1714
|
+
const response = await this.httpClient.get(path);
|
|
1715
|
+
const data = response.data || {};
|
|
1716
|
+
const canonicalUrl = data.canonical_url || data.canonicalUrl || data.url || source;
|
|
1717
|
+
this.sourceCanonicalCache.set(key, canonicalUrl);
|
|
1718
|
+
return canonicalUrl;
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
this.sourceCanonicalCache.set(key, source);
|
|
1721
|
+
return source;
|
|
1722
|
+
}
|
|
1723
|
+
})();
|
|
1724
|
+
|
|
1725
|
+
this.pendingSourceCanonicalRequests.set(key, pending);
|
|
1726
|
+
try {
|
|
1727
|
+
return await pending;
|
|
1728
|
+
} finally {
|
|
1729
|
+
this.pendingSourceCanonicalRequests.delete(key);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
#matches(json, params) {
|
|
1734
|
+
for (const [name, value] of Object.entries(params)) {
|
|
1735
|
+
if (!value) {
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
switch (name) {
|
|
1740
|
+
case 'url':
|
|
1741
|
+
if ((json.url || '').toLowerCase() !== value) {
|
|
1742
|
+
return false;
|
|
1743
|
+
}
|
|
1744
|
+
break;
|
|
1745
|
+
case 'system':
|
|
1746
|
+
if (!json.compose?.include?.some(i => (i.system || '').toLowerCase().includes(value))) {
|
|
1747
|
+
return false;
|
|
1748
|
+
}
|
|
1749
|
+
break;
|
|
1750
|
+
case 'identifier': {
|
|
1751
|
+
const identifiers = Array.isArray(json.identifier) ? json.identifier : (json.identifier ? [json.identifier] : []);
|
|
1752
|
+
const match = identifiers.some(i => (i.system || '').toLowerCase().includes(value) || (i.value || '').toLowerCase().includes(value));
|
|
1753
|
+
if (!match) {
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1756
|
+
break;
|
|
1757
|
+
}
|
|
1758
|
+
default: {
|
|
1759
|
+
const field = json[name];
|
|
1760
|
+
if (field == null || !String(field).toLowerCase().includes(value)) {
|
|
1761
|
+
return false;
|
|
1762
|
+
}
|
|
1763
|
+
break;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
return true;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
async #fetchAllPages(path) {
|
|
1771
|
+
const results = [];
|
|
1772
|
+
let page = 1;
|
|
1773
|
+
|
|
1774
|
+
// eslint-disable-next-line no-constant-condition
|
|
1775
|
+
while (true) {
|
|
1776
|
+
const response = await this.httpClient.get(path, { params: { page, limit: PAGE_SIZE } });
|
|
1777
|
+
const payload = response.data;
|
|
1778
|
+
|
|
1779
|
+
let items = [];
|
|
1780
|
+
if (Array.isArray(payload)) {
|
|
1781
|
+
items = payload;
|
|
1782
|
+
} else if (payload && typeof payload === 'object') {
|
|
1783
|
+
items = Array.isArray(payload.results)
|
|
1784
|
+
? payload.results
|
|
1785
|
+
: Array.isArray(payload.items)
|
|
1786
|
+
? payload.items
|
|
1787
|
+
: Array.isArray(payload.data)
|
|
1788
|
+
? payload.data
|
|
1789
|
+
: [];
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
if (!items || items.length === 0) {
|
|
1793
|
+
break;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
results.push(...items);
|
|
1797
|
+
|
|
1798
|
+
if (items.length < PAGE_SIZE) {
|
|
1799
|
+
break;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
page += 1;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
return results;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
#toIsoDate(value) {
|
|
1809
|
+
if (!value) {
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
const date = new Date(value);
|
|
1813
|
+
if (Number.isNaN(date.getTime())) {
|
|
1814
|
+
return null;
|
|
1815
|
+
}
|
|
1816
|
+
return date.toISOString();
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
#normalizeFilter(filter) {
|
|
1820
|
+
if (typeof filter !== 'string') {
|
|
1821
|
+
return null;
|
|
1822
|
+
}
|
|
1823
|
+
const normalized = filter.trim().toLowerCase();
|
|
1824
|
+
return normalized.length > 0 ? normalized : null;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
#conceptMatchesFilter(searchableText, code, display, definition, filter, filterMatcher) {
|
|
1828
|
+
if (!filter) {
|
|
1829
|
+
return true;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const codeText = code ? String(code).toLowerCase() : '';
|
|
1833
|
+
const displayText = display ? String(display).toLowerCase() : '';
|
|
1834
|
+
const definitionText = definition ? String(definition).toLowerCase() : '';
|
|
1835
|
+
|
|
1836
|
+
// FHIR allows terminology-server-defined behavior; guarantee baseline contains matching.
|
|
1837
|
+
if (codeText.includes(filter) || displayText.includes(filter) || definitionText.includes(filter)) {
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Preserve token/prefix behavior already provided by SearchFilterText.
|
|
1842
|
+
return !!(filterMatcher && filterMatcher.passes(searchableText));
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
module.exports = {
|
|
1847
|
+
OCLValueSetProvider
|
|
1848
|
+
};
|