fhirsmith 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -2
- package/config-template.json +16 -0
- package/extension-tracker/extension-tracker-template.html +124 -0
- package/extension-tracker/extension-tracker.js +697 -0
- package/extension-tracker/readme.md +63 -0
- package/folder/folder.js +305 -0
- package/folder/readme.md +57 -0
- package/library/html-server.js +8 -2
- package/package.json +4 -2
- package/packages/packages.js +8 -8
- package/publisher/publisher.js +104 -13
- package/server.js +58 -4
- package/tx/cs/cs-snomed.js +13 -7
- package/tx/library/extensions.js +6 -2
- package/tx/library/renderer.js +1 -1
- package/tx/ocl/cache/cache-paths.cjs +4 -5
- package/tx/ocl/cm-ocl.cjs +4 -1
- package/tx/ocl/cs-ocl.cjs +9 -9
- package/tx/ocl/jobs/background-queue.cjs +11 -5
- package/tx/ocl/shared/patches.cjs +37 -0
- package/tx/ocl/vs-ocl.cjs +92 -44
- package/tx/workers/expand.js +23 -12
- package/tx/workers/search.js +26 -15
- package/xig/xig.js +59 -8
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const Database = require('better-sqlite3');
|
|
4
|
+
const htmlServer = require('../library/html-server');
|
|
5
|
+
const escape = require('escape-html');
|
|
6
|
+
const packageJson = require('../package.json');
|
|
7
|
+
|
|
8
|
+
const TEMPLATE_NAME = 'ext-tracker';
|
|
9
|
+
|
|
10
|
+
class ExtensionTrackerModule {
|
|
11
|
+
constructor(stats) {
|
|
12
|
+
this.stats = stats;
|
|
13
|
+
this.db = null;
|
|
14
|
+
this.urlBase = '/ext-tracker';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
initialize(config, app) {
|
|
18
|
+
const dbPath = config.database || path.join(config.folder || '.', 'extension-tracker.db');
|
|
19
|
+
this.db = new Database(dbPath);
|
|
20
|
+
this.db.pragma('journal_mode = WAL');
|
|
21
|
+
this.db.pragma('foreign_keys = ON');
|
|
22
|
+
this.createSchema();
|
|
23
|
+
this.prepareStatements();
|
|
24
|
+
|
|
25
|
+
this.urlBase = config.url || '/ext-tracker';
|
|
26
|
+
|
|
27
|
+
// load template
|
|
28
|
+
const templatePath = path.join(__dirname, 'extension-tracker-template.html');
|
|
29
|
+
htmlServer.loadTemplate(TEMPLATE_NAME, templatePath);
|
|
30
|
+
|
|
31
|
+
const router = express.Router();
|
|
32
|
+
|
|
33
|
+
// POST - submit extension data
|
|
34
|
+
router.post(this.urlBase, express.json({ limit: '5mb' }), (req, res) => {
|
|
35
|
+
this.handleSubmission(req, res);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// GET - HTML views
|
|
39
|
+
router.get(this.urlBase, (req, res) => this.handleHome(req, res));
|
|
40
|
+
router.get(this.urlBase + '/extensions', (req, res) => this.handleExtensions(req, res));
|
|
41
|
+
router.get(this.urlBase + '/extensions/packages', (req, res) => this.handleExtensionsByPackage(req, res));
|
|
42
|
+
router.get(this.urlBase + '/profiles', (req, res) => this.handleProfiles(req, res));
|
|
43
|
+
router.get(this.urlBase + '/usage', (req, res) => this.handleUsage(req, res));
|
|
44
|
+
router.get(this.urlBase + '/usage/packages', (req, res) => this.handleUsageByPackage(req, res));
|
|
45
|
+
router.get(this.urlBase + '/package/:pkg', (req, res) => this.handlePackageDetail(req, res));
|
|
46
|
+
|
|
47
|
+
app.use('/', router);
|
|
48
|
+
const count = this.db.prepare('SELECT COUNT(*) as count FROM packages').get().count;
|
|
49
|
+
console.log(`Extension tracker: loaded (${count} packages in database) at ${this.urlBase}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
createSchema() {
|
|
53
|
+
this.db.exec(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS packages (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
package TEXT NOT NULL UNIQUE,
|
|
57
|
+
version TEXT NOT NULL,
|
|
58
|
+
fhir_version TEXT NOT NULL,
|
|
59
|
+
jurisdiction TEXT,
|
|
60
|
+
submitted_at TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS extensions (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
|
66
|
+
url TEXT NOT NULL,
|
|
67
|
+
title TEXT
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS extension_types (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
extension_id INTEGER NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
|
73
|
+
type TEXT NOT NULL
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
77
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
78
|
+
package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
|
79
|
+
resource_type TEXT NOT NULL,
|
|
80
|
+
url TEXT NOT NULL,
|
|
81
|
+
title TEXT
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS usages (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
|
87
|
+
extension_url TEXT NOT NULL,
|
|
88
|
+
location TEXT NOT NULL
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_extensions_package ON extensions(package_id);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_extensions_url ON extensions(url);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_extension_types_ext ON extension_types(extension_id);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_profiles_package ON profiles(package_id);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_profiles_resource ON profiles(resource_type);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_usages_package ON usages(package_id);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_usages_url ON usages(extension_url);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_usages_location ON usages(location);
|
|
99
|
+
`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
prepareStatements() {
|
|
103
|
+
this.stmts = {
|
|
104
|
+
deletePackage: this.db.prepare('DELETE FROM packages WHERE package = ?'),
|
|
105
|
+
insertPackage: this.db.prepare(
|
|
106
|
+
'INSERT INTO packages (package, version, fhir_version, jurisdiction, submitted_at) VALUES (?, ?, ?, ?, ?)'
|
|
107
|
+
),
|
|
108
|
+
insertExtension: this.db.prepare(
|
|
109
|
+
'INSERT INTO extensions (package_id, url, title) VALUES (?, ?, ?)'
|
|
110
|
+
),
|
|
111
|
+
insertExtensionType: this.db.prepare(
|
|
112
|
+
'INSERT INTO extension_types (extension_id, type) VALUES (?, ?)'
|
|
113
|
+
),
|
|
114
|
+
insertProfile: this.db.prepare(
|
|
115
|
+
'INSERT INTO profiles (package_id, resource_type, url, title) VALUES (?, ?, ?, ?)'
|
|
116
|
+
),
|
|
117
|
+
insertUsage: this.db.prepare(
|
|
118
|
+
'INSERT INTO usages (package_id, extension_url, location) VALUES (?, ?, ?)'
|
|
119
|
+
)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- Client-side column filtering ----
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a script block that adds filter inputs to each column header of a table.
|
|
127
|
+
* Filters combine (AND) and persist in cookies.
|
|
128
|
+
* @param {string} tableId - the id attribute of the table
|
|
129
|
+
* @param {Array} columns - array of { type: 'text'|'select', options?: string[] }
|
|
130
|
+
* @param {string} cookiePrefix - cookie name prefix for persistence
|
|
131
|
+
*/
|
|
132
|
+
buildColumnFilters(tableId, columns, cookiePrefix) {
|
|
133
|
+
const colsJson = JSON.stringify(columns.map(c => ({
|
|
134
|
+
type: c.type || 'text',
|
|
135
|
+
options: c.options || []
|
|
136
|
+
})));
|
|
137
|
+
|
|
138
|
+
return `
|
|
139
|
+
<script>
|
|
140
|
+
(function() {
|
|
141
|
+
var table = document.getElementById(${JSON.stringify(tableId)});
|
|
142
|
+
if (!table) return;
|
|
143
|
+
var cols = ${colsJson};
|
|
144
|
+
var prefix = ${JSON.stringify(cookiePrefix)};
|
|
145
|
+
var headerRow = table.querySelector('tr');
|
|
146
|
+
var filterRow = document.createElement('tr');
|
|
147
|
+
filterRow.className = 'filter-row';
|
|
148
|
+
var html = '';
|
|
149
|
+
for (var i = 0; i < cols.length; i++) {
|
|
150
|
+
var c = cols[i];
|
|
151
|
+
if (c.type === 'select') {
|
|
152
|
+
html += '<th><select data-col="' + i + '" class="col-filter" style="width:100%;box-sizing:border-box;">';
|
|
153
|
+
html += '<option value="">All</option>';
|
|
154
|
+
for (var j = 0; j < c.options.length; j++) {
|
|
155
|
+
var o = c.options[j].replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
|
|
156
|
+
html += '<option value="' + o + '">' + o + '</option>';
|
|
157
|
+
}
|
|
158
|
+
html += '</select></th>';
|
|
159
|
+
} else {
|
|
160
|
+
html += '<th><input type="text" data-col="' + i + '" class="col-filter" placeholder="filter..." style="width:100%;box-sizing:border-box;"></th>';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
filterRow.innerHTML = html;
|
|
164
|
+
headerRow.parentNode.insertBefore(filterRow, headerRow.nextSibling);
|
|
165
|
+
|
|
166
|
+
function getCookie(name) {
|
|
167
|
+
var m = document.cookie.match(new RegExp('(^|;)\\\\s*' + name + '=([^;]*)'));
|
|
168
|
+
return m ? decodeURIComponent(m[2]) : '';
|
|
169
|
+
}
|
|
170
|
+
function setCookie(name, val) {
|
|
171
|
+
document.cookie = name + '=' + encodeURIComponent(val) + ';path=/;max-age=86400;SameSite=Lax';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
var filters = filterRow.querySelectorAll('.col-filter');
|
|
175
|
+
var allRows = table.querySelectorAll('tr');
|
|
176
|
+
var rows = [];
|
|
177
|
+
for (var r = 2; r < allRows.length; r++) rows.push(allRows[r]);
|
|
178
|
+
|
|
179
|
+
function applyFilters() {
|
|
180
|
+
var visible = 0;
|
|
181
|
+
for (var ri = 0; ri < rows.length; ri++) {
|
|
182
|
+
var cells = rows[ri].querySelectorAll('td');
|
|
183
|
+
var show = true;
|
|
184
|
+
for (var fi = 0; fi < filters.length; fi++) {
|
|
185
|
+
var f = filters[fi];
|
|
186
|
+
var ci = parseInt(f.getAttribute('data-col'));
|
|
187
|
+
var val = f.value.toLowerCase();
|
|
188
|
+
if (val && cells[ci]) {
|
|
189
|
+
var text = cells[ci].textContent.toLowerCase();
|
|
190
|
+
if (f.tagName === 'SELECT') {
|
|
191
|
+
if (text !== val) show = false;
|
|
192
|
+
} else {
|
|
193
|
+
if (text.indexOf(val) === -1) show = false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
rows[ri].style.display = show ? '' : 'none';
|
|
198
|
+
if (show) visible++;
|
|
199
|
+
}
|
|
200
|
+
var counter = document.getElementById(${JSON.stringify(tableId)} + '-count');
|
|
201
|
+
if (counter) counter.textContent = visible + ' of ' + rows.length;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (var fi = 0; fi < filters.length; fi++) {
|
|
205
|
+
(function(f) {
|
|
206
|
+
var ci = f.getAttribute('data-col');
|
|
207
|
+
var saved = getCookie(prefix + '_' + ci);
|
|
208
|
+
if (saved) f.value = saved;
|
|
209
|
+
var evt = f.tagName === 'SELECT' ? 'change' : 'input';
|
|
210
|
+
f.addEventListener(evt, function() {
|
|
211
|
+
setCookie(prefix + '_' + ci, f.value);
|
|
212
|
+
applyFilters();
|
|
213
|
+
});
|
|
214
|
+
})(filters[fi]);
|
|
215
|
+
}
|
|
216
|
+
applyFilters();
|
|
217
|
+
})();
|
|
218
|
+
</script>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- Template rendering ----
|
|
222
|
+
|
|
223
|
+
renderPage(title, content, processingTime) {
|
|
224
|
+
if (!htmlServer.hasTemplate(TEMPLATE_NAME)) {
|
|
225
|
+
const templatePath = path.join(__dirname, 'extension-tracker-template.html');
|
|
226
|
+
htmlServer.loadTemplate(TEMPLATE_NAME, templatePath);
|
|
227
|
+
}
|
|
228
|
+
const count = this.db.prepare('SELECT COUNT(*) as count FROM packages').get().count;
|
|
229
|
+
const stats = {
|
|
230
|
+
version: packageJson.version,
|
|
231
|
+
totalPackages: count,
|
|
232
|
+
processingTime: processingTime
|
|
233
|
+
};
|
|
234
|
+
return htmlServer.renderPage(TEMPLATE_NAME, title, content, stats);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
sendHtmlResponse(res, title, content, startTime) {
|
|
238
|
+
const html = this.renderPage(title, content, Date.now() - startTime);
|
|
239
|
+
res.setHeader('Content-Type', 'text/html');
|
|
240
|
+
res.send(html);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---- POST handler ----
|
|
244
|
+
|
|
245
|
+
handleSubmission(req, res) {
|
|
246
|
+
const startTime = Date.now();
|
|
247
|
+
const data = req.body;
|
|
248
|
+
|
|
249
|
+
if (!data.package || !data.version || !data.fhirVersion) {
|
|
250
|
+
return res.status(400).json({ error: 'Missing required fields: package, version, fhirVersion' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
this.ingestData(data);
|
|
255
|
+
this.stats.countRequest('handleSubmission', Date.now() - startTime);
|
|
256
|
+
return res.status(200).json({ status: 'ok', package: data.package, version: data.version });
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.log(`Extension tracker: error ingesting ${data.package}: ${err.message}`);
|
|
259
|
+
return res.status(500).json({ error: 'Failed to process submission' });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
ingestData(data) {
|
|
264
|
+
const ingest = this.db.transaction(() => {
|
|
265
|
+
// delete any existing entry for this package (cascade cleans children)
|
|
266
|
+
this.stmts.deletePackage.run(data.package);
|
|
267
|
+
|
|
268
|
+
// insert package
|
|
269
|
+
const result = this.stmts.insertPackage.run(
|
|
270
|
+
data.package, data.version, data.fhirVersion,
|
|
271
|
+
data.jurisdiction || null, new Date().toISOString()
|
|
272
|
+
);
|
|
273
|
+
const packageId = result.lastInsertRowid;
|
|
274
|
+
|
|
275
|
+
// insert extensions
|
|
276
|
+
if (Array.isArray(data.extensions)) {
|
|
277
|
+
for (const ext of data.extensions) {
|
|
278
|
+
const extResult = this.stmts.insertExtension.run(packageId, ext.url, ext.title || null);
|
|
279
|
+
const extId = extResult.lastInsertRowid;
|
|
280
|
+
|
|
281
|
+
// deduplicate types
|
|
282
|
+
if (Array.isArray(ext.types)) {
|
|
283
|
+
const seen = new Set();
|
|
284
|
+
for (const type of ext.types) {
|
|
285
|
+
if (!seen.has(type)) {
|
|
286
|
+
seen.add(type);
|
|
287
|
+
this.stmts.insertExtensionType.run(extId, type);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// insert profiles
|
|
295
|
+
if (data.profiles && typeof data.profiles === 'object') {
|
|
296
|
+
for (const [resourceType, profileList] of Object.entries(data.profiles)) {
|
|
297
|
+
if (Array.isArray(profileList)) {
|
|
298
|
+
for (const profile of profileList) {
|
|
299
|
+
this.stmts.insertProfile.run(packageId, resourceType, profile.url, profile.title || null);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// insert usage
|
|
306
|
+
if (data.usage && typeof data.usage === 'object') {
|
|
307
|
+
for (const [extUrl, locations] of Object.entries(data.usage)) {
|
|
308
|
+
if (Array.isArray(locations)) {
|
|
309
|
+
for (const location of locations) {
|
|
310
|
+
this.stmts.insertUsage.run(packageId, extUrl, location);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
ingest();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- GET handlers ----
|
|
321
|
+
|
|
322
|
+
handleHome(req, res) {
|
|
323
|
+
const startTime = Date.now();
|
|
324
|
+
try {
|
|
325
|
+
const summary = this.db.prepare(`
|
|
326
|
+
SELECT
|
|
327
|
+
COUNT(*) as packageCount,
|
|
328
|
+
COUNT(DISTINCT fhir_version) as fhirVersionCount
|
|
329
|
+
FROM packages
|
|
330
|
+
`).get();
|
|
331
|
+
|
|
332
|
+
const extCount = this.db.prepare('SELECT COUNT(*) as count FROM extensions').get().count;
|
|
333
|
+
const profileCount = this.db.prepare('SELECT COUNT(*) as count FROM profiles').get().count;
|
|
334
|
+
const usageCount = this.db.prepare('SELECT COUNT(*) as count FROM usages').get().count;
|
|
335
|
+
|
|
336
|
+
const packages = this.db.prepare(
|
|
337
|
+
'SELECT package, version, fhir_version, jurisdiction, submitted_at FROM packages ORDER BY package'
|
|
338
|
+
).all();
|
|
339
|
+
|
|
340
|
+
let content = '<table class="grid">';
|
|
341
|
+
content += `<tr><td><b>Packages</b></td><td>${summary.packageCount}</td></tr>`;
|
|
342
|
+
content += `<tr><td><b>FHIR Versions</b></td><td>${summary.fhirVersionCount}</td></tr>`;
|
|
343
|
+
content += `<tr><td><b>Extensions defined</b></td><td>${extCount}</td></tr>`;
|
|
344
|
+
content += `<tr><td><b>Profiles defined</b></td><td>${profileCount}</td></tr>`;
|
|
345
|
+
content += `<tr><td><b>Extension usages tracked</b></td><td>${usageCount}</td></tr>`;
|
|
346
|
+
content += '</table>';
|
|
347
|
+
|
|
348
|
+
content += '<h3>Packages</h3>';
|
|
349
|
+
content += `<p><span id="pkg-table-count">${packages.length}</span> packages</p>`;
|
|
350
|
+
content += '<table class="grid" id="pkg-table"><tr><th>Package</th><th>Version</th><th>FHIR</th><th>Jurisdiction</th><th>Submitted</th></tr>';
|
|
351
|
+
for (const p of packages) {
|
|
352
|
+
content += '<tr>';
|
|
353
|
+
content += `<td><a href="${this.urlBase}/package/${encodeURIComponent(p.package)}">${escape(p.package)}</a></td>`;
|
|
354
|
+
content += `<td>${escape(p.version)}</td>`;
|
|
355
|
+
content += `<td>${escape(p.fhir_version)}</td>`;
|
|
356
|
+
content += `<td>${escape(p.jurisdiction || '')}</td>`;
|
|
357
|
+
content += `<td>${escape(p.submitted_at.substring(0, 10))}</td>`;
|
|
358
|
+
content += '</tr>';
|
|
359
|
+
}
|
|
360
|
+
content += '</table>';
|
|
361
|
+
|
|
362
|
+
content += this.buildColumnFilters('pkg-table', [
|
|
363
|
+
{ type: 'text' }, { type: 'text' }, { type: 'text' }, { type: 'text' }, { type: 'text' }
|
|
364
|
+
], 'pkgf');
|
|
365
|
+
|
|
366
|
+
this.stats.countRequest('handleHome', Date.now() - startTime);
|
|
367
|
+
this.sendHtmlResponse(res, 'Extension Tracker', content, startTime);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.log('Extension tracker: error rendering home:', error);
|
|
370
|
+
htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
handleExtensions(req, res) {
|
|
375
|
+
const startTime = Date.now();
|
|
376
|
+
try {
|
|
377
|
+
// Aggregate by extension URL: combine types across all occurrences, count packages
|
|
378
|
+
const rows = this.db.prepare(`
|
|
379
|
+
SELECT e.url,
|
|
380
|
+
MAX(e.title) as title,
|
|
381
|
+
GROUP_CONCAT(DISTINCT et.type) as types,
|
|
382
|
+
COUNT(DISTINCT e.package_id) as package_count
|
|
383
|
+
FROM extensions e
|
|
384
|
+
LEFT JOIN extension_types et ON et.extension_id = e.id
|
|
385
|
+
GROUP BY e.url
|
|
386
|
+
ORDER BY e.url
|
|
387
|
+
`).all();
|
|
388
|
+
|
|
389
|
+
let content = `<p><span id="ext-table-count">${rows.length}</span> extensions</p>`;
|
|
390
|
+
content += '<table class="grid" id="ext-table"><tr><th>Extension</th><th>Title</th><th>Types</th><th>Packages</th></tr>';
|
|
391
|
+
for (const r of rows) {
|
|
392
|
+
content += '<tr>';
|
|
393
|
+
content += `<td>${escape(r.url)}</td>`;
|
|
394
|
+
content += `<td>${escape(r.title || '')}</td>`;
|
|
395
|
+
content += `<td>${escape(r.types || '')}</td>`;
|
|
396
|
+
content += `<td><a href="${this.urlBase}/extensions/packages?url=${encodeURIComponent(r.url)}">${r.package_count}</a></td>`;
|
|
397
|
+
content += '</tr>';
|
|
398
|
+
}
|
|
399
|
+
content += '</table>';
|
|
400
|
+
|
|
401
|
+
content += this.buildColumnFilters('ext-table', [
|
|
402
|
+
{ type: 'text' }, { type: 'text' }, { type: 'text' }, { type: 'text' }
|
|
403
|
+
], 'extf');
|
|
404
|
+
|
|
405
|
+
this.stats.countRequest('handleExtensions', Date.now() - startTime);
|
|
406
|
+
this.sendHtmlResponse(res, 'Extensions', content, startTime);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
console.log('Extension tracker: error rendering extensions:', error);
|
|
409
|
+
htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
handleExtensionsByPackage(req, res) {
|
|
414
|
+
const startTime = Date.now();
|
|
415
|
+
try {
|
|
416
|
+
const filterUrl = req.query.url || null;
|
|
417
|
+
|
|
418
|
+
let query = `
|
|
419
|
+
SELECT e.url, e.title, p.package, p.version,
|
|
420
|
+
GROUP_CONCAT(DISTINCT et.type) as types
|
|
421
|
+
FROM extensions e
|
|
422
|
+
JOIN packages p ON p.id = e.package_id
|
|
423
|
+
LEFT JOIN extension_types et ON et.extension_id = e.id
|
|
424
|
+
`;
|
|
425
|
+
const params = [];
|
|
426
|
+
if (filterUrl) {
|
|
427
|
+
query += ' WHERE e.url = ?';
|
|
428
|
+
params.push(filterUrl);
|
|
429
|
+
}
|
|
430
|
+
query += ' GROUP BY e.id ORDER BY p.package, e.url';
|
|
431
|
+
|
|
432
|
+
const rows = this.db.prepare(query).all(...params);
|
|
433
|
+
|
|
434
|
+
let content = '<p><a href="' + this.urlBase + '/extensions">« Back to extensions</a></p>';
|
|
435
|
+
if (filterUrl) {
|
|
436
|
+
content += `<p>Extension: <b>${escape(filterUrl)}</b></p>`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
content += `<p><span id="extpkg-table-count">${rows.length}</span> entries by package</p>`;
|
|
440
|
+
content += '<table class="grid" id="extpkg-table"><tr><th>Extension</th><th>Title</th><th>Types</th><th>Package</th></tr>';
|
|
441
|
+
for (const r of rows) {
|
|
442
|
+
content += '<tr>';
|
|
443
|
+
content += `<td>${escape(r.url)}</td>`;
|
|
444
|
+
content += `<td>${escape(r.title || '')}</td>`;
|
|
445
|
+
content += `<td>${escape(r.types || '')}</td>`;
|
|
446
|
+
content += `<td><a href="${this.urlBase}/package/${encodeURIComponent(r.package)}">${escape(r.package)}#${escape(r.version)}</a></td>`;
|
|
447
|
+
content += '</tr>';
|
|
448
|
+
}
|
|
449
|
+
content += '</table>';
|
|
450
|
+
|
|
451
|
+
content += this.buildColumnFilters('extpkg-table', [
|
|
452
|
+
{ type: 'text' }, { type: 'text' }, { type: 'text' }, { type: 'text' }
|
|
453
|
+
], 'extpkgf');
|
|
454
|
+
|
|
455
|
+
this.stats.countRequest('handleExtensionsByPackage', Date.now() - startTime);
|
|
456
|
+
this.sendHtmlResponse(res, 'Extensions by Package', content, startTime);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.log('Extension tracker: error rendering extensions by package:', error);
|
|
459
|
+
htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
handleProfiles(req, res) {
|
|
464
|
+
const startTime = Date.now();
|
|
465
|
+
try {
|
|
466
|
+
const rows = this.db.prepare(`
|
|
467
|
+
SELECT pr.resource_type, pr.url, pr.title, p.package, p.version
|
|
468
|
+
FROM profiles pr
|
|
469
|
+
JOIN packages p ON p.id = pr.package_id
|
|
470
|
+
ORDER BY pr.resource_type, p.package, pr.url
|
|
471
|
+
`).all();
|
|
472
|
+
|
|
473
|
+
// get distinct resource types for the select filter
|
|
474
|
+
const resourceTypes = this.db.prepare(
|
|
475
|
+
'SELECT DISTINCT resource_type FROM profiles ORDER BY resource_type'
|
|
476
|
+
).all().map(r => r.resource_type);
|
|
477
|
+
|
|
478
|
+
let content = `<p><span id="prof-table-count">${rows.length}</span> profiles</p>`;
|
|
479
|
+
content += '<table class="grid" id="prof-table"><tr><th>Resource</th><th>Profile</th><th>Title</th><th>Package</th></tr>';
|
|
480
|
+
for (const r of rows) {
|
|
481
|
+
content += '<tr>';
|
|
482
|
+
content += `<td>${escape(r.resource_type)}</td>`;
|
|
483
|
+
content += `<td>${escape(r.url)}</td>`;
|
|
484
|
+
content += `<td>${escape(r.title || '')}</td>`;
|
|
485
|
+
content += `<td><a href="${this.urlBase}/package/${encodeURIComponent(r.package)}">${escape(r.package)}#${escape(r.version)}</a></td>`;
|
|
486
|
+
content += '</tr>';
|
|
487
|
+
}
|
|
488
|
+
content += '</table>';
|
|
489
|
+
|
|
490
|
+
content += this.buildColumnFilters('prof-table', [
|
|
491
|
+
{ type: 'select', options: resourceTypes },
|
|
492
|
+
{ type: 'text' },
|
|
493
|
+
{ type: 'text' },
|
|
494
|
+
{ type: 'text' }
|
|
495
|
+
], 'proff');
|
|
496
|
+
|
|
497
|
+
this.stats.countRequest('handleProfiles', Date.now() - startTime);
|
|
498
|
+
this.sendHtmlResponse(res, 'Profiles', content, startTime);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
console.log('Extension tracker: error rendering profiles:', error);
|
|
501
|
+
htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
handleUsage(req, res) {
|
|
506
|
+
const startTime = Date.now();
|
|
507
|
+
try {
|
|
508
|
+
// Aggregate usages by extension_url + location, counting distinct packages
|
|
509
|
+
const rows = this.db.prepare(`
|
|
510
|
+
SELECT u.extension_url, u.location, COUNT(DISTINCT u.package_id) as package_count
|
|
511
|
+
FROM usages u
|
|
512
|
+
GROUP BY u.extension_url, u.location
|
|
513
|
+
ORDER BY u.extension_url, u.location
|
|
514
|
+
`).all();
|
|
515
|
+
|
|
516
|
+
let content = `<p><span id="usage-table-count">${rows.length}</span> usage entries (aggregated across packages)</p>`;
|
|
517
|
+
content += '<table class="grid" id="usage-table"><tr><th>Extension</th><th>Used on</th><th>Packages</th></tr>';
|
|
518
|
+
for (const r of rows) {
|
|
519
|
+
content += '<tr>';
|
|
520
|
+
content += `<td>${escape(r.extension_url)}</td>`;
|
|
521
|
+
content += `<td>${escape(r.location)}</td>`;
|
|
522
|
+
content += `<td><a href="${this.urlBase}/usage/packages?url=${encodeURIComponent(r.extension_url)}&location=${encodeURIComponent(r.location)}">${r.package_count}</a></td>`;
|
|
523
|
+
content += '</tr>';
|
|
524
|
+
}
|
|
525
|
+
content += '</table>';
|
|
526
|
+
|
|
527
|
+
content += this.buildColumnFilters('usage-table', [
|
|
528
|
+
{ type: 'text' }, { type: 'text' }, { type: 'text' }
|
|
529
|
+
], 'usagef');
|
|
530
|
+
|
|
531
|
+
this.stats.countRequest('handleUsage', Date.now() - startTime);
|
|
532
|
+
this.sendHtmlResponse(res, 'Extension Usage', content, startTime);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
console.log('Extension tracker: error rendering usage:', error);
|
|
535
|
+
htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
handleUsageByPackage(req, res) {
|
|
540
|
+
const startTime = Date.now();
|
|
541
|
+
try {
|
|
542
|
+
const filterUrl = req.query.url || null;
|
|
543
|
+
const filterLocation = req.query.location || null;
|
|
544
|
+
|
|
545
|
+
let query = `
|
|
546
|
+
SELECT u.extension_url, u.location, p.package, p.version
|
|
547
|
+
FROM usages u
|
|
548
|
+
JOIN packages p ON p.id = u.package_id
|
|
549
|
+
`;
|
|
550
|
+
const conditions = [];
|
|
551
|
+
const params = [];
|
|
552
|
+
if (filterUrl) {
|
|
553
|
+
conditions.push('u.extension_url = ?');
|
|
554
|
+
params.push(filterUrl);
|
|
555
|
+
}
|
|
556
|
+
if (filterLocation) {
|
|
557
|
+
conditions.push('u.location = ?');
|
|
558
|
+
params.push(filterLocation);
|
|
559
|
+
}
|
|
560
|
+
if (conditions.length > 0) {
|
|
561
|
+
query += ' WHERE ' + conditions.join(' AND ');
|
|
562
|
+
}
|
|
563
|
+
query += ' ORDER BY p.package, u.extension_url, u.location';
|
|
564
|
+
|
|
565
|
+
const rows = this.db.prepare(query).all(...params);
|
|
566
|
+
|
|
567
|
+
let content = '<p><a href="' + this.urlBase + '/usage">« Back to aggregated usage</a></p>';
|
|
568
|
+
if (filterUrl || filterLocation) {
|
|
569
|
+
const parts = [];
|
|
570
|
+
if (filterUrl) parts.push(`extension: <b>${escape(filterUrl)}</b>`);
|
|
571
|
+
if (filterLocation) parts.push(`location: <b>${escape(filterLocation)}</b>`);
|
|
572
|
+
content += `<p>Filtered to ${parts.join(', ')}</p>`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
content += `<p><span id="usagepkg-table-count">${rows.length}</span> usage entries by package</p>`;
|
|
576
|
+
content += '<table class="grid" id="usagepkg-table"><tr><th>Extension</th><th>Used on</th><th>Package</th></tr>';
|
|
577
|
+
for (const r of rows) {
|
|
578
|
+
content += '<tr>';
|
|
579
|
+
content += `<td>${escape(r.extension_url)}</td>`;
|
|
580
|
+
content += `<td>${escape(r.location)}</td>`;
|
|
581
|
+
content += `<td><a href="${this.urlBase}/package/${encodeURIComponent(r.package)}">${escape(r.package)}#${escape(r.version)}</a></td>`;
|
|
582
|
+
content += '</tr>';
|
|
583
|
+
}
|
|
584
|
+
content += '</table>';
|
|
585
|
+
|
|
586
|
+
content += this.buildColumnFilters('usagepkg-table', [
|
|
587
|
+
{ type: 'text' }, { type: 'text' }, { type: 'text' }
|
|
588
|
+
], 'usagepkgf');
|
|
589
|
+
|
|
590
|
+
this.stats.countRequest('handleUsageByPackage', Date.now() - startTime);
|
|
591
|
+
this.sendHtmlResponse(res, 'Usage by Package', content, startTime);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
console.log('Extension tracker: error rendering usage by package:', error);
|
|
594
|
+
htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
handlePackageDetail(req, res) {
|
|
599
|
+
const startTime = Date.now();
|
|
600
|
+
try {
|
|
601
|
+
const pkgName = req.params.pkg;
|
|
602
|
+
const pkg = this.db.prepare('SELECT * FROM packages WHERE package = ?').get(pkgName);
|
|
603
|
+
if (!pkg) {
|
|
604
|
+
return res.status(404).send('Package not found');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const extensions = this.db.prepare(`
|
|
608
|
+
SELECT e.url, e.title, GROUP_CONCAT(DISTINCT et.type) as types
|
|
609
|
+
FROM extensions e
|
|
610
|
+
LEFT JOIN extension_types et ON et.extension_id = e.id
|
|
611
|
+
WHERE e.package_id = ?
|
|
612
|
+
GROUP BY e.id ORDER BY e.url
|
|
613
|
+
`).all(pkg.id);
|
|
614
|
+
|
|
615
|
+
const profiles = this.db.prepare(
|
|
616
|
+
'SELECT resource_type, url, title FROM profiles WHERE package_id = ? ORDER BY resource_type, url'
|
|
617
|
+
).all(pkg.id);
|
|
618
|
+
|
|
619
|
+
const usages = this.db.prepare(
|
|
620
|
+
'SELECT extension_url, location FROM usages WHERE package_id = ? ORDER BY extension_url, location'
|
|
621
|
+
).all(pkg.id);
|
|
622
|
+
|
|
623
|
+
let content = '<table class="grid">';
|
|
624
|
+
content += `<tr><td><b>Package</b></td><td>${escape(pkg.package)}</td></tr>`;
|
|
625
|
+
content += `<tr><td><b>Version</b></td><td>${escape(pkg.version)}</td></tr>`;
|
|
626
|
+
content += `<tr><td><b>FHIR Version</b></td><td>${escape(pkg.fhir_version)}</td></tr>`;
|
|
627
|
+
content += `<tr><td><b>Jurisdiction</b></td><td>${escape(pkg.jurisdiction || '')}</td></tr>`;
|
|
628
|
+
content += `<tr><td><b>Submitted</b></td><td>${escape(pkg.submitted_at)}</td></tr>`;
|
|
629
|
+
content += '</table>';
|
|
630
|
+
|
|
631
|
+
if (extensions.length > 0) {
|
|
632
|
+
content += `<h3>Extensions Defined (${extensions.length})</h3>`;
|
|
633
|
+
content += '<table class="grid"><tr><th>URL</th><th>Title</th><th>Types</th></tr>';
|
|
634
|
+
for (const e of extensions) {
|
|
635
|
+
content += '<tr>';
|
|
636
|
+
content += `<td>${escape(e.url)}</td>`;
|
|
637
|
+
content += `<td>${escape(e.title || '')}</td>`;
|
|
638
|
+
content += `<td>${escape(e.types || '')}</td>`;
|
|
639
|
+
content += '</tr>';
|
|
640
|
+
}
|
|
641
|
+
content += '</table>';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (profiles.length > 0) {
|
|
645
|
+
content += `<h3>Profiles Defined (${profiles.length})</h3>`;
|
|
646
|
+
content += '<table class="grid"><tr><th>Resource</th><th>URL</th><th>Title</th></tr>';
|
|
647
|
+
for (const p of profiles) {
|
|
648
|
+
content += '<tr>';
|
|
649
|
+
content += `<td>${escape(p.resource_type)}</td>`;
|
|
650
|
+
content += `<td>${escape(p.url)}</td>`;
|
|
651
|
+
content += `<td>${escape(p.title || '')}</td>`;
|
|
652
|
+
content += '</tr>';
|
|
653
|
+
}
|
|
654
|
+
content += '</table>';
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (usages.length > 0) {
|
|
658
|
+
content += `<h3>Extension Usage (${usages.length})</h3>`;
|
|
659
|
+
content += '<table class="grid"><tr><th>Extension</th><th>Used on</th></tr>';
|
|
660
|
+
for (const u of usages) {
|
|
661
|
+
content += '<tr>';
|
|
662
|
+
content += `<td><a href="${this.urlBase}/usage?url=${encodeURIComponent(u.extension_url)}">${escape(u.extension_url)}</a></td>`;
|
|
663
|
+
content += `<td>${escape(u.location)}</td>`;
|
|
664
|
+
content += '</tr>';
|
|
665
|
+
}
|
|
666
|
+
content += '</table>';
|
|
667
|
+
}
|
|
668
|
+
this.stats.countRequest('packageDetail', Date.now() - startTime);
|
|
669
|
+
|
|
670
|
+
this.sendHtmlResponse(res, `${escape(pkgName)}#${escape(pkg.version)}`, content, startTime);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
console.log('Extension tracker: error rendering package detail:', error);
|
|
673
|
+
htmlServer.sendErrorResponse(res, TEMPLATE_NAME, error);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ---- Module lifecycle ----
|
|
678
|
+
|
|
679
|
+
shutdown() {
|
|
680
|
+
if (this.db) {
|
|
681
|
+
this.db.close();
|
|
682
|
+
this.db = null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
getStatus() {
|
|
687
|
+
let startTime = Date.now();
|
|
688
|
+
if (!this.db) {
|
|
689
|
+
return { status: 'closed' };
|
|
690
|
+
}
|
|
691
|
+
const count = this.db.prepare('SELECT COUNT(*) as count FROM packages').get().count;
|
|
692
|
+
this.stats.countRequest('search', Date.now() - startTime);
|
|
693
|
+
return { status: 'running', packages: count };
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
module.exports = ExtensionTrackerModule;
|