fhirsmith 0.7.4 → 0.7.6

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 CHANGED
@@ -5,6 +5,46 @@ 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.7.6] - 2026-03-25
9
+
10
+ ### Added
11
+
12
+ - Dashboard endpoint (see dashboard.html)
13
+ - Initial cs-api documentation
14
+
15
+ ### Changed
16
+
17
+ - Update package crawler to support archived feed entries
18
+
19
+ ### Fixed
20
+
21
+ - OCL improvements:
22
+ - Improve multilingual support and caching for non-OCL expansions
23
+ - cache compose instead of pre-built expansions
24
+ - Fix ConceptMap rendering
25
+ - Ongoing on work on publishing module
26
+ - Tidy up tx-reg to prevent hanging
27
+
28
+ ### Tx Conformance Statement
29
+
30
+ FHIRsmith passed all 1464 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.3)
31
+
32
+ ## [v0.7.5] - 2026-03-19
33
+
34
+ ### Changed
35
+
36
+ - Support ignoring code systems when loading, and ban urn:iso:std:iso:3166#20210120 for tx.fhir.org
37
+
38
+ ### Fixed
39
+
40
+ - Fix handling of user defined codes for country codes
41
+ - Fix version bug when loading supplements
42
+ - FHIRsmith passed all 1460 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1-SNAPSHOT, runner v6.9.0)
43
+
44
+ ### Tx Conformance Statement
45
+
46
+ FHIRsmith passed all 1452 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1-SNAPSHOT, runner v6.9.0)
47
+
8
48
  ## [v0.7.4] - 2026-03-19
9
49
 
