@ubercode/dcmtk 0.9.4 → 0.10.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.
package/dist/servers.js CHANGED
@@ -7,10 +7,10 @@ import * as path from 'path';
7
7
  import { join, normalize } from 'path';
8
8
  import { existsSync } from 'fs';
9
9
  import * as net from 'net';
10
- import * as fs from 'fs/promises';
11
- import { copyFile, unlink, stat, rm, mkdtemp } from 'fs/promises';
12
10
  import * as os from 'os';
13
11
  import { tmpdir } from 'os';
12
+ import { setTimeout as setTimeout$1 } from 'timers/promises';
13
+ import { mkdir, rm, rename, copyFile, unlink, stat, mkdtemp } from 'fs/promises';
14
14
  import { XMLParser } from 'fast-xml-parser';
15
15
 
16
16
  var __defProp = Object.defineProperty;
@@ -1249,6 +1249,44 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
1249
1249
  signal.addEventListener("abort", this.abortHandler, { once: true });
1250
1250
  }
1251
1251
  };
1252
+ async function ensureDirectory(dirPath) {
1253
+ try {
1254
+ await mkdir(dirPath, { recursive: true });
1255
+ return ok(void 0);
1256
+ } catch (e) {
1257
+ const msg = e instanceof Error ? e.message : String(e);
1258
+ return err(new Error(`Failed to create directory ${dirPath}: ${msg}`));
1259
+ }
1260
+ }
1261
+ async function moveFile(src, dest) {
1262
+ try {
1263
+ await rename(src, dest);
1264
+ return ok(void 0);
1265
+ } catch {
1266
+ try {
1267
+ await copyFile(src, dest);
1268
+ await unlink(src);
1269
+ return ok(void 0);
1270
+ } catch (e) {
1271
+ const msg = e instanceof Error ? e.message : String(e);
1272
+ return err(new Error(`Failed to move file ${src} \u2192 ${dest}: ${msg}`));
1273
+ }
1274
+ }
1275
+ }
1276
+ async function statFileSafe(filePath) {
1277
+ try {
1278
+ const s = await stat(filePath);
1279
+ return s.size;
1280
+ } catch {
1281
+ return 0;
1282
+ }
1283
+ }
1284
+ async function removeDirSafe(dirPath) {
1285
+ try {
1286
+ await rm(dirPath, { recursive: true, force: true });
1287
+ } catch {
1288
+ }
1289
+ }
1252
1290
  function createDicomTag(input) {
1253
1291
  if (!DICOM_TAG_PATTERN.test(input)) {
1254
1292
  return err(new Error(`Invalid DICOM tag: "${input}". Expected format (XXXX,XXXX) where X is a hex digit`));
@@ -1312,227 +1350,6 @@ function tagPathToSegments(path2) {
1312
1350
  return segments;
1313
1351
  }
1314
1352
 
1315
- // src/dicom/ChangeSet.ts
1316
- var ERASE_PRIVATE_SENTINEL = "__ERASE_PRIVATE__";
1317
- function isControlChar(code) {
1318
- if (code <= 9) return true;
1319
- if (code === 11 || code === 12) return true;
1320
- if (code >= 14 && code <= 31) return true;
1321
- return code === 127;
1322
- }
1323
- function sanitizeValue(value) {
1324
- let result = "";
1325
- for (let i = 0; i < value.length; i++) {
1326
- const code = value.charCodeAt(i);
1327
- if (!isControlChar(code)) {
1328
- result += value[i];
1329
- }
1330
- }
1331
- return result;
1332
- }
1333
- function buildMergedModifications(base, other, erasures) {
1334
- const merged = new Map(base);
1335
- for (const [key, value] of other) {
1336
- merged.set(key, value);
1337
- }
1338
- for (const key of erasures) {
1339
- merged.delete(key);
1340
- }
1341
- return merged;
1342
- }
1343
- function applyBatchEntries(initial, entries) {
1344
- const keys = Object.keys(entries);
1345
- let cs = initial;
1346
- for (let i = 0; i < keys.length; i++) {
1347
- const key = keys[i];
1348
- if (key === void 0) continue;
1349
- const value = entries[key];
1350
- if (value === void 0) continue;
1351
- cs = cs.setTag(key, value);
1352
- }
1353
- return cs;
1354
- }
1355
- var ChangeSet = class _ChangeSet {
1356
- constructor(mods, erasures) {
1357
- __publicField(this, "mods");
1358
- __publicField(this, "erased");
1359
- this.mods = mods;
1360
- this.erased = erasures;
1361
- }
1362
- /** Creates an empty ChangeSet with no modifications or erasures. */
1363
- static empty() {
1364
- return new _ChangeSet(/* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
1365
- }
1366
- /**
1367
- * Sets a tag value, returning a new ChangeSet.
1368
- *
1369
- * Control characters (except LF/CR) are stripped from the value.
1370
- * If the tag was previously erased, it is removed from the erasure set.
1371
- *
1372
- * @param path - The DICOM tag path to set (e.g. `'(0010,0010)'`)
1373
- * @param value - The new value for the tag
1374
- * @returns A new ChangeSet with the modification applied
1375
- * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1376
- */
1377
- setTag(path2, value) {
1378
- const totalOps = this.mods.size + this.erased.size;
1379
- if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1380
- throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1381
- }
1382
- tagPathToSegments(path2);
1383
- const sanitized = sanitizeValue(value);
1384
- const newMods = new Map(this.mods);
1385
- newMods.set(path2, sanitized);
1386
- const newErasures = new Set(this.erased);
1387
- newErasures.delete(path2);
1388
- return new _ChangeSet(newMods, newErasures);
1389
- }
1390
- /**
1391
- * Marks a tag for erasure, returning a new ChangeSet.
1392
- *
1393
- * If the tag was previously set, the modification is removed.
1394
- *
1395
- * @param path - The DICOM tag path to erase (e.g. `'(0010,0010)'`)
1396
- * @returns A new ChangeSet with the erasure applied
1397
- * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1398
- */
1399
- eraseTag(path2) {
1400
- const totalOps = this.mods.size + this.erased.size;
1401
- if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1402
- throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1403
- }
1404
- tagPathToSegments(path2);
1405
- const newMods = new Map(this.mods);
1406
- newMods.delete(path2);
1407
- const newErasures = new Set(this.erased);
1408
- newErasures.add(path2);
1409
- return new _ChangeSet(newMods, newErasures);
1410
- }
1411
- /**
1412
- * Marks all private tags for erasure, returning a new ChangeSet.
1413
- *
1414
- * @returns A new ChangeSet with the erase-private flag set
1415
- * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1416
- */
1417
- erasePrivateTags() {
1418
- const totalOps = this.mods.size + this.erased.size;
1419
- if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1420
- throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1421
- }
1422
- const newErasures = new Set(this.erased);
1423
- newErasures.add(ERASE_PRIVATE_SENTINEL);
1424
- return new _ChangeSet(new Map(this.mods), newErasures);
1425
- }
1426
- // -----------------------------------------------------------------------
1427
- // Convenience setters for common DICOM tags
1428
- // -----------------------------------------------------------------------
1429
- /** Sets Patient's Name (0010,0010). */
1430
- setPatientName(value) {
1431
- return this.setTag("(0010,0010)", value);
1432
- }
1433
- /** Sets Patient ID (0010,0020). */
1434
- setPatientID(value) {
1435
- return this.setTag("(0010,0020)", value);
1436
- }
1437
- /** Sets Study Date (0008,0020). */
1438
- setStudyDate(value) {
1439
- return this.setTag("(0008,0020)", value);
1440
- }
1441
- /** Sets Modality (0008,0060). */
1442
- setModality(value) {
1443
- return this.setTag("(0008,0060)", value);
1444
- }
1445
- /** Sets Accession Number (0008,0050). */
1446
- setAccessionNumber(value) {
1447
- return this.setTag("(0008,0050)", value);
1448
- }
1449
- /** Sets Study Description (0008,1030). */
1450
- setStudyDescription(value) {
1451
- return this.setTag("(0008,1030)", value);
1452
- }
1453
- /** Sets Series Description (0008,103E). */
1454
- setSeriesDescription(value) {
1455
- return this.setTag("(0008,103E)", value);
1456
- }
1457
- /** Sets Institution Name (0008,0080). */
1458
- setInstitutionName(value) {
1459
- return this.setTag("(0008,0080)", value);
1460
- }
1461
- /**
1462
- * Sets multiple tags at once, returning a new ChangeSet.
1463
- *
1464
- * @param entries - A record of tag path → value pairs
1465
- * @returns A new ChangeSet with all modifications applied
1466
- */
1467
- setBatch(entries) {
1468
- return applyBatchEntries(this, entries);
1469
- }
1470
- /** All pending tag modifications as a readonly map of path → value. */
1471
- get modifications() {
1472
- return this.mods;
1473
- }
1474
- /** All pending tag erasures as a readonly set of paths. */
1475
- get erasures() {
1476
- return this.erased;
1477
- }
1478
- /** Total number of operations (modifications + erasures) in this ChangeSet. */
1479
- get operationCount() {
1480
- return this.mods.size + this.erased.size;
1481
- }
1482
- /** Whether the ChangeSet has no modifications and no erasures. */
1483
- get isEmpty() {
1484
- return this.mods.size === 0 && this.erased.size === 0;
1485
- }
1486
- /** Whether the erase-all-private-tags flag is set. */
1487
- get erasePrivate() {
1488
- return this.erased.has(ERASE_PRIVATE_SENTINEL);
1489
- }
1490
- /**
1491
- * Merges another ChangeSet into this one, returning a new ChangeSet.
1492
- *
1493
- * The `other` ChangeSet wins on conflicts: if the same tag is modified in both,
1494
- * `other`'s value is used. Erasures from both sets are unioned. An erasure in
1495
- * `other` removes a modification from `base`.
1496
- *
1497
- * @param other - The ChangeSet to merge in
1498
- * @returns A new ChangeSet with merged modifications and erasures
1499
- */
1500
- merge(other) {
1501
- const mergedErasures = /* @__PURE__ */ new Set([...this.erased, ...other.erased]);
1502
- const mergedMods = buildMergedModifications(this.mods, other.mods, mergedErasures);
1503
- return new _ChangeSet(mergedMods, mergedErasures);
1504
- }
1505
- /**
1506
- * Converts modifications to dcmodify-compatible TagModification array.
1507
- *
1508
- * @returns A readonly array of TagModification objects
1509
- */
1510
- toModifications() {
1511
- const result = [];
1512
- for (const [tag, value] of this.mods) {
1513
- result.push({ tag, value });
1514
- }
1515
- return result;
1516
- }
1517
- /**
1518
- * Converts erasures to dcmodify-compatible argument strings.
1519
- *
1520
- * The erase-private sentinel is excluded — use {@link erasePrivate} to check
1521
- * whether `-ep` should be passed.
1522
- *
1523
- * @returns A readonly array of tag path strings for `-e` arguments
1524
- */
1525
- toErasureArgs() {
1526
- const result = [];
1527
- for (const path2 of this.erased) {
1528
- if (path2 !== ERASE_PRIVATE_SENTINEL) {
1529
- result.push(path2);
1530
- }
1531
- }
1532
- return result;
1533
- }
1534
- };
1535
-
1536
1353
  // src/dicom/walkTags.ts
1537
1354
  var DEFAULT_MAX_DEPTH = 16;
1538
1355
  function walkTags(data, options) {
@@ -1876,83 +1693,304 @@ var DicomDataset = class _DicomDataset {
1876
1693
  } catch (e) {
1877
1694
  return err(stderr(e));
1878
1695
  }
1879
- return traversePath(this.data, segments);
1696
+ return traversePath(this.data, segments);
1697
+ }
1698
+ /**
1699
+ * Finds all values matching a path with wildcard `[*]` indices.
1700
+ *
1701
+ * Traverses all items in wildcard sequence positions using an iterative BFS queue.
1702
+ * The traversal is bounded to {@link MAX_TRAVERSAL_DEPTH} * 100 iterations (5 000).
1703
+ * For extremely large datasets this may truncate results silently; callers
1704
+ * needing completeness guarantees should verify dataset size independently.
1705
+ *
1706
+ * @param path - A branded DicomTagPath, e.g. `(0040,A730)[*].(0040,A160)`
1707
+ * @returns A readonly array of all matching values (may be empty)
1708
+ */
1709
+ findValues(path2) {
1710
+ let segments;
1711
+ try {
1712
+ segments = tagPathToSegments(path2);
1713
+ } catch {
1714
+ return [];
1715
+ }
1716
+ const result = collectWildcard(this.data, segments);
1717
+ return result.values;
1718
+ }
1719
+ /**
1720
+ * Walks all tags in this dataset, optionally filtering by VR and recursing into sequences.
1721
+ *
1722
+ * @param options - Optional VR filter and max depth
1723
+ * @returns A readonly array of all matching tag entries
1724
+ */
1725
+ walkTags(options) {
1726
+ return walkTags(this.data, options);
1727
+ }
1728
+ // -----------------------------------------------------------------------
1729
+ // Convenience readonly getters
1730
+ // -----------------------------------------------------------------------
1731
+ /** Accession Number (0008,0050). */
1732
+ get accession() {
1733
+ return this.getString(TAGS.AccessionNumber);
1734
+ }
1735
+ /** Patient's Name (0010,0010). */
1736
+ get patientName() {
1737
+ return this.getString(TAGS.PatientName);
1738
+ }
1739
+ /** Patient ID (0010,0020). */
1740
+ get patientID() {
1741
+ return this.getString(TAGS.PatientID);
1742
+ }
1743
+ /** Study Date (0008,0020). */
1744
+ get studyDate() {
1745
+ return this.getString(TAGS.StudyDate);
1746
+ }
1747
+ /** Modality (0008,0060). */
1748
+ get modality() {
1749
+ return this.getString(TAGS.Modality);
1750
+ }
1751
+ /** SOP Class UID (0008,0016) as a branded SOPClassUID, or undefined if missing/invalid. */
1752
+ get sopClassUID() {
1753
+ const uid = this.getString(TAGS.SOPClassUID);
1754
+ if (uid.length === 0) return void 0;
1755
+ const result = createSOPClassUID(uid);
1756
+ return result.ok ? result.value : void 0;
1757
+ }
1758
+ /** Study Instance UID (0020,000D). */
1759
+ get studyInstanceUID() {
1760
+ return this.getString(TAGS.StudyInstanceUID);
1761
+ }
1762
+ /** Series Instance UID (0020,000E). */
1763
+ get seriesInstanceUID() {
1764
+ return this.getString(TAGS.SeriesInstanceUID);
1765
+ }
1766
+ /** SOP Instance UID (0008,0018). */
1767
+ get sopInstanceUID() {
1768
+ return this.getString(TAGS.SOPInstanceUID);
1769
+ }
1770
+ /** Transfer Syntax UID (0002,0010). */
1771
+ get transferSyntaxUID() {
1772
+ return this.getString(TAGS.TransferSyntaxUID);
1773
+ }
1774
+ };
1775
+
1776
+ // src/dicom/ChangeSet.ts
1777
+ var ERASE_PRIVATE_SENTINEL = "__ERASE_PRIVATE__";
1778
+ function isControlChar(code) {
1779
+ if (code <= 9) return true;
1780
+ if (code === 11 || code === 12) return true;
1781
+ if (code >= 14 && code <= 31) return true;
1782
+ return code === 127;
1783
+ }
1784
+ function sanitizeValue(value) {
1785
+ let result = "";
1786
+ for (let i = 0; i < value.length; i++) {
1787
+ const code = value.charCodeAt(i);
1788
+ if (!isControlChar(code)) {
1789
+ result += value[i];
1790
+ }
1791
+ }
1792
+ return result;
1793
+ }
1794
+ function buildMergedModifications(base, other, erasures) {
1795
+ const merged = new Map(base);
1796
+ for (const [key, value] of other) {
1797
+ merged.set(key, value);
1798
+ }
1799
+ for (const key of erasures) {
1800
+ merged.delete(key);
1801
+ }
1802
+ return merged;
1803
+ }
1804
+ function applyBatchEntries(initial, entries) {
1805
+ const keys = Object.keys(entries);
1806
+ let cs = initial;
1807
+ for (let i = 0; i < keys.length; i++) {
1808
+ const key = keys[i];
1809
+ if (key === void 0) continue;
1810
+ const value = entries[key];
1811
+ if (value === void 0) continue;
1812
+ cs = cs.setTag(key, value);
1813
+ }
1814
+ return cs;
1815
+ }
1816
+ var ChangeSet = class _ChangeSet {
1817
+ constructor(mods, erasures) {
1818
+ __publicField(this, "mods");
1819
+ __publicField(this, "erased");
1820
+ this.mods = mods;
1821
+ this.erased = erasures;
1822
+ }
1823
+ /** Creates an empty ChangeSet with no modifications or erasures. */
1824
+ static empty() {
1825
+ return new _ChangeSet(/* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
1826
+ }
1827
+ /**
1828
+ * Sets a tag value, returning a new ChangeSet.
1829
+ *
1830
+ * Control characters (except LF/CR) are stripped from the value.
1831
+ * If the tag was previously erased, it is removed from the erasure set.
1832
+ *
1833
+ * @param path - The DICOM tag path to set (e.g. `'(0010,0010)'`)
1834
+ * @param value - The new value for the tag
1835
+ * @returns A new ChangeSet with the modification applied
1836
+ * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1837
+ */
1838
+ setTag(path2, value) {
1839
+ const totalOps = this.mods.size + this.erased.size;
1840
+ if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1841
+ throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1842
+ }
1843
+ tagPathToSegments(path2);
1844
+ const sanitized = sanitizeValue(value);
1845
+ const newMods = new Map(this.mods);
1846
+ newMods.set(path2, sanitized);
1847
+ const newErasures = new Set(this.erased);
1848
+ newErasures.delete(path2);
1849
+ return new _ChangeSet(newMods, newErasures);
1880
1850
  }
1881
1851
  /**
1882
- * Finds all values matching a path with wildcard `[*]` indices.
1852
+ * Marks a tag for erasure, returning a new ChangeSet.
1883
1853
  *
1884
- * Traverses all items in wildcard sequence positions using an iterative BFS queue.
1885
- * The traversal is bounded to {@link MAX_TRAVERSAL_DEPTH} * 100 iterations (5 000).
1886
- * For extremely large datasets this may truncate results silently; callers
1887
- * needing completeness guarantees should verify dataset size independently.
1854
+ * If the tag was previously set, the modification is removed.
1888
1855
  *
1889
- * @param path - A branded DicomTagPath, e.g. `(0040,A730)[*].(0040,A160)`
1890
- * @returns A readonly array of all matching values (may be empty)
1856
+ * @param path - The DICOM tag path to erase (e.g. `'(0010,0010)'`)
1857
+ * @returns A new ChangeSet with the erasure applied
1858
+ * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1891
1859
  */
1892
- findValues(path2) {
1893
- let segments;
1894
- try {
1895
- segments = tagPathToSegments(path2);
1896
- } catch {
1897
- return [];
1860
+ eraseTag(path2) {
1861
+ const totalOps = this.mods.size + this.erased.size;
1862
+ if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1863
+ throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1898
1864
  }
1899
- const result = collectWildcard(this.data, segments);
1900
- return result.values;
1865
+ tagPathToSegments(path2);
1866
+ const newMods = new Map(this.mods);
1867
+ newMods.delete(path2);
1868
+ const newErasures = new Set(this.erased);
1869
+ newErasures.add(path2);
1870
+ return new _ChangeSet(newMods, newErasures);
1901
1871
  }
1902
1872
  /**
1903
- * Walks all tags in this dataset, optionally filtering by VR and recursing into sequences.
1873
+ * Marks all private tags for erasure, returning a new ChangeSet.
1904
1874
  *
1905
- * @param options - Optional VR filter and max depth
1906
- * @returns A readonly array of all matching tag entries
1875
+ * @returns A new ChangeSet with the erase-private flag set
1876
+ * @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
1907
1877
  */
1908
- walkTags(options) {
1909
- return walkTags(this.data, options);
1878
+ erasePrivateTags() {
1879
+ const totalOps = this.mods.size + this.erased.size;
1880
+ if (totalOps >= MAX_CHANGESET_OPERATIONS) {
1881
+ throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
1882
+ }
1883
+ const newErasures = new Set(this.erased);
1884
+ newErasures.add(ERASE_PRIVATE_SENTINEL);
1885
+ return new _ChangeSet(new Map(this.mods), newErasures);
1910
1886
  }
1911
1887
  // -----------------------------------------------------------------------
1912
- // Convenience readonly getters
1888
+ // Convenience setters for common DICOM tags
1913
1889
  // -----------------------------------------------------------------------
1914
- /** Accession Number (0008,0050). */
1915
- get accession() {
1916
- return this.getString(TAGS.AccessionNumber);
1890
+ /** Sets Patient's Name (0010,0010). */
1891
+ setPatientName(value) {
1892
+ return this.setTag("(0010,0010)", value);
1917
1893
  }
1918
- /** Patient's Name (0010,0010). */
1919
- get patientName() {
1920
- return this.getString(TAGS.PatientName);
1894
+ /** Sets Patient ID (0010,0020). */
1895
+ setPatientID(value) {
1896
+ return this.setTag("(0010,0020)", value);
1921
1897
  }
1922
- /** Patient ID (0010,0020). */
1923
- get patientID() {
1924
- return this.getString(TAGS.PatientID);
1898
+ /** Sets Study Date (0008,0020). */
1899
+ setStudyDate(value) {
1900
+ return this.setTag("(0008,0020)", value);
1925
1901
  }
1926
- /** Study Date (0008,0020). */
1927
- get studyDate() {
1928
- return this.getString(TAGS.StudyDate);
1902
+ /** Sets Modality (0008,0060). */
1903
+ setModality(value) {
1904
+ return this.setTag("(0008,0060)", value);
1929
1905
  }
1930
- /** Modality (0008,0060). */
1931
- get modality() {
1932
- return this.getString(TAGS.Modality);
1906
+ /** Sets Accession Number (0008,0050). */
1907
+ setAccessionNumber(value) {
1908
+ return this.setTag("(0008,0050)", value);
1933
1909
  }
1934
- /** SOP Class UID (0008,0016) as a branded SOPClassUID, or undefined if missing/invalid. */
1935
- get sopClassUID() {
1936
- const uid = this.getString(TAGS.SOPClassUID);
1937
- if (uid.length === 0) return void 0;
1938
- const result = createSOPClassUID(uid);
1939
- return result.ok ? result.value : void 0;
1910
+ /** Sets Study Description (0008,1030). */
1911
+ setStudyDescription(value) {
1912
+ return this.setTag("(0008,1030)", value);
1940
1913
  }
1941
- /** Study Instance UID (0020,000D). */
1942
- get studyInstanceUID() {
1943
- return this.getString(TAGS.StudyInstanceUID);
1914
+ /** Sets Series Description (0008,103E). */
1915
+ setSeriesDescription(value) {
1916
+ return this.setTag("(0008,103E)", value);
1944
1917
  }
1945
- /** Series Instance UID (0020,000E). */
1946
- get seriesInstanceUID() {
1947
- return this.getString(TAGS.SeriesInstanceUID);
1918
+ /** Sets Institution Name (0008,0080). */
1919
+ setInstitutionName(value) {
1920
+ return this.setTag("(0008,0080)", value);
1948
1921
  }
1949
- /** SOP Instance UID (0008,0018). */
1950
- get sopInstanceUID() {
1951
- return this.getString(TAGS.SOPInstanceUID);
1922
+ /**
1923
+ * Sets multiple tags at once, returning a new ChangeSet.
1924
+ *
1925
+ * @param entries - A record of tag path → value pairs
1926
+ * @returns A new ChangeSet with all modifications applied
1927
+ */
1928
+ setBatch(entries) {
1929
+ return applyBatchEntries(this, entries);
1952
1930
  }
1953
- /** Transfer Syntax UID (0002,0010). */
1954
- get transferSyntaxUID() {
1955
- return this.getString(TAGS.TransferSyntaxUID);
1931
+ /** All pending tag modifications as a readonly map of path → value. */
1932
+ get modifications() {
1933
+ return this.mods;
1934
+ }
1935
+ /** All pending tag erasures as a readonly set of paths. */
1936
+ get erasures() {
1937
+ return this.erased;
1938
+ }
1939
+ /** Total number of operations (modifications + erasures) in this ChangeSet. */
1940
+ get operationCount() {
1941
+ return this.mods.size + this.erased.size;
1942
+ }
1943
+ /** Whether the ChangeSet has no modifications and no erasures. */
1944
+ get isEmpty() {
1945
+ return this.mods.size === 0 && this.erased.size === 0;
1946
+ }
1947
+ /** Whether the erase-all-private-tags flag is set. */
1948
+ get erasePrivate() {
1949
+ return this.erased.has(ERASE_PRIVATE_SENTINEL);
1950
+ }
1951
+ /**
1952
+ * Merges another ChangeSet into this one, returning a new ChangeSet.
1953
+ *
1954
+ * The `other` ChangeSet wins on conflicts: if the same tag is modified in both,
1955
+ * `other`'s value is used. Erasures from both sets are unioned. An erasure in
1956
+ * `other` removes a modification from `base`.
1957
+ *
1958
+ * @param other - The ChangeSet to merge in
1959
+ * @returns A new ChangeSet with merged modifications and erasures
1960
+ */
1961
+ merge(other) {
1962
+ const mergedErasures = /* @__PURE__ */ new Set([...this.erased, ...other.erased]);
1963
+ const mergedMods = buildMergedModifications(this.mods, other.mods, mergedErasures);
1964
+ return new _ChangeSet(mergedMods, mergedErasures);
1965
+ }
1966
+ /**
1967
+ * Converts modifications to dcmodify-compatible TagModification array.
1968
+ *
1969
+ * @returns A readonly array of TagModification objects
1970
+ */
1971
+ toModifications() {
1972
+ const result = [];
1973
+ for (const [tag, value] of this.mods) {
1974
+ result.push({ tag, value });
1975
+ }
1976
+ return result;
1977
+ }
1978
+ /**
1979
+ * Converts erasures to dcmodify-compatible argument strings.
1980
+ *
1981
+ * The erase-private sentinel is excluded — use {@link erasePrivate} to check
1982
+ * whether `-ep` should be passed.
1983
+ *
1984
+ * @returns A readonly array of tag path strings for `-e` arguments
1985
+ */
1986
+ toErasureArgs() {
1987
+ const result = [];
1988
+ for (const path2 of this.erased) {
1989
+ if (path2 !== ERASE_PRIVATE_SENTINEL) {
1990
+ result.push(path2);
1991
+ }
1992
+ }
1993
+ return result;
1956
1994
  }
1957
1995
  };
1958
1996
  function killTree(pid) {
@@ -2783,6 +2821,102 @@ var DicomReceiverOptionsSchema = z.object({
2783
2821
  }).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
2784
2822
  message: "minPoolSize must be <= maxPoolSize"
2785
2823
  });
2824
+ var Worker = class {
2825
+ constructor(dcmrecv, port, tempDir) {
2826
+ __publicField(this, "dcmrecv");
2827
+ __publicField(this, "port");
2828
+ __publicField(this, "tempDir");
2829
+ __publicField(this, "_state", "idle");
2830
+ __publicField(this, "_context");
2831
+ __publicField(this, "_pending", /* @__PURE__ */ new Set());
2832
+ __publicField(this, "_files", []);
2833
+ __publicField(this, "_fileSizes", []);
2834
+ __publicField(this, "_outputLines", []);
2835
+ __publicField(this, "_remoteSocket");
2836
+ __publicField(this, "_workerSocket");
2837
+ this.dcmrecv = dcmrecv;
2838
+ this.port = port;
2839
+ this.tempDir = tempDir;
2840
+ }
2841
+ /** Current worker state. */
2842
+ get state() {
2843
+ return this._state;
2844
+ }
2845
+ /** Active association context, or undefined when idle. */
2846
+ get context() {
2847
+ return this._context;
2848
+ }
2849
+ /** Files moved to the association directory during the current association. */
2850
+ get files() {
2851
+ return this._files;
2852
+ }
2853
+ /** Byte sizes parallel to files[], for transfer stats. */
2854
+ get fileSizes() {
2855
+ return this._fileSizes;
2856
+ }
2857
+ /** Captured output lines during the current association. */
2858
+ get outputLines() {
2859
+ return this._outputLines;
2860
+ }
2861
+ /** Marks the worker busy with a new association context. */
2862
+ beginAssociation(ctx) {
2863
+ this._state = "busy";
2864
+ this._context = ctx;
2865
+ this._files.length = 0;
2866
+ this._fileSizes.length = 0;
2867
+ this._outputLines.length = 0;
2868
+ this._pending.clear();
2869
+ }
2870
+ /** Returns the worker to idle and clears all association state. */
2871
+ endAssociation() {
2872
+ this._state = "idle";
2873
+ this._context = void 0;
2874
+ this._files.length = 0;
2875
+ this._fileSizes.length = 0;
2876
+ this._outputLines.length = 0;
2877
+ this._pending.clear();
2878
+ this._remoteSocket = void 0;
2879
+ this._workerSocket = void 0;
2880
+ }
2881
+ /** Tracks an in-flight file handling promise; auto-removes on completion. */
2882
+ trackFile(promise) {
2883
+ this._pending.add(promise);
2884
+ void promise.finally(() => {
2885
+ this._pending.delete(promise);
2886
+ });
2887
+ }
2888
+ /** Records a successfully received file path and its size. */
2889
+ recordFile(filePath, size) {
2890
+ this._files.push(filePath);
2891
+ this._fileSizes.push(size);
2892
+ }
2893
+ /** Awaits all in-flight file handling promises. */
2894
+ async drainPendingFiles() {
2895
+ if (this._pending.size > 0) {
2896
+ await Promise.all(this._pending);
2897
+ }
2898
+ }
2899
+ /** Appends a line to the output buffer, respecting the cap. */
2900
+ captureOutput(text) {
2901
+ if (this._outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
2902
+ this._outputLines.push(text);
2903
+ }
2904
+ }
2905
+ /** Stores the remote and worker sockets for later cleanup. */
2906
+ setSockets(remote, worker) {
2907
+ this._remoteSocket = remote;
2908
+ this._workerSocket = worker;
2909
+ }
2910
+ /** Destroys both sockets if they exist and are not already destroyed. */
2911
+ destroySockets() {
2912
+ if (this._remoteSocket !== void 0 && !this._remoteSocket.destroyed) {
2913
+ this._remoteSocket.destroy();
2914
+ }
2915
+ if (this._workerSocket !== void 0 && !this._workerSocket.destroyed) {
2916
+ this._workerSocket.destroy();
2917
+ }
2918
+ }
2919
+ };
2786
2920
  function allocatePort() {
2787
2921
  return new Promise((resolve) => {
2788
2922
  const server = net.createServer();
@@ -2800,6 +2934,13 @@ function allocatePort() {
2800
2934
  });
2801
2935
  });
2802
2936
  }
2937
+ function sumArray(arr) {
2938
+ let total = 0;
2939
+ for (let i = 0; i < arr.length; i++) {
2940
+ total += arr[i] ?? 0;
2941
+ }
2942
+ return total;
2943
+ }
2803
2944
  var DicomReceiver = class _DicomReceiver extends EventEmitter {
2804
2945
  constructor(options) {
2805
2946
  super();
@@ -2814,8 +2955,6 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
2814
2955
  __publicField(this, "stopping", false);
2815
2956
  __publicField(this, "abortHandler");
2816
2957
  this.setMaxListeners(20);
2817
- this.on("error", () => {
2818
- });
2819
2958
  this.options = options;
2820
2959
  this.minPoolSize = options.minPoolSize ?? DEFAULT_MIN_POOL_SIZE;
2821
2960
  this.maxPoolSize = options.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE;
@@ -3023,14 +3162,12 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3023
3162
  this.emit("error", { error: mkdirResult.error });
3024
3163
  return;
3025
3164
  }
3026
- worker.state = "busy";
3027
- worker.associationId = associationId;
3028
- worker.associationDir = associationDir;
3029
- worker.files = [];
3030
- worker.fileSizes = [];
3031
- worker.outputLines = [];
3032
- worker.pendingFiles = [];
3033
- worker.startAt = Date.now();
3165
+ const ctx = {
3166
+ associationId,
3167
+ associationDir,
3168
+ startAt: Date.now()
3169
+ };
3170
+ worker.beginAssociation(ctx);
3034
3171
  this.pipeConnection(worker, remoteSocket);
3035
3172
  void this.replenishPool();
3036
3173
  }
@@ -3040,7 +3177,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3040
3177
  if (idle !== void 0) return idle;
3041
3178
  const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
3042
3179
  for (let i = 0; i < maxRetries; i++) {
3043
- await delay(CONNECTION_RETRY_INTERVAL_MS);
3180
+ await setTimeout$1(CONNECTION_RETRY_INTERVAL_MS);
3044
3181
  const found = this.getIdleWorker();
3045
3182
  if (found !== void 0) return found;
3046
3183
  if (this.stopping) return void 0;
@@ -3057,8 +3194,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3057
3194
  /** Pipes remote socket bidirectionally to the worker's port. */
3058
3195
  pipeConnection(worker, remoteSocket) {
3059
3196
  const workerSocket = net.createConnection({ port: worker.port, host: "127.0.0.1" });
3060
- worker.remoteSocket = remoteSocket;
3061
- worker.workerSocket = workerSocket;
3197
+ worker.setSockets(remoteSocket, workerSocket);
3062
3198
  const cleanup = () => {
3063
3199
  remoteSocket.unpipe(workerSocket);
3064
3200
  workerSocket.unpipe(remoteSocket);
@@ -3117,21 +3253,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3117
3253
  const createResult = this.createDcmrecv(port, tempDir);
3118
3254
  if (!createResult.ok) return createResult;
3119
3255
  const dcmrecv = createResult.value;
3120
- const worker = {
3121
- dcmrecv,
3122
- port,
3123
- tempDir,
3124
- state: "idle",
3125
- associationId: void 0,
3126
- associationDir: void 0,
3127
- files: [],
3128
- fileSizes: [],
3129
- outputLines: [],
3130
- pendingFiles: [],
3131
- startAt: void 0,
3132
- remoteSocket: void 0,
3133
- workerSocket: void 0
3134
- };
3256
+ const worker = new Worker(dcmrecv, port, tempDir);
3135
3257
  this.wireWorkerEvents(worker);
3136
3258
  const startResult = await dcmrecv.start();
3137
3259
  if (!startResult.ok) {
@@ -3140,14 +3262,9 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3140
3262
  this.workers.set(port, worker);
3141
3263
  return ok(worker);
3142
3264
  }
3143
- /** Stops a single worker: stop process, clean temp dir, remove from pool. */
3265
+ /** Stops a single worker: destroy sockets, stop process, clean temp dir, remove from pool. */
3144
3266
  async stopWorker(worker) {
3145
- if (worker.remoteSocket !== void 0 && !worker.remoteSocket.destroyed) {
3146
- worker.remoteSocket.destroy();
3147
- }
3148
- if (worker.workerSocket !== void 0 && !worker.workerSocket.destroyed) {
3149
- worker.workerSocket.destroy();
3150
- }
3267
+ worker.destroySockets();
3151
3268
  await worker.dcmrecv.stop();
3152
3269
  worker.dcmrecv[Symbol.dispose]();
3153
3270
  await removeDirSafe(worker.tempDir);
@@ -3199,12 +3316,11 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3199
3316
  this.wireRefusingAssociation(worker);
3200
3317
  this.wireOutputCapture(worker);
3201
3318
  }
3202
- /** Wires FILE_RECEIVED from dcmrecv worker — files are processed serially per worker. */
3319
+ /** Wires FILE_RECEIVED from dcmrecv worker — captures context synchronously. */
3203
3320
  wireFileReceived(worker) {
3204
3321
  worker.dcmrecv.onFileReceived((data) => {
3205
- const assocId = worker.associationId;
3206
- const assocDir = worker.associationDir;
3207
- if (assocDir === void 0 || assocId === void 0) {
3322
+ const ctx = worker.context;
3323
+ if (ctx === void 0) {
3208
3324
  this.emit("error", {
3209
3325
  error: new Error(`DicomReceiver: FILE_RECEIVED with no active association (worker state: ${worker.state})`),
3210
3326
  filePath: data.filePath,
@@ -3214,21 +3330,21 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3214
3330
  });
3215
3331
  return;
3216
3332
  }
3217
- const promise = this.handleFileReceived(worker, data, assocId, assocDir);
3218
- worker.pendingFiles.push(promise);
3333
+ const promise = this.handleFileReceived(worker, data, ctx);
3334
+ worker.trackFile(promise);
3219
3335
  });
3220
3336
  }
3221
3337
  /** Moves a received file, opens it as DicomInstance, and emits FILE_RECEIVED. */
3222
- async handleFileReceived(worker, data, assocId, assocDir) {
3338
+ async handleFileReceived(worker, data, ctx) {
3223
3339
  try {
3224
- await this.moveAndEmitFile(worker, data, assocId, assocDir);
3340
+ await this.moveAndEmitFile(worker, data, ctx);
3225
3341
  } catch (thrown) {
3226
3342
  const error = thrown instanceof Error ? thrown : new Error(String(thrown));
3227
3343
  this.emit("error", {
3228
3344
  error,
3229
3345
  filePath: data.filePath,
3230
- associationId: assocId,
3231
- associationDir: assocDir,
3346
+ associationId: ctx.associationId,
3347
+ associationDir: ctx.associationDir,
3232
3348
  callingAE: data.callingAE,
3233
3349
  calledAE: data.calledAE,
3234
3350
  source: data.source
@@ -3236,21 +3352,20 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3236
3352
  }
3237
3353
  }
3238
3354
  /** Inner handler: move file, parse DICOM, emit FILE_RECEIVED or error. */
3239
- async moveAndEmitFile(worker, data, assocId, assocDir) {
3355
+ async moveAndEmitFile(worker, data, ctx) {
3240
3356
  const srcPath = data.filePath;
3241
- const destPath = path.join(assocDir, path.basename(srcPath));
3357
+ const destPath = path.join(ctx.associationDir, path.basename(srcPath));
3242
3358
  const moveResult = await moveFile(srcPath, destPath);
3243
3359
  const finalPath = moveResult.ok ? destPath : srcPath;
3244
3360
  const fileSize = await statFileSafe(finalPath);
3245
- worker.files.push(finalPath);
3246
- worker.fileSizes.push(fileSize);
3361
+ worker.recordFile(finalPath, fileSize);
3247
3362
  const openResult = await DicomInstance.open(finalPath);
3248
3363
  if (!openResult.ok) {
3249
3364
  this.emit("error", {
3250
3365
  error: openResult.error,
3251
3366
  filePath: finalPath,
3252
- associationId: assocId,
3253
- associationDir: assocDir,
3367
+ associationId: ctx.associationId,
3368
+ associationDir: ctx.associationDir,
3254
3369
  callingAE: data.callingAE,
3255
3370
  calledAE: data.calledAE,
3256
3371
  source: data.source
@@ -3260,8 +3375,8 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3260
3375
  this.emit("FILE_RECEIVED", {
3261
3376
  filePath: finalPath,
3262
3377
  fileSize,
3263
- associationId: assocId,
3264
- associationDir: assocDir,
3378
+ associationId: ctx.associationId,
3379
+ associationDir: ctx.associationDir,
3265
3380
  callingAE: data.callingAE,
3266
3381
  calledAE: data.calledAE,
3267
3382
  source: data.source,
@@ -3276,21 +3391,20 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3276
3391
  }
3277
3392
  /** Awaits ALL pending file operations, emits ASSOCIATION_COMPLETE, resets worker state. */
3278
3393
  async finalizeAssociation(worker, data) {
3279
- if (worker.pendingFiles.length > 0) {
3280
- await Promise.all(worker.pendingFiles);
3281
- }
3394
+ await worker.drainPendingFiles();
3282
3395
  this.emitAssociationComplete(worker, data);
3283
- this.resetWorker(worker);
3396
+ worker.endAssociation();
3284
3397
  void this.scaleDown();
3285
3398
  }
3286
3399
  /** Emits the ASSOCIATION_COMPLETE event with transfer stats. */
3287
3400
  emitAssociationComplete(worker, data) {
3288
- const assocId = worker.associationId ?? data.associationId;
3289
- const assocDir = worker.associationDir ?? "";
3401
+ const ctx = worker.context;
3402
+ const assocId = ctx?.associationId ?? data.associationId;
3403
+ const assocDir = ctx?.associationDir ?? "";
3404
+ const startAt = ctx?.startAt ?? Date.now();
3290
3405
  const files = [...worker.files];
3291
3406
  const output = [...worker.outputLines];
3292
3407
  const endAt = Date.now();
3293
- const startAt = worker.startAt ?? endAt;
3294
3408
  const totalBytes = sumArray(worker.fileSizes);
3295
3409
  const elapsedMs = endAt - startAt;
3296
3410
  const bytesPerSecond = elapsedMs > 0 ? Math.round(totalBytes / elapsedMs * 1e3) : 0;
@@ -3310,24 +3424,11 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3310
3424
  output
3311
3425
  });
3312
3426
  }
3313
- /** Resets worker state to idle after an association completes. */
3314
- resetWorker(worker) {
3315
- worker.state = "idle";
3316
- worker.associationId = void 0;
3317
- worker.associationDir = void 0;
3318
- worker.files = [];
3319
- worker.fileSizes = [];
3320
- worker.outputLines = [];
3321
- worker.pendingFiles = [];
3322
- worker.startAt = void 0;
3323
- worker.remoteSocket = void 0;
3324
- worker.workerSocket = void 0;
3325
- }
3326
3427
  /** Bubbles ASSOCIATION_RECEIVED from dcmrecv worker. */
3327
3428
  wireAssociationReceived(worker) {
3328
3429
  worker.dcmrecv.onEvent("ASSOCIATION_RECEIVED", (data) => {
3329
3430
  this.emit("ASSOCIATION_RECEIVED", {
3330
- associationId: worker.associationId ?? "",
3431
+ associationId: worker.context?.associationId ?? "",
3331
3432
  callingAE: data.callingAE,
3332
3433
  calledAE: data.calledAE,
3333
3434
  source: data.source
@@ -3338,7 +3439,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3338
3439
  wireCStoreRequest(worker) {
3339
3440
  worker.dcmrecv.onEvent("C_STORE_REQUEST", (data) => {
3340
3441
  this.emit("C_STORE_REQUEST", {
3341
- associationId: worker.associationId ?? "",
3442
+ associationId: worker.context?.associationId ?? "",
3342
3443
  raw: data.raw
3343
3444
  });
3344
3445
  });
@@ -3347,7 +3448,7 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3347
3448
  wireEchoRequest(worker) {
3348
3449
  worker.dcmrecv.onEvent("ECHO_REQUEST", () => {
3349
3450
  this.emit("ECHO_REQUEST", {
3350
- associationId: worker.associationId ?? ""
3451
+ associationId: worker.context?.associationId ?? ""
3351
3452
  });
3352
3453
  });
3353
3454
  }
@@ -3362,8 +3463,8 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3362
3463
  /** Captures worker output lines during busy associations. */
3363
3464
  wireOutputCapture(worker) {
3364
3465
  worker.dcmrecv.on("line", ({ text }) => {
3365
- if (worker.state === "busy" && worker.outputLines.length < MAX_OUTPUT_LINES_PER_ASSOCIATION) {
3366
- worker.outputLines.push(text);
3466
+ if (worker.state === "busy") {
3467
+ worker.captureOutput(text);
3367
3468
  }
3368
3469
  });
3369
3470
  }
@@ -3382,56 +3483,6 @@ var DicomReceiver = class _DicomReceiver extends EventEmitter {
3382
3483
  signal.addEventListener("abort", this.abortHandler, { once: true });
3383
3484
  }
3384
3485
  };
3385
- async function ensureDirectory(dirPath) {
3386
- try {
3387
- await fs.mkdir(dirPath, { recursive: true });
3388
- return ok(void 0);
3389
- } catch (e) {
3390
- const msg = e instanceof Error ? e.message : String(e);
3391
- return err(new Error(`Failed to create directory ${dirPath}: ${msg}`));
3392
- }
3393
- }
3394
- async function moveFile(src, dest) {
3395
- try {
3396
- await fs.rename(src, dest);
3397
- return ok(void 0);
3398
- } catch {
3399
- try {
3400
- await fs.copyFile(src, dest);
3401
- await fs.unlink(src);
3402
- return ok(void 0);
3403
- } catch (e) {
3404
- const msg = e instanceof Error ? e.message : String(e);
3405
- return err(new Error(`Failed to move file ${src} \u2192 ${dest}: ${msg}`));
3406
- }
3407
- }
3408
- }
3409
- async function statFileSafe(filePath) {
3410
- try {
3411
- const stat3 = await fs.stat(filePath);
3412
- return stat3.size;
3413
- } catch {
3414
- return 0;
3415
- }
3416
- }
3417
- function sumArray(arr) {
3418
- let total = 0;
3419
- for (let i = 0; i < arr.length; i++) {
3420
- total += arr[i] ?? 0;
3421
- }
3422
- return total;
3423
- }
3424
- async function removeDirSafe(dirPath) {
3425
- try {
3426
- await fs.rm(dirPath, { recursive: true, force: true });
3427
- } catch {
3428
- }
3429
- }
3430
- function delay(ms) {
3431
- return new Promise((resolve) => {
3432
- setTimeout(resolve, ms);
3433
- });
3434
- }
3435
3486
 
3436
3487
  // src/events/dcmprscp.ts
3437
3488
  var DcmprscpEvent = {