fhirsmith 0.7.5 → 0.8.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 (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -0
  3. package/library/html.js +4 -0
  4. package/library/languages.js +10 -0
  5. package/package.json +1 -1
  6. package/packages/package-crawler.js +106 -51
  7. package/packages/packages.js +14 -0
  8. package/publisher/publisher.js +118 -28
  9. package/registry/registry.js +99 -91
  10. package/root-bare-template.html +92 -0
  11. package/security.md +32 -0
  12. package/server.js +99 -22
  13. package/stats.js +43 -10
  14. package/tx/README.md +6 -6
  15. package/tx/cs/cs-api.js +3 -0
  16. package/tx/cs/cs-api.md +285 -0
  17. package/tx/cs/cs-loinc.js +14 -2
  18. package/tx/cs/cs-rxnorm.js +14 -10
  19. package/tx/cs/cs-snomed.js +166 -5
  20. package/tx/html/dash-metrics.liquid +147 -0
  21. package/tx/importers/import-rxnorm.module.js +4 -30
  22. package/tx/importers/readme.md +3 -1
  23. package/tx/library/canonical-resource.js +8 -0
  24. package/tx/library/conceptmap.js +3 -1
  25. package/tx/library/designations.js +4 -8
  26. package/tx/library/renderer.js +9 -9
  27. package/tx/library.js +10 -4
  28. package/tx/ocl/cm-ocl.cjs +185 -65
  29. package/tx/ocl/cs-ocl.cjs +69 -50
  30. package/tx/ocl/jobs/background-queue.cjs +0 -8
  31. package/tx/ocl/mappers/concept-mapper.cjs +13 -3
  32. package/tx/ocl/shared/patches.cjs +1 -0
  33. package/tx/ocl/vs-ocl.cjs +137 -157
  34. package/tx/operation-context.js +3 -3
  35. package/tx/provider.js +4 -3
  36. package/tx/sct/structures.js +5 -0
  37. package/tx/tx-html.js +36 -9
  38. package/tx/tx.fhir.org.yml +1 -1
  39. package/tx/tx.js +34 -11
  40. package/tx/vs/vs-database.js +127 -6
  41. package/tx/vs/vs-vsac.js +98 -3
  42. package/tx/workers/search.js +2 -1
  43. package/tx/workers/translate.js +39 -14
  44. package/tx/workers/validate.js +3 -3
  45. package/utilities/dashboard.html +274 -0
  46. package/xig/xig.js +171 -9
package/CHANGELOG.md CHANGED
@@ -5,6 +5,56 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [v0.8.0] - 2026-03-27
9
+
10
+ ### Added
11
+
12
+ - XIG: add JSON and CSV downloads
13
+ - TX: Add snomed filter support for inactive, moduleId, and properties
14
+
15
+ ### Changed
16
+
17
+ - Improve Dashboard Presentation
18
+ - Make docker image platform compatible with apple silicon (arm)
19
+ - TX: update rxnorm version for tx.fhir.org
20
+ - TX: Improve VSAC information page
21
+
22
+ ### Fixed
23
+
24
+ - XIG: fix valueset source filter
25
+ - TX: Fix bug in language processing looking up country codes
26
+ - TX: Fix up terminology search for LOINC and generally
27
+ - TX: fix rxnorm property support and search performance
28
+ - Publisher: fix status display when building draft IG
29
+
30
+ ### Tx Conformance Statement
31
+
32
+ FHIRsmith passed all 1498 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.4)
33
+
34
+ ## [v0.7.6] - 2026-03-25
35
+
36
+ ### Added
37
+
38
+ - Dashboard endpoint (see dashboard.html)
39
+ - Initial cs-api documentation
40
+
41
+ ### Changed
42
+
43
+ - Update package crawler to support archived feed entries
44
+
45
+ ### Fixed
46
+
47
+ - OCL improvements:
48
+ - Improve multilingual support and caching for non-OCL expansions
49
+ - cache compose instead of pre-built expansions
50
+ - Fix ConceptMap rendering
51
+ - Ongoing on work on publishing module
52
+ - Tidy up tx-reg to prevent hanging
53
+
54
+ ### Tx Conformance Statement
55
+
56
+ FHIRsmith passed all 1464 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.3)
57
+
8
58
  ## [v0.7.5] - 2026-03-19
