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.
Files changed (277) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/FHIRsmith.png +0 -0
  3. package/README.md +277 -0
  4. package/config-template.json +144 -0
  5. package/library/folder-setup.js +58 -0
  6. package/library/html-server.js +166 -0
  7. package/library/html.js +835 -0
  8. package/library/i18nsupport.js +259 -0
  9. package/library/languages.js +779 -0
  10. package/library/logger-telnet.js +205 -0
  11. package/library/logger.js +279 -0
  12. package/library/package-manager.js +876 -0
  13. package/library/utilities.js +196 -0
  14. package/library/version-utilities.js +1056 -0
  15. package/npmprojector/config-example.json +13 -0
  16. package/npmprojector/indexer.js +394 -0
  17. package/npmprojector/npmprojector.js +395 -0
  18. package/npmprojector/readme.md +174 -0
  19. package/npmprojector/watcher.js +335 -0
  20. package/package.json +119 -0
  21. package/packages/package-crawler.js +846 -0
  22. package/packages/packages-template.html +126 -0
  23. package/packages/packages.js +2838 -0
  24. package/passwords.ini +2 -0
  25. package/publisher/publisher-template.html +208 -0
  26. package/publisher/publisher.js +2167 -0
  27. package/publisher/task-draft.js +458 -0
  28. package/registry/api.js +735 -0
  29. package/registry/crawler.js +637 -0
  30. package/registry/model.js +513 -0
  31. package/registry/readme.md +243 -0
  32. package/registry/registry-data.json +121015 -0
  33. package/registry/registry-template.html +126 -0
  34. package/registry/registry.js +1395 -0
  35. package/registry/test-runner.js +237 -0
  36. package/root-template.html +124 -0
  37. package/server.js +524 -0
  38. package/shl/private-key.pem +5 -0
  39. package/shl/public-key.pem +18 -0
  40. package/shl/shl.js +1125 -0
  41. package/shl/vhl.js +69 -0
  42. package/static/FHIRsmith128.png +0 -0
  43. package/static/FHIRsmith16.png +0 -0
  44. package/static/FHIRsmith32.png +0 -0
  45. package/static/FHIRsmith64.png +0 -0
  46. package/static/assets/css/bootstrap-fhir.css +5302 -0
  47. package/static/assets/css/bootstrap-glyphicons.css +2 -0
  48. package/static/assets/css/bootstrap.css +4097 -0
  49. package/static/assets/css/jquery-ui.css +523 -0
  50. package/static/assets/css/jquery-ui.structure.css +863 -0
  51. package/static/assets/css/jquery-ui.structure.min.css +5 -0
  52. package/static/assets/css/jquery-ui.theme.css +439 -0
  53. package/static/assets/css/jquery-ui.theme.min.css +5 -0
  54. package/static/assets/css/jquery.ui.all.css +7 -0
  55. package/static/assets/css/modules.css +18 -0
  56. package/static/assets/css/project.css +367 -0
  57. package/static/assets/css/pygments-manni.css +66 -0
  58. package/static/assets/css/tags.css +74 -0
  59. package/static/assets/css/xml.css +2 -0
  60. package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
  61. package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
  62. package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
  63. package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
  64. package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
  65. package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
  66. package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
  67. package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
  68. package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
  69. package/static/assets/ico/favicon.ico +0 -0
  70. package/static/assets/ico/favicon.png +0 -0
  71. package/static/assets/images/fhir-logo-www.png +0 -0
  72. package/static/assets/images/fhir-logo.png +0 -0
  73. package/static/assets/images/hl7-logo.png +0 -0
  74. package/static/assets/images/logo_ansinew.jpg +0 -0
  75. package/static/assets/images/search.png +0 -0
  76. package/static/assets/images/stripe.png +0 -0
  77. package/static/assets/images/target.png +0 -0
  78. package/static/assets/images/tx-registry-root.gif +0 -0
  79. package/static/assets/images/tx-registry.png +0 -0
  80. package/static/assets/images/tx-server.png +0 -0
  81. package/static/assets/images/tx-version.png +0 -0
  82. package/static/assets/js/bootstrap.min.js +6 -0
  83. package/static/assets/js/fhir-gw.js +259 -0
  84. package/static/assets/js/fhir.js +2 -0
  85. package/static/assets/js/html5shiv.js +8 -0
  86. package/static/assets/js/jcookie.js +96 -0
  87. package/static/assets/js/jquery-ui.min.js +6 -0
  88. package/static/assets/js/jquery.js +10716 -0
  89. package/static/assets/js/jquery.min.js +2 -0
  90. package/static/assets/js/jquery.ui.core.js +314 -0
  91. package/static/assets/js/jquery.ui.draggable.js +825 -0
  92. package/static/assets/js/jquery.ui.mouse.js +162 -0
  93. package/static/assets/js/jquery.ui.resizable.js +842 -0
  94. package/static/assets/js/jquery.ui.widget.js +268 -0
  95. package/static/assets/js/json2.js +487 -0
  96. package/static/assets/js/jtip.js +97 -0
  97. package/static/assets/js/respond.min.js +6 -0
  98. package/static/assets/js/statuspage.js +70 -0
  99. package/static/assets/js/xml.js +2 -0
  100. package/static/dist/js/bootstrap.js +1964 -0
  101. package/static/favicon.png +0 -0
  102. package/static/fhir.css +626 -0
  103. package/static/icon-fhir-16.png +0 -0
  104. package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
  105. package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
  106. package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
  107. package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
  108. package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
  109. package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  110. package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
  111. package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
  112. package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
  113. package/static/images/ui-icons_222222_256x240.png +0 -0
  114. package/static/images/ui-icons_228ef1_256x240.png +0 -0
  115. package/static/images/ui-icons_ef8c08_256x240.png +0 -0
  116. package/static/images/ui-icons_ffd27a_256x240.png +0 -0
  117. package/static/images/ui-icons_ffffff_256x240.png +0 -0
  118. package/static/js/jquery.effects.blind.js +49 -0
  119. package/static/js/jquery.effects.bounce.js +78 -0
  120. package/static/js/jquery.effects.clip.js +54 -0
  121. package/static/js/jquery.effects.core.js +763 -0
  122. package/static/js/jquery.effects.drop.js +50 -0
  123. package/static/js/jquery.effects.explode.js +79 -0
  124. package/static/js/jquery.effects.fade.js +32 -0
  125. package/static/js/jquery.effects.fold.js +56 -0
  126. package/static/js/jquery.effects.highlight.js +50 -0
  127. package/static/js/jquery.effects.pulsate.js +51 -0
  128. package/static/js/jquery.effects.scale.js +178 -0
  129. package/static/js/jquery.effects.shake.js +57 -0
  130. package/static/js/jquery.effects.slide.js +50 -0
  131. package/static/js/jquery.effects.transfer.js +45 -0
  132. package/static/js/jquery.ui.accordion.js +611 -0
  133. package/static/js/jquery.ui.autocomplete.js +612 -0
  134. package/static/js/jquery.ui.button.js +416 -0
  135. package/static/js/jquery.ui.datepicker.js +1823 -0
  136. package/static/js/jquery.ui.dialog.js +878 -0
  137. package/static/js/jquery.ui.droppable.js +296 -0
  138. package/static/js/jquery.ui.position.js +252 -0
  139. package/static/js/jquery.ui.progressbar.js +109 -0
  140. package/static/js/jquery.ui.selectable.js +266 -0
  141. package/static/js/jquery.ui.slider.js +666 -0
  142. package/static/js/jquery.ui.sortable.js +1077 -0
  143. package/static/js/jquery.ui.tabs.js +758 -0
  144. package/stats.js +80 -0
  145. package/test-cache/vsac/vsac-valuesets.db +0 -0
  146. package/token/nginx_passport_setup.md +383 -0
  147. package/token/security_guide.md +294 -0
  148. package/token/token-template.html +330 -0
  149. package/token/token.js +1300 -0
  150. package/translations/Messages.properties +1510 -0
  151. package/translations/Messages_ar.properties +1399 -0
  152. package/translations/Messages_de.properties +836 -0
  153. package/translations/Messages_es.properties +737 -0
  154. package/translations/Messages_fr.properties +1 -0
  155. package/translations/Messages_ja.properties +893 -0
  156. package/translations/Messages_nl.properties +1357 -0
  157. package/translations/Messages_pt.properties +1302 -0
  158. package/translations/Messages_ru.properties +1 -0
  159. package/translations/Messages_uz.properties +1 -0
  160. package/translations/Messages_zh.properties +1 -0
  161. package/translations/rendering-phrases.properties +1128 -0
  162. package/translations/rendering-phrases_ar.properties +1091 -0
  163. package/translations/rendering-phrases_de.properties +6 -0
  164. package/translations/rendering-phrases_es.properties +6 -0
  165. package/translations/rendering-phrases_fr.properties +624 -0
  166. package/translations/rendering-phrases_ja.properties +21 -0
  167. package/translations/rendering-phrases_nl.properties +970 -0
  168. package/translations/rendering-phrases_pt.properties +1020 -0
  169. package/translations/rendering-phrases_ru.properties +1094 -0
  170. package/translations/rendering-phrases_uz.properties +1 -0
  171. package/translations/rendering-phrases_zh.properties +1 -0
  172. package/tx/README.md +418 -0
  173. package/tx/cm/cm-api.js +110 -0
  174. package/tx/cm/cm-database.js +735 -0
  175. package/tx/cm/cm-package.js +325 -0
  176. package/tx/cs/cs-api.js +789 -0
  177. package/tx/cs/cs-areacode.js +615 -0
  178. package/tx/cs/cs-country.js +1110 -0
  179. package/tx/cs/cs-cpt.js +785 -0
  180. package/tx/cs/cs-cs.js +1579 -0
  181. package/tx/cs/cs-currency.js +539 -0
  182. package/tx/cs/cs-db.js +1321 -0
  183. package/tx/cs/cs-hgvs.js +329 -0
  184. package/tx/cs/cs-lang.js +465 -0
  185. package/tx/cs/cs-loinc.js +1485 -0
  186. package/tx/cs/cs-mimetypes.js +238 -0
  187. package/tx/cs/cs-ndc.js +704 -0
  188. package/tx/cs/cs-omop.js +1025 -0
  189. package/tx/cs/cs-provider-api.js +43 -0
  190. package/tx/cs/cs-provider-list.js +37 -0
  191. package/tx/cs/cs-rxnorm.js +808 -0
  192. package/tx/cs/cs-snomed.js +1102 -0
  193. package/tx/cs/cs-ucum.js +514 -0
  194. package/tx/cs/cs-unii.js +271 -0
  195. package/tx/cs/cs-uri.js +218 -0
  196. package/tx/cs/cs-usstates.js +305 -0
  197. package/tx/dev.fhir.org.yml +14 -0
  198. package/tx/fixtures/test-cases-setup.json +18 -0
  199. package/tx/fixtures/test-cases.yml +16 -0
  200. package/tx/html/codesystem-operations.liquid +25 -0
  201. package/tx/html/home-metrics.liquid +247 -0
  202. package/tx/html/operations-form.liquid +148 -0
  203. package/tx/html/search-form.liquid +62 -0
  204. package/tx/html/tx-template.html +133 -0
  205. package/tx/html/valueset-operations.liquid +54 -0
  206. package/tx/importers/atc-to-fhir.js +316 -0
  207. package/tx/importers/import-loinc.module.js +1536 -0
  208. package/tx/importers/import-ndc.module.js +1088 -0
  209. package/tx/importers/import-rxnorm.module.js +898 -0
  210. package/tx/importers/import-sct.module.js +2457 -0
  211. package/tx/importers/import-unii.module.js +601 -0
  212. package/tx/importers/readme.md +453 -0
  213. package/tx/importers/subset-loinc.module.js +1081 -0
  214. package/tx/importers/subset-rxnorm.module.js +938 -0
  215. package/tx/importers/tx-import-base.js +351 -0
  216. package/tx/importers/tx-import-settings.js +310 -0
  217. package/tx/importers/tx-import.js +357 -0
  218. package/tx/library/canonical-resource.js +88 -0
  219. package/tx/library/capabilitystatement.js +292 -0
  220. package/tx/library/codesystem.js +774 -0
  221. package/tx/library/conceptmap.js +568 -0
  222. package/tx/library/designations.js +932 -0
  223. package/tx/library/errors.js +77 -0
  224. package/tx/library/extensions.js +117 -0
  225. package/tx/library/namingsystem.js +322 -0
  226. package/tx/library/operation-outcome.js +127 -0
  227. package/tx/library/parameters.js +105 -0
  228. package/tx/library/renderer.js +1559 -0
  229. package/tx/library/terminologycapabilities.js +418 -0
  230. package/tx/library/ucum-parsers.js +1029 -0
  231. package/tx/library/ucum-service.js +370 -0
  232. package/tx/library/ucum-types.js +1099 -0
  233. package/tx/library/valueset.js +543 -0
  234. package/tx/library.js +676 -0
  235. package/tx/ocl/cm-ocl.js +106 -0
  236. package/tx/ocl/cs-ocl.js +39 -0
  237. package/tx/ocl/vs-ocl.js +105 -0
  238. package/tx/operation-context.js +568 -0
  239. package/tx/params.js +613 -0
  240. package/tx/provider.js +403 -0
  241. package/tx/sct/ecl.js +1560 -0
  242. package/tx/sct/expressions.js +2077 -0
  243. package/tx/sct/structures.js +1396 -0
  244. package/tx/tx-html.js +1063 -0
  245. package/tx/tx.fhir.org.yml +39 -0
  246. package/tx/tx.js +927 -0
  247. package/tx/vs/vs-api.js +112 -0
  248. package/tx/vs/vs-database.js +786 -0
  249. package/tx/vs/vs-package.js +358 -0
  250. package/tx/vs/vs-vsac.js +366 -0
  251. package/tx/workers/batch-validate.js +129 -0
  252. package/tx/workers/batch.js +361 -0
  253. package/tx/workers/closure.js +32 -0
  254. package/tx/workers/expand.js +1845 -0
  255. package/tx/workers/lookup.js +407 -0
  256. package/tx/workers/metadata.js +467 -0
  257. package/tx/workers/operations.js +34 -0
  258. package/tx/workers/read.js +164 -0
  259. package/tx/workers/search.js +384 -0
  260. package/tx/workers/subsumes.js +334 -0
  261. package/tx/workers/translate.js +492 -0
  262. package/tx/workers/validate.js +2504 -0
  263. package/tx/workers/worker.js +904 -0
  264. package/tx/xml/capabilitystatement-xml.js +63 -0
  265. package/tx/xml/codesystem-xml.js +62 -0
  266. package/tx/xml/conceptmap-xml.js +65 -0
  267. package/tx/xml/namingsystem-xml.js +65 -0
  268. package/tx/xml/operationoutcome-xml.js +127 -0
  269. package/tx/xml/parameters-xml.js +312 -0
  270. package/tx/xml/terminologycapabilities-xml.js +64 -0
  271. package/tx/xml/valueset-xml.js +64 -0
  272. package/tx/xml/xml-base.js +603 -0
  273. package/vcl/vcl-parser.js +1098 -0
  274. package/vcl/vcl.js +253 -0
  275. package/windows-install.js +19 -0
  276. package/xig/xig-template.html +124 -0
  277. 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
+ '<': '&lt;',
81
+ '>': '&gt;',
82
+ '"': '&quot;',
83
+ "'": '&#x27;',
84
+ '/': '&#x2F;',
85
+ '`': '&#x60;',
86
+ '=': '&#x3D;'
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, '&#45;&#45;') // Escape double hyphens
739
+ .replace(/>/g, '&gt;') // Escape greater than
740
+ .replace(/</g, '&lt;'); // Escape less than
741
+ const escapedParams = JSON.stringify(qp)
742
+ .replace(/--/g, '&#45;&#45;')
743
+ .replace(/>/g, '&gt;')
744
+ .replace(/</g, '&lt;');
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>&nbsp;</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
+ };