10
50
  ### 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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
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();
@@ -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)
@@ -466,6 +479,13 @@ class PublisherModule {
466
479
  // Step 1: Create/scrub task directory
467
480
  await this.createTaskDirectory(taskDir);
468
481
 
482
+ // Record the log file path and local folder immediately so they're accessible
483
+ // even if the build fails later
484
+ await this.updateTaskStatus(task.id, task.status, {
485
+ build_output_path: logFile,
486
+ local_folder: taskDir
487
+ });
488
+
469
489
  // Step 2: Download latest publisher
470
490
  const publisherJar = await this.downloadPublisher(taskDir, task.id);
471
491
 
@@ -478,26 +498,13 @@ class PublisherModule {
478
498
  // Step 5: Verify package-id and version match the task
479
499
  await this.verifyBuildOutput(task, draftDir);
480
500
 
481
- // Update task with build output path
482
- await this.updateTaskStatus(task.id, task.status, {
483
- build_output_path: logFile,
484
- local_folder: taskDir
485
- });
486
-
487
501
  this.logger.info('Draft build completed for ' + task.npm_package_id + '#' + task.version);
488
502
  }
489
503
 
490
504
  async createTaskDirectory(taskDir) {
491
- const rimraf = require('rimraf');
492
-
493
505
  // Remove existing directory if it exists
494
506
  if (fs.existsSync(taskDir)) {
495
- await new Promise((resolve, reject) => {
496
- rimraf(taskDir, (err) => {
497
- if (err) reject(err);
498
- else resolve();
499
- });
500
- });
507
+ await require('fs').promises.rm(taskDir, { recursive: true, force: true });
501
508
  }
502
509
 
503
510
  // Create fresh directory
@@ -1107,7 +1114,8 @@ class PublisherModule {
1107
1114
  content += '<a href="/publisher/tasks/' + task.id + '/history" class="btn btn-sm btn-outline-secondary me-1">History</a>';
1108
1115
 
1109
1116
  if (task.status === 'waiting for approval') {
1110
- 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>';
1111
1119
  content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
1112
1120
  if (canApprove) {
1113
1121
  content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">';
@@ -1115,9 +1123,12 @@ class PublisherModule {
1115
1123
  content += '</form>';
1116
1124
  }
1117
1125
  } else {
1118
- if (task.build_output_path) {
1126
+ if (task.build_output_path || task.local_folder) {
1119
1127
  content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
1120
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
+ }
1121
1132
  if (task.failure_reason) {
1122
1133
  content += '<span class="text-danger small me-1">' + escape(task.failure_reason) + '</span>';
1123
1134
  }
@@ -1129,6 +1140,12 @@ class PublisherModule {
1129
1140
  content += '</form>';
1130
1141
  }
1131
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
+
1132
1149
  content += '</td>';
1133
1150
  content += '</tr>';
1134
1151
  }
@@ -1271,15 +1288,11 @@ class PublisherModule {
1271
1288
 
1272
1289
  // Remove build output directory
1273
1290
  if (task.local_folder && fs.existsSync(task.local_folder)) {
1274
- const rimraf = require('rimraf');
1275
- await new Promise((resolve) => {
1276
- rimraf(task.local_folder, (err) => {
1277
- if (err) {
1278
- this.logger.warn('Failed to remove task directory ' + task.local_folder + ': ' + err.message);
1279
- }
1280
- resolve(); // Continue even if directory removal fails
1281
- });
1282
- });
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
+ }
1283
1296
  }
1284
1297
 
1285
1298
  // Delete task logs
@@ -1303,13 +1316,44 @@ class PublisherModule {
1303
1316
  res.redirect('/publisher/tasks');
1304
1317
  } catch (error) {
1305
1318
  this.logger.error('Error deleting task:', error);
1306
- res.status(500).send('Failed to delete task');
1319
+ res.status(500).send('Failed to delete task: ' + error.message);
1307
1320
  }
1308
1321
  } finally {
1309
1322
  this.stats.countRequest('delete-task', Date.now() - start);
1310
1323
  }
1311
1324
  }
1312
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
+
1313
1357
  async getTaskOutput(req, res) {
1314
1358
  const start = Date.now();
1315
1359
  try {
@@ -1335,6 +1379,19 @@ class PublisherModule {
1335
1379
  }
1336
1380
  }
1337
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
+
1338
1395
  if (req.headers.accept && req.headers.accept.includes('text/html')) {
1339
1396
  const htmlServer = require('../library/html-server');
1340
1397
  let content = '<h3>Task Output: #' + task.id + ' - ' + task.npm_package_id + '#' + task.version + '</h3>';
@@ -1363,6 +1420,13 @@ class PublisherModule {
1363
1420
  content += '</div>';
1364
1421
  }
1365
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
+
1366
1430
  // Build log section
1367
1431
  if (buildLog) {
1368
1432
  content += '<h4>Build Log</h4>';
@@ -1372,6 +1436,15 @@ class PublisherModule {
1372
1436
  content += '<p><em>Build in progress... Log will appear when available.</em></p>';
1373
1437
  }
1374
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
+
1375
1448
  content += '<div class="mt-3"><a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a></div>';
1376
1449
 
1377
1450
  const html = htmlServer.renderPage('publisher', 'Task Output - FHIR Publisher', content, {
@@ -1409,6 +1482,11 @@ class PublisherModule {
1409
1482
  output += buildLog;
1410
1483
  }
1411
1484
 
1485
+ if (publishLog) {
1486
+ output += '\n--- Publication Log ---\n';
1487
+ output += publishLog;
1488
+ }
1489
+
1412
1490
  res.setHeader('Content-Type', 'text/plain');
1413
1491
  res.send(output);
1414
1492
  }
@@ -1477,8 +1555,11 @@ class PublisherModule {
1477
1555
  // Announcement section (for completed publications)
1478
1556
  if (task.announcement) {
1479
1557
  content += '<div class="card mb-4"><div class="card-body">';
1480
- content += '<h5>Announcement</h5>';
1481
- 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>';
1482
1563
  content += '</div></div>';
1483
1564
  }
1484
1565
 
@@ -1577,12 +1658,22 @@ class PublisherModule {
1577
1658
 
1578
1659
  // Links at the bottom
1579
1660
  content += '<div class="mt-3">';
1580
- if (task.build_output_path) {
1581
- 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>';
1582
1663
  }
1583
1664
  if (task.status === 'waiting for approval') {
1584
1665
  content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
1585
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
+ }
1586
1677
  content += '<a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a>';
1587
1678
  content += '</div>';
1588
1679