9
59
 
10
60
  ### Changed
package/README.md CHANGED
@@ -17,6 +17,14 @@ This server provides a set of server-side services that are useful for the FHIR
17
17
  * [VCL](vcl/readme.md) - **Parse VCL expressions** into FHIR ValueSet resources for http://fhir.org/vcl
18
18
  * (Coming) Token services
19
19
 
20
+ ## Summary Statement
21
+
22
+ * Maintainers: Grahame Grieve, Italo Macêdo, Josh Mandel, Jose Costa Teixeira
23
+ * Issues / Discussion: Use github issues
24
+ * License: BSD-3
25
+ * Contribution Policy: Make PRs. PRs have to pass all the tests
26
+ * Security Information: See [security.md](security.md)
27
+
20
28
  ## Build Status
21
29
  ![CI Build](https://github.com/HealthIntersections/fhirsmith/actions/workflows/ci.yml/badge.svg)
22
30
  [![Release](https://img.shields.io/github/v/release/HealthIntersections/fhirsmith?include_prereleases)](https://github.com/HealthIntersections/fhirsmith/releases)
package/library/html.js CHANGED
@@ -428,6 +428,10 @@ class XhtmlNode {
428
428
  return this.addTag('label').setAttribute('for', forId);
429
429
  }
430
430
 
431
+ colspan(width) {
432
+ return this.attr("colspan", String(width));
433
+ }
434
+
431
435
  // Conditional
432
436
  iff(test) {
433
437
  if (test) {
@@ -455,6 +455,16 @@ class Languages {
455
455
  return true;
456
456
  }
457
457
 
458
+ includesLanguage(code) {
459
+ const llang = new Language(code);
460
+ for (const lang of this.languages) {
461
+ if (lang.matches(llang)) {
462
+ return true;
463
+ }
464
+ }
465
+ return false;
466
+ }
467
+
458
468
  /**
459
469
  * Convert to string representation (similar to Accept-Language header format)
460
470
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
5
5
  "main": "server.js",
6
6
  "engines": {
@@ -27,6 +27,25 @@ class PackageCrawler {
27
27
  this.db.run('PRAGMA busy_timeout = 5000');
28
28
  }
29
29
 
30
+ async isFeedPageVisited(url) {
31
+ return new Promise((resolve, reject) => {
32
+ this.db.get('SELECT Url FROM FeedPages WHERE Url = ?', [url], (err, row) => {
33
+ if (err) reject(err);
34
+ else resolve(!!row);
35
+ });
36
+ });
37
+ }
38
+
39
+ async markFeedPageVisited(url) {
40
+ return new Promise((resolve, reject) => {
41
+ this.db.run(
42
+ 'INSERT OR REPLACE INTO FeedPages (Url, VisitedAt) VALUES (?, ?)',
43
+ [url, new Date().toISOString()],
44
+ (err) => { if (err) reject(err); else resolve(); }
45
+ );
46
+ });
47
+ }
48
+
30
49
  async crawl(log) {
31
50
  this.log = log;
32
51
  this.packages.clear();
@@ -101,7 +120,7 @@ class PackageCrawler {
101
120
  this.log.info(`Web crawler completed successfully in ${runTime}ms`);
102
121
  this.log.info(`Total bytes processed: ${this.totalBytes}`);
103
122
 
104
- this.stats.task('Package Crawler', 'Complete');
123
+ this.stats.taskDone('Package Crawler', 'Complete');
105
124
  return this.crawlerLog;
106
125
 
107
126
  } catch (error) {
@@ -109,7 +128,7 @@ class PackageCrawler {
109
128
  this.crawlerLog.runTime = `${runTime}ms`;
110
129
  this.crawlerLog.fatalException = error.message;
111
130
  this.crawlerLog.endTime = new Date().toISOString();
112
- this.stats.task('Package Crawler', 'Error: '+error.message);
131
+ this.stats.taskError('Package Crawler', 'Error: '+error.message);
113
132
 
114
133
  this.log.error('Web crawler failed: '+ error);
115
134
  throw error;
@@ -151,7 +170,8 @@ class PackageCrawler {
151
170
  const parser = new XMLParser({
152
171
  ignoreAttributes: false,
153
172
  attributeNamePrefix: '@_',
154
- textNodeName: '#text'
173
+ textNodeName: '#text',
174
+ entityExpansionLimit: 100000
155
175
  });
156
176
  return parser.parse(content);
157
177
  } else {
@@ -166,13 +186,13 @@ class PackageCrawler {
166
186
  const parser = new XMLParser({
167
187
  ignoreAttributes: false,
168
188
  attributeNamePrefix: '@_',
169
- textNodeName: '#text'
189
+ textNodeName: '#text',
190
+ entityExpansionLimit: 100000
170
191
  });
171
-
172
192
  return parser.parse(response.data);
173
193
  }
174
194
  } catch (error) {
175
- debugLog(error);
195
+ debugLog(`Failed to fetch XML from ${url}: ${error.message}`);
176
196
  if (error.response && error.response.status === 429) {
177
197
  throw new Error(`RATE_LIMITED: Server returned 429 Too Many Requests for ${url}`);
178
198
  }
@@ -200,7 +220,7 @@ class PackageCrawler {
200
220
  return Buffer.from(response.data);
201
221
  }
202
222
  } catch (error) {
203
- debugLog(error);
223
+ debugLog(`Failed to fetch ${url}: ${error.message}`);
204
224
  if (error.response && error.response.status === 429) {
205
225
  throw new Error(`RATE_LIMITED: Server returned 429 Too Many Requests for ${url}`);
206
226
  }
@@ -218,62 +238,96 @@ class PackageCrawler {
218
238
  this.log.info('Processing feed: ' + url);
219
239
  const startTime = Date.now();
220
240
 
221
- try {
222
- const xmlData = await this.fetchXml(url);
223
- feedLog.fetchTime = `${Date.now() - startTime}ms`;
241
+ // The first page (the root feed URL) is always processed — it contains the
242
+ // latest packages. Subsequent pages (followed via atom:link rel="next") are
243
+ // historical archives and only need to be visited once.
244
+ let currentUrl = url;
245
+ let isFirstPage = true;
224
246
 
225
- // Navigate the RSS structure
226
- let items = [];
227
- if (xmlData.rss && xmlData.rss.channel) {
228
- const channel = xmlData.rss.channel;
229
- items = Array.isArray(channel.item) ? channel.item : [channel.item].filter(Boolean);
247
+ while (currentUrl) {
248
+ if (this.abortController?.signal.aborted) break;
249
+
250
+ if (!isFirstPage) {
251
+ const alreadyVisited = await this.isFeedPageVisited(currentUrl);
252
+ if (alreadyVisited) {
253
+ this.log.info(`Feed page already visited, stopping: ${currentUrl}`);
254
+ break;
255
+ }
230
256
  }
231
257
 
232
- this.log.info(`Found ${items.length} items in feed`);
258
+ try {
259
+ this.log.info(`Fetching feed page: ${currentUrl}`);
260
+ const xmlData = await this.fetchXml(currentUrl);
261
+ if (isFirstPage) feedLog.fetchTime = `${Date.now() - startTime}ms`;
262
+
263
+ let items = [];
264
+ let nextUrl = null;
265
+
266
+ if (xmlData.rss && xmlData.rss.channel) {
267
+ const channel = xmlData.rss.channel;
268
+ items = Array.isArray(channel.item) ? channel.item : [channel.item].filter(Boolean);
269
+
270
+ // Check for RFC 5005 next-page link: <atom:link rel="next" href="..."/>
271
+ const atomLinks = channel['atom:link'];
272
+ if (atomLinks) {
273
+ const links = Array.isArray(atomLinks) ? atomLinks : [atomLinks];
274
+ const nextLink = links.find(l => l['@_rel'] === 'next');
275
+ if (nextLink && nextLink['@_href']) {
276
+ nextUrl = this.fixUrl(nextLink['@_href']);
277
+ }
278
+ }
279
+ }
233
280
 
234
- for (let i = 0; i < items.length; i++) {
235
- if (this.abortController?.signal.aborted) break;
236
- try {
237
- await this.updateItem(url, items[i], i, packageRestrictions, feedLog);
238
- } catch (itemError) {
239
- // Check if this is a 429 error on package download
240
- if (itemError.message.includes('RATE_LIMITED')) {
241
- this.log.info(`Rate limited while downloading package from ${url}, stopping feed processing`);
242
- feedLog.rateLimited = true;
243
- feedLog.rateLimitedAt = `item ${i}`;
244
- feedLog.rateLimitMessage = itemError.message;
245
- break; // Stop processing this feed
281
+ this.log.info(`Found ${items.length} items in feed page ${currentUrl}`);
282
+
283
+ let rateLimited = false;
284
+ for (let i = 0; i < items.length; i++) {
285
+ if (this.abortController?.signal.aborted) break;
286
+ try {
287
+ await this.updateItem(currentUrl, items[i], i, packageRestrictions, feedLog);
288
+ } catch (itemError) {
289
+ if (itemError.message.includes('RATE_LIMITED')) {
290
+ this.log.info(`Rate limited while downloading package from ${currentUrl}, stopping feed processing`);
291
+ feedLog.rateLimited = true;
292
+ feedLog.rateLimitedAt = `item ${i}`;
293
+ feedLog.rateLimitMessage = itemError.message;
294
+ rateLimited = true;
295
+ break;
296
+ }
297
+ this.log.error(`Error processing item ${i} from ${currentUrl}:` + itemError.message);
246
298
  }
247
- // For other errors, log and continue with next item
248
- this.log.error(`Error processing item ${i} from ${url}:` + itemError.message);
249
299
  }
250
- }
251
300
 
252
- // TODO: Send email if there were errors and email is provided
253
- if (this.errors && email && !feedLog.rateLimited) {
254
- this.log.info(`Would send error email to ${email} for feed ${url}`);
255
- }
301
+ if (rateLimited) break;
256
302
 
257
- } catch (error) {
258
- debugLog(error);
259
- // Check if this is a 429 error on feed fetch
260
- if (error.message.includes('RATE_LIMITED')) {
261
- this.log.info(`Rate limited while fetching feed ${url}, skipping this feed`);
262
- feedLog.rateLimited = true;
263
- feedLog.rateLimitMessage = error.message;
264
- feedLog.failTime = `${Date.now() - startTime}ms`;
265
- return; // Skip this feed entirely
266
- }
303
+ // Mark this page as visited now that we've successfully processed it.
304
+ // Don't mark the first page — it must always be re-crawled for new entries.
305
+ if (!isFirstPage) {
306
+ await this.markFeedPageVisited(currentUrl);
307
+ }
267
308
 
268
- feedLog.exception = error.message;
269
- feedLog.failTime = `${Date.now() - startTime}ms`;
270
- this.log.error(`Exception processing feed ${url}:` + error.message);
309
+ currentUrl = nextUrl;
310
+ isFirstPage = false;
271
311
 
272
- // TODO: Send email notification for non-rate-limit errors
273
- if (email) {
274
- this.log.info(`Would send exception email to ${email} for feed ${url}`);
312
+ } catch (error) {
313
+ debugLog(error);
314
+ if (error.message.includes('RATE_LIMITED')) {
315
+ this.log.info(`Rate limited while fetching feed ${currentUrl}, stopping`);
316
+ feedLog.rateLimited = true;
317
+ feedLog.rateLimitMessage = error.message;
318
+ feedLog.failTime = `${Date.now() - startTime}ms`;
319
+ break;
320
+ }
321
+ feedLog.exception = error.message;
322
+ feedLog.failTime = `${Date.now() - startTime}ms`;
323
+ this.log.error(`Exception processing feed ${currentUrl}:` + error.message);
324
+ break;
275
325
  }
276
326
  }
327
+
328
+ if (this.errors && email && !feedLog.rateLimited) {
329
+ this.log.info(`Would send error email to ${email} for feed ${url}`);
330
+ }
277
331
  }
278
332
 
279
333
  async updateItem(source, item, index, packageRestrictions, feedLog) {
@@ -699,6 +753,7 @@ class PackageCrawler {
699
753
  };
700
754
 
701
755
  } catch (error) {
756
+ console.log(error);
702
757
  throw new Error(`Failed to extract NPM package from ${source}: ${error.message}`);
703
758
  }
704
759
  }
@@ -617,6 +617,13 @@ class PackagesModule {
617
617
  this.createTables().then(resolve).catch(reject);
618
618
  } else {
619
619
  pckLog.info('Packages database already exists');
620
+ // Run migrations for tables added after initial schema
621
+ this.db.run(`CREATE TABLE IF NOT EXISTS FeedPages (
622
+ Url TEXT PRIMARY KEY,
623
+ VisitedAt TEXT NOT NULL
624
+ )`, (err) => {
625
+ if (err) pckLog.error('Failed to create FeedPages table:', err.message);
626
+ });
620
627
  resolve();
621
628
  }
622
629
  }
@@ -692,6 +699,13 @@ class PackagesModule {
692
699
  ManualToken TEXT(64) NOT NULL,
693
700
  Email TEXT(128) NOT NULL,
694
701
  Mask TEXT(64)
702
+ )`,
703
+
704
+ // FeedPages table - tracks which paginated feed archive pages have been visited
705
+ `CREATE TABLE FeedPages
706
+ (
707
+ Url TEXT PRIMARY KEY,
708
+ VisitedAt TEXT NOT NULL
695
709
  )`
696
710
  ];
697
711
 
@@ -269,6 +269,7 @@ class PublisherModule {
269
269
  this.router.post('/tasks', this.requireAuth.bind(this), this.createTask.bind(this));
270
270
  this.router.post('/tasks/:id/approve', this.requireAuth.bind(this), this.approveTask.bind(this));
271
271
  this.router.post('/tasks/:id/delete', this.requireAdmin.bind(this), this.deleteTask.bind(this));
272
+ this.router.post('/tasks/:id/retry', this.requireAuth.bind(this), this.retryTask.bind(this));
272
273
  this.router.get('/tasks/:id/output', this.getTaskOutput.bind(this));
273
274
  this.router.get('/tasks/:id/history', this.getTaskHistory.bind(this));
274
275
  this.router.get('/tasks/:id/qa', this.getTaskQA.bind(this));
@@ -295,19 +296,31 @@ class PublisherModule {
295
296
 
296
297
  // Background Task Processing
297
298
  startTaskProcessor() {
298
- const pollInterval = this.config.pollInterval || 5000; // Default 5 seconds
299
+ const pollInterval = this.config.pollInterval || 5000;
299
300
 
300
301
  this.logger.info('Starting task processor with ' + pollInterval + 'ms poll interval');
302
+ this.isProcessingStarted = null;
301
303
 
302
304
  this.taskProcessor = setInterval(async () => {
303
- if (!this.isProcessing && !this.shutdownRequested) {
304
- await this.processNextTask();
305
+ if (this.shutdownRequested) return;
306
+
307
+ if (this.isProcessing) {
308
+ const stuckMs = this.isProcessingStarted ? Date.now() - this.isProcessingStarted : 0;
309
+ if (stuckMs > 60 * 60 * 1000) {
310
+ this.logger.warn('Task processor appears stuck (' + Math.round(stuckMs / 60000) + ' min) — resetting');
311
+ this.isProcessing = false;
312
+ } else {
313
+ return;
314
+ }
305
315
  }
316
+
317
+ await this.processNextTask();
306
318
  }, pollInterval);
307
319
  }
308
320
 
309
321
  async processNextTask() {
310
322
  this.isProcessing = true;
323
+ this.isProcessingStarted = Date.now();
311
324
 
312
325
  try {
313
326
  // Look for queued tasks first (draft builds)
@@ -468,7 +481,7 @@ class PublisherModule {
468
481
 
469
482
  // Record the log file path and local folder immediately so they're accessible
470
483
  // even if the build fails later
471
- await this.updateTaskStatus(task.id, task.status, {
484
+ await this.updateTaskStatus(task.id, 'building', {
472
485
  build_output_path: logFile,
473
486
  local_folder: taskDir
474
487
  });
@@ -489,16 +502,9 @@ class PublisherModule {
489
502
  }
490
503
 
491
504
  async createTaskDirectory(taskDir) {
492
- const rimraf = require('rimraf');
493
-
494
505
  // Remove existing directory if it exists
495
506
  if (fs.existsSync(taskDir)) {
496
- await new Promise((resolve, reject) => {
497
- rimraf(taskDir, (err) => {
498
- if (err) reject(err);
499
- else resolve();
500
- });
501
- });
507
+ await require('fs').promises.rm(taskDir, { recursive: true, force: true });
502
508
  }
503
509
 
504
510
  // Create fresh directory
@@ -1108,7 +1114,8 @@ class PublisherModule {
1108
1114
  content += '<a href="/publisher/tasks/' + task.id + '/history" class="btn btn-sm btn-outline-secondary me-1">History</a>';
1109
1115
 
1110
1116
  if (task.status === 'waiting for approval') {
1111
- content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
1117
+ content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Log</a>';
1118
+ content += '<a href="/publisher/tasks/' + task.id + '/qa-files/index.html" class="btn btn-sm btn-outline-secondary me-1">View IG</a>';
1112
1119
  content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
1113
1120
  if (canApprove) {
1114
1121
  content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">';
@@ -1116,9 +1123,12 @@ class PublisherModule {
1116
1123
  content += '</form>';
1117
1124
  }
1118
1125
  } else {
1119
- if (task.build_output_path) {
1126
+ if (task.build_output_path || task.local_folder) {
1120
1127
  content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
1121
1128
  }
1129
+ if (task.announcement) {
1130
+ content += '<a href="/publisher/tasks/' + task.id + '/history" class="btn btn-sm btn-outline-success me-1">📢 Announcement</a>';
1131
+ }
1122
1132
  if (task.failure_reason) {
1123
1133
  content += '<span class="text-danger small me-1">' + escape(task.failure_reason) + '</span>';
1124
1134
  }
@@ -1130,6 +1140,12 @@ class PublisherModule {
1130
1140
  content += '</form>';
1131
1141
  }
1132
1142
 
1143
+ if (req.session.userId && task.status === 'failed') {
1144
+ content += ' <form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;">';
1145
+ content += '<button type="submit" class="btn btn-sm btn-warning">Retry</button>';
1146
+ content += '</form>';
1147
+ }
1148
+
1133
1149
  content += '</td>';
1134
1150
  content += '</tr>';
1135
1151
  }
@@ -1272,15 +1288,11 @@ class PublisherModule {
1272
1288
 
1273
1289
  // Remove build output directory
1274
1290
  if (task.local_folder && fs.existsSync(task.local_folder)) {
1275
- const rimraf = require('rimraf');
1276
- await new Promise((resolve) => {
1277
- rimraf(task.local_folder, (err) => {
1278
- if (err) {
1279
- this.logger.warn('Failed to remove task directory ' + task.local_folder + ': ' + err.message);
1280
- }
1281
- resolve(); // Continue even if directory removal fails
1282
- });
1283
- });
1291
+ try {
1292
+ await require('fs').promises.rm(task.local_folder, { recursive: true, force: true });
1293
+ } catch (err) {
1294
+ this.logger.warn('Failed to remove task directory ' + task.local_folder + ': ' + err.message);
1295
+ }
1284
1296
  }
1285
1297
 
1286
1298
  // Delete task logs
@@ -1304,13 +1316,44 @@ class PublisherModule {
1304
1316
  res.redirect('/publisher/tasks');
1305
1317
  } catch (error) {
1306
1318
  this.logger.error('Error deleting task:', error);
1307
- res.status(500).send('Failed to delete task');
1319
+ res.status(500).send('Failed to delete task: ' + error.message);
1308
1320
  }
1309
1321
  } finally {
1310
1322
  this.stats.countRequest('delete-task', Date.now() - start);
1311
1323
  }
1312
1324
  }
1313
1325
 
1326
+ async retryTask(req, res) {
1327
+ const start = Date.now();
1328
+ try {
1329
+ try {
1330
+ const taskId = req.params.id;
1331
+ const task = await this.getTask(taskId);
1332
+ if (!task) return res.status(404).send('Task not found');
1333
+ if (task.status !== 'failed') return res.status(400).send('Only failed tasks can be retried');
1334
+ const canQueue = await this.userCanQueue(req.session.userId, task.website_id);
1335
+ if (!canQueue) return res.status(403).send('You do not have permission to queue tasks for this website');
1336
+ const existingTask = await this.findActiveTask(task.npm_package_id, task.version);
1337
+ if (existingTask) return res.status(400).send('An active task for this package and version is already in progress.');
1338
+ const newTaskId = await new Promise((resolve, reject) => {
1339
+ this.db.run(
1340
+ 'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
1341
+ [req.session.userId, task.website_id, task.github_org, task.github_repo, task.git_branch, task.npm_package_id, task.version],
1342
+ function (err) { if (err) reject(err); else resolve(this.lastID); }
1343
+ );
1344
+ });
1345
+ this.logUserAction(req.session.userId, 'retry_task', newTaskId.toString(), req.ip);
1346
+ this.logger.info('Task retried: new ID=' + newTaskId + ' from task #' + taskId);
1347
+ res.redirect('/publisher/tasks/' + newTaskId + '/history');
1348
+ } catch (error) {
1349
+ this.logger.error('Error retrying task:', error);
1350
+ res.status(500).send('Failed to retry task: ' + error.message);
1351
+ }
1352
+ } finally {
1353
+ this.stats.countRequest('retry-task', Date.now() - start);
1354
+ }
1355
+ }
1356
+
1314
1357
  async getTaskOutput(req, res) {
1315
1358
  const start = Date.now();
1316
1359
  try {
@@ -1336,6 +1379,19 @@ class PublisherModule {
1336
1379
  }
1337
1380
  }
1338
1381
 
1382
+ // Get publication log if available
1383
+ let publishLog = '';
1384
+ if (task.local_folder) {
1385
+ const publishLogPath = path.join(task.local_folder, 'publication.log');
1386
+ if (fs.existsSync(publishLogPath)) {
1387
+ try {
1388
+ publishLog = fs.readFileSync(publishLogPath, 'utf8');
1389
+ } catch (error) {
1390
+ publishLog = 'Error reading publication log: ' + error.message;
1391
+ }
1392
+ }
1393
+ }
1394
+
1339
1395
  if (req.headers.accept && req.headers.accept.includes('text/html')) {
1340
1396
  const htmlServer = require('../library/html-server');
1341
1397
  let content = '<h3>Task Output: #' + task.id + ' - ' + task.npm_package_id + '#' + task.version + '</h3>';
@@ -1364,6 +1420,13 @@ class PublisherModule {
1364
1420
  content += '</div>';
1365
1421
  }
1366
1422
 
1423
+ // Announcement section
1424
+ if (task.announcement) {
1425
+ content += '<h4>Announcement</h4>';
1426
+ content += '<button onclick="navigator.clipboard.writeText(document.getElementById(\'announcement-text\').innerText)" class="btn btn-sm btn-outline-secondary mb-2">Copy to clipboard</button>';
1427
+ content += '<div id="announcement-text" class="output-viewer" style="white-space: pre-wrap;">' + escape(task.announcement) + '</div>';
1428
+ }
1429
+
1367
1430
  // Build log section
1368
1431
  if (buildLog) {
1369
1432
  content += '<h4>Build Log</h4>';
@@ -1373,6 +1436,15 @@ class PublisherModule {
1373
1436
  content += '<p><em>Build in progress... Log will appear when available.</em></p>';
1374
1437
  }
1375
1438
 
1439
+ // Publication log section
1440
+ if (publishLog) {
1441
+ content += '<h4>Publication Log</h4>';
1442
+ content += '<div class="output-viewer">' + escape(publishLog) + '</div>';
1443
+ } else if (task.status === 'publishing') {
1444
+ content += '<h4>Publication Log</h4>';
1445
+ content += '<p><em>Publication in progress... Log will appear when available.</em></p>';
1446
+ }
1447
+
1376
1448
  content += '<div class="mt-3"><a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a></div>';
1377
1449
 
1378
1450
  const html = htmlServer.renderPage('publisher', 'Task Output - FHIR Publisher', content, {
@@ -1410,6 +1482,11 @@ class PublisherModule {
1410
1482
  output += buildLog;
1411
1483
  }
1412
1484
 
1485
+ if (publishLog) {
1486
+ output += '\n--- Publication Log ---\n';
1487
+ output += publishLog;
1488
+ }
1489
+
1413
1490
  res.setHeader('Content-Type', 'text/plain');
1414
1491
  res.send(output);
1415
1492
  }
@@ -1478,8 +1555,11 @@ class PublisherModule {
1478
1555
  // Announcement section (for completed publications)
1479
1556
  if (task.announcement) {
1480
1557
  content += '<div class="card mb-4"><div class="card-body">';
1481
- content += '<h5>Announcement</h5>';
1482
- content += '<pre class="mb-0" style="white-space: pre-wrap;">' + escape(task.announcement) + '</pre>';
1558
+ content += '<div class="d-flex justify-content-between align-items-center mb-2">';
1559
+ content += '<h5 class="mb-0">Announcement</h5>';
1560
+ content += '<button onclick="navigator.clipboard.writeText(document.getElementById(\'hist-announcement\').innerText)" class="btn btn-sm btn-outline-secondary">Copy to clipboard</button>';
1561
+ content += '</div>';
1562
+ content += '<pre id="hist-announcement" class="mb-0" style="white-space: pre-wrap;">' + escape(task.announcement) + '</pre>';
1483
1563
  content += '</div></div>';
1484
1564
  }
1485
1565
 
@@ -1578,12 +1658,22 @@ class PublisherModule {
1578
1658
 
1579
1659
  // Links at the bottom
1580
1660
  content += '<div class="mt-3">';
1581
- if (task.build_output_path) {
1582
- content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-outline-info me-2">View Build Output</a>';
1661
+ if (task.build_output_path || task.local_folder) {
1662
+ content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-outline-info me-2">View Output</a>';
1583
1663
  }
1584
1664
  if (task.status === 'waiting for approval') {
1585
1665
  content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
1586
1666
  }
1667
+ if (req.session.userId && task.status === 'failed') {
1668
+ content += '<form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;" class="me-2">';
1669
+ content += '<button type="submit" class="btn btn-warning">Retry</button>';
1670
+ content += '</form>';
1671
+ }
1672
+ if (req.session.isAdmin && task.status === 'failed') {
1673
+ content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" class="me-2" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">';
1674
+ content += '<button type="submit" class="btn btn-danger">Delete</button>';
1675
+ content += '</form>';
1676
+ }
1587
1677
  content += '<a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a>';
1588
1678
  content += '</div>';
1589
1679