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
@@ -0,0 +1,1395 @@
1
+ // Enhanced registry.js with HTML rendering and resolver endpoints
2
+
3
+ const express = require('express');
4
+ const path = require('path');
5
+ const RegistryCrawler = require('./crawler');
6
+ const RegistryAPI = require('./api');
7
+ const htmlServer = require('../library/html-server');
8
+ const Logger = require('../library/logger');
9
+ const regLog = Logger.getInstance().child({ module: 'registry' });
10
+ const folders = require('../library/folder-setup');
11
+
12
+ class RegistryModule {
13
+ constructor(stats) {
14
+ this.router = express.Router();
15
+ this.logger = Logger.getInstance().child({ module: 'registry' });
16
+ this.crawler = null;
17
+ this.api = null;
18
+ this.config = null;
19
+ this.crawlInterval = null;
20
+ this.isInitialized = false;
21
+ this.lastCrawlTime = null;
22
+ this.crawlInProgress = false;
23
+
24
+ // Thread-safe data storage
25
+ this.currentData = null;
26
+ this.dataLock = false;
27
+ this.stats = stats;
28
+ }
29
+
30
+ /**
31
+ * Initialize the registry module
32
+ */
33
+ async initialize(config) {
34
+ this.logger.info('Initializing Registry module...');
35
+ this.config = config;
36
+
37
+ try {
38
+ // Initialize crawler with configuration
39
+ const crawlerConfig = {
40
+ masterUrl: config.masterUrl || 'https://fhir.github.io/ig-registry/tx-servers.json',
41
+ timeout: config.timeout || 30000,
42
+ userAgent: config.userAgent || 'FHIRRegistryServer/1.0',
43
+ apiKeys: config.apiKeys || {}
44
+ };
45
+
46
+ this.crawler = new RegistryCrawler(crawlerConfig);
47
+ this.crawler.useLog(regLog);
48
+
49
+ // Initialize API with crawler
50
+ this.api = new RegistryAPI(this.crawler);
51
+
52
+ // Load saved data if available
53
+ await this.loadSavedData();
54
+
55
+ // Set up routes
56
+ this.setupRoutes();
57
+
58
+ // Start periodic crawling if configured
59
+ if (config.crawlInterval && config.crawlInterval > 0) {
60
+ this.startPeriodicCrawl(config.crawlInterval);
61
+ }
62
+
63
+ this.isInitialized = true;
64
+ this.logger.info('Registry module initialized successfully');
65
+
66
+ } catch (error) {
67
+ this.logger.error('Failed to initialize Registry module:', error);
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Load saved registry data if available
74
+ */
75
+ async loadSavedData() {
76
+ try {
77
+ const fs = require('fs').promises;
78
+ const dataPath = folders.ensureFilePath('registry', 'registry-data.json'); // <-- CHANGE
79
+ const data = await fs.readFile(dataPath, 'utf8');
80
+ const jsonData = JSON.parse(data);
81
+
82
+ // Thread-safe update
83
+ await this.updateData(() => {
84
+ this.crawler.loadData(jsonData);
85
+ this.currentData = this.crawler.getData();
86
+ });
87
+
88
+ this.logger.info('Loaded saved registry data');
89
+ } catch (error) {
90
+ this.logger.info('No saved registry data found, will fetch fresh data');
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Save registry data to disk
96
+ */
97
+ async saveData() {
98
+ try {
99
+ const fs = require('fs').promises;
100
+ const dataPath = folders.ensureFilePath('registry', 'registry-data.json');
101
+
102
+ const data = this.crawler.saveData();
103
+ await fs.writeFile(dataPath, JSON.stringify(data, null, 2));
104
+ this.logger.debug('Saved registry data to disk');
105
+ } catch (error) {
106
+ this.logger.error('Failed to save registry data:', error);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Start periodic crawling
112
+ */
113
+ startPeriodicCrawl(intervalMinutes) {
114
+ const intervalMs = intervalMinutes * 60 * 1000;
115
+
116
+ // Run initial crawl after a short delay
117
+ setTimeout(() => {
118
+ this.performCrawl();
119
+ }, 5000);
120
+
121
+ // Set up periodic crawling
122
+ this.crawlInterval = setInterval(() => {
123
+ this.performCrawl();
124
+ }, intervalMs);
125
+
126
+ this.logger.info(`Started periodic crawl every ${intervalMinutes} minutes`);
127
+ }
128
+
129
+ /**
130
+ * Perform a single crawl
131
+ */
132
+ async performCrawl() {
133
+ if (this.crawlInProgress) {
134
+ this.logger.info('Crawl already in progress, skipping...');
135
+ return;
136
+ }
137
+
138
+ this.crawlInProgress = true;
139
+ this.logger.info('Starting registry crawl...');
140
+ const startTime = Date.now();
141
+
142
+ try {
143
+ // Perform the crawl
144
+ const newData = await this.crawler.crawl(this.config.masterUrl);
145
+
146
+ // Thread-safe update of current data
147
+ await this.updateData(() => {
148
+ this.currentData = newData;
149
+ });
150
+
151
+ this.lastCrawlTime = new Date();
152
+ const elapsed = Date.now() - startTime;
153
+
154
+ // Save to disk
155
+ await this.saveData();
156
+
157
+ // Get metadata
158
+ const metadata = this.crawler.getMetadata();
159
+ this.logger.info(`Crawl completed in ${(elapsed/1000).toFixed(1)}s. ` +
160
+ `Found ${newData.registries.length} registries, ` +
161
+ `${metadata.errors.length} errors, ` +
162
+ `downloaded ${this.crawler.formatBytes(metadata.totalBytes)}`);
163
+
164
+ } catch (error) {
165
+ this.logger.error('Crawl failed:', error);
166
+ } finally {
167
+ this.crawlInProgress = false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Thread-safe data update
173
+ */
174
+ async updateData(updateFn) {
175
+ // Simple lock mechanism - in production, consider using a proper mutex
176
+ while (this.dataLock) {
177
+ await new Promise(resolve => setTimeout(resolve, 10));
178
+ }
179
+
180
+ this.dataLock = true;
181
+ try {
182
+ updateFn();
183
+ } finally {
184
+ this.dataLock = false;
185
+ }
186
+ }
187
+
188
+ _normalizeQueryParams(query) {
189
+ const normalized = {};
190
+
191
+ // Process each parameter
192
+ Object.keys(query).forEach(key => {
193
+ const value = query[key];
194
+
195
+ // If the value is an array, take the first element
196
+ if (Array.isArray(value)) {
197
+ normalized[key] = value.length > 0 ? String(value[0]) : '';
198
+ } else {
199
+ // Convert to string to ensure consistent type
200
+ normalized[key] = value !== null && value !== undefined ? String(value) : '';
201
+ }
202
+ });
203
+
204
+ return normalized;
205
+ }
206
+
207
+ setupSecurityMiddleware() {
208
+ this.router.use((req, res, next) => {
209
+ // Basic security headers
210
+ res.setHeader('X-Content-Type-Options', 'nosniff');
211
+ res.setHeader('X-Frame-Options', 'DENY');
212
+ res.setHeader('X-XSS-Protection', '1; mode=block');
213
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
214
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
215
+
216
+ // Content Security Policy
217
+ res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'");
218
+
219
+ next();
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Set up Express routes
225
+ */
226
+ setupRoutes() {
227
+ this.setupSecurityMiddleware();
228
+
229
+ // Attach API to all routes
230
+ this.router.use((req, res, next) => {
231
+ req.registryAPI = this.api;
232
+ next();
233
+ });
234
+
235
+ // Main registry page
236
+ this.router.get('/', this.handleMainPage.bind(this));
237
+ this.router.get('/resolve', this.handleResolveEndpoint.bind(this));
238
+ this.router.get('/log', this.handleLogEndpoint.bind(this));
239
+ }
240
+
241
+ /**
242
+ * Render HTML page for code system or value set query
243
+ * Combines functionality from sendHtmlCS and sendHtmlVS
244
+ */
245
+ renderHtmlPage(req, res, jsonResult, basePath, registry, server, fhirVersion, codeSystem, valueSet) {
246
+ // Generate path with query parameters
247
+ let path = basePath;
248
+ if (registry) path += `&registry=${encodeURIComponent(registry)}`;
249
+ if (server) path += `&server=${encodeURIComponent(server)}`;
250
+ if (fhirVersion) path += `&fhirVersion=${encodeURIComponent(fhirVersion)}`;
251
+ if (codeSystem) path += `&url=${encodeURIComponent(codeSystem)}`;
252
+ if (valueSet) path += `&valueSet=${encodeURIComponent(valueSet)}`;
253
+
254
+ // Get registry documentation and info
255
+ const data = this.api.getData();
256
+ const registryInfo = data && data.doco ? data.doco : '';
257
+
258
+ // Get status text
259
+ const statusText = this.getStatusText();
260
+
261
+ // Render matches table
262
+ const matchesTable = this.api.renderJsonToHtml(
263
+ jsonResult, path, registry, server, fhirVersion
264
+ );
265
+
266
+ // Render registry info
267
+ const registryInfoHtml = this.api.renderInfoToHtml();
268
+
269
+ // Assemble template variables
270
+ const templateVars = {
271
+ path,
272
+ matches: matchesTable,
273
+ count: jsonResult.results.length,
274
+ registry: registry || '',
275
+ server: server || '',
276
+ fhirVersion: fhirVersion || '',
277
+ url: codeSystem || '',
278
+ valueSet: valueSet || '',
279
+ status: statusText,
280
+ 'tx-reg-doco': registryInfo,
281
+ 'tx-reg-view': registryInfoHtml
282
+ };
283
+
284
+ // Use HTML server to render the page
285
+ try {
286
+ if (!htmlServer.hasTemplate('registry')) {
287
+ const templatePath = path.join(__dirname, 'tx-registry-template.html');
288
+ htmlServer.loadTemplate('registry', templatePath);
289
+ }
290
+
291
+ return htmlServer.renderPage(
292
+ 'registry',
293
+ 'FHIR Terminology Server Registry',
294
+ this.buildHtmlContent(),
295
+ {
296
+ ...this.api.getStatistics(),
297
+ templateVars: templateVars
298
+ }
299
+ );
300
+ } catch (error) {
301
+ this.logger.error('Error rendering page:', error);
302
+ return `<html><body><h1>Error rendering page</h1><p>${error.message}</p></body></html>`;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Get status text about crawling
308
+ * Based on Pascal status function
309
+ */
310
+ getStatusText() {
311
+ if (this.crawlInProgress) {
312
+ return 'Scanning for updates now';
313
+ } else if (!this.lastCrawlTime) {
314
+ const nextScan = this.crawlInterval ?
315
+ new Date(Date.now() + this.crawlInterval) : null;
316
+
317
+ if (nextScan) {
318
+ const timeUntil = this.describePeriod(nextScan - Date.now());
319
+ return `First Scan in ${timeUntil}`;
320
+ } else {
321
+ return 'No automatic scanning configured';
322
+ }
323
+ } else {
324
+ const nextScan = this.crawlInterval ?
325
+ new Date(this.lastCrawlTime.getTime() + (this.config.crawlInterval * 60 * 1000)) : null;
326
+
327
+ if (nextScan) {
328
+ const timeUntil = this.describePeriod(nextScan - Date.now());
329
+ const timeSince = this.describePeriod(Date.now() - this.lastCrawlTime);
330
+ return `Next Scan in ${timeUntil}. Last scan was ${timeSince} ago`;
331
+ } else {
332
+ const timeSince = this.describePeriod(Date.now() - this.lastCrawlTime);
333
+ return `Last scan was ${timeSince} ago. No automatic scanning configured`;
334
+ }
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Format a time period in milliseconds to a human-readable string
340
+ * Based on Pascal DescribePeriod function
341
+ */
342
+ describePeriod(milliseconds) {
343
+ const seconds = Math.floor(milliseconds / 1000);
344
+
345
+ if (seconds < 60) {
346
+ return `${seconds} seconds`;
347
+ } else if (seconds < 3600) {
348
+ return `${Math.floor(seconds / 60)} minutes`;
349
+ } else if (seconds < 86400) {
350
+ return `${Math.floor(seconds / 3600)} hours`;
351
+ } else {
352
+ return `${Math.floor(seconds / 86400)} days`;
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Handle main registry page
358
+ */
359
+ async handleMainPage(req, res) {
360
+ const start = Date.now();
361
+ try {
362
+
363
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
364
+
365
+ if (!acceptsHtml) {
366
+ // Return JSON overview
367
+ return res.json({
368
+ name: 'FHIR Terminology Server Registry',
369
+ description: 'Registry and discovery service for FHIR terminology servers',
370
+ endpoints: {
371
+ status: '/registry/api/status',
372
+ statistics: '/registry/api/stats',
373
+ registries: '/registry/api/registries',
374
+ queryCodeSystem: '/registry/api/query/codesystem',
375
+ queryValueSet: '/registry/api/query/valueset',
376
+ bestServer: '/registry/api/best-server/{type}',
377
+ errors: '/registry/api/errors'
378
+ },
379
+ documentation: 'https://github.com/your-org/fhir-registry'
380
+ });
381
+ }
382
+
383
+ // Render HTML page
384
+ try {
385
+ const startTime = Date.now();
386
+
387
+ // Load template if needed
388
+ if (!htmlServer.hasTemplate('registry')) {
389
+ const templatePath = path.join(__dirname, 'registry-template.html');
390
+ htmlServer.loadTemplate('registry', templatePath);
391
+ }
392
+
393
+ const content = await this.buildHtmlContent();
394
+ const stats = this.api.getStatistics();
395
+ stats.processingTime = Date.now() - startTime;
396
+ stats.crawlInProgress = this.crawlInProgress;
397
+ stats.lastCrawl = this.lastCrawlTime;
398
+
399
+ const html = htmlServer.renderPage(
400
+ 'registry',
401
+ 'FHIR Terminology Server Registry',
402
+ content,
403
+ stats
404
+ );
405
+
406
+ res.setHeader('Content-Type', 'text/html');
407
+ res.send(html);
408
+
409
+ } catch (error) {
410
+ this.logger.error('Error rendering registry page:', error);
411
+ htmlServer.sendErrorResponse(res, 'registry', error);
412
+ }
413
+ } finally {
414
+ this.stats.countRequest('home', Date.now() - start);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Build HTML content for main page
420
+ */
421
+ /**
422
+ * Build HTML content for main page - simplified version
423
+ */
424
+ async buildHtmlContent() {
425
+ const stats = this.api.getStatistics();
426
+ let html = '';
427
+
428
+ // Skip the overview card and search forms
429
+
430
+ // Gather all server versions into a flat list
431
+ const serverVersions = [];
432
+ const data = this.api.getData();
433
+
434
+ data.registries.forEach(registry => {
435
+ const authority = registry.authority || '';
436
+
437
+ registry.servers.forEach(server => {
438
+ const usageTags = server.usageList || [];
439
+
440
+ server.versions.forEach(version => {
441
+ serverVersions.push({
442
+ serverName: server.name,
443
+ serverUrl: version.address,
444
+ software: version.software || 'Unknown',
445
+ authority: authority,
446
+ version: version.version,
447
+ security: version.security,
448
+ usage: usageTags,
449
+ codeSystems: version.codeSystems.length,
450
+ valueSets: version.valueSets.length,
451
+ lastSuccess: version.lastSuccess,
452
+ error: version.error
453
+ });
454
+ });
455
+ });
456
+ });
457
+
458
+ // Sort by server name
459
+ serverVersions.sort((a, b) => a.serverName.localeCompare(b.serverName));
460
+
461
+ // Servers list with last updated time
462
+
463
+ html += '<h3 class="card-title mb-0">Terminology Servers</h3>';
464
+
465
+ // Format the last updated date/time
466
+ let lastUpdatedText = 'Never updated';
467
+ if (stats.lastRun) {
468
+ const lastRunDate = new Date(stats.lastRun);
469
+ lastUpdatedText = `Last Updated: ${lastRunDate.toLocaleString()}`;
470
+ }
471
+ html += `<p>${lastUpdatedText}. <a href="https://github.com/FHIR/ig-registry/blob/master/tx-registry-doco.md">Register your own server</a>`+
472
+ ` - see <a href="https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/ecosystem.html">Documentation</a></p>`;
473
+
474
+ html += '<table class="grid">';
475
+ html += '<thead><tr>';
476
+ html += '<th>URL</th>';
477
+ html += '<th>Software</th>';
478
+ html += '<th>Authority</th>';
479
+ html += '<th>Version</th>';
480
+ html += '<th>Security</th>';
481
+ html += '<th>Usage</th>';
482
+ html += '<th>CS#</th>';
483
+ html += '<th>VS#</th>';
484
+ html += '<th>Status</th>';
485
+ html += '</tr></thead>';
486
+ html += '<tbody>';
487
+
488
+ for (const server of serverVersions) {
489
+ html += '<tr>';
490
+ html += `<td><a href="${server.serverUrl}" target="_blank">${this._escapeHtml(server.serverUrl)}</a></td>`;
491
+ html += `<td>${this._escapeHtml(server.software.replace("Reference Server", "HealthIntersections"))}</td>`;
492
+ html += `<td>${this._escapeHtml(server.authority.replace("Published by", ""))}</td>`;
493
+ html += `<td>${this._escapeHtml(server.version)}</td>`;
494
+ html += `<td>${this._escapeHtml(server.security || '')}</td>`;
495
+ html += '<td>';
496
+ if (server.usage && server.usage.length > 0) {
497
+ const badges = server.usage.map(tag =>
498
+ (tag == 'public' ? '' : `<span class="badge badge-info mr-1">${this._escapeHtml(tag)}</span>`)
499
+ );
500
+ html += badges.join(' ');
501
+ }
502
+ html += '</td>';
503
+ html += `<td>${server.codeSystems}</td>`;
504
+ html += `<td>${server.valueSets}</td>`;
505
+
506
+ // Status column
507
+ if (server.error) {
508
+ html += `<td><span class="text-danger">Error</span>`;
509
+ if (server.lastSuccess) {
510
+ const minutesSinceLastSuccess = Math.floor((Date.now() - server.lastSuccess) / 60000);
511
+ html += ` (${minutesSinceLastSuccess} min ago)`;
512
+ }
513
+ html += `</td>`;
514
+ } else {
515
+ html += `<td><span class="text-success">OK</span></td>`;
516
+ }
517
+
518
+ html += '</tr>';
519
+ }
520
+
521
+ html += '</tbody>';
522
+ html += '</table>';
523
+
524
+ // Add the authoritative code systems table
525
+ html += '<div class="mb-5">';
526
+ html += this._renderAuthoritativeCodeSystemsTable();
527
+ html += '</div>';
528
+
529
+ // Add the authoritative value sets table
530
+ html += '<div class="mb-5">';
531
+ html += this._renderAuthoritativeValueSetsTable();
532
+ html += '</div>';
533
+
534
+ return html;
535
+ }
536
+
537
+ /**
538
+ * Helper function to escape HTML special characters
539
+ */
540
+ _escapeHtml(text) {
541
+ if (!text) return '';
542
+ return text
543
+ .replace(/&/g, '&amp;')
544
+ .replace(/</g, '&lt;')
545
+ .replace(/>/g, '&gt;')
546
+ .replace(/"/g, '&quot;')
547
+ .replace(/'/g, '&#039;');
548
+ }
549
+
550
+ /**
551
+ * Gather information about authoritative code systems
552
+ * @returns {Array} Array of objects with code system information
553
+ */
554
+ _getAuthoritativeCodeSystems() {
555
+ const data = this.crawler.getData();
556
+ const authCSMap = new Map();
557
+
558
+ // Gather all authoritative code systems
559
+ data.registries.forEach(registry => {
560
+ registry.servers.forEach(server => {
561
+ server.authCSList.forEach(csMask => {
562
+ // Create or update entry for this code system mask
563
+ if (!authCSMap.has(csMask)) {
564
+ authCSMap.set(csMask, {
565
+ mask: csMask,
566
+ servers: new Map()
567
+ });
568
+ }
569
+
570
+ // Add server info
571
+ const csEntry = authCSMap.get(csMask);
572
+ if (!csEntry.servers.has(server.name)) {
573
+ csEntry.servers.set(server.name, {
574
+ name: server.name,
575
+ url: server.address,
576
+ versions: new Set()
577
+ });
578
+ }
579
+
580
+ // Add version info for this server
581
+ const serverEntry = csEntry.servers.get(server.name);
582
+ server.versions.forEach(version => {
583
+ if (!version.error) {
584
+ serverEntry.versions.add(version.version);
585
+ }
586
+ });
587
+ });
588
+ });
589
+ });
590
+
591
+ // Convert map to array and sort
592
+ const authCSList = Array.from(authCSMap.values())
593
+ .map(entry => {
594
+ // Convert servers map to array
595
+ entry.servers = Array.from(entry.servers.values())
596
+ .map(server => {
597
+ // Convert versions set to sorted array
598
+ server.versions = Array.from(server.versions)
599
+ .sort(this._compareVersionsForSort);
600
+ return server;
601
+ })
602
+ .sort((a, b) => a.name.localeCompare(b.name));
603
+ return entry;
604
+ })
605
+ .sort((a, b) => a.mask.localeCompare(b.mask));
606
+
607
+ return authCSList;
608
+ }
609
+
610
+ /**
611
+ * Gather information about authoritative value sets
612
+ * @returns {Array} Array of objects with value set information
613
+ */
614
+ _getAuthoritativeValueSets() {
615
+ const data = this.crawler.getData();
616
+ const authVSMap = new Map();
617
+
618
+ // Gather all authoritative value sets
619
+ data.registries.forEach(registry => {
620
+ registry.servers.forEach(server => {
621
+ server.authVSList.forEach(vsMask => {
622
+ // Create or update entry for this value set mask
623
+ if (!authVSMap.has(vsMask)) {
624
+ authVSMap.set(vsMask, {
625
+ mask: vsMask,
626
+ servers: new Map()
627
+ });
628
+ }
629
+
630
+ // Add server info
631
+ const vsEntry = authVSMap.get(vsMask);
632
+ if (!vsEntry.servers.has(server.name)) {
633
+ vsEntry.servers.set(server.name, {
634
+ name: server.name,
635
+ url: server.address,
636
+ versions: new Set()
637
+ });
638
+ }
639
+
640
+ // Add version info for this server
641
+ const serverEntry = vsEntry.servers.get(server.name);
642
+ server.versions.forEach(version => {
643
+ if (!version.error) {
644
+ serverEntry.versions.add(version.version);
645
+ }
646
+ });
647
+ });
648
+ });
649
+ });
650
+
651
+ // Convert map to array and sort
652
+ const authVSList = Array.from(authVSMap.values())
653
+ .map(entry => {
654
+ // Convert servers map to array
655
+ entry.servers = Array.from(entry.servers.values())
656
+ .map(server => {
657
+ // Convert versions set to sorted array
658
+ server.versions = Array.from(server.versions)
659
+ .sort(this._compareVersionsForSort);
660
+ return server;
661
+ })
662
+ .sort((a, b) => a.name.localeCompare(b.name));
663
+ return entry;
664
+ })
665
+ .sort((a, b) => a.mask.localeCompare(b.mask));
666
+
667
+ return authVSList;
668
+ }
669
+
670
+ /**
671
+ * Comparison function for sorting versions
672
+ * @param {string} a - First version
673
+ * @param {string} b - Second version
674
+ * @returns {number} Comparison result
675
+ */
676
+ _compareVersionsForSort(a, b) {
677
+ // Compare semantic versions
678
+ const aParts = a.split('.').map(p => parseInt(p) || 0);
679
+ const bParts = b.split('.').map(p => parseInt(p) || 0);
680
+
681
+ // Compare in reverse order (newest first)
682
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
683
+ const aVal = aParts[i] || 0;
684
+ const bVal = bParts[i] || 0;
685
+ if (aVal !== bVal) {
686
+ return bVal - aVal; // Reverse order (descending)
687
+ }
688
+ }
689
+
690
+ return 0;
691
+ }
692
+
693
+ /**
694
+ * Format FHIR version for display (convert to R3/R4/R5 format)
695
+ * @param {string} version - Full version string (e.g., "4.0.1")
696
+ * @returns {string} Simplified version (e.g., "R4")
697
+ */
698
+ _formatFhirVersion(version) {
699
+ if (!version) return '';
700
+
701
+ // Extract the major version number
702
+ const majorMatch = /^(\d+)\./.exec(version);
703
+ if (majorMatch) {
704
+ return `R${majorMatch[1]}`;
705
+ }
706
+
707
+ return version;
708
+ }
709
+
710
+ /**
711
+ * Describe SNOMED CT edition based on code
712
+ * @param {string} url - SNOMED CT URL or mask
713
+ * @returns {string} Formatted URL with edition description
714
+ */
715
+ _describeSnomedEdition(url) {
716
+ if (!url.startsWith('http://snomed.info/sct')) {
717
+ return url;
718
+ }
719
+
720
+ // For wildcards, just return as is
721
+ if (url.endsWith('*')) {
722
+ url = url.substring(0, url.length-1);
723
+ }
724
+
725
+ const parts = url.split('/');
726
+ let edition = '';
727
+
728
+ // Get the last non-empty part
729
+ let editionCode = '';
730
+ for (let i = parts.length - 1; i >= 0; i--) {
731
+ if (parts[i] && parts[i] !== 'version') {
732
+ editionCode = parts[i];
733
+ break;
734
+ }
735
+ }
736
+
737
+ // Match edition code to description
738
+ switch (editionCode) {
739
+ case '900000000000207008': edition = 'Intl'; break;
740
+ case '731000124108': edition = 'US'; break;
741
+ case '32506021000036107': edition = 'AU'; break;
742
+ case '449081005': edition = 'ES/Intl'; break;
743
+ case '554471000005108': edition = 'DK'; break;
744
+ case '11000146104': edition = 'NL'; break;
745
+ case '45991000052106': edition = 'SE'; break;
746
+ case '83821000000107': edition = 'UK'; break;
747
+ case '11000172109': edition = 'BE'; break;
748
+ case '11000221109': edition = 'AR'; break;
749
+ case '11000234105': edition = 'AT'; break;
750
+ case '20621000087109': edition = 'CA-EN'; break;
751
+ case '20611000087101': edition = 'CA'; break;
752
+ case '11000181102': edition = 'EE'; break;
753
+ case '11000229106': edition = 'FI'; break;
754
+ case '11000274103': edition = 'DE'; break;
755
+ case '1121000189102': edition = 'IN'; break;
756
+ case '11000220105': edition = 'IE'; break;
757
+ case '21000210109': edition = 'NZ'; break;
758
+ case '51000202101': edition = 'NO'; break;
759
+ case '11000267109': edition = 'KR'; break;
760
+ case '900000001000122104': edition = 'ES-ES'; break;
761
+ case '2011000195101': edition = 'CH'; break;
762
+ case '11000279109': edition = 'CX'; break;
763
+ case '999000021000000109': edition = 'UK+Clinical'; break;
764
+ case '5631000179106': edition = 'UY'; break;
765
+ case '21000325107': edition = 'CL'; break;
766
+ case '5991000124107': edition = 'US+ICD10CM'; break;
767
+ default: edition = editionCode ? '??' : ''; break;
768
+ }
769
+
770
+ if (edition) {
771
+ // For masks, add edition in parentheses
772
+ if (url.endsWith(editionCode)) {
773
+ return `${url} (${edition})`;
774
+ } else {
775
+ // For wildcards, just return as is
776
+ return url;
777
+ }
778
+ }
779
+
780
+ return url;
781
+ }
782
+
783
+ /**
784
+ * Highlight wildcards in masks
785
+ * @param {string} text - The text to process
786
+ * @returns {string} HTML with wildcards highlighted
787
+ */
788
+ _highlightWildcard(text) {
789
+ return text.replace(/\*/g, '<strong class="text-primary">*</strong>');
790
+ }
791
+
792
+ /**
793
+ * Render HTML table for authoritative code systems
794
+ * @returns {string} HTML string
795
+ */
796
+ _renderAuthoritativeCodeSystemsTable() {
797
+ const authCSList = this._getAuthoritativeCodeSystems();
798
+
799
+ let html = '<h3 class="card-title mb-3">Authoritative Code Systems</h3>';
800
+
801
+ if (authCSList.length === 0) {
802
+ html += '<p>No authoritative code systems defined.</p>';
803
+ return html;
804
+ }
805
+
806
+ html += '<div class="table-responsive">';
807
+ html += '<table class="table table-striped table-bordered">';
808
+ html += '<thead class="thead-light">';
809
+ html += '<tr>';
810
+ html += '<th>Code System Mask</th>';
811
+ html += '<th>Server</th>';
812
+ html += '<th>FHIR Versions</th>';
813
+ html += '</tr>';
814
+ html += '</thead>';
815
+ html += '<tbody>';
816
+
817
+ authCSList.forEach(cs => {
818
+ // Format mask with SNOMED CT edition if applicable
819
+ const formattedMask = this._describeSnomedEdition(cs.mask);
820
+
821
+ // First row for this code system
822
+ const rowspan = cs.servers.length;
823
+ html += '<tr>';
824
+ html += `<td rowspan="${rowspan}">${this._highlightWildcard(this._escapeHtml(formattedMask))}</td>`;
825
+ html += `<td><a href="${this._escapeHtml(cs.servers[0].url)}" target="_blank">${this._escapeHtml(cs.servers[0].url)}</a></td>`;
826
+
827
+ // Format versions as R3/R4/R5
828
+ const formattedVersions = cs.servers[0].versions.map(v => this._formatFhirVersion(v));
829
+ html += `<td>${formattedVersions.join(',')}</td>`;
830
+ html += '</tr>';
831
+
832
+ // Additional rows for this code system (if any)
833
+ for (let i = 1; i < cs.servers.length; i++) {
834
+ html += '<tr>';
835
+ html += `<td><a href="${this._escapeHtml(cs.servers[i].url)}" target="_blank">${this._escapeHtml(cs.servers[i].url)}</a></td>`;
836
+
837
+ // Format versions as R3/R4/R5
838
+ const formattedVersions = cs.servers[i].versions.map(v => this._formatFhirVersion(v));
839
+ html += `<td>${formattedVersions.join(',')}</td>`;
840
+ html += '</tr>';
841
+ }
842
+ });
843
+
844
+ html += '</tbody>';
845
+ html += '</table>';
846
+ html += '</div>';
847
+
848
+ return html;
849
+ }
850
+
851
+ /**
852
+ * Render HTML table for authoritative value sets
853
+ * @returns {string} HTML string
854
+ */
855
+ _renderAuthoritativeValueSetsTable() {
856
+ const authVSList = this._getAuthoritativeValueSets();
857
+
858
+ let html = '<h3 class="card-title mb-3">Authoritative Value Sets</h3>';
859
+
860
+ if (authVSList.length === 0) {
861
+ html += '<p>No authoritative value sets defined.</p>';
862
+ return html;
863
+ }
864
+
865
+ html += '<div class="table-responsive">';
866
+ html += '<table class="table table-striped table-bordered">';
867
+ html += '<thead class="thead-light">';
868
+ html += '<tr>';
869
+ html += '<th>Value Set Mask</th>';
870
+ html += '<th>Server</th>';
871
+ html += '<th>FHIR Versions</th>';
872
+ html += '</tr>';
873
+ html += '</thead>';
874
+ html += '<tbody>';
875
+
876
+ authVSList.forEach(vs => {
877
+ // First row for this value set
878
+ const rowspan = vs.servers.length;
879
+ html += '<tr>';
880
+ html += `<td rowspan="${rowspan}">${this._highlightWildcard(this._escapeHtml(vs.mask))}</td>`;
881
+ html += `<td><a href="${this._escapeHtml(vs.servers[0].url)}" target="_blank">${this._escapeHtml(vs.servers[0].url)}</a></td>`;
882
+
883
+ // Format versions as R3/R4/R5
884
+ const formattedVersions = vs.servers[0].versions.map(v => this._formatFhirVersion(v));
885
+ html += `<td>${formattedVersions.join(',')}</td>`;
886
+ html += '</tr>';
887
+
888
+ // Additional rows for this value set (if any)
889
+ for (let i = 1; i < vs.servers.length; i++) {
890
+ html += '<tr>';
891
+ html += `<td><a href="${this._escapeHtml(vs.servers[i].url)}" target="_blank">${this._escapeHtml(vs.servers[i].url)}</a></td>`;
892
+
893
+ // Format versions as R3/R4/R5
894
+ const formattedVersions = vs.servers[i].versions.map(v => this._formatFhirVersion(v));
895
+ html += `<td>${formattedVersions.join(',')}</td>`;
896
+ html += '</tr>';
897
+ }
898
+ });
899
+
900
+ html += '</tbody>';
901
+ html += '</table>';
902
+ html += '</div>';
903
+
904
+ return html;
905
+ }
906
+
907
+
908
+ /**
909
+ * Get module status for health check
910
+ */
911
+ getStatus() {
912
+ const metadata = this.crawler ? this.crawler.getMetadata() : null;
913
+ const stats = this.api ? this.api.getStatistics() : null;
914
+
915
+ return {
916
+ enabled: true,
917
+ initialized: this.isInitialized,
918
+ crawling: this.crawlInProgress,
919
+ lastCrawl: this.lastCrawlTime,
920
+ registries: stats?.registryCount || 0,
921
+ servers: stats?.serverCount || 0,
922
+ errors: metadata?.errors?.length || 0
923
+ };
924
+ }
925
+
926
+ /**
927
+ * Shutdown the module
928
+ */
929
+ async shutdown() {
930
+ this.logger.info('Shutting down Registry module...');
931
+
932
+ // Stop periodic crawling
933
+ if (this.crawlInterval) {
934
+ clearInterval(this.crawlInterval);
935
+ this.crawlInterval = null;
936
+ }
937
+
938
+ // Save current data
939
+ if (this.crawler && this.currentData) {
940
+ await this.saveData();
941
+ }
942
+
943
+ this.logger.info('Registry module shut down');
944
+ }
945
+
946
+
947
+ /**
948
+ * Validate a URL string for safety
949
+ * @param {string} url - URL to validate
950
+ * @param {Array} allowedProtocols - Array of allowed protocols (default: ['http:', 'https:'])
951
+ * @returns {boolean} True if URL is valid and safe
952
+ */
953
+ _isValidUrl(url, allowedProtocols = ['http:', 'https:', 'urn:']) {
954
+ if (!url || typeof url !== 'string') {
955
+ return false;
956
+ }
957
+
958
+ try {
959
+ const urlObj = new URL(url);
960
+ return allowedProtocols.includes(urlObj.protocol);
961
+ } catch (e) {
962
+ // URL parsing failed
963
+ return false;
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Handle resolve endpoint for browser users
969
+ * Serves a form when accessed directly from a browser
970
+ */
971
+ handleResolveEndpoint(req, res) {
972
+ const start = Date.now();
973
+ try {
974
+
975
+ try {
976
+ const params = this._normalizeQueryParams(req.query);
977
+ const {fhirVersion, url, valueSet, usage} = params;
978
+
979
+ // Convert authoritativeOnly to boolean
980
+ const authoritativeOnly = params.authoritativeOnly === 'true';
981
+
982
+ let cleanUrl = url == null ? null : url.split('|')[0];
983
+ let cleanVS = valueSet == null ? null : valueSet.split('|')[0];
984
+
985
+ // Validate URL parameters if provided
986
+ if (cleanUrl && !this._isValidUrl(cleanUrl)) {
987
+ return res.status(400).json({error: 'Invalid code system URL format'});
988
+ }
989
+
990
+ if (valueSet && !this._isValidUrl(cleanVS)) {
991
+ return res.status(400).json({error: 'Invalid value set URL format'});
992
+ }
993
+
994
+ // Check if this is a browser request (based on Accept header)
995
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
996
+ const hasRequiredParams = fhirVersion && (url || valueSet);
997
+
998
+ // If it's a browser and missing required params, show the form
999
+ if (acceptsHtml && !hasRequiredParams) {
1000
+ // Use the HTML template system
1001
+ try {
1002
+ const startTime = Date.now();
1003
+
1004
+ // Load template if needed
1005
+ if (!htmlServer.hasTemplate('registry')) {
1006
+ const templatePath = path.join(__dirname, 'registry-template.html');
1007
+ htmlServer.loadTemplate('registry', templatePath);
1008
+ }
1009
+
1010
+ const content = this.buildResolveFormContent(req.query);
1011
+ const stats = this.api.getStatistics();
1012
+ stats.processingTime = Date.now() - startTime;
1013
+
1014
+ const html = htmlServer.renderPage(
1015
+ 'registry',
1016
+ 'FHIR Terminology Server Resolver',
1017
+ content,
1018
+ stats
1019
+ );
1020
+
1021
+ res.setHeader('Content-Type', 'text/html');
1022
+ return res.send(html);
1023
+ } catch (error) {
1024
+ this.logger.error('Error rendering form page:', error);
1025
+ return res.send(this.buildStandaloneResolveForm(req.query));
1026
+ }
1027
+ }
1028
+
1029
+ // Otherwise, process the API request normally
1030
+ let result, matches;
1031
+
1032
+ // Validate required parameters
1033
+ if (!fhirVersion) {
1034
+ return res.status(400).json({error: 'A FHIR version is required'});
1035
+ }
1036
+
1037
+ if (!url && !valueSet) {
1038
+ return res.status(400).json({error: 'Either url or valueSet parameter is required'});
1039
+ }
1040
+
1041
+ if (valueSet) {
1042
+ // Value set resolve
1043
+ const resolveResult = this.api.resolveValueSet(fhirVersion, valueSet, authoritativeOnly, usage);
1044
+ result = resolveResult.result;
1045
+ matches = resolveResult.matches;
1046
+ this.logger.info(`Resolved ValueSet ${valueSet} for FHIR ${fhirVersion} (usage=${usage}): ${matches}`);
1047
+ } else {
1048
+ // Code system resolve
1049
+ const resolveResult = this.api.resolveCodeSystem(fhirVersion, url, authoritativeOnly, usage);
1050
+ result = resolveResult.result;
1051
+ matches = resolveResult.matches;
1052
+ this.logger.info(`Resolved CodeSystem ${url} for FHIR ${fhirVersion} (usage=${usage}): ${matches}`);
1053
+ }
1054
+
1055
+ // If only authoritative servers are requested, filter results
1056
+ if (authoritativeOnly === 'true' && result) {
1057
+ result.candidates = [];
1058
+ }
1059
+ if (acceptsHtml) {
1060
+ try {
1061
+ const startTime = Date.now();
1062
+
1063
+ // Load template if needed
1064
+ if (!htmlServer.hasTemplate('registry')) {
1065
+ const templatePath = path.join(__dirname, 'registry-template.html');
1066
+ htmlServer.loadTemplate('registry', templatePath);
1067
+ }
1068
+
1069
+ const content = this.buildResolveResultContent(result, fhirVersion, url || valueSet, usage);
1070
+ const stats = this.api.getStatistics();
1071
+ stats.processingTime = Date.now() - startTime;
1072
+
1073
+ const html = htmlServer.renderPage(
1074
+ 'registry',
1075
+ 'FHIR Terminology Server Resolution Results',
1076
+ content,
1077
+ stats
1078
+ );
1079
+
1080
+ res.setHeader('Content-Type', 'text/html');
1081
+ return res.send(html);
1082
+ } catch (error) {
1083
+ this.logger.error('Error rendering resolve result page:', error);
1084
+ // Fall back to JSON if template rendering fails
1085
+ }
1086
+ }
1087
+ res.json(result);
1088
+ } catch (error) {
1089
+ this.logger.error('Error in resolve endpoint:', error);
1090
+ res.status(400).json({error: error.message});
1091
+ }
1092
+ } finally {
1093
+ this.stats.countRequest('resolve', Date.now() - start);
1094
+ }
1095
+ }
1096
+
1097
+ buildResolveResultContent(result, fhirVersion, resourceUrl, usage) {
1098
+ let html = '';
1099
+
1100
+ // Query information section
1101
+ html += '<div class="card mb-4">';
1102
+ html += '<div class="card-header">';
1103
+ html += '<h2 class="card-title">Query Information</h2>';
1104
+ html += '</div>';
1105
+ html += '<div class="card-body">';
1106
+ html += `<p><strong>FHIR Version:</strong> ${this._escapeHtml(fhirVersion)}</p>`;
1107
+ html += `<p><strong>Resource URL:</strong> ${this._escapeHtml(resourceUrl)}</p>`;
1108
+ html += `<p><strong>Registry URL:</strong> <a href="${result['registry-url']}" target="_blank">${this._escapeHtml(result['registry-url'])}</a></p>`;
1109
+ if (usage) {
1110
+ html += `<p><strong>Usage:</strong> ${this._escapeHtml(usage)}</p>`;
1111
+ }
1112
+ html += '</div>';
1113
+ html += '</div>';
1114
+
1115
+ // Authoritative servers section
1116
+ html += '<div class="card mb-4">';
1117
+ html += '<div class="card-header">';
1118
+ html += '<h2 class="card-title">Authoritative Servers</h2>';
1119
+ html += '</div>';
1120
+ html += '<div class="card-body">';
1121
+
1122
+ if (result.authoritative && result.authoritative.length > 0) {
1123
+ html += '<table class="table table-bordered table-striped">';
1124
+ html += '<thead>';
1125
+ html += '<tr>';
1126
+ html += '<th>Server Name</th>';
1127
+ html += '<th>URL</th>';
1128
+ html += '<th>Security</th>';
1129
+ html += '<th>Access Info</th>';
1130
+ html += '</tr>';
1131
+ html += '</thead>';
1132
+ html += '<tbody>';
1133
+
1134
+ result.authoritative.forEach(server => {
1135
+ html += '<tr>';
1136
+ html += `<td>${this._escapeHtml(server['server-name'])}</td>`;
1137
+ html += `<td><a href="${server.url}" target="_blank">${this._escapeHtml(server.url)}</a></td>`;
1138
+ html += `<td>${this.renderSecurityTags(server)}</td>`;
1139
+ html += `<td>${server.access_info ? this._escapeHtml(server.access_info) : ''}</td>`;
1140
+ html += '</tr>';
1141
+ });
1142
+
1143
+ html += '</tbody>';
1144
+ html += '</table>';
1145
+ } else {
1146
+ html += '<p>No authoritative servers found.</p>';
1147
+ }
1148
+
1149
+ html += '</div>';
1150
+ html += '</div>';
1151
+
1152
+ // Candidate servers section
1153
+ html += '<div class="card mb-4">';
1154
+ html += '<div class="card-header">';
1155
+ html += '<h2 class="card-title">Candidate Servers</h2>';
1156
+ html += '</div>';
1157
+ html += '<div class="card-body">';
1158
+
1159
+ if (result.candidates && result.candidates.length > 0) {
1160
+ html += '<table class="table table-bordered table-striped">';
1161
+ html += '<thead>';
1162
+ html += '<tr>';
1163
+ html += '<th>Server Name</th>';
1164
+ html += '<th>URL</th>';
1165
+ html += '<th>Security</th>';
1166
+ html += '<th>Access Info</th>';
1167
+ html += '</tr>';
1168
+ html += '</thead>';
1169
+ html += '<tbody>';
1170
+
1171
+ result.candidates.forEach(server => {
1172
+ html += '<tr>';
1173
+ html += `<td>${this._escapeHtml(server['server-name'])}</td>`;
1174
+ html += `<td><a href="${server.url}" target="_blank">${this._escapeHtml(server.url)}</a></td>`;
1175
+ html += `<td>${this.renderSecurityTags(server)}</td>`;
1176
+ html += `<td>${server.access_info ? this._escapeHtml(server.access_info) : ''}</td>`;
1177
+ html += '</tr>';
1178
+ });
1179
+
1180
+ html += '</tbody>';
1181
+ html += '</table>';
1182
+ } else {
1183
+ html += '<p>No candidate servers found.</p>';
1184
+ }
1185
+
1186
+ html += '</div>';
1187
+ html += '</div>';
1188
+
1189
+ // Back button
1190
+ html += '<div class="mb-4">';
1191
+ html += '<a href="/registry/resolve" class="btn btn-primary">« Back to Resolver Form</a>';
1192
+ html += '</div>';
1193
+
1194
+ return html;
1195
+ }
1196
+
1197
+ // Add this helper method to render security tags
1198
+
1199
+ renderSecurityTags(server) {
1200
+ const tags = [];
1201
+
1202
+ if (server.open) tags.push('<span class="badge bg-success me-1">Open</span>');
1203
+ if (server.password) tags.push('<span class="badge bg-danger me-1">Password</span>');
1204
+ if (server.token) tags.push('<span class="badge bg-primary me-1">Token</span>');
1205
+ if (server.oauth) tags.push('<span class="badge bg-warning me-1">OAuth</span>');
1206
+ if (server.smart) tags.push('<span class="badge bg-info me-1">Smart</span>');
1207
+ if (server.cert) tags.push('<span class="badge bg-secondary me-1">Certificate</span>');
1208
+
1209
+ return tags.length > 0 ? tags.join(' ') : 'None';
1210
+ }
1211
+
1212
+ /**
1213
+ * Build content for the resolve form, to be used with the HTML template
1214
+ */
1215
+ buildResolveFormContent(queryParams = {}) {
1216
+ const fhirVersion = queryParams.fhirVersion || '';
1217
+ const url = queryParams.url || '';
1218
+ const valueSet = queryParams.valueSet || '';
1219
+ const authoritativeOnly = queryParams.authoritativeOnly === 'true';
1220
+
1221
+ let html = '';
1222
+
1223
+ html += '<p>This tool helps you find the most appropriate terminology server for a given code system or value set.</p>';
1224
+ html += '<p class="text-muted small">Fields marked with * are required.</p>';
1225
+
1226
+ // Form
1227
+ html += '<form action="/tx-reg/resolve" method="get">';
1228
+
1229
+ // FHIR Version field
1230
+ html += '<p>';
1231
+ html += '<label for="fhirVersion" class="form-label fw-bold">FHIR Version <span class="text-danger">*</span></label>';
1232
+ html += `<input type="text" class="form-control" id="fhirVersion" name="fhirVersion" size="8"
1233
+ value="${this._escapeHtml(fhirVersion)}" required>`;
1234
+ html += '</p>';
1235
+ html += '<p class="text-muted small">Examples: R4, 4.0.1, 5.0.0, etc.</p>';
1236
+
1237
+ html += '<div class="alert alert-info">Either Code System URL or Value Set URL must be provided:</div>';
1238
+ html += '<p>';
1239
+ html += '<label for="url" class="form-label fw-bold">Code System URL</label>';
1240
+ html += `<input type="url" class="form-control" id="url" name="url"
1241
+ value="${this._escapeHtml(url)}">`;
1242
+ html += '</p>';
1243
+ html += '<p class="text-muted small">Example: http://loinc.org</p>';
1244
+
1245
+ // ValueSet URL field - now vertical
1246
+ html += '<p>';
1247
+ html += '<label for="valueSet" class="form-label fw-bold">Value Set URL</label>';
1248
+ html += `<input type="url" class="form-control" id="valueSet" name="valueSet"
1249
+ value="${this._escapeHtml(valueSet)}">`;
1250
+ html += '</p>';
1251
+ html += '<p class="text-muted small">Example: http://hl7.org/fhir/ValueSet/observation-codes</p>';
1252
+
1253
+
1254
+ // Authoritative Only checkbox
1255
+ html += '<p>';
1256
+ html += `<input type="checkbox" class="form-check-input" id="authoritativeOnly"
1257
+ name="authoritativeOnly" value="true" ${authoritativeOnly ? 'checked' : ''}>`;
1258
+ html += '<label class="form-check-label" for="authoritativeOnly">&nbsp;Show only authoritative servers</label>';
1259
+ html += '</p>';
1260
+
1261
+ // Submit button
1262
+ html += '<p>';
1263
+ html += '<button type="submit" class="btn btn-primary">Find Servers</button>';
1264
+ html += '</p>';
1265
+
1266
+ html += '</form>';
1267
+
1268
+ // Client-side validation script
1269
+ html += `
1270
+ <script>
1271
+ // Client-side validation to ensure either url or valueSet is provided
1272
+ document.querySelector('form').addEventListener('submit', function(e) {
1273
+ const url = document.getElementById('url').value.trim();
1274
+ const valueSet = document.getElementById('valueSet').value.trim();
1275
+
1276
+ if (!url && !valueSet) {
1277
+ e.preventDefault();
1278
+ alert('You must provide either a Code System URL or a Value Set URL');
1279
+ }
1280
+ });
1281
+ </script>`;
1282
+
1283
+ return html;
1284
+ }
1285
+
1286
+ handleLogEndpoint(req, res) {
1287
+ const start = Date.now();
1288
+ try {
1289
+
1290
+ try {
1291
+ const params = this._normalizeQueryParams(req.query);
1292
+ const requestedLimit = parseInt(params.limit, 10);
1293
+ const limit = isNaN(requestedLimit) ? 100 : Math.min(requestedLimit, 1000);
1294
+
1295
+ // Get logs from crawler
1296
+ const logs = this.crawler.getLogs(limit);
1297
+
1298
+ // Determine response format based on Accept header
1299
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
1300
+
1301
+ if (acceptsHtml) {
1302
+ try {
1303
+ const startTime = Date.now();
1304
+
1305
+ // Load template if needed
1306
+ if (!htmlServer.hasTemplate('registry')) {
1307
+ const templatePath = path.join(__dirname, 'registry-template.html');
1308
+ htmlServer.loadTemplate('registry', templatePath);
1309
+ }
1310
+
1311
+ const content = this.buildLogContent(logs);
1312
+ const stats = this.api.getStatistics();
1313
+ stats.processingTime = Date.now() - startTime;
1314
+
1315
+ const html = htmlServer.renderPage(
1316
+ 'registry',
1317
+ 'FHIR Terminology Server Registry - Logs',
1318
+ content,
1319
+ stats
1320
+ );
1321
+
1322
+ res.setHeader('Content-Type', 'text/html');
1323
+ res.send(html);
1324
+ } catch (error) {
1325
+ this.logger.error('Error rendering log page:', error);
1326
+ res.status(500).send(`<pre>Error rendering log page: ${error.message}</pre>`);
1327
+ }
1328
+ } else {
1329
+ // Return JSON logs
1330
+ res.json({
1331
+ count: logs.length,
1332
+ logs: logs
1333
+ });
1334
+ }
1335
+ } catch (error) {
1336
+ this.logger.error('Error in log endpoint:', error);
1337
+ res.status(500).json({error: error.message});
1338
+ }
1339
+ } finally {
1340
+ this.stats.countRequest('log', Date.now() - start);
1341
+ }
1342
+ }
1343
+
1344
+ /**
1345
+ * Build log content for template
1346
+ * @param {Array} logs - Array of log entries
1347
+ * @retucountRequestrns {string} HTML content
1348
+ */
1349
+ buildLogContent(logs) {
1350
+ let html = '';
1351
+
1352
+ // Create a pre tag for logs
1353
+ html += '<pre class="p-3 bg-light border rounded" style="overflow: auto; white-space: pre-wrap;">';
1354
+
1355
+ if (logs.length === 0) {
1356
+ html += 'No logs available';
1357
+ } else {
1358
+ // Get the first log timestamp as a reference point
1359
+ const firstTimestamp = new Date(logs[0].timestamp).getTime();
1360
+
1361
+ // Format each log entry
1362
+ logs.forEach((log, index) => {
1363
+ const currentTime = new Date(log.timestamp);
1364
+
1365
+ // For the first entry, show the full timestamp
1366
+ let timeDisplay;
1367
+ if (index === 0) {
1368
+ timeDisplay = currentTime.toISOString().replace('T', ' ').substr(0, 19);
1369
+ } else {
1370
+ // For subsequent entries, show milliseconds relative to the first entry
1371
+ const timeDiff = (currentTime.getTime() - firstTimestamp) / 1000;
1372
+ timeDisplay = `+${timeDiff.toFixed(3)}s`;
1373
+ }
1374
+
1375
+ // Color code by level
1376
+ let levelStyle = '';
1377
+ switch (log.level.toLowerCase()) {
1378
+ case 'error': levelStyle = 'color: #d9534f; font-weight: bold;'; break;
1379
+ case 'warn': levelStyle = 'color: #f0ad4e;'; break;
1380
+ case 'debug': levelStyle = 'color: #5cb85c;'; break;
1381
+ default: levelStyle = 'color: #0275d8;'; // info
1382
+ }
1383
+
1384
+ // Format: [time] [LEVEL] message
1385
+ html += `<span style="color: #666;">[${timeDisplay}]</span> <span style="${levelStyle}">[${log.level.toUpperCase()}]</span> ${this._escapeHtml(log.message)}\n`;
1386
+ });
1387
+ }
1388
+
1389
+ html += '</pre>';
1390
+
1391
+ return html;
1392
+ }
1393
+ }
1394
+
1395
+ module.exports = RegistryModule;