fhirsmith 0.3.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 +42 -0
- package/FHIRsmith.png +0 -0
- package/README.md +277 -0
- package/config-template.json +144 -0
- package/library/folder-setup.js +58 -0
- package/library/html-server.js +166 -0
- package/library/html.js +835 -0
- package/library/i18nsupport.js +259 -0
- package/library/languages.js +779 -0
- package/library/logger-telnet.js +205 -0
- package/library/logger.js +279 -0
- package/library/package-manager.js +876 -0
- package/library/utilities.js +196 -0
- package/library/version-utilities.js +1056 -0
- package/npmprojector/config-example.json +13 -0
- package/npmprojector/indexer.js +394 -0
- package/npmprojector/npmprojector.js +395 -0
- package/npmprojector/readme.md +174 -0
- package/npmprojector/watcher.js +335 -0
- package/package.json +119 -0
- package/packages/package-crawler.js +846 -0
- package/packages/packages-template.html +126 -0
- package/packages/packages.js +2838 -0
- package/passwords.ini +2 -0
- package/publisher/publisher-template.html +208 -0
- package/publisher/publisher.js +2167 -0
- package/publisher/task-draft.js +458 -0
- package/registry/api.js +735 -0
- package/registry/crawler.js +637 -0
- package/registry/model.js +513 -0
- package/registry/readme.md +243 -0
- package/registry/registry-data.json +121015 -0
- package/registry/registry-template.html +126 -0
- package/registry/registry.js +1395 -0
- package/registry/test-runner.js +237 -0
- package/root-template.html +124 -0
- package/server.js +524 -0
- package/shl/private-key.pem +5 -0
- package/shl/public-key.pem +18 -0
- package/shl/shl.js +1125 -0
- package/shl/vhl.js +69 -0
- package/static/FHIRsmith128.png +0 -0
- package/static/FHIRsmith16.png +0 -0
- package/static/FHIRsmith32.png +0 -0
- package/static/FHIRsmith64.png +0 -0
- package/static/assets/css/bootstrap-fhir.css +5302 -0
- package/static/assets/css/bootstrap-glyphicons.css +2 -0
- package/static/assets/css/bootstrap.css +4097 -0
- package/static/assets/css/jquery-ui.css +523 -0
- package/static/assets/css/jquery-ui.structure.css +863 -0
- package/static/assets/css/jquery-ui.structure.min.css +5 -0
- package/static/assets/css/jquery-ui.theme.css +439 -0
- package/static/assets/css/jquery-ui.theme.min.css +5 -0
- package/static/assets/css/jquery.ui.all.css +7 -0
- package/static/assets/css/modules.css +18 -0
- package/static/assets/css/project.css +367 -0
- package/static/assets/css/pygments-manni.css +66 -0
- package/static/assets/css/tags.css +74 -0
- package/static/assets/css/xml.css +2 -0
- package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
- package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
- package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
- package/static/assets/ico/favicon.ico +0 -0
- package/static/assets/ico/favicon.png +0 -0
- package/static/assets/images/fhir-logo-www.png +0 -0
- package/static/assets/images/fhir-logo.png +0 -0
- package/static/assets/images/hl7-logo.png +0 -0
- package/static/assets/images/logo_ansinew.jpg +0 -0
- package/static/assets/images/search.png +0 -0
- package/static/assets/images/stripe.png +0 -0
- package/static/assets/images/target.png +0 -0
- package/static/assets/images/tx-registry-root.gif +0 -0
- package/static/assets/images/tx-registry.png +0 -0
- package/static/assets/images/tx-server.png +0 -0
- package/static/assets/images/tx-version.png +0 -0
- package/static/assets/js/bootstrap.min.js +6 -0
- package/static/assets/js/fhir-gw.js +259 -0
- package/static/assets/js/fhir.js +2 -0
- package/static/assets/js/html5shiv.js +8 -0
- package/static/assets/js/jcookie.js +96 -0
- package/static/assets/js/jquery-ui.min.js +6 -0
- package/static/assets/js/jquery.js +10716 -0
- package/static/assets/js/jquery.min.js +2 -0
- package/static/assets/js/jquery.ui.core.js +314 -0
- package/static/assets/js/jquery.ui.draggable.js +825 -0
- package/static/assets/js/jquery.ui.mouse.js +162 -0
- package/static/assets/js/jquery.ui.resizable.js +842 -0
- package/static/assets/js/jquery.ui.widget.js +268 -0
- package/static/assets/js/json2.js +487 -0
- package/static/assets/js/jtip.js +97 -0
- package/static/assets/js/respond.min.js +6 -0
- package/static/assets/js/statuspage.js +70 -0
- package/static/assets/js/xml.js +2 -0
- package/static/dist/js/bootstrap.js +1964 -0
- package/static/favicon.png +0 -0
- package/static/fhir.css +626 -0
- package/static/icon-fhir-16.png +0 -0
- package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
- package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
- package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
- package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
- package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
- package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
- package/static/images/ui-icons_222222_256x240.png +0 -0
- package/static/images/ui-icons_228ef1_256x240.png +0 -0
- package/static/images/ui-icons_ef8c08_256x240.png +0 -0
- package/static/images/ui-icons_ffd27a_256x240.png +0 -0
- package/static/images/ui-icons_ffffff_256x240.png +0 -0
- package/static/js/jquery.effects.blind.js +49 -0
- package/static/js/jquery.effects.bounce.js +78 -0
- package/static/js/jquery.effects.clip.js +54 -0
- package/static/js/jquery.effects.core.js +763 -0
- package/static/js/jquery.effects.drop.js +50 -0
- package/static/js/jquery.effects.explode.js +79 -0
- package/static/js/jquery.effects.fade.js +32 -0
- package/static/js/jquery.effects.fold.js +56 -0
- package/static/js/jquery.effects.highlight.js +50 -0
- package/static/js/jquery.effects.pulsate.js +51 -0
- package/static/js/jquery.effects.scale.js +178 -0
- package/static/js/jquery.effects.shake.js +57 -0
- package/static/js/jquery.effects.slide.js +50 -0
- package/static/js/jquery.effects.transfer.js +45 -0
- package/static/js/jquery.ui.accordion.js +611 -0
- package/static/js/jquery.ui.autocomplete.js +612 -0
- package/static/js/jquery.ui.button.js +416 -0
- package/static/js/jquery.ui.datepicker.js +1823 -0
- package/static/js/jquery.ui.dialog.js +878 -0
- package/static/js/jquery.ui.droppable.js +296 -0
- package/static/js/jquery.ui.position.js +252 -0
- package/static/js/jquery.ui.progressbar.js +109 -0
- package/static/js/jquery.ui.selectable.js +266 -0
- package/static/js/jquery.ui.slider.js +666 -0
- package/static/js/jquery.ui.sortable.js +1077 -0
- package/static/js/jquery.ui.tabs.js +758 -0
- package/stats.js +80 -0
- package/test-cache/vsac/vsac-valuesets.db +0 -0
- package/token/nginx_passport_setup.md +383 -0
- package/token/security_guide.md +294 -0
- package/token/token-template.html +330 -0
- package/token/token.js +1300 -0
- package/translations/Messages.properties +1510 -0
- package/translations/Messages_ar.properties +1399 -0
- package/translations/Messages_de.properties +836 -0
- package/translations/Messages_es.properties +737 -0
- package/translations/Messages_fr.properties +1 -0
- package/translations/Messages_ja.properties +893 -0
- package/translations/Messages_nl.properties +1357 -0
- package/translations/Messages_pt.properties +1302 -0
- package/translations/Messages_ru.properties +1 -0
- package/translations/Messages_uz.properties +1 -0
- package/translations/Messages_zh.properties +1 -0
- package/translations/rendering-phrases.properties +1128 -0
- package/translations/rendering-phrases_ar.properties +1091 -0
- package/translations/rendering-phrases_de.properties +6 -0
- package/translations/rendering-phrases_es.properties +6 -0
- package/translations/rendering-phrases_fr.properties +624 -0
- package/translations/rendering-phrases_ja.properties +21 -0
- package/translations/rendering-phrases_nl.properties +970 -0
- package/translations/rendering-phrases_pt.properties +1020 -0
- package/translations/rendering-phrases_ru.properties +1094 -0
- package/translations/rendering-phrases_uz.properties +1 -0
- package/translations/rendering-phrases_zh.properties +1 -0
- package/tx/README.md +418 -0
- package/tx/cm/cm-api.js +110 -0
- package/tx/cm/cm-database.js +735 -0
- package/tx/cm/cm-package.js +325 -0
- package/tx/cs/cs-api.js +789 -0
- package/tx/cs/cs-areacode.js +615 -0
- package/tx/cs/cs-country.js +1110 -0
- package/tx/cs/cs-cpt.js +785 -0
- package/tx/cs/cs-cs.js +1579 -0
- package/tx/cs/cs-currency.js +539 -0
- package/tx/cs/cs-db.js +1321 -0
- package/tx/cs/cs-hgvs.js +329 -0
- package/tx/cs/cs-lang.js +465 -0
- package/tx/cs/cs-loinc.js +1485 -0
- package/tx/cs/cs-mimetypes.js +238 -0
- package/tx/cs/cs-ndc.js +704 -0
- package/tx/cs/cs-omop.js +1025 -0
- package/tx/cs/cs-provider-api.js +43 -0
- package/tx/cs/cs-provider-list.js +37 -0
- package/tx/cs/cs-rxnorm.js +808 -0
- package/tx/cs/cs-snomed.js +1102 -0
- package/tx/cs/cs-ucum.js +514 -0
- package/tx/cs/cs-unii.js +271 -0
- package/tx/cs/cs-uri.js +218 -0
- package/tx/cs/cs-usstates.js +305 -0
- package/tx/dev.fhir.org.yml +14 -0
- package/tx/fixtures/test-cases-setup.json +18 -0
- package/tx/fixtures/test-cases.yml +16 -0
- package/tx/html/codesystem-operations.liquid +25 -0
- package/tx/html/home-metrics.liquid +247 -0
- package/tx/html/operations-form.liquid +148 -0
- package/tx/html/search-form.liquid +62 -0
- package/tx/html/tx-template.html +133 -0
- package/tx/html/valueset-operations.liquid +54 -0
- package/tx/importers/atc-to-fhir.js +316 -0
- package/tx/importers/import-loinc.module.js +1536 -0
- package/tx/importers/import-ndc.module.js +1088 -0
- package/tx/importers/import-rxnorm.module.js +898 -0
- package/tx/importers/import-sct.module.js +2457 -0
- package/tx/importers/import-unii.module.js +601 -0
- package/tx/importers/readme.md +453 -0
- package/tx/importers/subset-loinc.module.js +1081 -0
- package/tx/importers/subset-rxnorm.module.js +938 -0
- package/tx/importers/tx-import-base.js +351 -0
- package/tx/importers/tx-import-settings.js +310 -0
- package/tx/importers/tx-import.js +357 -0
- package/tx/library/canonical-resource.js +88 -0
- package/tx/library/capabilitystatement.js +292 -0
- package/tx/library/codesystem.js +774 -0
- package/tx/library/conceptmap.js +568 -0
- package/tx/library/designations.js +932 -0
- package/tx/library/errors.js +77 -0
- package/tx/library/extensions.js +117 -0
- package/tx/library/namingsystem.js +322 -0
- package/tx/library/operation-outcome.js +127 -0
- package/tx/library/parameters.js +105 -0
- package/tx/library/renderer.js +1559 -0
- package/tx/library/terminologycapabilities.js +418 -0
- package/tx/library/ucum-parsers.js +1029 -0
- package/tx/library/ucum-service.js +370 -0
- package/tx/library/ucum-types.js +1099 -0
- package/tx/library/valueset.js +543 -0
- package/tx/library.js +676 -0
- package/tx/ocl/cm-ocl.js +106 -0
- package/tx/ocl/cs-ocl.js +39 -0
- package/tx/ocl/vs-ocl.js +105 -0
- package/tx/operation-context.js +568 -0
- package/tx/params.js +613 -0
- package/tx/provider.js +403 -0
- package/tx/sct/ecl.js +1560 -0
- package/tx/sct/expressions.js +2077 -0
- package/tx/sct/structures.js +1396 -0
- package/tx/tx-html.js +1063 -0
- package/tx/tx.fhir.org.yml +39 -0
- package/tx/tx.js +927 -0
- package/tx/vs/vs-api.js +112 -0
- package/tx/vs/vs-database.js +786 -0
- package/tx/vs/vs-package.js +358 -0
- package/tx/vs/vs-vsac.js +366 -0
- package/tx/workers/batch-validate.js +129 -0
- package/tx/workers/batch.js +361 -0
- package/tx/workers/closure.js +32 -0
- package/tx/workers/expand.js +1845 -0
- package/tx/workers/lookup.js +407 -0
- package/tx/workers/metadata.js +467 -0
- package/tx/workers/operations.js +34 -0
- package/tx/workers/read.js +164 -0
- package/tx/workers/search.js +384 -0
- package/tx/workers/subsumes.js +334 -0
- package/tx/workers/translate.js +492 -0
- package/tx/workers/validate.js +2504 -0
- package/tx/workers/worker.js +904 -0
- package/tx/xml/capabilitystatement-xml.js +63 -0
- package/tx/xml/codesystem-xml.js +62 -0
- package/tx/xml/conceptmap-xml.js +65 -0
- package/tx/xml/namingsystem-xml.js +65 -0
- package/tx/xml/operationoutcome-xml.js +127 -0
- package/tx/xml/parameters-xml.js +312 -0
- package/tx/xml/terminologycapabilities-xml.js +64 -0
- package/tx/xml/valueset-xml.js +64 -0
- package/tx/xml/xml-base.js +603 -0
- package/vcl/vcl-parser.js +1098 -0
- package/vcl/vcl.js +253 -0
- package/windows-install.js +19 -0
- package/xig/xig-template.html +124 -0
- package/xig/xig.js +3049 -0
package/xig/xig.js
ADDED
|
@@ -0,0 +1,3049 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
const express = require('express');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const https = require('https');
|
|
11
|
+
const http = require('http');
|
|
12
|
+
const cron = require('node-cron');
|
|
13
|
+
const sqlite3 = require('sqlite3').verbose();
|
|
14
|
+
const { EventEmitter } = require('events');
|
|
15
|
+
const zlib = require('zlib');
|
|
16
|
+
const htmlServer = require('../library/html-server');
|
|
17
|
+
const folders = require('../library/folder-setup');
|
|
18
|
+
|
|
19
|
+
const Logger = require('../library/logger');
|
|
20
|
+
const xigLog = Logger.getInstance().child({ module: 'xig' });
|
|
21
|
+
|
|
22
|
+
const router = express.Router();
|
|
23
|
+
|
|
24
|
+
// Configuration
|
|
25
|
+
const XIG_DB_URL = 'http://fhir.org/guides/stats/xig.db';
|
|
26
|
+
const XIG_DB_PATH = folders.filePath('xig', 'xig.db');
|
|
27
|
+
const TEMPLATE_PATH = path.join(__dirname, 'xig-template.html');
|
|
28
|
+
|
|
29
|
+
// Global database instance
|
|
30
|
+
let xigDb = null;
|
|
31
|
+
|
|
32
|
+
// Request tracking
|
|
33
|
+
let requestStats = {
|
|
34
|
+
total: 0,
|
|
35
|
+
startTime: new Date(),
|
|
36
|
+
dailyCounts: new Map() // date string -> count
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Cache object - this is the "atomic" reference that gets replaced
|
|
40
|
+
let configCache = {
|
|
41
|
+
loaded: false,
|
|
42
|
+
lastUpdated: null,
|
|
43
|
+
maps: {}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Event emitter for cache updates
|
|
47
|
+
const cacheEmitter = new EventEmitter();
|
|
48
|
+
|
|
49
|
+
// Cache loading lock to prevent concurrent loads
|
|
50
|
+
let cacheLoadInProgress = false;
|
|
51
|
+
|
|
52
|
+
// Update history - tracks every download attempt for diagnostics
|
|
53
|
+
const MAX_UPDATE_HISTORY = 20;
|
|
54
|
+
let updateHistory = [];
|
|
55
|
+
let updateInProgress = false;
|
|
56
|
+
|
|
57
|
+
function recordUpdateAttempt(entry) {
|
|
58
|
+
updateHistory.unshift(entry); // newest first
|
|
59
|
+
if (updateHistory.length > MAX_UPDATE_HISTORY) {
|
|
60
|
+
updateHistory.length = MAX_UPDATE_HISTORY;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getLastUpdateAttempt() {
|
|
65
|
+
return updateHistory.length > 0 ? updateHistory[0] : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getUpdateHistory() {
|
|
69
|
+
return updateHistory;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Enhanced HTML escaping
|
|
73
|
+
function escapeHtml(text) {
|
|
74
|
+
if (typeof text !== 'string') {
|
|
75
|
+
return String(text);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const map = {
|
|
79
|
+
'&': '&',
|
|
80
|
+
'<': '<',
|
|
81
|
+
'>': '>',
|
|
82
|
+
'"': '"',
|
|
83
|
+
"'": ''',
|
|
84
|
+
'/': '/',
|
|
85
|
+
'`': '`',
|
|
86
|
+
'=': '='
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return text.replace(/[&<>"'`=/]/g, function(m) { return map[m]; });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// URL validation for external requests
|
|
93
|
+
function validateExternalUrl(url) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = new URL(url);
|
|
96
|
+
|
|
97
|
+
// Only allow http and https
|
|
98
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
99
|
+
throw new Error(`Protocol ${parsed.protocol} not allowed`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Block private IP ranges
|
|
103
|
+
const hostname = parsed.hostname;
|
|
104
|
+
if (hostname === 'localhost' ||
|
|
105
|
+
hostname === '127.0.0.1' ||
|
|
106
|
+
hostname.startsWith('10.') ||
|
|
107
|
+
hostname.startsWith('192.168.') ||
|
|
108
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname)) {
|
|
109
|
+
throw new Error('Private IP addresses not allowed');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return parsed;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new Error(`Invalid URL: ${error.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Secure SQL query building with parameterized queries
|
|
119
|
+
function buildSecureResourceQuery(queryParams, offset = 0, limit = 50) {
|
|
120
|
+
const { realm, auth, ver, type, rt, text } = queryParams;
|
|
121
|
+
|
|
122
|
+
let baseQuery = `
|
|
123
|
+
SELECT
|
|
124
|
+
ResourceKey, ResourceType, Type, Kind, Description, PackageKey,
|
|
125
|
+
Realm, Authority, R2, R2B, R3, R4, R4B, R5, R6,
|
|
126
|
+
Id, Url, Version, Status, Date, Name, Title, Content,
|
|
127
|
+
Supplements, Details, FMM, WG, StandardsStatus, Web
|
|
128
|
+
FROM Resources
|
|
129
|
+
WHERE 1=1
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
const conditions = [];
|
|
133
|
+
const params = [];
|
|
134
|
+
|
|
135
|
+
// Realm filter
|
|
136
|
+
if (realm && realm !== '') {
|
|
137
|
+
conditions.push('AND realm = ?');
|
|
138
|
+
params.push(realm);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Authority filter
|
|
142
|
+
if (auth && auth !== '') {
|
|
143
|
+
conditions.push('AND authority = ?');
|
|
144
|
+
params.push(auth);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Version filter
|
|
148
|
+
if (ver) {
|
|
149
|
+
switch (ver) {
|
|
150
|
+
case 'R2':
|
|
151
|
+
conditions.push('AND R2 = 1');
|
|
152
|
+
break;
|
|
153
|
+
case 'R2B':
|
|
154
|
+
conditions.push('AND R2B = 1');
|
|
155
|
+
break;
|
|
156
|
+
case 'R3':
|
|
157
|
+
conditions.push('AND R3 = 1');
|
|
158
|
+
break;
|
|
159
|
+
case 'R4':
|
|
160
|
+
conditions.push('AND R4 = 1');
|
|
161
|
+
break;
|
|
162
|
+
case 'R4B':
|
|
163
|
+
conditions.push('AND R4B = 1');
|
|
164
|
+
break;
|
|
165
|
+
case 'R5':
|
|
166
|
+
conditions.push('AND R5 = 1');
|
|
167
|
+
break;
|
|
168
|
+
case 'R6':
|
|
169
|
+
conditions.push('AND R6 = 1');
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Type-specific filters
|
|
175
|
+
switch (type) {
|
|
176
|
+
case 'cs': // CodeSystem
|
|
177
|
+
conditions.push("AND ResourceType = 'CodeSystem'");
|
|
178
|
+
break;
|
|
179
|
+
|
|
180
|
+
case 'rp': // Resource Profiles
|
|
181
|
+
conditions.push("AND ResourceType = 'StructureDefinition' AND kind = 'resource'");
|
|
182
|
+
if (rt && rt !== '' && hasCachedValue('profileResources', rt)) {
|
|
183
|
+
conditions.push('AND Type = ?');
|
|
184
|
+
params.push(rt);
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
|
|
188
|
+
case 'dp': // Datatype Profiles
|
|
189
|
+
conditions.push("AND ResourceType = 'StructureDefinition' AND (kind = 'complex-type' OR kind = 'primitive-type')");
|
|
190
|
+
if (rt && rt !== '' && hasCachedValue('profileTypes', rt)) {
|
|
191
|
+
conditions.push('AND Type = ?');
|
|
192
|
+
params.push(rt);
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
|
|
196
|
+
case 'lm': // Logical Models
|
|
197
|
+
conditions.push("AND ResourceType = 'StructureDefinition' AND kind = 'logical'");
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case 'ext': // Extensions
|
|
201
|
+
conditions.push("AND ResourceType = 'StructureDefinition' AND Type = 'Extension'");
|
|
202
|
+
if (rt && rt !== '' && hasCachedValue('extensionContexts', rt)) {
|
|
203
|
+
conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 2 AND Code = ?)');
|
|
204
|
+
params.push(rt);
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case 'vs': // ValueSets
|
|
209
|
+
conditions.push("AND ResourceType = 'ValueSet'");
|
|
210
|
+
if (rt && rt !== '' && hasTerminologySource(rt)) {
|
|
211
|
+
conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 1 AND Code = ?)');
|
|
212
|
+
params.push(rt);
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
|
|
216
|
+
case 'cm': // ConceptMaps
|
|
217
|
+
conditions.push("AND ResourceType = 'ConceptMap'");
|
|
218
|
+
if (rt && rt !== '' && hasTerminologySource(rt)) {
|
|
219
|
+
conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 1 AND Code = ?)');
|
|
220
|
+
params.push(rt);
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
|
|
224
|
+
default:
|
|
225
|
+
// No specific type selected
|
|
226
|
+
if (rt && rt !== '' && hasCachedValue('resourceTypes', rt)) {
|
|
227
|
+
conditions.push('AND ResourceType = ?');
|
|
228
|
+
params.push(rt);
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Text search filter
|
|
234
|
+
if (text && text !== '') {
|
|
235
|
+
const ftsText = text.replace(/"/g, '""');
|
|
236
|
+
if (type === 'cs') {
|
|
237
|
+
conditions.push(`AND (ResourceKey IN (SELECT ResourceKey FROM ResourceFTS WHERE ResourceFTS MATCH ?)
|
|
238
|
+
OR ResourceKey IN (SELECT ResourceKey FROM CodeSystemFTS WHERE CodeSystemFTS MATCH ?))`);
|
|
239
|
+
params.push(`{Description Narrative} : "${ftsText}"`, `{Display Definition} : "${ftsText}"`);
|
|
240
|
+
} else {
|
|
241
|
+
conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM ResourceFTS WHERE ResourceFTS MATCH ?)');
|
|
242
|
+
params.push(`{Description Narrative} : "${ftsText}"`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Build final query
|
|
247
|
+
const fullQuery = baseQuery + ' ' + conditions.join(' ') + ' ORDER BY ResourceType, Type, Description LIMIT ? OFFSET ?';
|
|
248
|
+
params.push(limit, offset);
|
|
249
|
+
|
|
250
|
+
return { query: fullQuery, params };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildSecureResourceCountQuery(queryParams) {
|
|
254
|
+
const { realm, auth, ver, type, rt, text } = queryParams;
|
|
255
|
+
|
|
256
|
+
let baseQuery = 'SELECT COUNT(*) as total FROM Resources WHERE 1=1';
|
|
257
|
+
const conditions = [];
|
|
258
|
+
const params = [];
|
|
259
|
+
|
|
260
|
+
// Same conditions as main query but for counting
|
|
261
|
+
if (realm && realm !== '') {
|
|
262
|
+
conditions.push('AND realm = ?');
|
|
263
|
+
params.push(realm);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (auth && auth !== '') {
|
|
267
|
+
conditions.push('AND authority = ?');
|
|
268
|
+
params.push(auth);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (ver) {
|
|
272
|
+
switch (ver) {
|
|
273
|
+
case 'R2': conditions.push('AND R2 = 1'); break;
|
|
274
|
+
case 'R2B': conditions.push('AND R2B = 1'); break;
|
|
275
|
+
case 'R3': conditions.push('AND R3 = 1'); break;
|
|
276
|
+
case 'R4': conditions.push('AND R4 = 1'); break;
|
|
277
|
+
case 'R4B': conditions.push('AND R4B = 1'); break;
|
|
278
|
+
case 'R5': conditions.push('AND R5 = 1'); break;
|
|
279
|
+
case 'R6': conditions.push('AND R6 = 1'); break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
switch (type) {
|
|
284
|
+
case 'cs':
|
|
285
|
+
conditions.push("AND ResourceType = 'CodeSystem'");
|
|
286
|
+
break;
|
|
287
|
+
case 'rp':
|
|
288
|
+
conditions.push("AND ResourceType = 'StructureDefinition' AND kind = 'resource'");
|
|
289
|
+
if (rt && rt !== '' && hasCachedValue('profileResources', rt)) {
|
|
290
|
+
conditions.push('AND Type = ?');
|
|
291
|
+
params.push(rt);
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
case 'dp':
|
|
295
|
+
conditions.push("AND ResourceType = 'StructureDefinition' AND (kind = 'complex-type' OR kind = 'primitive-type')");
|
|
296
|
+
if (rt && rt !== '' && hasCachedValue('profileTypes', rt)) {
|
|
297
|
+
conditions.push('AND Type = ?');
|
|
298
|
+
params.push(rt);
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
case 'lm':
|
|
302
|
+
conditions.push("AND ResourceType = 'StructureDefinition' AND kind = 'logical'");
|
|
303
|
+
break;
|
|
304
|
+
case 'ext':
|
|
305
|
+
conditions.push("AND ResourceType = 'StructureDefinition' AND Type = 'Extension'");
|
|
306
|
+
if (rt && rt !== '' && hasCachedValue('extensionContexts', rt)) {
|
|
307
|
+
conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 2 AND Code = ?)');
|
|
308
|
+
params.push(rt);
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
case 'vs':
|
|
312
|
+
conditions.push("AND ResourceType = 'ValueSet'");
|
|
313
|
+
if (rt && rt !== '' && hasTerminologySource(rt)) {
|
|
314
|
+
conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 1 AND Code = ?)');
|
|
315
|
+
params.push(rt);
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
case 'cm':
|
|
319
|
+
conditions.push("AND ResourceType = 'ConceptMap'");
|
|
320
|
+
if (rt && rt !== '' && hasTerminologySource(rt)) {
|
|
321
|
+
conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM Categories WHERE Mode = 1 AND Code = ?)');
|
|
322
|
+
params.push(rt);
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
default:
|
|
326
|
+
if (rt && rt !== '' && hasCachedValue('resourceTypes', rt)) {
|
|
327
|
+
conditions.push('AND ResourceType = ?');
|
|
328
|
+
params.push(rt);
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (text && text !== '') {
|
|
334
|
+
const ftsText = text.replace(/"/g, '""');
|
|
335
|
+
if (type === 'cs') {
|
|
336
|
+
conditions.push(`AND (ResourceKey IN (SELECT ResourceKey FROM ResourceFTS WHERE ResourceFTS MATCH ?)
|
|
337
|
+
OR ResourceKey IN (SELECT ResourceKey FROM CodeSystemFTS WHERE CodeSystemFTS MATCH ?))`);
|
|
338
|
+
params.push(`{Description Narrative} : "${ftsText}"`, `{Display Definition} : "${ftsText}"`);
|
|
339
|
+
} else {
|
|
340
|
+
conditions.push('AND ResourceKey IN (SELECT ResourceKey FROM ResourceFTS WHERE ResourceFTS MATCH ?)');
|
|
341
|
+
params.push(`{Description Narrative} : "${ftsText}"`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const fullQuery = baseQuery + ' ' + conditions.join(' ');
|
|
346
|
+
return { query: fullQuery, params };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Template Functions
|
|
350
|
+
|
|
351
|
+
function loadTemplate() {
|
|
352
|
+
try {
|
|
353
|
+
// Load using shared HTML server
|
|
354
|
+
const templateLoaded = htmlServer.loadTemplate('xig', TEMPLATE_PATH);
|
|
355
|
+
if (!templateLoaded) {
|
|
356
|
+
xigLog.error('Failed to load HTML template via shared framework');
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
xigLog.error(`Failed to load HTML template: ${error.message}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderPage(title, content, options = {}) {
|
|
364
|
+
try {
|
|
365
|
+
return htmlServer.renderPage('xig', title, content, options);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
throw new Error(`Failed to render page: ${error.message}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function gatherPageStatistics() {
|
|
372
|
+
const startTime = Date.now();
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
// Get database age info
|
|
376
|
+
const dbAge = getDatabaseAgeInfo();
|
|
377
|
+
let downloadDate = 'Unknown';
|
|
378
|
+
|
|
379
|
+
if (dbAge.lastDownloaded) {
|
|
380
|
+
downloadDate = dbAge.lastDownloaded.toISOString().split('T')[0];
|
|
381
|
+
} else {
|
|
382
|
+
downloadDate = 'Never';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Get counts from database
|
|
386
|
+
const tableCounts = await getDatabaseTableCounts();
|
|
387
|
+
|
|
388
|
+
const endTime = Date.now();
|
|
389
|
+
const processingTime = endTime - startTime;
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
downloadDate: downloadDate,
|
|
393
|
+
totalResources: tableCounts.resources || 0,
|
|
394
|
+
totalPackages: tableCounts.packages || 0,
|
|
395
|
+
processingTime: processingTime,
|
|
396
|
+
version: getMetadata('fhir-version') || '4.0.1'
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
} catch (error) {
|
|
400
|
+
xigLog.error(`Error gathering page statistics: ${error.message}`);
|
|
401
|
+
|
|
402
|
+
const endTime = Date.now();
|
|
403
|
+
const processingTime = endTime - startTime;
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
downloadDate: 'Error',
|
|
407
|
+
totalResources: 0,
|
|
408
|
+
totalPackages: 0,
|
|
409
|
+
processingTime: processingTime,
|
|
410
|
+
version: '4.0.1'
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Function to build simple content HTML
|
|
416
|
+
function buildContentHtml(contentData) {
|
|
417
|
+
if (typeof contentData === 'string') {
|
|
418
|
+
return contentData;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let html = '';
|
|
422
|
+
|
|
423
|
+
if (contentData.message) {
|
|
424
|
+
html += `<p>${escapeHtml(contentData.message)}</p>`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (contentData.data && Array.isArray(contentData.data)) {
|
|
428
|
+
html += '<ul>';
|
|
429
|
+
contentData.data.forEach(item => {
|
|
430
|
+
html += `<li>${escapeHtml(item)}</li>`;
|
|
431
|
+
});
|
|
432
|
+
html += '</ul>';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (contentData.table) {
|
|
436
|
+
html += '<table class="table table-striped">';
|
|
437
|
+
if (contentData.table.headers) {
|
|
438
|
+
html += '<thead><tr>';
|
|
439
|
+
contentData.table.headers.forEach(header => {
|
|
440
|
+
html += `<th>${escapeHtml(header)}</th>`;
|
|
441
|
+
});
|
|
442
|
+
html += '</tr></thead>';
|
|
443
|
+
}
|
|
444
|
+
if (contentData.table.rows) {
|
|
445
|
+
html += '<tbody>';
|
|
446
|
+
contentData.table.rows.forEach(row => {
|
|
447
|
+
html += '<tr>';
|
|
448
|
+
row.forEach(cell => {
|
|
449
|
+
html += `<td>${escapeHtml(cell)}</td>`;
|
|
450
|
+
});
|
|
451
|
+
html += '</tr>';
|
|
452
|
+
});
|
|
453
|
+
html += '</tbody>';
|
|
454
|
+
}
|
|
455
|
+
html += '</table>';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return html;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// SQL Filter Building Functions
|
|
462
|
+
|
|
463
|
+
function sqlEscapeString(str) {
|
|
464
|
+
if (!str) return '';
|
|
465
|
+
// Escape single quotes for SQL
|
|
466
|
+
return str.replace(/'/g, "''");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function buildSqlFilter(queryParams) {
|
|
470
|
+
const { realm, auth, ver, type, rt, text } = queryParams;
|
|
471
|
+
let filter = '';
|
|
472
|
+
|
|
473
|
+
// Realm filter
|
|
474
|
+
if (realm && realm !== '') {
|
|
475
|
+
filter += ` and realm = '${sqlEscapeString(realm)}'`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Authority filter
|
|
479
|
+
if (auth && auth !== '') {
|
|
480
|
+
filter += ` and authority = '${sqlEscapeString(auth)}'`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Version filter - check specific version columns
|
|
484
|
+
if (ver) {
|
|
485
|
+
switch (ver) {
|
|
486
|
+
case 'R2':
|
|
487
|
+
filter += ' and R2 = 1';
|
|
488
|
+
break;
|
|
489
|
+
case 'R2B':
|
|
490
|
+
filter += ' and R2B = 1';
|
|
491
|
+
break;
|
|
492
|
+
case 'R3':
|
|
493
|
+
filter += ' and R3 = 1';
|
|
494
|
+
break;
|
|
495
|
+
case 'R4':
|
|
496
|
+
filter += ' and R4 = 1';
|
|
497
|
+
break;
|
|
498
|
+
case 'R4B':
|
|
499
|
+
filter += ' and R4B = 1';
|
|
500
|
+
break;
|
|
501
|
+
case 'R5':
|
|
502
|
+
filter += ' and R5 = 1';
|
|
503
|
+
break;
|
|
504
|
+
case 'R6':
|
|
505
|
+
filter += ' and R6 = 1';
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Type-specific filters
|
|
511
|
+
switch (type) {
|
|
512
|
+
case 'cs': // CodeSystem
|
|
513
|
+
filter += " and ResourceType = 'CodeSystem'";
|
|
514
|
+
break;
|
|
515
|
+
|
|
516
|
+
case 'rp': // Resource Profiles
|
|
517
|
+
filter += " and ResourceType = 'StructureDefinition' and kind = 'resource'";
|
|
518
|
+
if (rt && rt !== '' && hasCachedValue('profileResources', rt)) {
|
|
519
|
+
filter += ` and Type = '${sqlEscapeString(rt)}'`;
|
|
520
|
+
}
|
|
521
|
+
break;
|
|
522
|
+
|
|
523
|
+
case 'dp': // Datatype Profiles
|
|
524
|
+
filter += " and ResourceType = 'StructureDefinition' and (kind = 'complex-type' or kind = 'primitive-type')";
|
|
525
|
+
if (rt && rt !== '' && hasCachedValue('profileTypes', rt)) {
|
|
526
|
+
filter += ` and Type = '${sqlEscapeString(rt)}'`;
|
|
527
|
+
}
|
|
528
|
+
break;
|
|
529
|
+
|
|
530
|
+
case 'lm': // Logical Models
|
|
531
|
+
filter += " and ResourceType = 'StructureDefinition' and kind = 'logical'";
|
|
532
|
+
break;
|
|
533
|
+
|
|
534
|
+
case 'ext': // Extensions
|
|
535
|
+
filter += " and ResourceType = 'StructureDefinition' and (Type = 'Extension')";
|
|
536
|
+
if (rt && rt !== '' && hasCachedValue('extensionContexts', rt)) {
|
|
537
|
+
filter += ` and ResourceKey in (Select ResourceKey from Categories where Mode = 2 and Code = '${sqlEscapeString(rt)}')`;
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
|
|
541
|
+
case 'vs': // ValueSets
|
|
542
|
+
filter += " and ResourceType = 'ValueSet'";
|
|
543
|
+
if (rt && rt !== '' && hasTerminologySource(rt)) {
|
|
544
|
+
filter += ` and ResourceKey in (Select ResourceKey from Categories where Mode = 1 and Code = '${sqlEscapeString(rt)}')`;
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
|
|
548
|
+
case 'cm': // ConceptMaps
|
|
549
|
+
filter += " and ResourceType = 'ConceptMap'";
|
|
550
|
+
if (rt && rt !== '' && hasTerminologySource(rt)) {
|
|
551
|
+
filter += ` and ResourceKey in (Select ResourceKey from Categories where Mode = 1 and Code = '${sqlEscapeString(rt)}')`;
|
|
552
|
+
}
|
|
553
|
+
break;
|
|
554
|
+
|
|
555
|
+
default:
|
|
556
|
+
// No specific type selected - handle rt parameter for general resource filtering
|
|
557
|
+
if (rt && rt !== '' && hasCachedValue('resourceTypes', rt)) {
|
|
558
|
+
filter += ` and ResourceType = '${sqlEscapeString(rt)}'`;
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Text search filter
|
|
564
|
+
if (text && text !== '') {
|
|
565
|
+
const escapedText = sqlEscapeString(text);
|
|
566
|
+
if (type === 'cs') {
|
|
567
|
+
// Special handling for CodeSystems - search both resource and CodeSystem-specific fields
|
|
568
|
+
filter += ` and (ResourceKey in (select ResourceKey from ResourceFTS where Description match '"${escapedText}"' or Narrative match '"${escapedText}"') ` +
|
|
569
|
+
`or ResourceKey in (select ResourceKey from CodeSystemFTS where Display match '"${escapedText}"' or Definition match '"${escapedText}"'))`;
|
|
570
|
+
} else {
|
|
571
|
+
// Standard resource text search
|
|
572
|
+
filter += ` and ResourceKey in (select ResourceKey from ResourceFTS where Description match '"${escapedText}"' or Narrative match '"${escapedText}"')`;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Convert to proper WHERE clause
|
|
577
|
+
if (filter !== '') {
|
|
578
|
+
// Remove the first " and " and prepend "WHERE "
|
|
579
|
+
filter = 'WHERE ' + filter.substring(4);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return filter;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Helper function to check if a terminology source exists
|
|
586
|
+
// This is a placeholder - you might need to implement this based on your data
|
|
587
|
+
function hasTerminologySource(sourceCode) {
|
|
588
|
+
// For now, return true if the source code exists in txSources cache
|
|
589
|
+
// You might need to adjust this logic based on your actual requirements
|
|
590
|
+
return hasCachedValue('txSources', sourceCode);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function buildResourceListQuery(queryParams, offset = 0, limit = 50) {
|
|
594
|
+
const whereClause = buildSqlFilter(queryParams);
|
|
595
|
+
|
|
596
|
+
// Build the complete SQL query
|
|
597
|
+
let sql = `
|
|
598
|
+
SELECT
|
|
599
|
+
ResourceKey,
|
|
600
|
+
ResourceType,
|
|
601
|
+
Type,
|
|
602
|
+
Kind,
|
|
603
|
+
Description,
|
|
604
|
+
PackageKey,
|
|
605
|
+
Realm,
|
|
606
|
+
Authority,
|
|
607
|
+
R2, R2B, R3, R4, R4B, R5, R6,
|
|
608
|
+
Id,
|
|
609
|
+
Url,
|
|
610
|
+
Version,
|
|
611
|
+
Status,
|
|
612
|
+
Date,
|
|
613
|
+
Name,
|
|
614
|
+
Title,
|
|
615
|
+
Content,
|
|
616
|
+
Supplements,
|
|
617
|
+
Details,
|
|
618
|
+
FMM,
|
|
619
|
+
WG,
|
|
620
|
+
StandardsStatus,
|
|
621
|
+
Web
|
|
622
|
+
FROM Resources
|
|
623
|
+
${whereClause}
|
|
624
|
+
ORDER BY ResourceType, Type, Description
|
|
625
|
+
LIMIT ${limit} OFFSET ${offset}
|
|
626
|
+
`;
|
|
627
|
+
|
|
628
|
+
return sql.trim();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Resource List Table Functions
|
|
632
|
+
|
|
633
|
+
function buildPaginationControls(count, offset, baseUrl, queryParams) {
|
|
634
|
+
if (count <= 200) {
|
|
635
|
+
return ''; // No pagination needed
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let html = '<p>';
|
|
639
|
+
|
|
640
|
+
// Start link
|
|
641
|
+
if (offset > 200) {
|
|
642
|
+
const startParams = { ...queryParams };
|
|
643
|
+
delete startParams.offset; // Remove offset to go to start
|
|
644
|
+
const startUrl = buildPaginationUrl(baseUrl, startParams);
|
|
645
|
+
html += `<a href="${startUrl}">Start</a> `;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Prev link
|
|
649
|
+
if (offset >= 200) {
|
|
650
|
+
const prevParams = { ...queryParams, offset: (offset - 200).toString() };
|
|
651
|
+
const prevUrl = buildPaginationUrl(baseUrl, prevParams);
|
|
652
|
+
html += `<a href="${prevUrl}">Prev</a> `;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Current range
|
|
656
|
+
const endRange = Math.min(offset + 200, count);
|
|
657
|
+
html += `<b>Rows ${offset} - ${endRange}</b>`;
|
|
658
|
+
|
|
659
|
+
// Next link (only if there are more results)
|
|
660
|
+
if (offset + 200 < count) {
|
|
661
|
+
const nextParams = { ...queryParams, offset: (offset + 200).toString() };
|
|
662
|
+
const nextUrl = buildPaginationUrl(baseUrl, nextParams);
|
|
663
|
+
html += ` <a href="${nextUrl}">Next</a>`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
html += '</p>';
|
|
667
|
+
return html;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function buildPaginationUrl(baseUrl, params) {
|
|
671
|
+
const queryString = Object.keys(params)
|
|
672
|
+
.filter(key => params[key] && params[key] !== '')
|
|
673
|
+
.map(key => `${key}=${encodeURIComponent(params[key])}`)
|
|
674
|
+
.join('&');
|
|
675
|
+
return baseUrl + (queryString ? '?' + queryString : '');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function showVersion(row) {
|
|
679
|
+
const versions = ['R2', 'R2B', 'R3', 'R4', 'R4B', 'R5', 'R6'];
|
|
680
|
+
const supportedVersions = versions.filter(v => row[v] === 1);
|
|
681
|
+
return supportedVersions.join(', ');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function formatDate(dateString) {
|
|
685
|
+
if (!dateString) return '';
|
|
686
|
+
try {
|
|
687
|
+
const date = new Date(dateString);
|
|
688
|
+
const year = date.getFullYear();
|
|
689
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
690
|
+
return `${year}-${month}`;
|
|
691
|
+
} catch (error) {
|
|
692
|
+
return dateString; // Return original if parsing fails
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function getPackage(packageKey) {
|
|
697
|
+
if (!configCache.loaded || !configCache.maps.packages) {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return configCache.maps.packages.get(packageKey) || null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function renderExtension(details) {
|
|
705
|
+
if (!details) return '<td></td><td></td><td></td>';
|
|
706
|
+
|
|
707
|
+
// Extension details are stored in a structured format
|
|
708
|
+
// For now, we'll do basic parsing - you may need to adjust based on actual format
|
|
709
|
+
try {
|
|
710
|
+
const parts = details.split('|');
|
|
711
|
+
const context = parts[0] || '';
|
|
712
|
+
const modifier = parts[1] || '';
|
|
713
|
+
const type = parts[2] || '';
|
|
714
|
+
|
|
715
|
+
return `<td>${escapeHtml(context)}</td><td>${escapeHtml(modifier)}</td><td>${escapeHtml(type)}</td>`;
|
|
716
|
+
} catch (error) {
|
|
717
|
+
return `<td colspan="3">${escapeHtml(details)}</td>`;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function buildResourceTable(queryParams, resourceCount, offset = 0) {
|
|
722
|
+
if (!xigDb || resourceCount === 0) {
|
|
723
|
+
return '<p>No resources to display.</p>';
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const { ver, realm, auth, type, rt } = queryParams;
|
|
727
|
+
const parts = []; // Use array instead of string concatenation
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
// Add pagination controls
|
|
731
|
+
parts.push(buildPaginationControls(resourceCount, offset, '/xig', queryParams));
|
|
732
|
+
|
|
733
|
+
// Get resource data with pagination
|
|
734
|
+
const { query: resourceQuery, params: qp } = buildSecureResourceQuery(queryParams, offset, 200);
|
|
735
|
+
|
|
736
|
+
// Add SQL query as HTML comment for debugging/transparency
|
|
737
|
+
const escapedQuery = resourceQuery
|
|
738
|
+
.replace(/--/g, '--') // Escape double hyphens
|
|
739
|
+
.replace(/>/g, '>') // Escape greater than
|
|
740
|
+
.replace(/</g, '<'); // Escape less than
|
|
741
|
+
const escapedParams = JSON.stringify(qp)
|
|
742
|
+
.replace(/--/g, '--')
|
|
743
|
+
.replace(/>/g, '>')
|
|
744
|
+
.replace(/</g, '<');
|
|
745
|
+
|
|
746
|
+
parts.push(`<!-- SQL Query: ${escapedQuery} -->`);
|
|
747
|
+
parts.push(`<!-- Parameters: ${escapedParams} -->`);
|
|
748
|
+
// Build table start and headers
|
|
749
|
+
parts.push(
|
|
750
|
+
'<table class="table table-striped table-bordered">',
|
|
751
|
+
'<tr>',
|
|
752
|
+
'<th>Package</th>'
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
if (!ver || ver === '') {
|
|
756
|
+
parts.push('<th>Version</th>');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
parts.push(
|
|
760
|
+
'<th>Identity</th>',
|
|
761
|
+
'<th>Name/Title</th>',
|
|
762
|
+
'<th>Status</th>',
|
|
763
|
+
'<th>FMM</th>',
|
|
764
|
+
'<th>WG</th>',
|
|
765
|
+
'<th>Date</th>'
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
if (!realm || realm === '') {
|
|
769
|
+
parts.push('<th>Realm</th>');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (!auth || auth === '') {
|
|
773
|
+
parts.push('<th>Auth</th>');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Type-specific columns
|
|
777
|
+
switch (type) {
|
|
778
|
+
case 'cs': // CodeSystem
|
|
779
|
+
parts.push('<th>Content</th>');
|
|
780
|
+
break;
|
|
781
|
+
case 'rp': // Resource Profiles
|
|
782
|
+
if (!rt || rt === '') {
|
|
783
|
+
parts.push('<th>Resource</th>');
|
|
784
|
+
}
|
|
785
|
+
break;
|
|
786
|
+
case 'dp': // Datatype Profiles
|
|
787
|
+
if (!rt || rt === '') {
|
|
788
|
+
parts.push('<th>DataType</th>');
|
|
789
|
+
}
|
|
790
|
+
break;
|
|
791
|
+
case 'ext': // Extensions
|
|
792
|
+
parts.push('<th>Context</th>', '<th>Modifier</th>', '<th>Type</th>');
|
|
793
|
+
break;
|
|
794
|
+
case 'vs': // ValueSets
|
|
795
|
+
parts.push('<th>Source(s)</th>');
|
|
796
|
+
break;
|
|
797
|
+
case 'cm': // ConceptMaps
|
|
798
|
+
parts.push('<th>Source(s)</th>');
|
|
799
|
+
break;
|
|
800
|
+
case 'lm': // Logical Models
|
|
801
|
+
parts.push('<th>Type</th>');
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
parts.push('</tr>');
|
|
806
|
+
|
|
807
|
+
const resourceRows = await new Promise((resolve, reject) => {
|
|
808
|
+
xigDb.all(resourceQuery, qp, (err, rows) => {
|
|
809
|
+
if (err) reject(err);
|
|
810
|
+
else resolve(rows || []);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// Determine resource type prefix for links
|
|
815
|
+
let resourceTypePrefix = '';
|
|
816
|
+
switch (type) {
|
|
817
|
+
case 'cs':
|
|
818
|
+
resourceTypePrefix = 'CodeSystem/';
|
|
819
|
+
break;
|
|
820
|
+
case 'rp':
|
|
821
|
+
case 'dp':
|
|
822
|
+
case 'ext':
|
|
823
|
+
case 'lm':
|
|
824
|
+
resourceTypePrefix = 'StructureDefinition/';
|
|
825
|
+
break;
|
|
826
|
+
case 'vs':
|
|
827
|
+
resourceTypePrefix = 'ValueSet/';
|
|
828
|
+
break;
|
|
829
|
+
case 'cm':
|
|
830
|
+
resourceTypePrefix = 'ConceptMap/';
|
|
831
|
+
break;
|
|
832
|
+
default:
|
|
833
|
+
resourceTypePrefix = '';
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Render each row
|
|
838
|
+
for (const row of resourceRows) {
|
|
839
|
+
parts.push('<tr>');
|
|
840
|
+
|
|
841
|
+
// Package column
|
|
842
|
+
const packageObj = getPackage(row.PackageKey);
|
|
843
|
+
if (packageObj && packageObj.Web) {
|
|
844
|
+
parts.push(`<td><a href="${escapeHtml(packageObj.Web)}" target="_blank">${escapeHtml(packageObj.Id)}</a></td>`);
|
|
845
|
+
} else if (packageObj) {
|
|
846
|
+
parts.push(`<td>${escapeHtml(packageObj.Id)}</td>`);
|
|
847
|
+
} else {
|
|
848
|
+
parts.push(`<td>Package ${escapeHtml(String(row.PackageKey))}</td>`);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Version column (if not filtered)
|
|
852
|
+
if (!ver || ver === '') {
|
|
853
|
+
parts.push(`<td>${escapeHtml(showVersion(row))}</td>`);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Identity column with complex link logic
|
|
857
|
+
let identityLink = '';
|
|
858
|
+
if (packageObj && packageObj.PID) {
|
|
859
|
+
const packagePid = packageObj.PID.replace(/#/g, '|'); // Convert # to | for URL
|
|
860
|
+
identityLink = `/xig/resource/${encodeURIComponent(packagePid)}/${encodeURIComponent(row.ResourceType)}/${encodeURIComponent(row.Id)}`;
|
|
861
|
+
} else {
|
|
862
|
+
// Fallback for missing package info
|
|
863
|
+
identityLink = `/xig/resource/unknown/${encodeURIComponent(row.ResourceType)}/${encodeURIComponent(row.Id)}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const identityText = (row.ResourceType + '/').replace(resourceTypePrefix, '') + row.Id;
|
|
867
|
+
parts.push(`<td><a href="${identityLink}">${escapeHtml(identityText)}</a></td>`);
|
|
868
|
+
|
|
869
|
+
// Name/Title column
|
|
870
|
+
const displayName = row.Title || row.Name || '';
|
|
871
|
+
parts.push(`<td>${escapeHtml(displayName)}</td>`);
|
|
872
|
+
|
|
873
|
+
// Status column
|
|
874
|
+
if (row.StandardsStatus) {
|
|
875
|
+
parts.push(`<td>${escapeHtml(row.StandardsStatus || '')}</td>`);
|
|
876
|
+
} else {
|
|
877
|
+
parts.push(`<td>${escapeHtml(row.Status || '')}</td>`);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// FMM/WG Columns
|
|
881
|
+
parts.push(`<td>${escapeHtml(row.FMM || '')}</td>`);
|
|
882
|
+
parts.push(`<td>${escapeHtml(row.WG || '')}</td>`);
|
|
883
|
+
|
|
884
|
+
// Date column
|
|
885
|
+
parts.push(`<td>${formatDate(row.Date)}</td>`);
|
|
886
|
+
|
|
887
|
+
// Realm column (if not filtered)
|
|
888
|
+
if (!realm || realm === '') {
|
|
889
|
+
parts.push(`<td>${escapeHtml(row.Realm || '')}</td>`);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Authority column (if not filtered)
|
|
893
|
+
if (!auth || auth === '') {
|
|
894
|
+
parts.push(`<td>${escapeHtml(row.Authority || '')}</td>`);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Type-specific columns
|
|
898
|
+
switch (type) {
|
|
899
|
+
case 'cs': // CodeSystem
|
|
900
|
+
if (row.Supplements && row.Supplements !== '') {
|
|
901
|
+
parts.push(`<td>Suppl: ${escapeHtml(row.Supplements)}</td>`);
|
|
902
|
+
} else {
|
|
903
|
+
parts.push(`<td>${escapeHtml(row.Content || '')}</td>`);
|
|
904
|
+
}
|
|
905
|
+
break;
|
|
906
|
+
case 'rp': // Resource Profiles
|
|
907
|
+
if (!rt || rt === '') {
|
|
908
|
+
parts.push(`<td>${escapeHtml(row.Type || '')}</td>`);
|
|
909
|
+
}
|
|
910
|
+
break;
|
|
911
|
+
case 'dp': // Datatype Profiles
|
|
912
|
+
if (!rt || rt === '') {
|
|
913
|
+
parts.push(`<td>${escapeHtml(row.Type || '')}</td>`);
|
|
914
|
+
}
|
|
915
|
+
break;
|
|
916
|
+
case 'ext': // Extensions
|
|
917
|
+
parts.push(renderExtension(row.Details));
|
|
918
|
+
break;
|
|
919
|
+
case 'vs': // ValueSets
|
|
920
|
+
case 'cm': { // ConceptMaps
|
|
921
|
+
const details = (row.Details || '').replace(/,/g, ' ');
|
|
922
|
+
parts.push(`<td>${escapeHtml(details)}</td>`);
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
case 'lm': { // Logical Models
|
|
926
|
+
const packageCanonical = packageObj ? packageObj.Canonical : '';
|
|
927
|
+
const typeText = (row.Type || '').replace(packageCanonical + 'StructureDefinition/', '');
|
|
928
|
+
parts.push(`<td>${escapeHtml(typeText)}</td>`);
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
parts.push('</tr>');
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
parts.push('</table>');
|
|
937
|
+
|
|
938
|
+
// Single join operation at the end
|
|
939
|
+
return parts.join('');
|
|
940
|
+
|
|
941
|
+
} catch (error) {
|
|
942
|
+
xigLog.error(`Error building resource table: ${error.message}`);
|
|
943
|
+
return `<p class="text-danger">Error loading resource list: ${escapeHtml(error.message)}</p>`;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Summary Statistics Functions
|
|
948
|
+
|
|
949
|
+
async function buildSummaryStats(queryParams, baseUrl) {
|
|
950
|
+
const { ver, auth, realm } = queryParams;
|
|
951
|
+
const currentFilter = buildSqlFilter(queryParams);
|
|
952
|
+
let html = '';
|
|
953
|
+
|
|
954
|
+
if (!xigDb) {
|
|
955
|
+
return '<p class="text-warning">Database not available for summary statistics</p>';
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
try {
|
|
959
|
+
html += '<div style="background-color:rgb(254, 250, 198); border: 1px black solid; padding: 6px; font-size: 12px; font-family: verdana;">';
|
|
960
|
+
// Version breakdown (only if no version filter is applied)
|
|
961
|
+
if (!ver || ver === '') {
|
|
962
|
+
html += '<p><strong>By Version</strong></p>';
|
|
963
|
+
html += '<ul style="columns: 4; -webkit-columns: 4; -moz-columns: 4">';
|
|
964
|
+
|
|
965
|
+
const versions = getCachedSet('versions');
|
|
966
|
+
for (const version of versions) {
|
|
967
|
+
try {
|
|
968
|
+
let sql;
|
|
969
|
+
if (currentFilter === '') {
|
|
970
|
+
sql = `SELECT COUNT(*) as count FROM Resources WHERE ${version} = 1`;
|
|
971
|
+
} else {
|
|
972
|
+
sql = `SELECT COUNT(*) as count FROM Resources ${currentFilter} AND ${version} = 1`;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const count = await new Promise((resolve, reject) => {
|
|
976
|
+
xigDb.get(sql, [], (err, row) => {
|
|
977
|
+
if (err) reject(err);
|
|
978
|
+
else resolve(row ? row.count : 0);
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
const linkUrl = buildVersionLinkUrl(baseUrl, queryParams, version);
|
|
983
|
+
html += `<li><a href="${linkUrl}">${escapeHtml(version)}</a>: ${count.toLocaleString()}</li>`;
|
|
984
|
+
} catch (error) {
|
|
985
|
+
html += `<li>${escapeHtml(version)}: Error</li>`;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
html += '</ul>';
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Authority breakdown (only if no authority filter is applied)
|
|
992
|
+
if (!auth || auth === '') {
|
|
993
|
+
html += '<p><strong>By Authority</strong></p>';
|
|
994
|
+
html += '<ul style="columns: 4; -webkit-columns: 4; -moz-columns: 4">';
|
|
995
|
+
|
|
996
|
+
let sql;
|
|
997
|
+
if (currentFilter === '') {
|
|
998
|
+
sql = 'SELECT Authority, COUNT(*) as count FROM Resources GROUP BY Authority ORDER BY Authority';
|
|
999
|
+
} else {
|
|
1000
|
+
sql = `SELECT Authority, COUNT(*) as count FROM Resources ${currentFilter} GROUP BY Authority ORDER BY Authority`;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const authorityResults = await new Promise((resolve, reject) => {
|
|
1004
|
+
xigDb.all(sql, [], (err, rows) => {
|
|
1005
|
+
if (err) reject(err);
|
|
1006
|
+
else resolve(rows || []);
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
authorityResults.forEach(row => {
|
|
1011
|
+
const authority = row.Authority;
|
|
1012
|
+
const count = row.count;
|
|
1013
|
+
|
|
1014
|
+
if (!authority || authority === '') {
|
|
1015
|
+
html += `<li>none: ${count.toLocaleString()}</li>`;
|
|
1016
|
+
} else {
|
|
1017
|
+
const linkUrl = buildAuthorityLinkUrl(baseUrl, queryParams, authority);
|
|
1018
|
+
html += `<li><a href="${linkUrl}">${escapeHtml(authority)}</a>: ${count.toLocaleString()}</li>`;
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
html += '</ul>';
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Realm breakdown (only if no realm filter is applied)
|
|
1025
|
+
if (!realm || realm === '') {
|
|
1026
|
+
html += '<p><strong>By Realm</strong></p>';
|
|
1027
|
+
html += '<ul style="columns: 4; -webkit-columns: 4; -moz-columns: 4">';
|
|
1028
|
+
|
|
1029
|
+
let sql;
|
|
1030
|
+
if (currentFilter === '') {
|
|
1031
|
+
sql = 'SELECT Realm, COUNT(*) as count FROM Resources GROUP BY Realm ORDER BY Realm';
|
|
1032
|
+
} else {
|
|
1033
|
+
sql = `SELECT Realm, COUNT(*) as count FROM Resources ${currentFilter} GROUP BY Realm ORDER BY Realm`;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const realmResults = await new Promise((resolve, reject) => {
|
|
1037
|
+
xigDb.all(sql, [], (err, rows) => {
|
|
1038
|
+
if (err) reject(err);
|
|
1039
|
+
else resolve(rows || []);
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
var c = 0;
|
|
1044
|
+
realmResults.forEach(row => {
|
|
1045
|
+
const realmCode = row.Realm;
|
|
1046
|
+
const count = row.count;
|
|
1047
|
+
|
|
1048
|
+
if (!realmCode || realmCode === '') {
|
|
1049
|
+
html += `<li>none: ${count.toLocaleString()}</li>`;
|
|
1050
|
+
} else if (realmCode.length > 3) {
|
|
1051
|
+
c++;
|
|
1052
|
+
} else {
|
|
1053
|
+
const linkUrl = buildRealmLinkUrl(baseUrl, queryParams, realmCode);
|
|
1054
|
+
html += `<li><a href="${linkUrl}">${escapeHtml(realmCode)}</a>: ${count.toLocaleString()}</li>`;
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
if (c > 0) {
|
|
1058
|
+
html += `<li>other: ${c}</li>`;
|
|
1059
|
+
}
|
|
1060
|
+
html += '</ul>';
|
|
1061
|
+
}
|
|
1062
|
+
html += '</div><p> </p>';
|
|
1063
|
+
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
console.error(error);
|
|
1066
|
+
xigLog.error(`Error building summary stats: ${error.message}`);
|
|
1067
|
+
html += `<p class="text-warning">Error loading summary statistics: ${escapeHtml(error.message)}</p>`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return html;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Helper functions to build links for summary stats
|
|
1074
|
+
function buildVersionLinkUrl(baseUrl, currentParams, version) {
|
|
1075
|
+
const params = { ...currentParams, ver: version };
|
|
1076
|
+
const queryString = Object.keys(params)
|
|
1077
|
+
.filter(key => params[key] && params[key] !== '')
|
|
1078
|
+
.map(key => `${key}=${encodeURIComponent(params[key])}`)
|
|
1079
|
+
.join('&');
|
|
1080
|
+
return baseUrl + (queryString ? '?' + queryString : '');
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function buildAuthorityLinkUrl(baseUrl, currentParams, authority) {
|
|
1084
|
+
const params = { ...currentParams, auth: authority };
|
|
1085
|
+
const queryString = Object.keys(params)
|
|
1086
|
+
.filter(key => params[key] && params[key] !== '')
|
|
1087
|
+
.map(key => `${key}=${encodeURIComponent(params[key])}`)
|
|
1088
|
+
.join('&');
|
|
1089
|
+
return baseUrl + (queryString ? '?' + queryString : '');
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function buildRealmLinkUrl(baseUrl, currentParams, realm) {
|
|
1093
|
+
const params = { ...currentParams, realm: realm };
|
|
1094
|
+
const queryString = Object.keys(params)
|
|
1095
|
+
.filter(key => params[key] && params[key] !== '')
|
|
1096
|
+
.map(key => `${key}=${encodeURIComponent(params[key])}`)
|
|
1097
|
+
.join('&');
|
|
1098
|
+
return baseUrl + (queryString ? '?' + queryString : '');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Form Building Functions
|
|
1102
|
+
|
|
1103
|
+
function makeSelect(selectedValue, optionsList, name = 'rt') {
|
|
1104
|
+
let html = `<select name="${name}" size="1">`;
|
|
1105
|
+
|
|
1106
|
+
// Add empty option
|
|
1107
|
+
if (!selectedValue || selectedValue === '') {
|
|
1108
|
+
html += '<option value="" selected="true"></option>';
|
|
1109
|
+
} else {
|
|
1110
|
+
html += '<option value=""></option>';
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Add options from list
|
|
1114
|
+
optionsList.forEach(item => {
|
|
1115
|
+
let code, display;
|
|
1116
|
+
|
|
1117
|
+
// Handle "code=display" format or just "code"
|
|
1118
|
+
if (item.includes('=')) {
|
|
1119
|
+
[code, display] = item.split('=', 2);
|
|
1120
|
+
} else {
|
|
1121
|
+
code = item;
|
|
1122
|
+
display = item;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (selectedValue === code) {
|
|
1126
|
+
html += `<option value="${escapeHtml(code)}" selected="true">${escapeHtml(display)}</option>`;
|
|
1127
|
+
} else {
|
|
1128
|
+
html += `<option value="${escapeHtml(code)}">${escapeHtml(display)}</option>`;
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
html += '</select>';
|
|
1133
|
+
return html;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function buildAdditionalForm(queryParams) {
|
|
1137
|
+
const { ver, realm, auth, type, rt, text } = queryParams;
|
|
1138
|
+
|
|
1139
|
+
let html = '<form method="GET" action="" style="background-color: #eeeeee; border: 1px black solid; padding: 6px; font-size: 12px; font-family: verdana;">';
|
|
1140
|
+
|
|
1141
|
+
// Add hidden inputs to preserve current filter state
|
|
1142
|
+
if (ver && ver !== '') {
|
|
1143
|
+
html += `<input type="hidden" name="ver" value="${escapeHtml(ver)}"/>`;
|
|
1144
|
+
}
|
|
1145
|
+
if (realm && realm !== '') {
|
|
1146
|
+
html += `<input type="hidden" name="realm" value="${escapeHtml(realm)}"/>`;
|
|
1147
|
+
}
|
|
1148
|
+
if (auth && auth !== '') {
|
|
1149
|
+
html += `<input type="hidden" name="auth" value="${escapeHtml(auth)}"/>`;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Add type-specific fields
|
|
1153
|
+
switch (type) {
|
|
1154
|
+
case 'cs': // CodeSystem
|
|
1155
|
+
html += '<input type="hidden" name="type" value="cs"/>';
|
|
1156
|
+
break;
|
|
1157
|
+
|
|
1158
|
+
case 'rp': { // Resource Profiles
|
|
1159
|
+
html += '<input type="hidden" name="type" value="rp"/>';
|
|
1160
|
+
const profileResources = getCachedSet('profileResources');
|
|
1161
|
+
if (profileResources.length > 0) {
|
|
1162
|
+
html += 'Type: ' + makeSelect(rt, profileResources) + ' ';
|
|
1163
|
+
}
|
|
1164
|
+
break;
|
|
1165
|
+
}
|
|
1166
|
+
case 'dp': { // Datatype Profiles
|
|
1167
|
+
html += '<input type="hidden" name="type" value="dp"/>';
|
|
1168
|
+
const profileTypes = getCachedSet('profileTypes');
|
|
1169
|
+
if (profileTypes.length > 0) {
|
|
1170
|
+
html += 'Type: ' + makeSelect(rt, profileTypes) + ' ';
|
|
1171
|
+
}
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
case 'lm': // Logical Models
|
|
1175
|
+
html += '<input type="hidden" name="type" value="lm"/>';
|
|
1176
|
+
break;
|
|
1177
|
+
|
|
1178
|
+
case 'ext': {// Extensions
|
|
1179
|
+
html += '<input type="hidden" name="type" value="ext"/>';
|
|
1180
|
+
const extensionContexts = getCachedSet('extensionContexts');
|
|
1181
|
+
if (extensionContexts.length > 0) {
|
|
1182
|
+
html += 'Context: ' + makeSelect(rt, extensionContexts) + ' ';
|
|
1183
|
+
}
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
case 'vs': {// ValueSets
|
|
1187
|
+
html += '<input type="hidden" name="type" value="vs"/>';
|
|
1188
|
+
const txSources = getCachedMap('txSources');
|
|
1189
|
+
if (Object.keys(txSources).length > 0) {
|
|
1190
|
+
// Convert txSources map to "code=display" format
|
|
1191
|
+
const sourceOptions = Object.keys(txSources).map(code => `${code}=${txSources[code]}`);
|
|
1192
|
+
html += 'Source: ' + makeSelect(rt, sourceOptions) + ' ';
|
|
1193
|
+
}
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
1196
|
+
case 'cm': { // ConceptMaps
|
|
1197
|
+
html += '<input type="hidden" name="type" value="cm"/>';
|
|
1198
|
+
const txSourcesCM = getCachedMap('txSources');
|
|
1199
|
+
if (Object.keys(txSourcesCM).length > 0) {
|
|
1200
|
+
// Convert txSources map to "code=display" format
|
|
1201
|
+
const sourceOptionsCM = Object.keys(txSourcesCM).map(code => `${code}=${txSourcesCM[code]}`);
|
|
1202
|
+
html += 'Source: ' + makeSelect(rt, sourceOptionsCM) + ' ';
|
|
1203
|
+
}
|
|
1204
|
+
break;
|
|
1205
|
+
}
|
|
1206
|
+
default: {
|
|
1207
|
+
// Default case - show resource types
|
|
1208
|
+
const resourceTypes = getCachedSet('resourceTypes');
|
|
1209
|
+
if (resourceTypes.length > 0) {
|
|
1210
|
+
html += 'Type: ' + makeSelect(rt, resourceTypes);
|
|
1211
|
+
}
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Add text search field
|
|
1217
|
+
html += `Text: <input type="text" name="text" value="${escapeHtml(text || '')}" class="" style="width: 200px;"/> `;
|
|
1218
|
+
|
|
1219
|
+
// Add submit button
|
|
1220
|
+
html += '<input type="submit" value="Search" style="color:rgb(89, 137, 241)"/>';
|
|
1221
|
+
|
|
1222
|
+
html += '</form>';
|
|
1223
|
+
|
|
1224
|
+
return html;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Helper function to get cached map as object
|
|
1228
|
+
function getCachedMap(tableName) {
|
|
1229
|
+
const cache = getCachedTable(tableName);
|
|
1230
|
+
if (cache instanceof Map) {
|
|
1231
|
+
const obj = {};
|
|
1232
|
+
cache.forEach((value, key) => {
|
|
1233
|
+
obj[key] = value;
|
|
1234
|
+
});
|
|
1235
|
+
return obj;
|
|
1236
|
+
}
|
|
1237
|
+
return {};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Control Panel Functions
|
|
1241
|
+
|
|
1242
|
+
function capitalizeFirst(str) {
|
|
1243
|
+
if (!str) return '';
|
|
1244
|
+
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function buildPageHeading(queryParams) {
|
|
1248
|
+
const { type, realm, auth, ver, rt } = queryParams;
|
|
1249
|
+
|
|
1250
|
+
let heading = '<h2>';
|
|
1251
|
+
|
|
1252
|
+
// Determine the main heading based on type
|
|
1253
|
+
switch (type) {
|
|
1254
|
+
case 'cs':
|
|
1255
|
+
heading += 'CodeSystems';
|
|
1256
|
+
break;
|
|
1257
|
+
case 'rp':
|
|
1258
|
+
heading += 'Resource Profiles';
|
|
1259
|
+
break;
|
|
1260
|
+
case 'dp':
|
|
1261
|
+
heading += 'Datatype Profiles';
|
|
1262
|
+
break;
|
|
1263
|
+
case 'lm':
|
|
1264
|
+
heading += 'Logical models';
|
|
1265
|
+
break;
|
|
1266
|
+
case 'ext':
|
|
1267
|
+
heading += 'Extensions';
|
|
1268
|
+
break;
|
|
1269
|
+
case 'vs':
|
|
1270
|
+
heading += 'ValueSets';
|
|
1271
|
+
break;
|
|
1272
|
+
case 'cm':
|
|
1273
|
+
heading += 'ConceptMaps';
|
|
1274
|
+
break;
|
|
1275
|
+
default:
|
|
1276
|
+
// No type selected or unknown type
|
|
1277
|
+
if (rt && rt !== '') {
|
|
1278
|
+
heading += `Resources - ${escapeHtml(rt)}`;
|
|
1279
|
+
} else {
|
|
1280
|
+
heading += 'Resources - All Kinds';
|
|
1281
|
+
}
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Add additional qualifiers
|
|
1286
|
+
if (realm && realm !== '') {
|
|
1287
|
+
heading += `, Realm ${escapeHtml(realm.toUpperCase())}`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (auth && auth !== '') {
|
|
1291
|
+
heading += `, Authority ${escapeHtml(capitalizeFirst(auth))}`;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (ver && ver !== '') {
|
|
1295
|
+
heading += `, Version ${escapeHtml(ver)}`;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
heading += '</h2>';
|
|
1299
|
+
|
|
1300
|
+
return heading;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function buildBaseUrl(baseUrl, params, excludeParam) {
|
|
1304
|
+
const filteredParams = { ...params };
|
|
1305
|
+
delete filteredParams[excludeParam];
|
|
1306
|
+
|
|
1307
|
+
const queryString = Object.keys(filteredParams)
|
|
1308
|
+
.filter(key => filteredParams[key] && filteredParams[key] !== '')
|
|
1309
|
+
.map(key => `${key}=${encodeURIComponent(filteredParams[key])}`)
|
|
1310
|
+
.join('&');
|
|
1311
|
+
|
|
1312
|
+
return baseUrl + (queryString ? '?' + queryString : '');
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function buildVersionBar(baseUrl, currentParams) {
|
|
1316
|
+
const { ver } = currentParams;
|
|
1317
|
+
const baseUrlWithoutVer = buildBaseUrl(baseUrl, currentParams, 'ver');
|
|
1318
|
+
|
|
1319
|
+
let html = 'Version: ';
|
|
1320
|
+
|
|
1321
|
+
// "All" link/bold
|
|
1322
|
+
if (!ver || ver === '') {
|
|
1323
|
+
html += '<b>All</b>';
|
|
1324
|
+
} else {
|
|
1325
|
+
html += `<a href="${baseUrlWithoutVer}">All</a>`;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Version links
|
|
1329
|
+
const versions = getCachedSet('versions');
|
|
1330
|
+
versions.forEach(version => {
|
|
1331
|
+
if (version === ver) {
|
|
1332
|
+
html += ` | <b>${escapeHtml(version)}</b>`;
|
|
1333
|
+
} else {
|
|
1334
|
+
const separator = baseUrlWithoutVer.includes('?') ? '&' : '?';
|
|
1335
|
+
html += ` | <a href="${baseUrlWithoutVer}${separator}ver=${encodeURIComponent(version)}">${escapeHtml(version)}</a>`;
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
return html;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function buildAuthorityBar(baseUrl, currentParams) {
|
|
1343
|
+
const { auth } = currentParams;
|
|
1344
|
+
const baseUrlWithoutAuth = buildBaseUrl(baseUrl, currentParams, 'auth');
|
|
1345
|
+
|
|
1346
|
+
let html = 'Authority: ';
|
|
1347
|
+
|
|
1348
|
+
// "All" link/bold
|
|
1349
|
+
if (!auth || auth === '') {
|
|
1350
|
+
html += '<b>All</b>';
|
|
1351
|
+
} else {
|
|
1352
|
+
html += `<a href="${baseUrlWithoutAuth}">All</a>`;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// Authority links
|
|
1356
|
+
const authorities = getCachedSet('authorities');
|
|
1357
|
+
authorities.forEach(authority => {
|
|
1358
|
+
if (authority === auth) {
|
|
1359
|
+
html += ` | <b>${escapeHtml(authority)}</b>`;
|
|
1360
|
+
} else {
|
|
1361
|
+
const separator = baseUrlWithoutAuth.includes('?') ? '&' : '?';
|
|
1362
|
+
html += ` | <a href="${baseUrlWithoutAuth}${separator}auth=${encodeURIComponent(authority)}">${escapeHtml(authority)}</a>`;
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
return html;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function buildRealmBar(baseUrl, currentParams) {
|
|
1370
|
+
const { realm } = currentParams;
|
|
1371
|
+
const baseUrlWithoutRealm = buildBaseUrl(baseUrl, currentParams, 'realm');
|
|
1372
|
+
|
|
1373
|
+
let html = 'Realm: ';
|
|
1374
|
+
|
|
1375
|
+
// "All" link/bold
|
|
1376
|
+
if (!realm || realm === '') {
|
|
1377
|
+
html += '<b>All</b>';
|
|
1378
|
+
} else {
|
|
1379
|
+
html += `<a href="${baseUrlWithoutRealm}">All</a>`;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Realm links
|
|
1383
|
+
const realms = getCachedSet('realms');
|
|
1384
|
+
realms.forEach(realmCode => {
|
|
1385
|
+
if (realmCode === realm) {
|
|
1386
|
+
html += ` | <b>${escapeHtml(realmCode)}</b>`;
|
|
1387
|
+
} else {
|
|
1388
|
+
const separator = baseUrlWithoutRealm.includes('?') ? '&' : '?';
|
|
1389
|
+
html += ` | <a href="${baseUrlWithoutRealm}${separator}realm=${encodeURIComponent(realmCode)}">${escapeHtml(realmCode)}</a>`;
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
return html;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function buildTypeBar(baseUrl, currentParams) {
|
|
1397
|
+
const { type } = currentParams;
|
|
1398
|
+
const baseUrlWithoutType = buildBaseUrl(baseUrl, currentParams, 'type');
|
|
1399
|
+
|
|
1400
|
+
let html = 'View: ';
|
|
1401
|
+
|
|
1402
|
+
// "All" link/bold
|
|
1403
|
+
if (!type || type === '') {
|
|
1404
|
+
html += '<b>All</b>';
|
|
1405
|
+
} else {
|
|
1406
|
+
html += `<a href="${baseUrlWithoutType}">All</a>`;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Type links - using the types map (rp=Resource Profiles, etc.)
|
|
1410
|
+
const typesMap = getCachedTable('types');
|
|
1411
|
+
if (typesMap instanceof Map) {
|
|
1412
|
+
typesMap.forEach((display, code) => {
|
|
1413
|
+
if (code === type) {
|
|
1414
|
+
html += ` | <b>${escapeHtml(display)}</b>`;
|
|
1415
|
+
} else {
|
|
1416
|
+
const separator = baseUrlWithoutType.includes('?') ? '&' : '?';
|
|
1417
|
+
html += ` | <a href="${baseUrlWithoutType}${separator}type=${encodeURIComponent(code)}">${escapeHtml(display)}</a>`;
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
return html;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function buildControlPanel(baseUrl, queryParams) {
|
|
1426
|
+
const versionBar = buildVersionBar(baseUrl, queryParams);
|
|
1427
|
+
const authorityBar = buildAuthorityBar(baseUrl, queryParams);
|
|
1428
|
+
const realmBar = buildRealmBar(baseUrl, queryParams);
|
|
1429
|
+
const typeBar = buildTypeBar(baseUrl, queryParams);
|
|
1430
|
+
|
|
1431
|
+
return `
|
|
1432
|
+
<div class="control-panel mb-4 p-3 border rounded bg-light">
|
|
1433
|
+
<ul style="background-color: #eeeeee; border: 1px black solid; margin: 6px">
|
|
1434
|
+
<li>${versionBar}</li>
|
|
1435
|
+
<li>${authorityBar}</li>
|
|
1436
|
+
<li>${realmBar}</li>
|
|
1437
|
+
<li>${typeBar}</li>
|
|
1438
|
+
</ul>
|
|
1439
|
+
</div>
|
|
1440
|
+
`;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Cache Functions
|
|
1444
|
+
|
|
1445
|
+
function getCachedSet(tableName) {
|
|
1446
|
+
const cache = getCachedTable(tableName);
|
|
1447
|
+
if (cache instanceof Set) {
|
|
1448
|
+
return Array.from(cache).sort(); // Sort for consistent order
|
|
1449
|
+
}
|
|
1450
|
+
return [];
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function getCachedValue(tableName, key) {
|
|
1454
|
+
if (!configCache.loaded || !configCache.maps[tableName]) {
|
|
1455
|
+
return null;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const cache = configCache.maps[tableName];
|
|
1459
|
+
if (cache instanceof Map) {
|
|
1460
|
+
return cache.get(key);
|
|
1461
|
+
}
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function hasCachedValue(tableName, value) {
|
|
1466
|
+
if (!configCache.loaded || !configCache.maps[tableName]) {
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const cache = configCache.maps[tableName];
|
|
1471
|
+
if (cache instanceof Set) {
|
|
1472
|
+
return cache.has(value);
|
|
1473
|
+
}
|
|
1474
|
+
return false;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function getCachedTable(tableName) {
|
|
1478
|
+
if (!configCache.loaded || !configCache.maps[tableName]) {
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
return configCache.maps[tableName];
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function isCacheLoaded() {
|
|
1485
|
+
return configCache.loaded;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function getCacheStats() {
|
|
1489
|
+
if (!configCache.loaded) {
|
|
1490
|
+
return { loaded: false };
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const stats = {
|
|
1494
|
+
loaded: true,
|
|
1495
|
+
lastUpdated: configCache.lastUpdated,
|
|
1496
|
+
tables: {}
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
Object.keys(configCache.maps).forEach(tableName => {
|
|
1500
|
+
const cache = configCache.maps[tableName];
|
|
1501
|
+
if (cache instanceof Map) {
|
|
1502
|
+
stats.tables[tableName] = { type: 'Map', size: cache.size };
|
|
1503
|
+
} else if (cache instanceof Set) {
|
|
1504
|
+
stats.tables[tableName] = { type: 'Set', size: cache.size };
|
|
1505
|
+
} else {
|
|
1506
|
+
stats.tables[tableName] = { type: 'Unknown', size: 0 };
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
return stats;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function getMetadata(key) {
|
|
1514
|
+
return getCachedValue('metadata', key);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Database Functions
|
|
1518
|
+
|
|
1519
|
+
function downloadFile(url, destination, maxRedirects = 5) {
|
|
1520
|
+
return new Promise((resolve, reject) => {
|
|
1521
|
+
xigLog.info(`Starting download from ${url}`);
|
|
1522
|
+
const downloadMeta = {
|
|
1523
|
+
url: url,
|
|
1524
|
+
finalUrl: url,
|
|
1525
|
+
redirectCount: 0,
|
|
1526
|
+
httpStatus: null,
|
|
1527
|
+
contentLength: null,
|
|
1528
|
+
downloadedBytes: 0,
|
|
1529
|
+
serverLastModified: null,
|
|
1530
|
+
startTime: Date.now()
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
function attemptDownload(currentUrl, redirectCount = 0) {
|
|
1534
|
+
if (redirectCount > maxRedirects) {
|
|
1535
|
+
reject(Object.assign(new Error(`Too many redirects (${maxRedirects})`), { downloadMeta }));
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
try {
|
|
1540
|
+
const validatedUrl = validateExternalUrl(currentUrl);
|
|
1541
|
+
const protocol = validatedUrl.protocol === 'https:' ? https : http;
|
|
1542
|
+
|
|
1543
|
+
const request = protocol.get(validatedUrl, (response) => {
|
|
1544
|
+
downloadMeta.httpStatus = response.statusCode;
|
|
1545
|
+
downloadMeta.finalUrl = currentUrl;
|
|
1546
|
+
downloadMeta.redirectCount = redirectCount;
|
|
1547
|
+
downloadMeta.serverLastModified = response.headers['last-modified'] || null;
|
|
1548
|
+
downloadMeta.contentLength = response.headers['content-length'] ? parseInt(response.headers['content-length']) : null;
|
|
1549
|
+
|
|
1550
|
+
// Handle redirects
|
|
1551
|
+
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
1552
|
+
let redirectUrl = response.headers.location;
|
|
1553
|
+
if (!redirectUrl.startsWith('http')) {
|
|
1554
|
+
const urlObj = new URL(currentUrl);
|
|
1555
|
+
if (redirectUrl.startsWith('/')) {
|
|
1556
|
+
redirectUrl = `${urlObj.protocol}//${urlObj.host}${redirectUrl}`;
|
|
1557
|
+
} else {
|
|
1558
|
+
redirectUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}/${redirectUrl}`;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
attemptDownload(redirectUrl, redirectCount + 1);
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
if (response.statusCode !== 200) {
|
|
1567
|
+
reject(Object.assign(
|
|
1568
|
+
new Error(`Download failed with HTTP ${response.statusCode}`),
|
|
1569
|
+
{ downloadMeta }
|
|
1570
|
+
));
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Check content length
|
|
1575
|
+
const maxSize = 10 * 1024 * 1024 * 1024; // 10GB limit
|
|
1576
|
+
if (downloadMeta.contentLength && downloadMeta.contentLength > maxSize) {
|
|
1577
|
+
reject(Object.assign(new Error('File too large'), { downloadMeta }));
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
const fileStream = fs.createWriteStream(destination);
|
|
1582
|
+
|
|
1583
|
+
response.on('data', (chunk) => {
|
|
1584
|
+
downloadMeta.downloadedBytes += chunk.length;
|
|
1585
|
+
if (downloadMeta.downloadedBytes > maxSize) {
|
|
1586
|
+
request.destroy();
|
|
1587
|
+
fs.unlink(destination, () => {}); // Clean up
|
|
1588
|
+
reject(Object.assign(new Error('File too large'), { downloadMeta }));
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
response.pipe(fileStream);
|
|
1594
|
+
|
|
1595
|
+
fileStream.on('finish', () => {
|
|
1596
|
+
fileStream.close();
|
|
1597
|
+
downloadMeta.durationMs = Date.now() - downloadMeta.startTime;
|
|
1598
|
+
xigLog.info(`Download completed successfully. Downloaded ${downloadMeta.downloadedBytes} bytes to ${destination}`);
|
|
1599
|
+
resolve(downloadMeta);
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
fileStream.on('error', (err) => {
|
|
1603
|
+
fs.unlink(destination, () => {}); // Delete partial file
|
|
1604
|
+
reject(Object.assign(err, { downloadMeta }));
|
|
1605
|
+
});
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
request.on('error', (err) => {
|
|
1609
|
+
reject(Object.assign(err, { downloadMeta }));
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
request.setTimeout(300000, () => { // 5 minutes timeout
|
|
1613
|
+
request.destroy();
|
|
1614
|
+
reject(Object.assign(new Error('Download timeout after 5 minutes'), { downloadMeta }));
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
reject(Object.assign(error, { downloadMeta }));
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
attemptDownload(url);
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function validateDatabaseFile(filePath) {
|
|
1627
|
+
return new Promise((resolve, reject) => {
|
|
1628
|
+
if (!fs.existsSync(filePath)) {
|
|
1629
|
+
reject(new Error('Database file does not exist'));
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Try to open the SQLite database to validate it
|
|
1634
|
+
const testDb = new sqlite3.Database(filePath, sqlite3.OPEN_READONLY, (err) => {
|
|
1635
|
+
if (err) {
|
|
1636
|
+
reject(new Error(`Invalid SQLite database: ${err.message}`));
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Try a simple query to ensure the database is accessible
|
|
1641
|
+
testDb.get("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1", (err) => {
|
|
1642
|
+
testDb.close();
|
|
1643
|
+
|
|
1644
|
+
if (err) {
|
|
1645
|
+
reject(new Error(`Database validation failed: ${err.message}`));
|
|
1646
|
+
} else {
|
|
1647
|
+
resolve();
|
|
1648
|
+
}
|
|
1649
|
+
});
|
|
1650
|
+
});
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
async function loadConfigCache() {
|
|
1655
|
+
if (cacheLoadInProgress) {
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
if (!xigDb) {
|
|
1660
|
+
xigLog.error('No database connection available for cache loading');
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
cacheLoadInProgress = true;
|
|
1665
|
+
|
|
1666
|
+
try {
|
|
1667
|
+
// Create new cache object (this will be atomically replaced)
|
|
1668
|
+
const newCache = {
|
|
1669
|
+
loaded: false,
|
|
1670
|
+
lastUpdated: new Date(),
|
|
1671
|
+
maps: {}
|
|
1672
|
+
};
|
|
1673
|
+
|
|
1674
|
+
// Helper function for simple queries
|
|
1675
|
+
const executeQuery = (sql, params = []) => {
|
|
1676
|
+
return new Promise((resolve, reject) => {
|
|
1677
|
+
xigDb.all(sql, params, (err, rows) => {
|
|
1678
|
+
if (err) {
|
|
1679
|
+
reject(err);
|
|
1680
|
+
} else {
|
|
1681
|
+
resolve(rows);
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1687
|
+
// Load metadata
|
|
1688
|
+
const metadataRows = await executeQuery('SELECT Name, Value FROM Metadata');
|
|
1689
|
+
newCache.maps.metadata = new Map();
|
|
1690
|
+
metadataRows.forEach(row => {
|
|
1691
|
+
newCache.maps.metadata.set(row.Name, row.Value);
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
// Load realms
|
|
1695
|
+
const realmRows = await executeQuery('SELECT Code FROM Realms');
|
|
1696
|
+
newCache.maps.realms = new Set();
|
|
1697
|
+
realmRows.forEach(row => {
|
|
1698
|
+
if (row.Code.length <= 3) {
|
|
1699
|
+
newCache.maps.realms.add(row.Code);
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
// Load authorities
|
|
1704
|
+
const authRows = await executeQuery('SELECT Code FROM Authorities');
|
|
1705
|
+
newCache.maps.authorities = new Set();
|
|
1706
|
+
authRows.forEach(row => {
|
|
1707
|
+
newCache.maps.authorities.add(row.Code);
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
// Load packages
|
|
1711
|
+
const packageRows = await executeQuery('SELECT PackageKey, Id, PID, Web, Canonical FROM Packages');
|
|
1712
|
+
newCache.maps.packages = new Map();
|
|
1713
|
+
newCache.maps.packagesById = new Map();
|
|
1714
|
+
packageRows.forEach(row => {
|
|
1715
|
+
const packageObj = {
|
|
1716
|
+
PackageKey: row.PackageKey,
|
|
1717
|
+
Id: row.Id,
|
|
1718
|
+
PID: row.PID,
|
|
1719
|
+
Web: row.Web,
|
|
1720
|
+
Canonical: row.Canonical
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
// Index by PackageKey
|
|
1724
|
+
newCache.maps.packages.set(row.PackageKey, packageObj);
|
|
1725
|
+
|
|
1726
|
+
// Index by PID with # replaced by |
|
|
1727
|
+
const pidKey = row.PID ? row.PID.replace(/#/g, '|') : row.PID;
|
|
1728
|
+
if (pidKey) {
|
|
1729
|
+
newCache.maps.packagesById.set(pidKey, packageObj);
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
// Check if Resources table exists before querying it
|
|
1734
|
+
const tableCheckQuery = "SELECT name FROM sqlite_master WHERE type='table' AND name='Resources'";
|
|
1735
|
+
const resourcesTableExists = await executeQuery(tableCheckQuery);
|
|
1736
|
+
|
|
1737
|
+
if (resourcesTableExists.length > 0) {
|
|
1738
|
+
// Load resource-related caches
|
|
1739
|
+
const profileResourceRows = await executeQuery(
|
|
1740
|
+
"SELECT DISTINCT Type FROM Resources WHERE ResourceType = 'StructureDefinition' AND Kind = 'resource'"
|
|
1741
|
+
);
|
|
1742
|
+
newCache.maps.profileResources = new Set();
|
|
1743
|
+
profileResourceRows.forEach(row => {
|
|
1744
|
+
if (row.Type && row.Type.trim() !== '') { // Filter out null/undefined/empty values
|
|
1745
|
+
newCache.maps.profileResources.add(row.Type);
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
const profileTypeRows = await executeQuery(
|
|
1750
|
+
"SELECT DISTINCT Type FROM Resources WHERE ResourceType = 'StructureDefinition' AND (Kind = 'complex-type' OR Kind = 'primitive-type')"
|
|
1751
|
+
);
|
|
1752
|
+
newCache.maps.profileTypes = new Set();
|
|
1753
|
+
profileTypeRows.forEach(row => {
|
|
1754
|
+
if (row.Type && row.Type.trim() !== '') { // Filter out null/undefined/empty values
|
|
1755
|
+
newCache.maps.profileTypes.add(row.Type);
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
const resourceTypeRows = await executeQuery('SELECT DISTINCT ResourceType FROM Resources');
|
|
1760
|
+
newCache.maps.resourceTypes = new Set();
|
|
1761
|
+
resourceTypeRows.forEach(row => {
|
|
1762
|
+
newCache.maps.resourceTypes.add(row.ResourceType);
|
|
1763
|
+
});
|
|
1764
|
+
} else {
|
|
1765
|
+
newCache.maps.profileResources = new Set();
|
|
1766
|
+
newCache.maps.profileTypes = new Set();
|
|
1767
|
+
newCache.maps.resourceTypes = new Set();
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Load categories
|
|
1771
|
+
const extensionContextRows = await executeQuery('SELECT DISTINCT Code FROM Categories WHERE Mode = 2');
|
|
1772
|
+
newCache.maps.extensionContexts = new Set();
|
|
1773
|
+
extensionContextRows.forEach(row => {
|
|
1774
|
+
newCache.maps.extensionContexts.add(row.Code);
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
const extensionTypeRows = await executeQuery('SELECT DISTINCT Code FROM Categories WHERE Mode = 3');
|
|
1778
|
+
newCache.maps.extensionTypes = new Set();
|
|
1779
|
+
extensionTypeRows.forEach(row => {
|
|
1780
|
+
newCache.maps.extensionTypes.add(row.Code);
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
// Load TX sources
|
|
1784
|
+
const txSourceRows = await executeQuery('SELECT Code, Display FROM TxSource');
|
|
1785
|
+
newCache.maps.txSources = new Map();
|
|
1786
|
+
txSourceRows.forEach(row => {
|
|
1787
|
+
newCache.maps.txSources.set(row.Code, row.Display);
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
// Add fixed dictionaries
|
|
1791
|
+
newCache.maps.versions = new Set(['R2', 'R2B', 'R3', 'R4', 'R4B', 'R5', 'R6']);
|
|
1792
|
+
|
|
1793
|
+
newCache.maps.types = new Map([
|
|
1794
|
+
['rp', 'Resource Profiles'],
|
|
1795
|
+
['dp', 'Datatype Profiles'],
|
|
1796
|
+
['ext', 'Extensions'],
|
|
1797
|
+
['lm', 'Logical Models'],
|
|
1798
|
+
['cs', 'CodeSystems'],
|
|
1799
|
+
['vs', 'ValueSets'],
|
|
1800
|
+
['cm', 'ConceptMaps']
|
|
1801
|
+
]);
|
|
1802
|
+
|
|
1803
|
+
newCache.loaded = true;
|
|
1804
|
+
|
|
1805
|
+
// ATOMIC REPLACEMENT
|
|
1806
|
+
const oldCache = configCache;
|
|
1807
|
+
configCache = newCache;
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
// Emit event
|
|
1811
|
+
cacheEmitter.emit('cacheUpdated', newCache, oldCache);
|
|
1812
|
+
xigLog.info(`XIG Loaded from database`);
|
|
1813
|
+
|
|
1814
|
+
} catch (error) {
|
|
1815
|
+
xigLog.error(`Config cache load failed: ${error.message}`);
|
|
1816
|
+
} finally {
|
|
1817
|
+
cacheLoadInProgress = false;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function initializeDatabase() {
|
|
1822
|
+
return new Promise((resolve, reject) => {
|
|
1823
|
+
if (!fs.existsSync(XIG_DB_PATH)) {
|
|
1824
|
+
xigLog.error('XIG database file not found, will download on first update');
|
|
1825
|
+
resolve();
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
xigDb = new sqlite3.Database(XIG_DB_PATH, sqlite3.OPEN_READONLY, async (err) => {
|
|
1830
|
+
if (err) {
|
|
1831
|
+
xigLog.error(`Failed to open XIG database: ${err.message}`);
|
|
1832
|
+
reject(err);
|
|
1833
|
+
} else {
|
|
1834
|
+
|
|
1835
|
+
try {
|
|
1836
|
+
await loadConfigCache();
|
|
1837
|
+
} catch (cacheError) {
|
|
1838
|
+
xigLog.warn(`Failed to load config cache: ${cacheError.message}`);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
resolve();
|
|
1842
|
+
}
|
|
1843
|
+
});
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
async function updateXigDatabase() {
|
|
1848
|
+
if (updateInProgress) {
|
|
1849
|
+
xigLog.warn('Update already in progress, skipping');
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
updateInProgress = true;
|
|
1854
|
+
const entry = {
|
|
1855
|
+
timestamp: new Date(),
|
|
1856
|
+
trigger: new Error().stack.includes('cron') ? 'cron' : 'manual',
|
|
1857
|
+
status: 'started',
|
|
1858
|
+
sourceUrl: XIG_DB_URL,
|
|
1859
|
+
error: null,
|
|
1860
|
+
downloadMeta: null,
|
|
1861
|
+
previousFileAge: null,
|
|
1862
|
+
durationMs: null
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
// Record current file age before attempting
|
|
1866
|
+
const currentAge = getDatabaseAgeInfo();
|
|
1867
|
+
entry.previousFileAge = currentAge.daysOld;
|
|
1868
|
+
|
|
1869
|
+
const updateStart = Date.now();
|
|
1870
|
+
|
|
1871
|
+
try {
|
|
1872
|
+
fs.mkdirSync(path.dirname(XIG_DB_PATH), { recursive: true });
|
|
1873
|
+
|
|
1874
|
+
const tempPath = XIG_DB_PATH + '.tmp';
|
|
1875
|
+
|
|
1876
|
+
const downloadMeta = await downloadFile(XIG_DB_URL, tempPath);
|
|
1877
|
+
entry.downloadMeta = downloadMeta;
|
|
1878
|
+
|
|
1879
|
+
await validateDatabaseFile(tempPath);
|
|
1880
|
+
entry.status = 'validated';
|
|
1881
|
+
|
|
1882
|
+
if (xigDb) {
|
|
1883
|
+
await new Promise((resolve) => {
|
|
1884
|
+
xigDb.close((err) => {
|
|
1885
|
+
if (err) {
|
|
1886
|
+
xigLog.warn(`Warning: Error closing existing database: ${err.message}`);
|
|
1887
|
+
}
|
|
1888
|
+
xigDb = null;
|
|
1889
|
+
resolve();
|
|
1890
|
+
});
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
if (fs.existsSync(XIG_DB_PATH)) {
|
|
1895
|
+
fs.unlinkSync(XIG_DB_PATH);
|
|
1896
|
+
}
|
|
1897
|
+
fs.renameSync(tempPath, XIG_DB_PATH);
|
|
1898
|
+
|
|
1899
|
+
await initializeDatabase();
|
|
1900
|
+
|
|
1901
|
+
entry.status = 'success';
|
|
1902
|
+
entry.durationMs = Date.now() - updateStart;
|
|
1903
|
+
xigLog.info(`XIG database updated successfully in ${entry.durationMs}ms (${downloadMeta.downloadedBytes} bytes)`);
|
|
1904
|
+
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
entry.status = 'failed';
|
|
1907
|
+
entry.error = error.message;
|
|
1908
|
+
entry.downloadMeta = error.downloadMeta || entry.downloadMeta;
|
|
1909
|
+
entry.durationMs = Date.now() - updateStart;
|
|
1910
|
+
xigLog.error(`XIG database update failed: ${error.message}`);
|
|
1911
|
+
|
|
1912
|
+
const tempPath = XIG_DB_PATH + '.tmp';
|
|
1913
|
+
if (fs.existsSync(tempPath)) {
|
|
1914
|
+
fs.unlinkSync(tempPath);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
if (!xigDb) {
|
|
1918
|
+
await initializeDatabase();
|
|
1919
|
+
}
|
|
1920
|
+
} finally {
|
|
1921
|
+
updateInProgress = false;
|
|
1922
|
+
recordUpdateAttempt(entry);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// Request tracking middleware
|
|
1927
|
+
function trackRequest(req, res, next) {
|
|
1928
|
+
requestStats.total++;
|
|
1929
|
+
|
|
1930
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1931
|
+
const currentCount = requestStats.dailyCounts.get(today) || 0;
|
|
1932
|
+
requestStats.dailyCounts.set(today, currentCount + 1);
|
|
1933
|
+
|
|
1934
|
+
const thirtyDaysAgo = new Date();
|
|
1935
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
1936
|
+
const cutoffDate = thirtyDaysAgo.toISOString().split('T')[0];
|
|
1937
|
+
|
|
1938
|
+
for (const [date] of requestStats.dailyCounts.entries()) {
|
|
1939
|
+
if (date < cutoffDate) {
|
|
1940
|
+
requestStats.dailyCounts.delete(date);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
next();
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
router.use(trackRequest);
|
|
1948
|
+
|
|
1949
|
+
// Statistics functions
|
|
1950
|
+
function getDatabaseTableCounts() {
|
|
1951
|
+
return new Promise((resolve) => {
|
|
1952
|
+
if (!xigDb) {
|
|
1953
|
+
resolve({ packages: 0, resources: 0 });
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const counts = {};
|
|
1958
|
+
let completedQueries = 0;
|
|
1959
|
+
const totalQueries = 2;
|
|
1960
|
+
|
|
1961
|
+
xigDb.get('SELECT COUNT(*) as count FROM Packages', [], (err, row) => {
|
|
1962
|
+
if (err) {
|
|
1963
|
+
counts.packages = 0;
|
|
1964
|
+
} else {
|
|
1965
|
+
counts.packages = row.count;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
completedQueries++;
|
|
1969
|
+
if (completedQueries === totalQueries) {
|
|
1970
|
+
resolve(counts);
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
xigDb.get('SELECT COUNT(*) as count FROM Resources', [], (err, row) => {
|
|
1975
|
+
if (err) {
|
|
1976
|
+
counts.resources = 0;
|
|
1977
|
+
} else {
|
|
1978
|
+
counts.resources = row.count;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
completedQueries++;
|
|
1982
|
+
if (completedQueries === totalQueries) {
|
|
1983
|
+
resolve(counts);
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function getRequestStats() {
|
|
1990
|
+
const now = new Date();
|
|
1991
|
+
const daysRunning = Math.max(1, Math.ceil((now - requestStats.startTime) / (1000 * 60 * 60 * 24)));
|
|
1992
|
+
const averagePerDay = Math.round(requestStats.total / daysRunning);
|
|
1993
|
+
|
|
1994
|
+
return {
|
|
1995
|
+
total: requestStats.total,
|
|
1996
|
+
startTime: requestStats.startTime,
|
|
1997
|
+
daysRunning: daysRunning,
|
|
1998
|
+
averagePerDay: averagePerDay,
|
|
1999
|
+
dailyCounts: requestStats.dailyCounts
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
function getDatabaseAgeInfo() {
|
|
2004
|
+
if (!fs.existsSync(XIG_DB_PATH)) {
|
|
2005
|
+
return {
|
|
2006
|
+
lastDownloaded: null,
|
|
2007
|
+
daysOld: null,
|
|
2008
|
+
status: 'No database file'
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
const stats = fs.statSync(XIG_DB_PATH);
|
|
2013
|
+
const lastModified = stats.mtime;
|
|
2014
|
+
const now = new Date();
|
|
2015
|
+
const ageInDays = Math.floor((now - lastModified) / (1000 * 60 * 60 * 24));
|
|
2016
|
+
|
|
2017
|
+
return {
|
|
2018
|
+
lastDownloaded: lastModified,
|
|
2019
|
+
daysOld: ageInDays,
|
|
2020
|
+
status: ageInDays === 0 ? 'Today' :
|
|
2021
|
+
ageInDays === 1 ? '1 day ago' :
|
|
2022
|
+
`${ageInDays} days ago`
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function buildStatsTable(statsData) {
|
|
2027
|
+
let html = '<table class="table table-striped table-bordered">';
|
|
2028
|
+
html += '<thead class="table-dark">';
|
|
2029
|
+
html += '<tr><th>Metric</th><th>Value</th><th>Details</th></tr>';
|
|
2030
|
+
html += '</thead>';
|
|
2031
|
+
html += '<tbody>';
|
|
2032
|
+
|
|
2033
|
+
// Cache Statistics
|
|
2034
|
+
html += '<tr class="table-info"><td colspan="3"><strong>Cache Statistics</strong></td></tr>';
|
|
2035
|
+
|
|
2036
|
+
if (statsData.cache.loaded) {
|
|
2037
|
+
Object.keys(statsData.cache.tables).forEach(tableName => {
|
|
2038
|
+
const tableInfo = statsData.cache.tables[tableName];
|
|
2039
|
+
html += `<tr>`;
|
|
2040
|
+
html += `<td>Cache: ${escapeHtml(tableName)}</td>`;
|
|
2041
|
+
html += `<td>${tableInfo.size.toLocaleString()}</td>`;
|
|
2042
|
+
html += `<td>${tableInfo.type}</td>`;
|
|
2043
|
+
html += `</tr>`;
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
html += `<tr>`;
|
|
2047
|
+
html += `<td>Cache Last Updated</td>`;
|
|
2048
|
+
html += `<td>${new Date(statsData.cache.lastUpdated).toLocaleString()}</td>`;
|
|
2049
|
+
html += `<td>Automatically updated when database changes</td>`;
|
|
2050
|
+
html += `</tr>`;
|
|
2051
|
+
} else {
|
|
2052
|
+
html += '<tr><td>Cache Status</td><td class="text-warning">Not Loaded</td><td>Cache is still initializing</td></tr>';
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// Database Statistics
|
|
2056
|
+
html += '<tr class="table-info"><td colspan="3"><strong>Database Statistics</strong></td></tr>';
|
|
2057
|
+
|
|
2058
|
+
html += `<tr>`;
|
|
2059
|
+
html += `<td>Database File</td>`;
|
|
2060
|
+
html += `<td>${(statsData.database.fileSize / 1024 / 1024).toFixed(2)} MB</td>`;
|
|
2061
|
+
html += `<td>${escapeHtml(XIG_DB_PATH)}</td>`;
|
|
2062
|
+
html += `</tr>`;
|
|
2063
|
+
|
|
2064
|
+
html += `<tr>`;
|
|
2065
|
+
html += `<td>Download Source</td>`;
|
|
2066
|
+
html += `<td colspan="2"><code>${escapeHtml(XIG_DB_URL)}</code></td>`;
|
|
2067
|
+
html += `</tr>`;
|
|
2068
|
+
|
|
2069
|
+
html += `<tr>`;
|
|
2070
|
+
html += `<td>Last Downloaded</td>`;
|
|
2071
|
+
html += `<td>${statsData.databaseAge.status}</td>`;
|
|
2072
|
+
if (statsData.databaseAge.lastDownloaded) {
|
|
2073
|
+
html += `<td>${statsData.databaseAge.lastDownloaded.toLocaleString()}</td>`;
|
|
2074
|
+
} else {
|
|
2075
|
+
html += `<td>Never downloaded</td>`;
|
|
2076
|
+
}
|
|
2077
|
+
html += `</tr>`;
|
|
2078
|
+
|
|
2079
|
+
// Table counts
|
|
2080
|
+
html += `<tr>`;
|
|
2081
|
+
html += `<td>Packages</td>`;
|
|
2082
|
+
html += `<td>${statsData.tableCounts.packages.toLocaleString()}</td>`;
|
|
2083
|
+
html += `<td>FHIR Implementation Guide packages</td>`;
|
|
2084
|
+
html += `</tr>`;
|
|
2085
|
+
|
|
2086
|
+
html += `<tr>`;
|
|
2087
|
+
html += `<td>Resources</td>`;
|
|
2088
|
+
html += `<td>${statsData.tableCounts.resources.toLocaleString()}</td>`;
|
|
2089
|
+
html += `<td>FHIR resources across all packages</td>`;
|
|
2090
|
+
html += `</tr>`;
|
|
2091
|
+
|
|
2092
|
+
// Update History
|
|
2093
|
+
html += '<tr class="table-info"><td colspan="3"><strong>Update History</strong></td></tr>';
|
|
2094
|
+
|
|
2095
|
+
if (updateInProgress) {
|
|
2096
|
+
html += '<tr><td>⏳ Update In Progress</td><td colspan="2">A download is currently running...</td></tr>';
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const history = getUpdateHistory();
|
|
2100
|
+
if (history.length === 0) {
|
|
2101
|
+
html += '<tr><td colspan="3" class="text-muted">No update attempts since server started</td></tr>';
|
|
2102
|
+
} else {
|
|
2103
|
+
history.forEach((entry, idx) => {
|
|
2104
|
+
const time = new Date(entry.timestamp).toLocaleString();
|
|
2105
|
+
const statusIcon = entry.status === 'success' ? '✅' : '❌';
|
|
2106
|
+
const statusClass = entry.status === 'success' ? '' : 'table-danger';
|
|
2107
|
+
|
|
2108
|
+
let detail = '';
|
|
2109
|
+
if (entry.status === 'success' && entry.downloadMeta) {
|
|
2110
|
+
const mb = (entry.downloadMeta.downloadedBytes / 1024 / 1024).toFixed(1);
|
|
2111
|
+
const secs = (entry.durationMs / 1000).toFixed(1);
|
|
2112
|
+
detail = `Downloaded ${mb} MB in ${secs}s`;
|
|
2113
|
+
if (entry.downloadMeta.httpStatus) {
|
|
2114
|
+
detail += ` (HTTP ${entry.downloadMeta.httpStatus})`;
|
|
2115
|
+
}
|
|
2116
|
+
} else if (entry.status === 'failed') {
|
|
2117
|
+
detail = escapeHtml(entry.error || 'Unknown error');
|
|
2118
|
+
if (entry.downloadMeta) {
|
|
2119
|
+
if (entry.downloadMeta.httpStatus) {
|
|
2120
|
+
detail += ` (HTTP ${entry.downloadMeta.httpStatus})`;
|
|
2121
|
+
}
|
|
2122
|
+
if (entry.downloadMeta.finalUrl !== entry.sourceUrl) {
|
|
2123
|
+
detail += `<br>Redirected to: <code>${escapeHtml(entry.downloadMeta.finalUrl)}</code>`;
|
|
2124
|
+
}
|
|
2125
|
+
if (entry.downloadMeta.downloadedBytes > 0) {
|
|
2126
|
+
detail += `<br>Partial download: ${(entry.downloadMeta.downloadedBytes / 1024 / 1024).toFixed(1)} MB`;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
if (entry.durationMs) {
|
|
2130
|
+
detail += `<br>Duration: ${(entry.durationMs / 1000).toFixed(1)}s`;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
html += `<tr class="${statusClass}">`;
|
|
2135
|
+
html += `<td>${statusIcon} ${idx === 0 ? '<strong>Latest</strong>' : `#${idx + 1}`}</td>`;
|
|
2136
|
+
html += `<td>${time}</td>`;
|
|
2137
|
+
html += `<td>${detail}</td>`;
|
|
2138
|
+
html += `</tr>`;
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// Request Statistics
|
|
2143
|
+
html += '<tr class="table-info"><td colspan="3"><strong>Request Statistics</strong></td></tr>';
|
|
2144
|
+
|
|
2145
|
+
html += `<tr>`;
|
|
2146
|
+
html += `<td>Total Requests</td>`;
|
|
2147
|
+
html += `<td>${statsData.requests.total.toLocaleString()}</td>`;
|
|
2148
|
+
html += `<td>Since ${statsData.requests.startTime.toLocaleString()}</td>`;
|
|
2149
|
+
html += `</tr>`;
|
|
2150
|
+
|
|
2151
|
+
html += `<tr>`;
|
|
2152
|
+
html += `<td>Average per Day</td>`;
|
|
2153
|
+
html += `<td>${statsData.requests.averagePerDay.toLocaleString()}</td>`;
|
|
2154
|
+
html += `<td>Based on ${statsData.requests.daysRunning} days running</td>`;
|
|
2155
|
+
html += `</tr>`;
|
|
2156
|
+
|
|
2157
|
+
// Recent daily activity (last 7 days)
|
|
2158
|
+
const recentDays = [];
|
|
2159
|
+
for (let i = 6; i >= 0; i--) {
|
|
2160
|
+
const date = new Date();
|
|
2161
|
+
date.setDate(date.getDate() - i);
|
|
2162
|
+
const dateStr = date.toISOString().split('T')[0];
|
|
2163
|
+
const count = statsData.requests.dailyCounts.get(dateStr) || 0;
|
|
2164
|
+
recentDays.push(`${dateStr}: ${count}`);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
html += `<tr>`;
|
|
2168
|
+
html += `<td>Recent Activity</td>`;
|
|
2169
|
+
html += `<td>Last 7 days</td>`;
|
|
2170
|
+
html += `<td>${recentDays.join('<br>')}</td>`;
|
|
2171
|
+
html += `</tr>`;
|
|
2172
|
+
|
|
2173
|
+
html += '</tbody>';
|
|
2174
|
+
html += '</table>';
|
|
2175
|
+
|
|
2176
|
+
return html;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function getDatabaseInfo() {
|
|
2180
|
+
return new Promise((resolve, reject) => {
|
|
2181
|
+
if (!xigDb) {
|
|
2182
|
+
resolve({
|
|
2183
|
+
connected: false,
|
|
2184
|
+
lastModified: fs.existsSync(XIG_DB_PATH) ? fs.statSync(XIG_DB_PATH).mtime : null,
|
|
2185
|
+
fileSize: fs.existsSync(XIG_DB_PATH) ? fs.statSync(XIG_DB_PATH).size : 0
|
|
2186
|
+
});
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
xigDb.get("SELECT COUNT(*) as tableCount FROM sqlite_master WHERE type='table'", (err, row) => {
|
|
2191
|
+
if (err) {
|
|
2192
|
+
reject(err);
|
|
2193
|
+
} else {
|
|
2194
|
+
resolve({
|
|
2195
|
+
connected: true,
|
|
2196
|
+
tableCount: row.tableCount,
|
|
2197
|
+
lastModified: fs.existsSync(XIG_DB_PATH) ? fs.statSync(XIG_DB_PATH).mtime : null,
|
|
2198
|
+
fileSize: fs.existsSync(XIG_DB_PATH) ? fs.statSync(XIG_DB_PATH).size : 0
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
});
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Routes
|
|
2206
|
+
router.get('/:packagePid/:resourceType/:resourceId', async (req, res) => {
|
|
2207
|
+
const start = Date.now();
|
|
2208
|
+
try {
|
|
2209
|
+
|
|
2210
|
+
const { packagePid, resourceType, resourceId } = req.params;
|
|
2211
|
+
|
|
2212
|
+
// Check if this looks like a package/resource pattern
|
|
2213
|
+
// Package PIDs typically contain dots and pipes: hl7.fhir.uv.extensions|current
|
|
2214
|
+
// Resource types are FHIR resource names: StructureDefinition, ValueSet, etc.
|
|
2215
|
+
|
|
2216
|
+
const isPackagePidFormat = packagePid.includes('.') || packagePid.includes('|');
|
|
2217
|
+
const isFhirResourceType = /^[A-Z][a-zA-Z]+$/.test(resourceType);
|
|
2218
|
+
|
|
2219
|
+
if (isPackagePidFormat && isFhirResourceType) {
|
|
2220
|
+
// This looks like a legacy resource URL, redirect to the proper format
|
|
2221
|
+
res.redirect(301, `/xig/resource/${packagePid}/${resourceType}/${resourceId}`);
|
|
2222
|
+
} else {
|
|
2223
|
+
// Not a resource URL pattern, return 404
|
|
2224
|
+
res.status(404).send('Not Found');
|
|
2225
|
+
}
|
|
2226
|
+
} finally {
|
|
2227
|
+
this.stats.countRequest(':id', Date.now() - start);
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
// Resources list endpoint with control panel
|
|
2232
|
+
router.get('/', async (req, res) => {
|
|
2233
|
+
const start = Date.now();
|
|
2234
|
+
try {
|
|
2235
|
+
|
|
2236
|
+
const startTime = Date.now(); // Add this at the very beginning
|
|
2237
|
+
|
|
2238
|
+
try {
|
|
2239
|
+
const title = 'FHIR Resources';
|
|
2240
|
+
|
|
2241
|
+
// Parse query parameters
|
|
2242
|
+
const queryParams = {
|
|
2243
|
+
ver: req.query.ver || '',
|
|
2244
|
+
auth: req.query.auth || '',
|
|
2245
|
+
realm: req.query.realm || '',
|
|
2246
|
+
type: req.query.type || '',
|
|
2247
|
+
rt: req.query.rt || '',
|
|
2248
|
+
text: req.query.text || '',
|
|
2249
|
+
offset: req.query.offset || '0'
|
|
2250
|
+
};
|
|
2251
|
+
|
|
2252
|
+
// Parse offset for pagination
|
|
2253
|
+
const offset = parseInt(queryParams.offset) || 0;
|
|
2254
|
+
|
|
2255
|
+
// Build control panel
|
|
2256
|
+
const controlPanel = buildControlPanel('/xig', queryParams);
|
|
2257
|
+
|
|
2258
|
+
// Build dynamic heading
|
|
2259
|
+
const pageHeading = buildPageHeading(queryParams);
|
|
2260
|
+
|
|
2261
|
+
// Get resource count
|
|
2262
|
+
let resourceCount = 0;
|
|
2263
|
+
let countError = null;
|
|
2264
|
+
|
|
2265
|
+
try {
|
|
2266
|
+
if (xigDb) {
|
|
2267
|
+
const {query: countQuery, params: countParams} = buildSecureResourceCountQuery(queryParams);
|
|
2268
|
+
resourceCount = await new Promise((resolve, reject) => {
|
|
2269
|
+
xigDb.get(countQuery, countParams, (err, row) => {
|
|
2270
|
+
if (err) {
|
|
2271
|
+
reject(err);
|
|
2272
|
+
} else {
|
|
2273
|
+
resolve(row ? row.total : 0);
|
|
2274
|
+
}
|
|
2275
|
+
});
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
} catch (error) {
|
|
2279
|
+
countError = error.message;
|
|
2280
|
+
xigLog.error(`Error getting resource count: ${error.message}`);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// Build resource count paragraph
|
|
2284
|
+
let countParagraph = '<p>';
|
|
2285
|
+
if (countError) {
|
|
2286
|
+
countParagraph += `<span class="text-warning">Unable to get resource count: ${escapeHtml(countError)}</span>`;
|
|
2287
|
+
} else {
|
|
2288
|
+
countParagraph += `${resourceCount.toLocaleString()} resources`;
|
|
2289
|
+
}
|
|
2290
|
+
countParagraph += '</p>';
|
|
2291
|
+
|
|
2292
|
+
// Build additional form
|
|
2293
|
+
const additionalForm = buildAdditionalForm(queryParams);
|
|
2294
|
+
|
|
2295
|
+
// Build summary statistics
|
|
2296
|
+
const summaryStats = await buildSummaryStats(queryParams, '/xig');
|
|
2297
|
+
|
|
2298
|
+
// Build resource table
|
|
2299
|
+
const resourceTable = await buildResourceTable(queryParams, resourceCount, offset);
|
|
2300
|
+
|
|
2301
|
+
// Build content
|
|
2302
|
+
let content = controlPanel;
|
|
2303
|
+
content += pageHeading;
|
|
2304
|
+
content += countParagraph;
|
|
2305
|
+
content += additionalForm;
|
|
2306
|
+
content += summaryStats;
|
|
2307
|
+
content += resourceTable;
|
|
2308
|
+
// Gather statistics and render
|
|
2309
|
+
const stats = await gatherPageStatistics();
|
|
2310
|
+
stats.processingTime = Date.now() - startTime;
|
|
2311
|
+
|
|
2312
|
+
const html = renderPage(title, content, stats);
|
|
2313
|
+
res.setHeader('Content-Type', 'text/html');
|
|
2314
|
+
res.send(html);
|
|
2315
|
+
|
|
2316
|
+
} catch (error) {
|
|
2317
|
+
xigLog.error(`Error rendering resources page: ${error.message}`);
|
|
2318
|
+
htmlServer.sendErrorResponse(res, 'xig', error);
|
|
2319
|
+
}
|
|
2320
|
+
} finally {
|
|
2321
|
+
globalStats.countRequest('/', Date.now() - start);
|
|
2322
|
+
}
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
// Stats endpoint
|
|
2326
|
+
router.get('/stats', async (req, res) => {
|
|
2327
|
+
const start = Date.now();
|
|
2328
|
+
try {
|
|
2329
|
+
|
|
2330
|
+
const startTime = Date.now(); // Add this at the very beginning
|
|
2331
|
+
|
|
2332
|
+
try {
|
|
2333
|
+
|
|
2334
|
+
const [dbInfo, tableCounts] = await Promise.all([
|
|
2335
|
+
getDatabaseInfo(),
|
|
2336
|
+
getDatabaseTableCounts()
|
|
2337
|
+
]);
|
|
2338
|
+
|
|
2339
|
+
const statsData = {
|
|
2340
|
+
cache: getCacheStats(),
|
|
2341
|
+
database: dbInfo,
|
|
2342
|
+
databaseAge: getDatabaseAgeInfo(),
|
|
2343
|
+
tableCounts: tableCounts,
|
|
2344
|
+
requests: getRequestStats()
|
|
2345
|
+
};
|
|
2346
|
+
|
|
2347
|
+
const content = buildStatsTable(statsData);
|
|
2348
|
+
|
|
2349
|
+
let introContent = '';
|
|
2350
|
+
const lastAttempt = getLastUpdateAttempt();
|
|
2351
|
+
|
|
2352
|
+
if (statsData.databaseAge.daysOld !== null && statsData.databaseAge.daysOld > 1) {
|
|
2353
|
+
introContent += `<div class="alert alert-warning">`;
|
|
2354
|
+
introContent += `<strong>⚠ Database is ${statsData.databaseAge.daysOld} days old.</strong> `;
|
|
2355
|
+
introContent += `Automatic updates are scheduled daily at 2 AM. `;
|
|
2356
|
+
if (lastAttempt) {
|
|
2357
|
+
if (lastAttempt.status === 'failed') {
|
|
2358
|
+
introContent += `<br><strong>Last update attempt failed</strong> at ${new Date(lastAttempt.timestamp).toLocaleString()}: `;
|
|
2359
|
+
introContent += `${escapeHtml(lastAttempt.error || 'Unknown error')}`;
|
|
2360
|
+
if (lastAttempt.downloadMeta && lastAttempt.downloadMeta.httpStatus) {
|
|
2361
|
+
introContent += ` (HTTP ${lastAttempt.downloadMeta.httpStatus})`;
|
|
2362
|
+
}
|
|
2363
|
+
} else if (lastAttempt.status === 'success') {
|
|
2364
|
+
introContent += `<br>Last successful update: ${new Date(lastAttempt.timestamp).toLocaleString()} `;
|
|
2365
|
+
introContent += `(file age based on filesystem mtime)`;
|
|
2366
|
+
}
|
|
2367
|
+
} else {
|
|
2368
|
+
introContent += `<br>No update attempts recorded since server started.`;
|
|
2369
|
+
}
|
|
2370
|
+
introContent += `</div>`;
|
|
2371
|
+
} else if (lastAttempt && lastAttempt.status === 'failed') {
|
|
2372
|
+
// DB is fresh but last attempt failed — still worth showing
|
|
2373
|
+
introContent += `<div class="alert alert-warning">`;
|
|
2374
|
+
introContent += `<strong>Last update attempt failed</strong> at ${new Date(lastAttempt.timestamp).toLocaleString()}: `;
|
|
2375
|
+
introContent += `${escapeHtml(lastAttempt.error || 'Unknown error')}`;
|
|
2376
|
+
introContent += `</div>`;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
if (!statsData.cache.loaded) {
|
|
2380
|
+
introContent += `<div class="alert alert-info">`;
|
|
2381
|
+
introContent += `<strong>Info:</strong> Cache is still loading. Some statistics may be incomplete.`;
|
|
2382
|
+
introContent += `</div>`;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
const fullContent = introContent + content;
|
|
2386
|
+
|
|
2387
|
+
const stats = await gatherPageStatistics();
|
|
2388
|
+
stats.processingTime = Date.now() - startTime;
|
|
2389
|
+
|
|
2390
|
+
const html = renderPage('FHIR IG Statistics Status', fullContent, stats);
|
|
2391
|
+
res.setHeader('Content-Type', 'text/html');
|
|
2392
|
+
res.send(html);
|
|
2393
|
+
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
xigLog.error(`Error generating stats page: ${error.message}`);
|
|
2396
|
+
htmlServer.sendErrorResponse(res, 'xig', error);
|
|
2397
|
+
}
|
|
2398
|
+
} finally {
|
|
2399
|
+
globalStats.countRequest('stats', Date.now() - start);
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
// Resource detail endpoint - handles individual resource pages
|
|
2404
|
+
router.get('/resource/:packagePid/:resourceType/:resourceId', async (req, res) => {
|
|
2405
|
+
const start = Date.now();
|
|
2406
|
+
try {
|
|
2407
|
+
const startTime = Date.now(); // Add this at the very beginning
|
|
2408
|
+
try {
|
|
2409
|
+
const { packagePid, resourceType, resourceId } = req.params;
|
|
2410
|
+
|
|
2411
|
+
// Convert URL-safe package PID back to database format (| to #)
|
|
2412
|
+
const dbPackagePid = packagePid.replace(/\|/g, '#');
|
|
2413
|
+
|
|
2414
|
+
if (!xigDb) {
|
|
2415
|
+
throw new Error('Database not available');
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// Get package information first
|
|
2419
|
+
const packageObj = getPackageByPid(dbPackagePid);
|
|
2420
|
+
if (!packageObj) {
|
|
2421
|
+
return res.status(404).send(renderPage('Resource Not Found',
|
|
2422
|
+
`<div class="alert alert-danger">Unknown Package: ${escapeHtml(packagePid)}</div>`));
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// Get resource details
|
|
2426
|
+
const resourceQuery = `
|
|
2427
|
+
SELECT * FROM Resources
|
|
2428
|
+
WHERE PackageKey = ? AND ResourceType = ? AND Id = ?
|
|
2429
|
+
`;
|
|
2430
|
+
|
|
2431
|
+
const resourceData = await new Promise((resolve, reject) => {
|
|
2432
|
+
xigDb.get(resourceQuery, [packageObj.PackageKey, resourceType, resourceId], (err, row) => {
|
|
2433
|
+
if (err) reject(err);
|
|
2434
|
+
else resolve(row);
|
|
2435
|
+
});
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
if (!resourceData) {
|
|
2439
|
+
return res.status(404).send(renderPage('Resource Not Found',
|
|
2440
|
+
`<div class="alert alert-danger">Unknown Resource: ${escapeHtml(resourceType)}/${escapeHtml(resourceId)} in package ${escapeHtml(packagePid)}</div>`));
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// Build the resource detail page
|
|
2444
|
+
const content = await buildResourceDetailPage(packageObj, resourceData, req.secure);
|
|
2445
|
+
const title = `${resourceType}/${resourceId}`;
|
|
2446
|
+
const stats = await gatherPageStatistics();
|
|
2447
|
+
stats.processingTime = Date.now() - startTime;
|
|
2448
|
+
|
|
2449
|
+
const html = renderPage(title, content, stats);
|
|
2450
|
+
res.setHeader('Content-Type', 'text/html');
|
|
2451
|
+
res.send(html);
|
|
2452
|
+
|
|
2453
|
+
} catch (error) {
|
|
2454
|
+
xigLog.error(`Error rendering resource detail page: ${error.message}`);
|
|
2455
|
+
htmlServer.sendErrorResponse(res, 'xig', error);
|
|
2456
|
+
}
|
|
2457
|
+
} finally {
|
|
2458
|
+
globalStats.countRequest(':pid', Date.now() - start);
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
// Helper function to get package by PID
|
|
2463
|
+
function getPackageByPid(pid) {
|
|
2464
|
+
if (!configCache.loaded || !configCache.maps.packagesById) {
|
|
2465
|
+
return null;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// Try with both # and | variants
|
|
2469
|
+
const pidWithPipe = pid.replace(/#/g, '|');
|
|
2470
|
+
return configCache.maps.packagesById.get(pid) ||
|
|
2471
|
+
configCache.maps.packagesById.get(pidWithPipe) ||
|
|
2472
|
+
null;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// Main function to build resource detail page content
|
|
2476
|
+
async function buildResourceDetailPage(packageObj, resourceData, secure = false) {
|
|
2477
|
+
let html = '';
|
|
2478
|
+
|
|
2479
|
+
try {
|
|
2480
|
+
// Build basic resource metadata table
|
|
2481
|
+
html += await buildResourceMetadataTable(packageObj, resourceData);
|
|
2482
|
+
|
|
2483
|
+
// Build dependencies sections
|
|
2484
|
+
html += await buildResourceDependencies(resourceData, secure);
|
|
2485
|
+
|
|
2486
|
+
// Build narrative section (if available)
|
|
2487
|
+
html += await buildResourceNarrative(resourceData.ResourceKey, packageObj);
|
|
2488
|
+
|
|
2489
|
+
// Build source section
|
|
2490
|
+
html += await buildResourceSource(resourceData.ResourceKey);
|
|
2491
|
+
|
|
2492
|
+
} catch (error) {
|
|
2493
|
+
xigLog.error(`Error building resource detail content: ${error.message}`);
|
|
2494
|
+
html += `<div class="alert alert-warning">Error loading some content: ${escapeHtml(error.message)}</div>`;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
return html;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// Build the main resource metadata table
|
|
2501
|
+
async function buildResourceMetadataTable(packageObj, resourceData) {
|
|
2502
|
+
let html = '<table class="table table-bordered">';
|
|
2503
|
+
|
|
2504
|
+
// Package
|
|
2505
|
+
if (packageObj && packageObj.Web) {
|
|
2506
|
+
html += `<tr><td><strong>Package</strong></td><td><a href="${escapeHtml(packageObj.Web)}" target="_blank">${escapeHtml(packageObj.Id)}</a></td></tr>`;
|
|
2507
|
+
} else if (packageObj) {
|
|
2508
|
+
html += `<tr><td><strong>Package</strong></td><td>${escapeHtml(packageObj.Id)}</td></tr>`;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// Type
|
|
2512
|
+
html += `<tr><td><strong>Resource Type</strong></td><td>${escapeHtml(resourceData.ResourceType)}</td></tr>`;
|
|
2513
|
+
|
|
2514
|
+
// Id
|
|
2515
|
+
html += `<tr><td><strong>Id</strong></td><td>${escapeHtml(resourceData.Id)}</td></tr>`;
|
|
2516
|
+
|
|
2517
|
+
// FHIR Versions
|
|
2518
|
+
const versions = showVersion(resourceData);
|
|
2519
|
+
if (versions.includes(',')) {
|
|
2520
|
+
html += `<tr><td><strong>FHIR Versions</strong></td><td>${escapeHtml(versions)}</td></tr>`;
|
|
2521
|
+
} else {
|
|
2522
|
+
html += `<tr><td><strong>FHIR Version</strong></td><td>${escapeHtml(versions)}</td></tr>`;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// Source
|
|
2526
|
+
if (resourceData.Web) {
|
|
2527
|
+
html += `<tr><td><strong>Source</strong></td><td><a href="${escapeHtml(resourceData.Web)}" target="_blank">${escapeHtml(resourceData.Web)}</a></td></tr>`;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// Add all other non-empty fields
|
|
2531
|
+
const fields = [
|
|
2532
|
+
{ key: 'Url', label: 'URL' },
|
|
2533
|
+
{ key: 'Version', label: 'Version' },
|
|
2534
|
+
{ key: 'Status', label: 'Status' },
|
|
2535
|
+
{ key: 'Date', label: 'Date' },
|
|
2536
|
+
{ key: 'Name', label: 'Name' },
|
|
2537
|
+
{ key: 'Title', label: 'Title' },
|
|
2538
|
+
{ key: 'Realm', label: 'Realm' },
|
|
2539
|
+
{ key: 'Authority', label: 'Authority' },
|
|
2540
|
+
{ key: 'Description', label: 'Description' },
|
|
2541
|
+
{ key: 'Purpose', label: 'Purpose' },
|
|
2542
|
+
{ key: 'Copyright', label: 'Copyright' },
|
|
2543
|
+
{ key: 'CopyrightLabel', label: 'Copyright Label' },
|
|
2544
|
+
{ key: 'Content', label: 'Content' },
|
|
2545
|
+
{ key: 'Type', label: 'Type' },
|
|
2546
|
+
{ key: 'Supplements', label: 'Supplements' },
|
|
2547
|
+
{ key: 'valueSet', label: 'ValueSet' },
|
|
2548
|
+
{ key: 'Kind', label: 'Kind' }
|
|
2549
|
+
];
|
|
2550
|
+
|
|
2551
|
+
fields.forEach(field => {
|
|
2552
|
+
const value = resourceData[field.key];
|
|
2553
|
+
if (value && value !== '') {
|
|
2554
|
+
if (field.key === 'Experimental') {
|
|
2555
|
+
const expValue = value === '1' ? 'True' : 'False';
|
|
2556
|
+
html += `<tr><td><strong>${field.label}</strong></td><td>${expValue}</td></tr>`;
|
|
2557
|
+
} else {
|
|
2558
|
+
html += `<tr><td><strong>${field.label}</strong></td><td>${escapeHtml(value)}</td></tr>`;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
html += '</table>';
|
|
2564
|
+
return html;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// Build resources that use this resource (dependencies pointing TO this resource)
|
|
2568
|
+
async function buildResourceDependencies(resourceData, secure = false) {
|
|
2569
|
+
let html = '<hr/><h3>Resources that use this resource</h3>';
|
|
2570
|
+
|
|
2571
|
+
try {
|
|
2572
|
+
const dependenciesQuery = `
|
|
2573
|
+
SELECT Packages.PID, Resources.ResourceType, Resources.Id, Resources.Url, Resources.Web, Resources.Name, Resources.Title
|
|
2574
|
+
FROM DependencyList, Resources, Packages
|
|
2575
|
+
WHERE DependencyList.TargetKey = ?
|
|
2576
|
+
AND DependencyList.SourceKey = Resources.ResourceKey
|
|
2577
|
+
AND Resources.PackageKey = Packages.PackageKey
|
|
2578
|
+
ORDER BY ResourceType
|
|
2579
|
+
`;
|
|
2580
|
+
|
|
2581
|
+
const dependencies = await new Promise((resolve, reject) => {
|
|
2582
|
+
xigDb.all(dependenciesQuery, [resourceData.ResourceKey], (err, rows) => {
|
|
2583
|
+
if (err) reject(err);
|
|
2584
|
+
else resolve(rows || []);
|
|
2585
|
+
});
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
if (dependencies.length === 0) {
|
|
2589
|
+
html += '<p style="color: #808080">No resources found</p>';
|
|
2590
|
+
} else {
|
|
2591
|
+
html += buildDependencyTable(dependencies, secure);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// Build resources that this resource uses (dependencies FROM this resource)
|
|
2595
|
+
html += '<hr/><h3>Resources that this resource uses</h3>';
|
|
2596
|
+
|
|
2597
|
+
const usesQuery = `
|
|
2598
|
+
SELECT Packages.PID, Resources.ResourceType, Resources.Id, Resources.Url, Resources.Web, Resources.Name, Resources.Title
|
|
2599
|
+
FROM DependencyList, Resources, Packages
|
|
2600
|
+
WHERE DependencyList.SourceKey = ?
|
|
2601
|
+
AND DependencyList.TargetKey = Resources.ResourceKey
|
|
2602
|
+
AND Resources.PackageKey = Packages.PackageKey
|
|
2603
|
+
ORDER BY ResourceType
|
|
2604
|
+
`;
|
|
2605
|
+
|
|
2606
|
+
const uses = await new Promise((resolve, reject) => {
|
|
2607
|
+
xigDb.all(usesQuery, [resourceData.ResourceKey], (err, rows) => {
|
|
2608
|
+
if (err) reject(err);
|
|
2609
|
+
else resolve(rows || []);
|
|
2610
|
+
});
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
if (uses.length === 0) {
|
|
2614
|
+
html += '<p style="color: #808080">No resources found</p>';
|
|
2615
|
+
} else {
|
|
2616
|
+
html += buildDependencyTable(uses, secure);
|
|
2617
|
+
}
|
|
2618
|
+
if (resourceData && resourceData.ResourceType === 'StructureDefinition' && resourceData.Type === 'Extension') {
|
|
2619
|
+
html += await buildExtensionExamplesSection(resourceData.Url);
|
|
2620
|
+
}
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
html += `<div class="alert alert-warning">Error loading dependencies: ${escapeHtml(error.message)}</div>`;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
return html;
|
|
2626
|
+
}
|
|
2627
|
+
async function buildExtensionExamplesSection(resourceUrl) {
|
|
2628
|
+
let html = '<hr/><h3>Examples of Use for Extension</h3>';
|
|
2629
|
+
|
|
2630
|
+
try {
|
|
2631
|
+
if (!xigDb) {
|
|
2632
|
+
html += '<p style="color: #808080"><em>Database not available</em></p>';
|
|
2633
|
+
return html;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// Query to find extension examples using the resource URL
|
|
2637
|
+
const extensionExamplesQuery = `
|
|
2638
|
+
SELECT eu.Url, eu.Name, eu.Version
|
|
2639
|
+
FROM ExtensionDefns ed
|
|
2640
|
+
JOIN ExtensionUsages eusage ON ed.ExtensionDefnKey = eusage.ExtensionDefnKey
|
|
2641
|
+
JOIN ExtensionUsers eu ON eusage.ExtensionUserKey = eu.ExtensionUserKey
|
|
2642
|
+
WHERE ed.Url = ?
|
|
2643
|
+
ORDER BY eu.Name
|
|
2644
|
+
`;
|
|
2645
|
+
|
|
2646
|
+
const extensionExamples = await new Promise((resolve, reject) => {
|
|
2647
|
+
xigDb.all(extensionExamplesQuery, [resourceUrl], (err, rows) => {
|
|
2648
|
+
if (err) reject(err);
|
|
2649
|
+
else resolve(rows || []);
|
|
2650
|
+
});
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
if (extensionExamples.length === 0) {
|
|
2654
|
+
html += '<p style="color: #808080">No extension usage examples found</p>';
|
|
2655
|
+
} else {
|
|
2656
|
+
html += '<table class="table table-bordered table-striped">';
|
|
2657
|
+
html += '<thead><tr><th>Resource</th><th>Version</th></tr></thead>';
|
|
2658
|
+
html += '<tbody>';
|
|
2659
|
+
|
|
2660
|
+
extensionExamples.forEach(example => {
|
|
2661
|
+
const versionMap = { 1: 'R2', 2: 'R2B', 3: 'R3', 4: 'R4', 5: 'R4B', 6: 'R5' };
|
|
2662
|
+
const versionName = example.Version ? (versionMap[example.Version] || example.Version.toString()) : '';
|
|
2663
|
+
|
|
2664
|
+
html += '<tr>';
|
|
2665
|
+
html += `<td><a href="${escapeHtml(example.Url || '')}">${escapeHtml(example.Name || '')}</a></td>`;
|
|
2666
|
+
html += `<td>${escapeHtml(versionName)}</td>`;
|
|
2667
|
+
html += '</tr>';
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2670
|
+
html += '</tbody>';
|
|
2671
|
+
html += '</table>';
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
} catch (error) {
|
|
2675
|
+
xigLog.error(`Error loading extension examples: ${error.message}`);
|
|
2676
|
+
html += `<div class="alert alert-warning">Error loading extension examples: ${escapeHtml(error.message)}</div>`;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
return html;
|
|
2680
|
+
}
|
|
2681
|
+
// Helper function to build dependency tables
|
|
2682
|
+
function buildDependencyTable(dependencies) {
|
|
2683
|
+
let html = '';
|
|
2684
|
+
let currentType = '';
|
|
2685
|
+
|
|
2686
|
+
dependencies.forEach(dep => {
|
|
2687
|
+
if (currentType !== dep.ResourceType) {
|
|
2688
|
+
if (currentType !== '') {
|
|
2689
|
+
html += '</table>';
|
|
2690
|
+
}
|
|
2691
|
+
currentType = dep.ResourceType;
|
|
2692
|
+
html += '<table class="table table-bordered">';
|
|
2693
|
+
html += `<tr style="background-color: #eeeeee"><td colspan="2"><strong>${escapeHtml(currentType)}</strong></td></tr>`;
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
html += '<tr>';
|
|
2697
|
+
|
|
2698
|
+
// Build the link to the resource detail page
|
|
2699
|
+
const packagePid = dep.PID.replace(/#/g, '|'); // Convert # to | for URL
|
|
2700
|
+
const resourceUrl = `/xig/resource/${encodeURIComponent(packagePid)}/${encodeURIComponent(dep.ResourceType)}/${encodeURIComponent(dep.Id)}`;
|
|
2701
|
+
|
|
2702
|
+
// Resource link
|
|
2703
|
+
if (dep.Url && dep.Url !== '') {
|
|
2704
|
+
// Remove common prefix if present
|
|
2705
|
+
let displayUrl = dep.Url;
|
|
2706
|
+
// This is a simplified version - you might need more sophisticated prefix removal
|
|
2707
|
+
if (displayUrl.includes('/')) {
|
|
2708
|
+
const parts = displayUrl.split('/');
|
|
2709
|
+
displayUrl = parts[parts.length - 1];
|
|
2710
|
+
}
|
|
2711
|
+
html += `<td><a href="${resourceUrl}">${escapeHtml(displayUrl)}</a></td>`;
|
|
2712
|
+
} else {
|
|
2713
|
+
const displayId = dep.ResourceType + '/' + dep.Id;
|
|
2714
|
+
html += `<td><a href="${resourceUrl}">${escapeHtml(displayId)}</a></td>`;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// Title or Name
|
|
2718
|
+
const displayName = dep.Title || dep.Name || '';
|
|
2719
|
+
html += `<td>${escapeHtml(displayName)}</td>`;
|
|
2720
|
+
|
|
2721
|
+
html += '</tr>';
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
if (currentType !== '') {
|
|
2725
|
+
html += '</table>';
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
return html;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// Build narrative section (simplified - full implementation would need BLOB decompression)
|
|
2732
|
+
async function buildResourceNarrative(resourceKey, packageObj) {
|
|
2733
|
+
let html = '';
|
|
2734
|
+
|
|
2735
|
+
try {
|
|
2736
|
+
html += '<hr/><h3>Narrative</h3>';
|
|
2737
|
+
|
|
2738
|
+
if (!xigDb) {
|
|
2739
|
+
html += '<p style="color: #808080"><em>Database not available</em></p>';
|
|
2740
|
+
return html;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// Get the BLOB data from Contents table
|
|
2744
|
+
const contentsQuery = 'SELECT Json FROM Contents WHERE ResourceKey = ?';
|
|
2745
|
+
|
|
2746
|
+
const blobData = await new Promise((resolve, reject) => {
|
|
2747
|
+
xigDb.get(contentsQuery, [resourceKey], (err, row) => {
|
|
2748
|
+
if (err) reject(err);
|
|
2749
|
+
else resolve(row);
|
|
2750
|
+
});
|
|
2751
|
+
});
|
|
2752
|
+
|
|
2753
|
+
if (!blobData || !blobData.Json) {
|
|
2754
|
+
html += '<p style="color: #808080"><em>No content data available</em></p>';
|
|
2755
|
+
return html;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// Decompress the GZIP data
|
|
2759
|
+
const decompressedData = await new Promise((resolve, reject) => {
|
|
2760
|
+
zlib.gunzip(blobData.Json, (err, result) => {
|
|
2761
|
+
if (err) reject(err);
|
|
2762
|
+
else resolve(result);
|
|
2763
|
+
});
|
|
2764
|
+
});
|
|
2765
|
+
|
|
2766
|
+
// Parse as JSON
|
|
2767
|
+
const jsonData = JSON.parse(decompressedData.toString('utf8'));
|
|
2768
|
+
|
|
2769
|
+
// Extract narrative from text.div
|
|
2770
|
+
if (jsonData.text && jsonData.text.div) {
|
|
2771
|
+
let narrativeDiv = jsonData.text.div;
|
|
2772
|
+
|
|
2773
|
+
// Fix narrative links to be relative to the package canonical base
|
|
2774
|
+
if (packageObj && packageObj.Web) {
|
|
2775
|
+
const baseUrl = packageObj.Web.substring(0, packageObj.Web.lastIndexOf('/'));
|
|
2776
|
+
narrativeDiv = fixNarrative(narrativeDiv, baseUrl);
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
html += '<p style="color: maroon">Note: links and images are rebased to the (stated) source</p>';
|
|
2780
|
+
html += narrativeDiv;
|
|
2781
|
+
} else {
|
|
2782
|
+
html += '<p style="color: #808080"><em>No narrative content found in resource</em></p>';
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
} catch (error) {
|
|
2786
|
+
xigLog.error(`Error loading narrative: ${error.message}`);
|
|
2787
|
+
html += `<div class="alert alert-warning">Error loading narrative: ${escapeHtml(error.message)}</div>`;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
return html;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// Build source section (simplified - full implementation would need BLOB decompression)
|
|
2794
|
+
async function buildResourceSource(resourceKey) {
|
|
2795
|
+
let html = '';
|
|
2796
|
+
|
|
2797
|
+
try {
|
|
2798
|
+
html += '<hr/><h3>Source1</h3>';
|
|
2799
|
+
|
|
2800
|
+
if (!xigDb) {
|
|
2801
|
+
html += '<p style="color: #808080"><em>Database not available</em></p>';
|
|
2802
|
+
return html;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// Get the BLOB data from Contents table
|
|
2806
|
+
const contentsQuery = 'SELECT Json FROM Contents WHERE ResourceKey = ?';
|
|
2807
|
+
|
|
2808
|
+
const blobData = await new Promise((resolve, reject) => {
|
|
2809
|
+
xigDb.get(contentsQuery, [resourceKey], (err, row) => {
|
|
2810
|
+
if (err) reject(err);
|
|
2811
|
+
else resolve(row);
|
|
2812
|
+
});
|
|
2813
|
+
});
|
|
2814
|
+
|
|
2815
|
+
if (!blobData || !blobData.Json) {
|
|
2816
|
+
html += '<p style="color: #808080"><em>No content data available</em></p>';
|
|
2817
|
+
return html;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// Decompress the GZIP data
|
|
2821
|
+
const decompressedData = await new Promise((resolve, reject) => {
|
|
2822
|
+
zlib.gunzip(blobData.Json, (err, result) => {
|
|
2823
|
+
if (err) reject(err);
|
|
2824
|
+
else resolve(result);
|
|
2825
|
+
});
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
// Parse and format as JSON
|
|
2829
|
+
const jsonData = JSON.parse(decompressedData.toString('utf8'));
|
|
2830
|
+
if (jsonData.text && jsonData.text.div) {
|
|
2831
|
+
jsonData.text.div = "<!-- snip (see above) -->";
|
|
2832
|
+
}
|
|
2833
|
+
const formattedJson = JSON.stringify(jsonData, null, 2);
|
|
2834
|
+
|
|
2835
|
+
html += '<pre>';
|
|
2836
|
+
html += escapeHtml(formattedJson);
|
|
2837
|
+
html += '</pre>';
|
|
2838
|
+
|
|
2839
|
+
} catch (error) {
|
|
2840
|
+
xigLog.error(`Error loading source: ${error.message}`);
|
|
2841
|
+
html += `<div class="alert alert-warning">Error loading source: ${escapeHtml(error.message)}</div>`;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
return html;
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
function fixNarrative(narrativeHtml, baseUrl) {
|
|
2848
|
+
if (!narrativeHtml || !baseUrl) {
|
|
2849
|
+
return narrativeHtml;
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
try {
|
|
2853
|
+
// Fix relative image sources (but not http/https/data: URLs)
|
|
2854
|
+
let fixed = narrativeHtml.replace(/src="(?!http|https|data:|#)([^"]+)"/g, `src="${baseUrl}/$1"`);
|
|
2855
|
+
|
|
2856
|
+
// Fix relative links (but not http/https/data:/mailto:/# URLs)
|
|
2857
|
+
fixed = fixed.replace(/href="(?!http|https|data:|mailto:|#)([^"]+)"/g, `href="${baseUrl}/$1"`);
|
|
2858
|
+
|
|
2859
|
+
return fixed;
|
|
2860
|
+
} catch (error) {
|
|
2861
|
+
xigLog.error(`Error fixing narrative links: ${error.message}`);
|
|
2862
|
+
return narrativeHtml; // Return original if fixing fails
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// JSON endpoints
|
|
2867
|
+
router.get('/status', async (req, res) => {
|
|
2868
|
+
const start = Date.now();
|
|
2869
|
+
try {
|
|
2870
|
+
|
|
2871
|
+
try {
|
|
2872
|
+
const dbInfo = await getDatabaseInfo();
|
|
2873
|
+
await res.json({
|
|
2874
|
+
status: 'OK',
|
|
2875
|
+
database: dbInfo,
|
|
2876
|
+
databaseAge: getDatabaseAgeInfo(),
|
|
2877
|
+
downloadUrl: XIG_DB_URL,
|
|
2878
|
+
localPath: XIG_DB_PATH,
|
|
2879
|
+
cache: getCacheStats(),
|
|
2880
|
+
updateInProgress: updateInProgress,
|
|
2881
|
+
lastUpdateAttempt: getLastUpdateAttempt(),
|
|
2882
|
+
updateHistory: getUpdateHistory()
|
|
2883
|
+
});
|
|
2884
|
+
} catch (error) {
|
|
2885
|
+
res.status(500).json({
|
|
2886
|
+
status: 'ERROR',
|
|
2887
|
+
error: error.message,
|
|
2888
|
+
cache: getCacheStats(),
|
|
2889
|
+
updateHistory: getUpdateHistory()
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
} finally {
|
|
2893
|
+
globalStats.countRequest('stats', Date.now() - start);
|
|
2894
|
+
}
|
|
2895
|
+
});
|
|
2896
|
+
|
|
2897
|
+
router.get('/cache', async (req, res) => {
|
|
2898
|
+
const start = Date.now();
|
|
2899
|
+
try {
|
|
2900
|
+
|
|
2901
|
+
await res.json(getCacheStats());
|
|
2902
|
+
} finally {
|
|
2903
|
+
globalStats.countRequest('cacheStats', Date.now() - start);
|
|
2904
|
+
}
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
router.post('/update', async (req, res) => {
|
|
2908
|
+
try {
|
|
2909
|
+
xigLog.info('Manual update triggered via API');
|
|
2910
|
+
await updateXigDatabase();
|
|
2911
|
+
res.json({
|
|
2912
|
+
status: 'SUCCESS',
|
|
2913
|
+
message: 'XIG database updated successfully'
|
|
2914
|
+
});
|
|
2915
|
+
} catch (error) {
|
|
2916
|
+
res.status(500).json({
|
|
2917
|
+
status: 'ERROR',
|
|
2918
|
+
message: 'Failed to update XIG database',
|
|
2919
|
+
error: error.message
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
});
|
|
2923
|
+
|
|
2924
|
+
let globalStats;
|
|
2925
|
+
// Initialize the XIG module
|
|
2926
|
+
async function initializeXigModule(stats) {
|
|
2927
|
+
try {
|
|
2928
|
+
globalStats = stats;
|
|
2929
|
+
loadTemplate();
|
|
2930
|
+
|
|
2931
|
+
await initializeDatabase();
|
|
2932
|
+
|
|
2933
|
+
if (!fs.existsSync(XIG_DB_PATH)) {
|
|
2934
|
+
xigLog.info('No existing XIG database found, triggering initial download');
|
|
2935
|
+
setTimeout(() => {
|
|
2936
|
+
updateXigDatabase();
|
|
2937
|
+
}, 5000);
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// Check if auto-update is enabled
|
|
2941
|
+
// Note: This assumes we're called only when XIG is enabled
|
|
2942
|
+
cron.schedule('0 2 * * *', () => {
|
|
2943
|
+
updateXigDatabase();
|
|
2944
|
+
});
|
|
2945
|
+
|
|
2946
|
+
} catch (error) {
|
|
2947
|
+
xigLog.error(`XIG module initialization failed: ${error.message}`);
|
|
2948
|
+
throw error; // Re-throw so caller knows about failure
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// Graceful shutdown
|
|
2953
|
+
function shutdown() {
|
|
2954
|
+
return new Promise((resolve) => {
|
|
2955
|
+
if (xigDb) {
|
|
2956
|
+
xigDb.close((err) => {
|
|
2957
|
+
if (err) {
|
|
2958
|
+
xigLog.error(`Error closing XIG database: ${err.message}`);
|
|
2959
|
+
} else {
|
|
2960
|
+
xigLog.error('XIG database connection closed');
|
|
2961
|
+
}
|
|
2962
|
+
resolve();
|
|
2963
|
+
});
|
|
2964
|
+
} else {
|
|
2965
|
+
resolve();
|
|
2966
|
+
}
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
// Export everything
|
|
2971
|
+
module.exports = {
|
|
2972
|
+
router,
|
|
2973
|
+
updateXigDatabase,
|
|
2974
|
+
getDatabaseInfo,
|
|
2975
|
+
shutdown,
|
|
2976
|
+
initializeXigModule,
|
|
2977
|
+
|
|
2978
|
+
// Cache functions
|
|
2979
|
+
getCachedValue,
|
|
2980
|
+
getCachedTable,
|
|
2981
|
+
hasCachedValue,
|
|
2982
|
+
getCachedSet,
|
|
2983
|
+
isCacheLoaded,
|
|
2984
|
+
getCacheStats,
|
|
2985
|
+
loadConfigCache,
|
|
2986
|
+
getMetadata,
|
|
2987
|
+
|
|
2988
|
+
// Template functions
|
|
2989
|
+
renderPage,
|
|
2990
|
+
buildContentHtml,
|
|
2991
|
+
escapeHtml,
|
|
2992
|
+
loadTemplate,
|
|
2993
|
+
|
|
2994
|
+
// Control panel functions
|
|
2995
|
+
buildControlPanel,
|
|
2996
|
+
buildVersionBar,
|
|
2997
|
+
buildAuthorityBar,
|
|
2998
|
+
buildRealmBar,
|
|
2999
|
+
buildTypeBar,
|
|
3000
|
+
buildBaseUrl,
|
|
3001
|
+
buildPageHeading,
|
|
3002
|
+
capitalizeFirst,
|
|
3003
|
+
|
|
3004
|
+
// Form building functions
|
|
3005
|
+
buildAdditionalForm,
|
|
3006
|
+
makeSelect,
|
|
3007
|
+
getCachedMap,
|
|
3008
|
+
|
|
3009
|
+
// Resource table functions
|
|
3010
|
+
buildResourceTable,
|
|
3011
|
+
buildPaginationControls,
|
|
3012
|
+
buildPaginationUrl,
|
|
3013
|
+
showVersion,
|
|
3014
|
+
formatDate,
|
|
3015
|
+
renderExtension,
|
|
3016
|
+
getPackageByPid,
|
|
3017
|
+
buildResourceDetailPage,
|
|
3018
|
+
buildResourceMetadataTable,
|
|
3019
|
+
buildResourceDependencies,
|
|
3020
|
+
buildDependencyTable,
|
|
3021
|
+
buildResourceNarrative,
|
|
3022
|
+
buildResourceSource,
|
|
3023
|
+
fixNarrative,
|
|
3024
|
+
|
|
3025
|
+
// Summary statistics functions
|
|
3026
|
+
buildSummaryStats,
|
|
3027
|
+
buildVersionLinkUrl,
|
|
3028
|
+
buildAuthorityLinkUrl,
|
|
3029
|
+
buildRealmLinkUrl,
|
|
3030
|
+
|
|
3031
|
+
// SQL filter functions
|
|
3032
|
+
buildSqlFilter,
|
|
3033
|
+
buildResourceListQuery,
|
|
3034
|
+
buildSecureResourceCountQuery,
|
|
3035
|
+
sqlEscapeString,
|
|
3036
|
+
hasTerminologySource,
|
|
3037
|
+
gatherPageStatistics,
|
|
3038
|
+
|
|
3039
|
+
// Statistics functions
|
|
3040
|
+
getDatabaseTableCounts,
|
|
3041
|
+
getRequestStats,
|
|
3042
|
+
getDatabaseAgeInfo,
|
|
3043
|
+
buildStatsTable,
|
|
3044
|
+
getUpdateHistory,
|
|
3045
|
+
getLastUpdateAttempt,
|
|
3046
|
+
|
|
3047
|
+
// Event emitter
|
|
3048
|
+
cacheEmitter
|
|
3049
|
+
};
|