@ubercode/dcmtk 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -15
- package/dist/DicomInstance-D9plqHp5.d.ts +625 -0
- package/dist/DicomInstance-DNHPkkzl.d.cts +625 -0
- package/dist/{dcmodify-CTXBWKU9.d.cts → dcmodify-B-_uUIKB.d.ts} +4 -2
- package/dist/{dcmodify-Daeafqrm.d.ts → dcmodify-Gds9u5Vj.d.cts} +4 -2
- package/dist/dicom.cjs +329 -51
- package/dist/dicom.cjs.map +1 -1
- package/dist/dicom.d.cts +368 -3
- package/dist/dicom.d.ts +368 -3
- package/dist/dicom.js +329 -51
- package/dist/dicom.js.map +1 -1
- package/dist/index.cjs +1993 -423
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +324 -10
- package/dist/index.d.ts +324 -10
- package/dist/index.js +1962 -417
- package/dist/index.js.map +1 -1
- package/dist/servers.cjs +2380 -197
- package/dist/servers.cjs.map +1 -1
- package/dist/servers.d.cts +1654 -3
- package/dist/servers.d.ts +1654 -3
- package/dist/servers.js +2306 -146
- package/dist/servers.js.map +1 -1
- package/dist/tools.cjs +98 -51
- package/dist/tools.cjs.map +1 -1
- package/dist/tools.d.cts +21 -4
- package/dist/tools.d.ts +21 -4
- package/dist/tools.js +98 -52
- package/dist/tools.js.map +1 -1
- package/dist/{types-zHhxS7d2.d.cts → types-Cgumy1N4.d.cts} +1 -24
- package/dist/{types-zHhxS7d2.d.ts → types-Cgumy1N4.d.ts} +1 -24
- package/dist/utils.cjs.map +1 -1
- package/dist/utils.d.cts +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js.map +1 -1
- package/package.json +8 -8
- package/dist/index-BZxi4104.d.ts +0 -826
- package/dist/index-CapkWqxy.d.ts +0 -1295
- package/dist/index-DX4C3zbo.d.cts +0 -826
- package/dist/index-r7AvpkCE.d.cts +0 -1295
package/dist/servers.cjs
CHANGED
|
@@ -6,11 +6,37 @@ var child_process = require('child_process');
|
|
|
6
6
|
var stderrLib = require('stderr-lib');
|
|
7
7
|
var kill = require('tree-kill');
|
|
8
8
|
var path = require('path');
|
|
9
|
-
var fs = require('fs');
|
|
9
|
+
var fs$1 = require('fs');
|
|
10
|
+
var net = require('net');
|
|
11
|
+
var fs = require('fs/promises');
|
|
12
|
+
var os = require('os');
|
|
13
|
+
var fastXmlParser = require('fast-xml-parser');
|
|
10
14
|
|
|
11
15
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
12
16
|
|
|
17
|
+
function _interopNamespace(e) {
|
|
18
|
+
if (e && e.__esModule) return e;
|
|
19
|
+
var n = Object.create(null);
|
|
20
|
+
if (e) {
|
|
21
|
+
Object.keys(e).forEach(function (k) {
|
|
22
|
+
if (k !== 'default') {
|
|
23
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
24
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
25
|
+
enumerable: true,
|
|
26
|
+
get: function () { return e[k]; }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
n.default = e;
|
|
32
|
+
return Object.freeze(n);
|
|
33
|
+
}
|
|
34
|
+
|
|
13
35
|
var kill__default = /*#__PURE__*/_interopDefault(kill);
|
|
36
|
+
var path__namespace = /*#__PURE__*/_interopNamespace(path);
|
|
37
|
+
var net__namespace = /*#__PURE__*/_interopNamespace(net);
|
|
38
|
+
var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
|
|
39
|
+
var os__namespace = /*#__PURE__*/_interopNamespace(os);
|
|
14
40
|
|
|
15
41
|
var __defProp = Object.defineProperty;
|
|
16
42
|
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
@@ -25,6 +51,7 @@ function err(error) {
|
|
|
25
51
|
}
|
|
26
52
|
|
|
27
53
|
// src/constants.ts
|
|
54
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
28
55
|
var DEFAULT_START_TIMEOUT_MS = 1e4;
|
|
29
56
|
var DEFAULT_DRAIN_TIMEOUT_MS = 5e3;
|
|
30
57
|
var DEFAULT_BLOCK_TIMEOUT_MS = 1e3;
|
|
@@ -33,6 +60,8 @@ var UNIX_SEARCH_PATHS = ["/usr/local/bin", "/usr/bin", "/opt/local/bin", "/opt/h
|
|
|
33
60
|
var REQUIRED_BINARIES = ["dcm2json", "dcm2xml", "dcmodify", "dcmdump", "dcmrecv", "dcmsend", "echoscu"];
|
|
34
61
|
var MAX_BLOCK_LINES = 1e3;
|
|
35
62
|
var MAX_EVENT_PATTERNS = 200;
|
|
63
|
+
var MAX_TRAVERSAL_DEPTH = 50;
|
|
64
|
+
var MAX_CHANGESET_OPERATIONS = 1e4;
|
|
36
65
|
var MAX_OUTPUT_BYTES = 100 * 1024 * 1024;
|
|
37
66
|
|
|
38
67
|
// src/DcmtkProcess.ts
|
|
@@ -380,7 +409,10 @@ var LineParser = class extends events.EventEmitter {
|
|
|
380
409
|
};
|
|
381
410
|
|
|
382
411
|
// src/patterns.ts
|
|
383
|
-
var
|
|
412
|
+
var DICOM_TAG_PATTERN = /^\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)$/;
|
|
413
|
+
var AE_TITLE_PATTERN = /^[\x20-\x5b\x5d-\x7e]+$/;
|
|
414
|
+
var UID_PATTERN = /^[0-9]+(\.[0-9]+)*$/;
|
|
415
|
+
var UID_MAX_LENGTH = 64;
|
|
384
416
|
var PATH_TRAVERSAL_PATTERN = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
385
417
|
function isValidAETitle(value) {
|
|
386
418
|
return AE_TITLE_PATTERN.test(value);
|
|
@@ -397,7 +429,7 @@ function binaryName(name) {
|
|
|
397
429
|
}
|
|
398
430
|
function hasRequiredBinaries(dir) {
|
|
399
431
|
for (const bin of REQUIRED_BINARIES) {
|
|
400
|
-
if (!fs.existsSync(path.join(dir, binaryName(bin)))) {
|
|
432
|
+
if (!fs$1.existsSync(path.join(dir, binaryName(bin)))) {
|
|
401
433
|
return false;
|
|
402
434
|
}
|
|
403
435
|
}
|
|
@@ -486,6 +518,39 @@ function resolveBinary(toolName) {
|
|
|
486
518
|
return ok(path.join(pathResult.value, binaryName2));
|
|
487
519
|
}
|
|
488
520
|
|
|
521
|
+
// src/tools/_toolError.ts
|
|
522
|
+
var MAX_ARGS_LENGTH = 200;
|
|
523
|
+
var MAX_STDERR_LENGTH = 500;
|
|
524
|
+
function truncate(value, maxLength) {
|
|
525
|
+
if (value.length <= maxLength) {
|
|
526
|
+
return value;
|
|
527
|
+
}
|
|
528
|
+
return `${value.substring(0, maxLength)}...`;
|
|
529
|
+
}
|
|
530
|
+
function createToolError(toolName, args, exitCode, stderr6) {
|
|
531
|
+
const argsStr = truncate(args.join(" "), MAX_ARGS_LENGTH);
|
|
532
|
+
const stderrStr = truncate(stderr6.trim(), MAX_STDERR_LENGTH);
|
|
533
|
+
const parts = [`${toolName} failed (exit code ${String(exitCode)})`];
|
|
534
|
+
if (argsStr.length > 0) {
|
|
535
|
+
parts.push(`args: ${argsStr}`);
|
|
536
|
+
}
|
|
537
|
+
if (stderrStr.length > 0) {
|
|
538
|
+
parts.push(`stderr: ${stderrStr}`);
|
|
539
|
+
}
|
|
540
|
+
return new Error(parts.join(" | "));
|
|
541
|
+
}
|
|
542
|
+
function createValidationError(toolName, zodError) {
|
|
543
|
+
const parts = [];
|
|
544
|
+
for (let i = 0; i < zodError.issues.length; i++) {
|
|
545
|
+
const issue = zodError.issues[i];
|
|
546
|
+
if (issue === void 0) continue;
|
|
547
|
+
const path2 = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
|
|
548
|
+
parts.push(`${path2}: ${issue.message}`);
|
|
549
|
+
}
|
|
550
|
+
const detail = parts.length > 0 ? parts.join("; ") : "unknown validation error";
|
|
551
|
+
return new Error(`${toolName}: invalid options \u2014 ${detail}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
489
554
|
// src/events/dcmrecv.ts
|
|
490
555
|
var DcmrecvEvent = {
|
|
491
556
|
LISTENING: "LISTENING",
|
|
@@ -497,7 +562,11 @@ var DcmrecvEvent = {
|
|
|
497
562
|
ASSOCIATION_ABORTED: "ASSOCIATION_ABORTED",
|
|
498
563
|
ECHO_REQUEST: "ECHO_REQUEST",
|
|
499
564
|
CANNOT_START_LISTENER: "CANNOT_START_LISTENER",
|
|
500
|
-
REFUSING_ASSOCIATION: "REFUSING_ASSOCIATION"
|
|
565
|
+
REFUSING_ASSOCIATION: "REFUSING_ASSOCIATION",
|
|
566
|
+
/** Synthetic: STORED_FILE enriched with association context. */
|
|
567
|
+
FILE_RECEIVED: "FILE_RECEIVED",
|
|
568
|
+
/** Synthetic: emitted on association release/abort with summary. */
|
|
569
|
+
ASSOCIATION_COMPLETE: "ASSOCIATION_COMPLETE"
|
|
501
570
|
};
|
|
502
571
|
var DCMRECV_PATTERNS = [
|
|
503
572
|
{
|
|
@@ -507,9 +576,9 @@ var DCMRECV_PATTERNS = [
|
|
|
507
576
|
},
|
|
508
577
|
{
|
|
509
578
|
event: DcmrecvEvent.ASSOCIATION_RECEIVED,
|
|
510
|
-
pattern: /Association Received\s{1,100}(
|
|
579
|
+
pattern: /Association Received\s{1,100}(\S+):\s+(\S+)\s+->\s+(\S+)/,
|
|
511
580
|
processor: (match) => ({
|
|
512
|
-
|
|
581
|
+
source: match[1] ?? "",
|
|
513
582
|
callingAE: match[2] ?? "",
|
|
514
583
|
calledAE: match[3] ?? ""
|
|
515
584
|
})
|
|
@@ -537,7 +606,7 @@ var DCMRECV_PATTERNS = [
|
|
|
537
606
|
},
|
|
538
607
|
{
|
|
539
608
|
event: DcmrecvEvent.ASSOCIATION_RELEASE,
|
|
540
|
-
pattern: /
|
|
609
|
+
pattern: /Association Release/i,
|
|
541
610
|
processor: () => void 0
|
|
542
611
|
},
|
|
543
612
|
{
|
|
@@ -567,6 +636,95 @@ var DCMRECV_PATTERNS = [
|
|
|
567
636
|
];
|
|
568
637
|
var DCMRECV_FATAL_EVENTS = /* @__PURE__ */ new Set([DcmrecvEvent.CANNOT_START_LISTENER]);
|
|
569
638
|
|
|
639
|
+
// src/servers/AssociationTracker.ts
|
|
640
|
+
var AssociationTracker = class {
|
|
641
|
+
constructor() {
|
|
642
|
+
__publicField(this, "association");
|
|
643
|
+
__publicField(this, "counter", 0);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Begins a new association, transitioning from IDLE to ACTIVE.
|
|
647
|
+
*
|
|
648
|
+
* If an association is already active, it is silently ended (abort)
|
|
649
|
+
* and the new one begins.
|
|
650
|
+
*
|
|
651
|
+
* @param data - Association metadata
|
|
652
|
+
* @returns The unique association ID
|
|
653
|
+
*/
|
|
654
|
+
beginAssociation(data) {
|
|
655
|
+
this.counter++;
|
|
656
|
+
const associationId = `assoc-${String(this.counter)}`;
|
|
657
|
+
this.association = {
|
|
658
|
+
associationId,
|
|
659
|
+
callingAE: data.callingAE,
|
|
660
|
+
calledAE: data.calledAE,
|
|
661
|
+
source: data.source,
|
|
662
|
+
startTime: Date.now(),
|
|
663
|
+
files: []
|
|
664
|
+
};
|
|
665
|
+
return associationId;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Tracks a file received during the current association.
|
|
669
|
+
*
|
|
670
|
+
* If no association is active, returns a TrackedFile with empty context.
|
|
671
|
+
*
|
|
672
|
+
* @param filePath - Path to the received file
|
|
673
|
+
* @returns A TrackedFile enriched with association context
|
|
674
|
+
*/
|
|
675
|
+
trackFile(filePath) {
|
|
676
|
+
if (this.association === void 0) {
|
|
677
|
+
return {
|
|
678
|
+
filePath,
|
|
679
|
+
associationId: "",
|
|
680
|
+
callingAE: "",
|
|
681
|
+
calledAE: "",
|
|
682
|
+
source: ""
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
this.association.files.push(filePath);
|
|
686
|
+
return {
|
|
687
|
+
filePath,
|
|
688
|
+
associationId: this.association.associationId,
|
|
689
|
+
callingAE: this.association.callingAE,
|
|
690
|
+
calledAE: this.association.calledAE,
|
|
691
|
+
source: this.association.source
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Ends the current association, transitioning from ACTIVE to IDLE.
|
|
696
|
+
*
|
|
697
|
+
* @param reason - Why the association ended
|
|
698
|
+
* @returns An AssociationSummary, or undefined if no association was active
|
|
699
|
+
*/
|
|
700
|
+
endAssociation(reason) {
|
|
701
|
+
if (this.association === void 0) return void 0;
|
|
702
|
+
const summary = {
|
|
703
|
+
associationId: this.association.associationId,
|
|
704
|
+
callingAE: this.association.callingAE,
|
|
705
|
+
calledAE: this.association.calledAE,
|
|
706
|
+
source: this.association.source,
|
|
707
|
+
files: [...this.association.files],
|
|
708
|
+
durationMs: Date.now() - this.association.startTime,
|
|
709
|
+
endReason: reason
|
|
710
|
+
};
|
|
711
|
+
this.association = void 0;
|
|
712
|
+
return summary;
|
|
713
|
+
}
|
|
714
|
+
/** The currently active association context, or undefined. */
|
|
715
|
+
get current() {
|
|
716
|
+
return this.association;
|
|
717
|
+
}
|
|
718
|
+
/** Whether an association is currently active. */
|
|
719
|
+
get isActive() {
|
|
720
|
+
return this.association !== void 0;
|
|
721
|
+
}
|
|
722
|
+
/** Resets the tracker to IDLE, discarding any active association. */
|
|
723
|
+
reset() {
|
|
724
|
+
this.association = void 0;
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
|
|
570
728
|
// src/servers/Dcmrecv.ts
|
|
571
729
|
var SubdirectoryMode = {
|
|
572
730
|
NONE: "none",
|
|
@@ -651,13 +809,16 @@ function addNetworkArgs(args, options) {
|
|
|
651
809
|
}
|
|
652
810
|
}
|
|
653
811
|
var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
|
|
654
|
-
constructor(config,
|
|
812
|
+
constructor(config, parser2, signal) {
|
|
655
813
|
super(config);
|
|
656
814
|
__publicField(this, "parser");
|
|
815
|
+
__publicField(this, "tracker");
|
|
657
816
|
__publicField(this, "abortSignal");
|
|
658
817
|
__publicField(this, "abortHandler");
|
|
659
|
-
this.parser =
|
|
818
|
+
this.parser = parser2;
|
|
819
|
+
this.tracker = new AssociationTracker();
|
|
660
820
|
this.wireParser();
|
|
821
|
+
this.wireTracker();
|
|
661
822
|
if (signal !== void 0) {
|
|
662
823
|
this.wireAbortSignal(signal);
|
|
663
824
|
}
|
|
@@ -698,6 +859,24 @@ var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
|
|
|
698
859
|
onStoredFile(listener) {
|
|
699
860
|
return this.onEvent("STORED_FILE", listener);
|
|
700
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* Registers a listener for received files enriched with association context.
|
|
864
|
+
*
|
|
865
|
+
* @param listener - Callback receiving tracked file data
|
|
866
|
+
* @returns this for chaining
|
|
867
|
+
*/
|
|
868
|
+
onFileReceived(listener) {
|
|
869
|
+
return this.onEvent("FILE_RECEIVED", listener);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Registers a listener for completed associations.
|
|
873
|
+
*
|
|
874
|
+
* @param listener - Callback receiving association summary
|
|
875
|
+
* @returns this for chaining
|
|
876
|
+
*/
|
|
877
|
+
onAssociationComplete(listener) {
|
|
878
|
+
return this.onEvent("ASSOCIATION_COMPLETE", listener);
|
|
879
|
+
}
|
|
701
880
|
/**
|
|
702
881
|
* Creates a new Dcmrecv server instance.
|
|
703
882
|
*
|
|
@@ -707,16 +886,16 @@ var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
|
|
|
707
886
|
static create(options) {
|
|
708
887
|
const validation = DcmrecvOptionsSchema.safeParse(options);
|
|
709
888
|
if (!validation.success) {
|
|
710
|
-
return err(
|
|
889
|
+
return err(createValidationError("dcmrecv", validation.error));
|
|
711
890
|
}
|
|
712
891
|
const binaryResult = resolveBinary("dcmrecv");
|
|
713
892
|
if (!binaryResult.ok) {
|
|
714
893
|
return err(binaryResult.error);
|
|
715
894
|
}
|
|
716
895
|
const args = buildArgs(options);
|
|
717
|
-
const
|
|
896
|
+
const parser2 = new LineParser();
|
|
718
897
|
for (const pattern of DCMRECV_PATTERNS) {
|
|
719
|
-
const addResult =
|
|
898
|
+
const addResult = parser2.addPattern(pattern);
|
|
720
899
|
if (!addResult.ok) {
|
|
721
900
|
return err(addResult.error);
|
|
722
901
|
}
|
|
@@ -728,7 +907,7 @@ var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
|
|
|
728
907
|
drainTimeoutMs: options.drainTimeoutMs,
|
|
729
908
|
isStartedPredicate: (line) => /listening/i.test(line)
|
|
730
909
|
};
|
|
731
|
-
return ok(new _Dcmrecv(config,
|
|
910
|
+
return ok(new _Dcmrecv(config, parser2, options.signal));
|
|
732
911
|
}
|
|
733
912
|
/** Wires the line parser to the process output. */
|
|
734
913
|
wireParser() {
|
|
@@ -740,7 +919,29 @@ var Dcmrecv = class _Dcmrecv extends DcmtkProcess {
|
|
|
740
919
|
this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
|
|
741
920
|
void this.stop();
|
|
742
921
|
}
|
|
743
|
-
this.emit(event,
|
|
922
|
+
this.emit(event, data);
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
/** Wires the AssociationTracker to server events. */
|
|
926
|
+
wireTracker() {
|
|
927
|
+
this.onEvent("ASSOCIATION_RECEIVED", (data) => {
|
|
928
|
+
this.tracker.beginAssociation(data);
|
|
929
|
+
});
|
|
930
|
+
this.onEvent("STORED_FILE", (data) => {
|
|
931
|
+
const tracked = this.tracker.trackFile(data.filePath);
|
|
932
|
+
this.emit(DcmrecvEvent.FILE_RECEIVED, tracked);
|
|
933
|
+
});
|
|
934
|
+
this.onEvent("ASSOCIATION_RELEASE", () => {
|
|
935
|
+
const summary = this.tracker.endAssociation("release");
|
|
936
|
+
if (summary !== void 0) {
|
|
937
|
+
this.emit(DcmrecvEvent.ASSOCIATION_COMPLETE, summary);
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
this.onEvent("ASSOCIATION_ABORTED", () => {
|
|
941
|
+
const summary = this.tracker.endAssociation("abort");
|
|
942
|
+
if (summary !== void 0) {
|
|
943
|
+
this.emit(DcmrecvEvent.ASSOCIATION_COMPLETE, summary);
|
|
944
|
+
}
|
|
744
945
|
});
|
|
745
946
|
}
|
|
746
947
|
/** Wires an AbortSignal to stop the server. */
|
|
@@ -763,6 +964,11 @@ var StorescpEvent = {
|
|
|
763
964
|
STORING_FILE: "STORING_FILE",
|
|
764
965
|
SUBDIRECTORY_CREATED: "SUBDIRECTORY_CREATED"
|
|
765
966
|
};
|
|
967
|
+
var STORESCP_ASSOCIATION_RECEIVED = {
|
|
968
|
+
event: StorescpEvent.ASSOCIATION_RECEIVED,
|
|
969
|
+
pattern: /Association Received/i,
|
|
970
|
+
processor: () => ({ source: "", callingAE: "", calledAE: "" })
|
|
971
|
+
};
|
|
766
972
|
var STORESCP_ADDITIONAL_PATTERNS = [
|
|
767
973
|
{
|
|
768
974
|
event: StorescpEvent.STORING_FILE,
|
|
@@ -779,7 +985,11 @@ var STORESCP_ADDITIONAL_PATTERNS = [
|
|
|
779
985
|
})
|
|
780
986
|
}
|
|
781
987
|
];
|
|
782
|
-
var STORESCP_PATTERNS = [
|
|
988
|
+
var STORESCP_PATTERNS = [
|
|
989
|
+
...DCMRECV_PATTERNS.filter((p) => p.event !== DcmrecvEvent.ASSOCIATION_RECEIVED),
|
|
990
|
+
STORESCP_ASSOCIATION_RECEIVED,
|
|
991
|
+
...STORESCP_ADDITIONAL_PATTERNS
|
|
992
|
+
];
|
|
783
993
|
var STORESCP_FATAL_EVENTS = /* @__PURE__ */ new Set([...DCMRECV_FATAL_EVENTS]);
|
|
784
994
|
|
|
785
995
|
// src/servers/StoreSCP.ts
|
|
@@ -789,6 +999,23 @@ var PreferredTransferSyntax = {
|
|
|
789
999
|
IMPLICIT: "implicit",
|
|
790
1000
|
ACCEPT_ALL: "accept-all"
|
|
791
1001
|
};
|
|
1002
|
+
var StoreSCPPreset = {
|
|
1003
|
+
/** Basic storage: unique filenames to avoid collisions. */
|
|
1004
|
+
BASIC_STORAGE: {
|
|
1005
|
+
uniqueFilenames: true
|
|
1006
|
+
},
|
|
1007
|
+
/** Testing: unique filenames, preserving raw transfer syntax. */
|
|
1008
|
+
TESTING: {
|
|
1009
|
+
uniqueFilenames: true,
|
|
1010
|
+
bitPreserving: true
|
|
1011
|
+
},
|
|
1012
|
+
/** Production: unique filenames with reasonable timeouts. */
|
|
1013
|
+
PRODUCTION: {
|
|
1014
|
+
uniqueFilenames: true,
|
|
1015
|
+
acseTimeout: 30,
|
|
1016
|
+
dimseTimeout: 60
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
792
1019
|
var StoreSCPOptionsSchema = zod.z.object({
|
|
793
1020
|
port: zod.z.number().int().min(1).max(65535),
|
|
794
1021
|
aeTitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
|
|
@@ -895,24 +1122,20 @@ function buildExecArgs(args, options) {
|
|
|
895
1122
|
}
|
|
896
1123
|
}
|
|
897
1124
|
var StoreSCP = class _StoreSCP extends DcmtkProcess {
|
|
898
|
-
constructor(config,
|
|
1125
|
+
constructor(config, parser2, signal) {
|
|
899
1126
|
super(config);
|
|
900
1127
|
__publicField(this, "parser");
|
|
1128
|
+
__publicField(this, "tracker");
|
|
901
1129
|
__publicField(this, "abortSignal");
|
|
902
1130
|
__publicField(this, "abortHandler");
|
|
903
|
-
this.parser =
|
|
1131
|
+
this.parser = parser2;
|
|
1132
|
+
this.tracker = new AssociationTracker();
|
|
904
1133
|
this.wireParser();
|
|
1134
|
+
this.wireTracker();
|
|
905
1135
|
if (signal !== void 0) {
|
|
906
1136
|
this.wireAbortSignal(signal);
|
|
907
1137
|
}
|
|
908
1138
|
}
|
|
909
|
-
/**
|
|
910
|
-
* Registers a typed listener for a storescp-specific event.
|
|
911
|
-
*
|
|
912
|
-
* @param event - The event name from StoreSCPEventMap
|
|
913
|
-
* @param listener - Callback receiving typed event data
|
|
914
|
-
* @returns this for chaining
|
|
915
|
-
*/
|
|
916
1139
|
/** Disposes the server and its parser, preventing listener leaks. */
|
|
917
1140
|
[Symbol.dispose]() {
|
|
918
1141
|
if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
|
|
@@ -921,6 +1144,13 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
|
|
|
921
1144
|
this.parser[Symbol.dispose]();
|
|
922
1145
|
super[Symbol.dispose]();
|
|
923
1146
|
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Registers a typed listener for a storescp-specific event.
|
|
1149
|
+
*
|
|
1150
|
+
* @param event - The event name from StoreSCPEventMap
|
|
1151
|
+
* @param listener - Callback receiving typed event data
|
|
1152
|
+
* @returns this for chaining
|
|
1153
|
+
*/
|
|
924
1154
|
onEvent(event, listener) {
|
|
925
1155
|
return this.on(event, listener);
|
|
926
1156
|
}
|
|
@@ -942,6 +1172,24 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
|
|
|
942
1172
|
onStoringFile(listener) {
|
|
943
1173
|
return this.onEvent("STORING_FILE", listener);
|
|
944
1174
|
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Registers a listener for received files enriched with association context.
|
|
1177
|
+
*
|
|
1178
|
+
* @param listener - Callback receiving tracked file data
|
|
1179
|
+
* @returns this for chaining
|
|
1180
|
+
*/
|
|
1181
|
+
onFileReceived(listener) {
|
|
1182
|
+
return this.onEvent("FILE_RECEIVED", listener);
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Registers a listener for completed associations.
|
|
1186
|
+
*
|
|
1187
|
+
* @param listener - Callback receiving association summary
|
|
1188
|
+
* @returns this for chaining
|
|
1189
|
+
*/
|
|
1190
|
+
onAssociationComplete(listener) {
|
|
1191
|
+
return this.onEvent("ASSOCIATION_COMPLETE", listener);
|
|
1192
|
+
}
|
|
945
1193
|
/**
|
|
946
1194
|
* Creates a new StoreSCP server instance.
|
|
947
1195
|
*
|
|
@@ -951,16 +1199,16 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
|
|
|
951
1199
|
static create(options) {
|
|
952
1200
|
const validation = StoreSCPOptionsSchema.safeParse(options);
|
|
953
1201
|
if (!validation.success) {
|
|
954
|
-
return err(
|
|
1202
|
+
return err(createValidationError("storescp", validation.error));
|
|
955
1203
|
}
|
|
956
1204
|
const binaryResult = resolveBinary("storescp");
|
|
957
1205
|
if (!binaryResult.ok) {
|
|
958
1206
|
return err(binaryResult.error);
|
|
959
1207
|
}
|
|
960
1208
|
const args = buildArgs2(options);
|
|
961
|
-
const
|
|
1209
|
+
const parser2 = new LineParser();
|
|
962
1210
|
for (const pattern of STORESCP_PATTERNS) {
|
|
963
|
-
const addResult =
|
|
1211
|
+
const addResult = parser2.addPattern(pattern);
|
|
964
1212
|
if (!addResult.ok) {
|
|
965
1213
|
return err(addResult.error);
|
|
966
1214
|
}
|
|
@@ -972,7 +1220,7 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
|
|
|
972
1220
|
drainTimeoutMs: options.drainTimeoutMs
|
|
973
1221
|
// storescp doesn't print "listening" — resolve on spawn
|
|
974
1222
|
};
|
|
975
|
-
return ok(new _StoreSCP(config,
|
|
1223
|
+
return ok(new _StoreSCP(config, parser2, options.signal));
|
|
976
1224
|
}
|
|
977
1225
|
/** Wires the line parser to the process output. */
|
|
978
1226
|
wireParser() {
|
|
@@ -984,7 +1232,33 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
|
|
|
984
1232
|
this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
|
|
985
1233
|
void this.stop();
|
|
986
1234
|
}
|
|
987
|
-
this.emit(event,
|
|
1235
|
+
this.emit(event, data);
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
/** Wires the AssociationTracker to server events. */
|
|
1239
|
+
wireTracker() {
|
|
1240
|
+
this.onEvent("ASSOCIATION_RECEIVED", (data) => {
|
|
1241
|
+
this.tracker.beginAssociation(data);
|
|
1242
|
+
});
|
|
1243
|
+
this.onEvent("STORING_FILE", (data) => {
|
|
1244
|
+
const tracked = this.tracker.trackFile(data.filePath);
|
|
1245
|
+
this.emit(DcmrecvEvent.FILE_RECEIVED, tracked);
|
|
1246
|
+
});
|
|
1247
|
+
this.onEvent("STORED_FILE", (data) => {
|
|
1248
|
+
const tracked = this.tracker.trackFile(data.filePath);
|
|
1249
|
+
this.emit(DcmrecvEvent.FILE_RECEIVED, tracked);
|
|
1250
|
+
});
|
|
1251
|
+
this.onEvent("ASSOCIATION_RELEASE", () => {
|
|
1252
|
+
const summary = this.tracker.endAssociation("release");
|
|
1253
|
+
if (summary !== void 0) {
|
|
1254
|
+
this.emit(DcmrecvEvent.ASSOCIATION_COMPLETE, summary);
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
this.onEvent("ASSOCIATION_ABORTED", () => {
|
|
1258
|
+
const summary = this.tracker.endAssociation("abort");
|
|
1259
|
+
if (summary !== void 0) {
|
|
1260
|
+
this.emit(DcmrecvEvent.ASSOCIATION_COMPLETE, summary);
|
|
1261
|
+
}
|
|
988
1262
|
});
|
|
989
1263
|
}
|
|
990
1264
|
/** Wires an AbortSignal to stop the server. */
|
|
@@ -1000,160 +1274,2066 @@ var StoreSCP = class _StoreSCP extends DcmtkProcess {
|
|
|
1000
1274
|
signal.addEventListener("abort", this.abortHandler, { once: true });
|
|
1001
1275
|
}
|
|
1002
1276
|
};
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
DATABASE_READY: "DATABASE_READY",
|
|
1007
|
-
ASSOCIATION_RECEIVED: "ASSOCIATION_RECEIVED",
|
|
1008
|
-
ASSOCIATION_ACKNOWLEDGED: "ASSOCIATION_ACKNOWLEDGED",
|
|
1009
|
-
ASSOCIATION_RELEASE: "ASSOCIATION_RELEASE",
|
|
1010
|
-
ASSOCIATION_ABORTED: "ASSOCIATION_ABORTED",
|
|
1011
|
-
CANNOT_START_LISTENER: "CANNOT_START_LISTENER",
|
|
1012
|
-
CONFIG_ERROR: "CONFIG_ERROR"
|
|
1013
|
-
};
|
|
1014
|
-
var DCMPRSCP_PATTERNS = [
|
|
1015
|
-
{
|
|
1016
|
-
event: DcmprscpEvent.DATABASE_READY,
|
|
1017
|
-
pattern: /Using database in directory\s+'([^']+)'/,
|
|
1018
|
-
processor: (match) => ({
|
|
1019
|
-
directory: match[1] ?? ""
|
|
1020
|
-
})
|
|
1021
|
-
},
|
|
1022
|
-
{
|
|
1023
|
-
event: DcmprscpEvent.ASSOCIATION_RECEIVED,
|
|
1024
|
-
pattern: /Association Received\s{0,20}\(([^)]+)\)/,
|
|
1025
|
-
processor: (match) => ({
|
|
1026
|
-
peerInfo: (match[1] ?? "").trim()
|
|
1027
|
-
})
|
|
1028
|
-
},
|
|
1029
|
-
{
|
|
1030
|
-
event: DcmprscpEvent.ASSOCIATION_ACKNOWLEDGED,
|
|
1031
|
-
pattern: /Association Acknowledged \(Max Send PDV:\s*(\d+)\)/,
|
|
1032
|
-
processor: (match) => ({
|
|
1033
|
-
maxSendPDV: Number(match[1])
|
|
1034
|
-
})
|
|
1035
|
-
},
|
|
1036
|
-
{
|
|
1037
|
-
event: DcmprscpEvent.ASSOCIATION_RELEASE,
|
|
1038
|
-
pattern: /Association Release/i,
|
|
1039
|
-
processor: () => void 0
|
|
1040
|
-
},
|
|
1041
|
-
{
|
|
1042
|
-
event: DcmprscpEvent.ASSOCIATION_ABORTED,
|
|
1043
|
-
pattern: /Association Abort/i,
|
|
1044
|
-
processor: () => void 0
|
|
1045
|
-
},
|
|
1046
|
-
{
|
|
1047
|
-
event: DcmprscpEvent.CANNOT_START_LISTENER,
|
|
1048
|
-
pattern: /cannot initialise network|cannot listen/i,
|
|
1049
|
-
processor: (match) => ({
|
|
1050
|
-
message: match[0] ?? ""
|
|
1051
|
-
})
|
|
1052
|
-
},
|
|
1053
|
-
{
|
|
1054
|
-
event: DcmprscpEvent.CONFIG_ERROR,
|
|
1055
|
-
pattern: /can't open configuration file|no (?:default )?print scp/i,
|
|
1056
|
-
processor: (match) => ({
|
|
1057
|
-
message: match[0] ?? ""
|
|
1058
|
-
})
|
|
1277
|
+
function createDicomTag(input) {
|
|
1278
|
+
if (!DICOM_TAG_PATTERN.test(input)) {
|
|
1279
|
+
return err(new Error(`Invalid DICOM tag: "${input}". Expected format (XXXX,XXXX) where X is a hex digit`));
|
|
1059
1280
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1281
|
+
return ok(input);
|
|
1282
|
+
}
|
|
1283
|
+
function createSOPClassUID(input) {
|
|
1284
|
+
if (input.length === 0 || input.length > UID_MAX_LENGTH) {
|
|
1285
|
+
return err(new Error(`Invalid SOP Class UID: "${input}". Must be 1-${UID_MAX_LENGTH} characters`));
|
|
1286
|
+
}
|
|
1287
|
+
if (!UID_PATTERN.test(input)) {
|
|
1288
|
+
return err(new Error(`Invalid SOP Class UID: "${input}". Must be a dotted numeric OID`));
|
|
1289
|
+
}
|
|
1290
|
+
return ok(input);
|
|
1291
|
+
}
|
|
1292
|
+
function createDicomFilePath(input) {
|
|
1293
|
+
if (input.length === 0) {
|
|
1294
|
+
return err(new Error("Invalid DICOM file path: empty string"));
|
|
1295
|
+
}
|
|
1296
|
+
if (PATH_TRAVERSAL_PATTERN.test(input)) {
|
|
1297
|
+
return err(new Error(`Invalid DICOM file path: path traversal detected in "${input}"`));
|
|
1298
|
+
}
|
|
1299
|
+
const normalized = path.normalize(input);
|
|
1300
|
+
return ok(normalized);
|
|
1301
|
+
}
|
|
1062
1302
|
|
|
1063
|
-
// src/
|
|
1064
|
-
var
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1303
|
+
// src/dicom/tagPath.ts
|
|
1304
|
+
var SEGMENT_PATTERN = /^\(([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)(?:\[(\d+|\*)\])?/;
|
|
1305
|
+
function matchToSegment(match) {
|
|
1306
|
+
const group = match[1];
|
|
1307
|
+
const element = match[2];
|
|
1308
|
+
const indexStr = match[3];
|
|
1309
|
+
if (group === void 0 || element === void 0) {
|
|
1310
|
+
throw new Error("Failed to parse tag group/element");
|
|
1311
|
+
}
|
|
1312
|
+
const tagResult = createDicomTag(`(${group},${element})`);
|
|
1313
|
+
if (!tagResult.ok) throw new Error(`Invalid tag in path: (${group},${element})`);
|
|
1314
|
+
if (indexStr === "*") return { tag: tagResult.value, isWildcard: true };
|
|
1315
|
+
if (indexStr !== void 0) return { tag: tagResult.value, index: Number(indexStr) };
|
|
1316
|
+
return { tag: tagResult.value };
|
|
1317
|
+
}
|
|
1318
|
+
function advancePastMatch(remaining, matchLength) {
|
|
1319
|
+
const after = remaining.slice(matchLength);
|
|
1320
|
+
if (!after.startsWith(".")) return after;
|
|
1321
|
+
if (after.length === 1) throw new Error("Tag path cannot end with a dot separator");
|
|
1322
|
+
return after.slice(1);
|
|
1323
|
+
}
|
|
1324
|
+
function tagPathToSegments(path2) {
|
|
1325
|
+
const segments = [];
|
|
1326
|
+
let remaining = path2;
|
|
1327
|
+
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && remaining.length > 0; i++) {
|
|
1328
|
+
const match = SEGMENT_PATTERN.exec(remaining);
|
|
1329
|
+
if (match === null) {
|
|
1330
|
+
throw new Error(`Invalid tag path segment at position ${path2.length - remaining.length}: "${remaining}"`);
|
|
1331
|
+
}
|
|
1332
|
+
segments.push(matchToSegment(match));
|
|
1333
|
+
remaining = advancePastMatch(remaining, match[0].length);
|
|
1078
1334
|
}
|
|
1079
|
-
if (
|
|
1080
|
-
|
|
1335
|
+
if (remaining.length > 0) throw new Error(`Tag path exceeds maximum depth of ${MAX_TRAVERSAL_DEPTH}`);
|
|
1336
|
+
if (segments.length === 0) throw new Error("Tag path is empty");
|
|
1337
|
+
return segments;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// src/dicom/ChangeSet.ts
|
|
1341
|
+
var ERASE_PRIVATE_SENTINEL = "__ERASE_PRIVATE__";
|
|
1342
|
+
function isControlChar(code) {
|
|
1343
|
+
if (code <= 9) return true;
|
|
1344
|
+
if (code === 11 || code === 12) return true;
|
|
1345
|
+
if (code >= 14 && code <= 31) return true;
|
|
1346
|
+
return code === 127;
|
|
1347
|
+
}
|
|
1348
|
+
function sanitizeValue(value) {
|
|
1349
|
+
let result = "";
|
|
1350
|
+
for (let i = 0; i < value.length; i++) {
|
|
1351
|
+
const code = value.charCodeAt(i);
|
|
1352
|
+
if (!isControlChar(code)) {
|
|
1353
|
+
result += value[i];
|
|
1354
|
+
}
|
|
1081
1355
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1356
|
+
return result;
|
|
1357
|
+
}
|
|
1358
|
+
function buildMergedModifications(base, other, erasures) {
|
|
1359
|
+
const merged = new Map(base);
|
|
1360
|
+
for (const [key, value] of other) {
|
|
1361
|
+
merged.set(key, value);
|
|
1084
1362
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1363
|
+
for (const key of erasures) {
|
|
1364
|
+
merged.delete(key);
|
|
1087
1365
|
}
|
|
1088
|
-
return
|
|
1366
|
+
return merged;
|
|
1089
1367
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1368
|
+
function applyBatchEntries(initial, entries) {
|
|
1369
|
+
const keys = Object.keys(entries);
|
|
1370
|
+
let cs = initial;
|
|
1371
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1372
|
+
const key = keys[i];
|
|
1373
|
+
if (key === void 0) continue;
|
|
1374
|
+
const value = entries[key];
|
|
1375
|
+
if (value === void 0) continue;
|
|
1376
|
+
cs = cs.setTag(key, value);
|
|
1377
|
+
}
|
|
1378
|
+
return cs;
|
|
1379
|
+
}
|
|
1380
|
+
var ChangeSet = class _ChangeSet {
|
|
1381
|
+
constructor(mods, erasures) {
|
|
1382
|
+
__publicField(this, "mods");
|
|
1383
|
+
__publicField(this, "erased");
|
|
1384
|
+
this.mods = mods;
|
|
1385
|
+
this.erased = erasures;
|
|
1386
|
+
}
|
|
1387
|
+
/** Creates an empty ChangeSet with no modifications or erasures. */
|
|
1388
|
+
static empty() {
|
|
1389
|
+
return new _ChangeSet(/* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Sets a tag value, returning a new ChangeSet.
|
|
1393
|
+
*
|
|
1394
|
+
* Control characters (except LF/CR) are stripped from the value.
|
|
1395
|
+
* If the tag was previously erased, it is removed from the erasure set.
|
|
1396
|
+
*
|
|
1397
|
+
* @param path - The DICOM tag path to set (e.g. `'(0010,0010)'`)
|
|
1398
|
+
* @param value - The new value for the tag
|
|
1399
|
+
* @returns A new ChangeSet with the modification applied
|
|
1400
|
+
* @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
|
|
1401
|
+
*/
|
|
1402
|
+
setTag(path2, value) {
|
|
1403
|
+
const totalOps = this.mods.size + this.erased.size;
|
|
1404
|
+
if (totalOps >= MAX_CHANGESET_OPERATIONS) {
|
|
1405
|
+
throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
|
|
1100
1406
|
}
|
|
1407
|
+
tagPathToSegments(path2);
|
|
1408
|
+
const sanitized = sanitizeValue(value);
|
|
1409
|
+
const newMods = new Map(this.mods);
|
|
1410
|
+
newMods.set(path2, sanitized);
|
|
1411
|
+
const newErasures = new Set(this.erased);
|
|
1412
|
+
newErasures.delete(path2);
|
|
1413
|
+
return new _ChangeSet(newMods, newErasures);
|
|
1101
1414
|
}
|
|
1102
1415
|
/**
|
|
1103
|
-
*
|
|
1416
|
+
* Marks a tag for erasure, returning a new ChangeSet.
|
|
1104
1417
|
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
* @
|
|
1418
|
+
* If the tag was previously set, the modification is removed.
|
|
1419
|
+
*
|
|
1420
|
+
* @param path - The DICOM tag path to erase (e.g. `'(0010,0010)'`)
|
|
1421
|
+
* @returns A new ChangeSet with the erasure applied
|
|
1422
|
+
* @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
|
|
1108
1423
|
*/
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
if (
|
|
1112
|
-
|
|
1424
|
+
eraseTag(path2) {
|
|
1425
|
+
const totalOps = this.mods.size + this.erased.size;
|
|
1426
|
+
if (totalOps >= MAX_CHANGESET_OPERATIONS) {
|
|
1427
|
+
throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
|
|
1113
1428
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1429
|
+
tagPathToSegments(path2);
|
|
1430
|
+
const newMods = new Map(this.mods);
|
|
1431
|
+
newMods.delete(path2);
|
|
1432
|
+
const newErasures = new Set(this.erased);
|
|
1433
|
+
newErasures.add(path2);
|
|
1434
|
+
return new _ChangeSet(newMods, newErasures);
|
|
1116
1435
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1436
|
+
/**
|
|
1437
|
+
* Marks all private tags for erasure, returning a new ChangeSet.
|
|
1438
|
+
*
|
|
1439
|
+
* @returns A new ChangeSet with the erase-private flag set
|
|
1440
|
+
* @throws Error if operation count would exceed MAX_CHANGESET_OPERATIONS
|
|
1441
|
+
*/
|
|
1442
|
+
erasePrivateTags() {
|
|
1443
|
+
const totalOps = this.mods.size + this.erased.size;
|
|
1444
|
+
if (totalOps >= MAX_CHANGESET_OPERATIONS) {
|
|
1445
|
+
throw new Error(`ChangeSet operation limit (${MAX_CHANGESET_OPERATIONS}) exceeded`);
|
|
1446
|
+
}
|
|
1447
|
+
const newErasures = new Set(this.erased);
|
|
1448
|
+
newErasures.add(ERASE_PRIVATE_SENTINEL);
|
|
1449
|
+
return new _ChangeSet(new Map(this.mods), newErasures);
|
|
1450
|
+
}
|
|
1451
|
+
// -----------------------------------------------------------------------
|
|
1452
|
+
// Convenience setters for common DICOM tags
|
|
1453
|
+
// -----------------------------------------------------------------------
|
|
1454
|
+
/** Sets Patient's Name (0010,0010). */
|
|
1455
|
+
setPatientName(value) {
|
|
1456
|
+
return this.setTag("(0010,0010)", value);
|
|
1457
|
+
}
|
|
1458
|
+
/** Sets Patient ID (0010,0020). */
|
|
1459
|
+
setPatientID(value) {
|
|
1460
|
+
return this.setTag("(0010,0020)", value);
|
|
1461
|
+
}
|
|
1462
|
+
/** Sets Study Date (0008,0020). */
|
|
1463
|
+
setStudyDate(value) {
|
|
1464
|
+
return this.setTag("(0008,0020)", value);
|
|
1465
|
+
}
|
|
1466
|
+
/** Sets Modality (0008,0060). */
|
|
1467
|
+
setModality(value) {
|
|
1468
|
+
return this.setTag("(0008,0060)", value);
|
|
1469
|
+
}
|
|
1470
|
+
/** Sets Accession Number (0008,0050). */
|
|
1471
|
+
setAccessionNumber(value) {
|
|
1472
|
+
return this.setTag("(0008,0050)", value);
|
|
1473
|
+
}
|
|
1474
|
+
/** Sets Study Description (0008,1030). */
|
|
1475
|
+
setStudyDescription(value) {
|
|
1476
|
+
return this.setTag("(0008,1030)", value);
|
|
1477
|
+
}
|
|
1478
|
+
/** Sets Series Description (0008,103E). */
|
|
1479
|
+
setSeriesDescription(value) {
|
|
1480
|
+
return this.setTag("(0008,103E)", value);
|
|
1481
|
+
}
|
|
1482
|
+
/** Sets Institution Name (0008,0080). */
|
|
1483
|
+
setInstitutionName(value) {
|
|
1484
|
+
return this.setTag("(0008,0080)", value);
|
|
1119
1485
|
}
|
|
1120
1486
|
/**
|
|
1121
|
-
*
|
|
1487
|
+
* Sets multiple tags at once, returning a new ChangeSet.
|
|
1122
1488
|
*
|
|
1123
|
-
* @param
|
|
1124
|
-
* @returns
|
|
1489
|
+
* @param entries - A record of tag path → value pairs
|
|
1490
|
+
* @returns A new ChangeSet with all modifications applied
|
|
1125
1491
|
*/
|
|
1126
|
-
|
|
1127
|
-
return this
|
|
1492
|
+
setBatch(entries) {
|
|
1493
|
+
return applyBatchEntries(this, entries);
|
|
1494
|
+
}
|
|
1495
|
+
/** All pending tag modifications as a readonly map of path → value. */
|
|
1496
|
+
get modifications() {
|
|
1497
|
+
return this.mods;
|
|
1498
|
+
}
|
|
1499
|
+
/** All pending tag erasures as a readonly set of paths. */
|
|
1500
|
+
get erasures() {
|
|
1501
|
+
return this.erased;
|
|
1502
|
+
}
|
|
1503
|
+
/** Total number of operations (modifications + erasures) in this ChangeSet. */
|
|
1504
|
+
get operationCount() {
|
|
1505
|
+
return this.mods.size + this.erased.size;
|
|
1506
|
+
}
|
|
1507
|
+
/** Whether the ChangeSet has no modifications and no erasures. */
|
|
1508
|
+
get isEmpty() {
|
|
1509
|
+
return this.mods.size === 0 && this.erased.size === 0;
|
|
1510
|
+
}
|
|
1511
|
+
/** Whether the erase-all-private-tags flag is set. */
|
|
1512
|
+
get erasePrivate() {
|
|
1513
|
+
return this.erased.has(ERASE_PRIVATE_SENTINEL);
|
|
1128
1514
|
}
|
|
1129
1515
|
/**
|
|
1130
|
-
*
|
|
1516
|
+
* Merges another ChangeSet into this one, returning a new ChangeSet.
|
|
1131
1517
|
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1518
|
+
* The `other` ChangeSet wins on conflicts: if the same tag is modified in both,
|
|
1519
|
+
* `other`'s value is used. Erasures from both sets are unioned. An erasure in
|
|
1520
|
+
* `other` removes a modification from `base`.
|
|
1521
|
+
*
|
|
1522
|
+
* @param other - The ChangeSet to merge in
|
|
1523
|
+
* @returns A new ChangeSet with merged modifications and erasures
|
|
1134
1524
|
*/
|
|
1135
|
-
|
|
1136
|
-
|
|
1525
|
+
merge(other) {
|
|
1526
|
+
const mergedErasures = /* @__PURE__ */ new Set([...this.erased, ...other.erased]);
|
|
1527
|
+
const mergedMods = buildMergedModifications(this.mods, other.mods, mergedErasures);
|
|
1528
|
+
return new _ChangeSet(mergedMods, mergedErasures);
|
|
1137
1529
|
}
|
|
1138
1530
|
/**
|
|
1139
|
-
*
|
|
1531
|
+
* Converts modifications to dcmodify-compatible TagModification array.
|
|
1140
1532
|
*
|
|
1141
|
-
* @
|
|
1142
|
-
* @returns A Result containing the server instance or a validation/resolution error
|
|
1533
|
+
* @returns A readonly array of TagModification objects
|
|
1143
1534
|
*/
|
|
1144
|
-
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1535
|
+
toModifications() {
|
|
1536
|
+
const result = [];
|
|
1537
|
+
for (const [tag, value] of this.mods) {
|
|
1538
|
+
result.push({ tag, value });
|
|
1148
1539
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1540
|
+
return result;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Converts erasures to dcmodify-compatible argument strings.
|
|
1544
|
+
*
|
|
1545
|
+
* The erase-private sentinel is excluded — use {@link erasePrivate} to check
|
|
1546
|
+
* whether `-ep` should be passed.
|
|
1547
|
+
*
|
|
1548
|
+
* @returns A readonly array of tag path strings for `-e` arguments
|
|
1549
|
+
*/
|
|
1550
|
+
toErasureArgs() {
|
|
1551
|
+
const result = [];
|
|
1552
|
+
for (const path2 of this.erased) {
|
|
1553
|
+
if (path2 !== ERASE_PRIVATE_SENTINEL) {
|
|
1554
|
+
result.push(path2);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
return result;
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
var HEX_TAG_KEY = /^[0-9A-Fa-f]{8}$/;
|
|
1561
|
+
function isDicomJsonElement(value) {
|
|
1562
|
+
return typeof value === "object" && value !== null && "vr" in value && typeof value["vr"] === "string";
|
|
1563
|
+
}
|
|
1564
|
+
function isValidDicomJsonModel(obj) {
|
|
1565
|
+
const keys = Object.keys(obj);
|
|
1566
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1567
|
+
const key = keys[i];
|
|
1568
|
+
if (key === void 0) continue;
|
|
1569
|
+
if (!HEX_TAG_KEY.test(key)) return false;
|
|
1570
|
+
if (!isDicomJsonElement(obj[key])) return false;
|
|
1571
|
+
}
|
|
1572
|
+
return true;
|
|
1573
|
+
}
|
|
1574
|
+
var TAG_WITH_PARENS = /^\(([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)$/;
|
|
1575
|
+
var TAG_HEX_ONLY = /^[0-9A-Fa-f]{8}$/;
|
|
1576
|
+
function normalizeTag(tag) {
|
|
1577
|
+
const parenMatch = TAG_WITH_PARENS.exec(tag);
|
|
1578
|
+
if (parenMatch !== null) {
|
|
1579
|
+
const group = parenMatch[1];
|
|
1580
|
+
const element = parenMatch[2];
|
|
1581
|
+
if (group === void 0 || element === void 0) return err(new Error(`Invalid tag: "${tag}"`));
|
|
1582
|
+
return ok(`${group}${element}`.toUpperCase());
|
|
1583
|
+
}
|
|
1584
|
+
if (TAG_HEX_ONLY.test(tag)) {
|
|
1585
|
+
return ok(tag.toUpperCase());
|
|
1586
|
+
}
|
|
1587
|
+
return err(new Error(`Invalid tag format: "${tag}". Expected (XXXX,XXXX) or XXXXXXXX`));
|
|
1588
|
+
}
|
|
1589
|
+
function resolveElement(data, tagKey) {
|
|
1590
|
+
return data[tagKey];
|
|
1591
|
+
}
|
|
1592
|
+
function extractPNAlphabetic(first) {
|
|
1593
|
+
if (typeof first !== "object" || first === null) return "";
|
|
1594
|
+
const pn = first;
|
|
1595
|
+
const alphabetic = pn["Alphabetic"];
|
|
1596
|
+
return typeof alphabetic === "string" ? alphabetic : "";
|
|
1597
|
+
}
|
|
1598
|
+
function primitiveToString(value) {
|
|
1599
|
+
if (typeof value === "string") return value;
|
|
1600
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1601
|
+
return "";
|
|
1602
|
+
}
|
|
1603
|
+
function extractString(element) {
|
|
1604
|
+
const values = element.Value;
|
|
1605
|
+
if (values === void 0 || values.length === 0) return "";
|
|
1606
|
+
const first = values[0];
|
|
1607
|
+
if (first === void 0 || first === null) return "";
|
|
1608
|
+
if (element.vr === "PN") return extractPNAlphabetic(first);
|
|
1609
|
+
return primitiveToString(first);
|
|
1610
|
+
}
|
|
1611
|
+
function extractNumber(element) {
|
|
1612
|
+
const values = element.Value;
|
|
1613
|
+
if (values === void 0 || values.length === 0) {
|
|
1614
|
+
return err(new Error("Element has no Value array"));
|
|
1615
|
+
}
|
|
1616
|
+
const first = values[0];
|
|
1617
|
+
if (typeof first === "number") return ok(first);
|
|
1618
|
+
if (typeof first === "string") {
|
|
1619
|
+
const parsed = Number(first);
|
|
1620
|
+
if (Number.isNaN(parsed)) {
|
|
1621
|
+
return err(new Error(`Cannot convert "${first}" to number`));
|
|
1622
|
+
}
|
|
1623
|
+
return ok(parsed);
|
|
1624
|
+
}
|
|
1625
|
+
return err(new Error(`Value is not numeric: ${typeof first}`));
|
|
1626
|
+
}
|
|
1627
|
+
function extractStrings(element) {
|
|
1628
|
+
const values = element.Value;
|
|
1629
|
+
if (values === void 0) return [];
|
|
1630
|
+
const result = [];
|
|
1631
|
+
for (const v of values) {
|
|
1632
|
+
if (typeof v === "string") {
|
|
1633
|
+
result.push(v);
|
|
1634
|
+
} else if (typeof v === "number" || typeof v === "boolean") {
|
|
1635
|
+
result.push(String(v));
|
|
1636
|
+
} else {
|
|
1637
|
+
result.push("");
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
return result;
|
|
1641
|
+
}
|
|
1642
|
+
function getSequenceItems(element) {
|
|
1643
|
+
if (element.vr !== "SQ" || element.Value === void 0) return void 0;
|
|
1644
|
+
return element.Value;
|
|
1645
|
+
}
|
|
1646
|
+
function isPlausibleModel(value) {
|
|
1647
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1648
|
+
}
|
|
1649
|
+
function descendIntoSequence(element, seg) {
|
|
1650
|
+
const items = getSequenceItems(element);
|
|
1651
|
+
if (items === void 0) {
|
|
1652
|
+
return err(new Error(`Tag ${seg.tag} is not a sequence`));
|
|
1653
|
+
}
|
|
1654
|
+
const idx = seg.index ?? 0;
|
|
1655
|
+
const item = items[idx];
|
|
1656
|
+
if (!isPlausibleModel(item)) {
|
|
1657
|
+
return err(new Error(`Sequence ${seg.tag} has no valid item at index ${idx}`));
|
|
1658
|
+
}
|
|
1659
|
+
return ok(item);
|
|
1660
|
+
}
|
|
1661
|
+
function traversePath(data, segments) {
|
|
1662
|
+
let current = data;
|
|
1663
|
+
for (let i = 0; i < segments.length; i++) {
|
|
1664
|
+
const seg = segments[i];
|
|
1665
|
+
if (seg === void 0) return err(new Error("Unexpected undefined segment"));
|
|
1666
|
+
const tagResult = normalizeTag(seg.tag);
|
|
1667
|
+
if (!tagResult.ok) return err(tagResult.error);
|
|
1668
|
+
const element = resolveElement(current, tagResult.value);
|
|
1669
|
+
if (element === void 0) return err(new Error(`Tag ${seg.tag} not found`));
|
|
1670
|
+
if (i === segments.length - 1) return ok(element);
|
|
1671
|
+
const descent = descendIntoSequence(element, seg);
|
|
1672
|
+
if (!descent.ok) return err(descent.error);
|
|
1673
|
+
current = descent.value;
|
|
1674
|
+
}
|
|
1675
|
+
return err(new Error("Empty path segments"));
|
|
1676
|
+
}
|
|
1677
|
+
function collectWildcard(data, segments) {
|
|
1678
|
+
const results = [];
|
|
1679
|
+
const queue = [{ data, segmentIndex: 0 }];
|
|
1680
|
+
const maxIterations = MAX_TRAVERSAL_DEPTH * 100;
|
|
1681
|
+
let iteration = 0;
|
|
1682
|
+
for (; iteration < maxIterations && queue.length > 0; iteration++) {
|
|
1683
|
+
const entry = queue.shift();
|
|
1684
|
+
if (entry === void 0) break;
|
|
1685
|
+
processQueueEntry(entry, segments, results, queue);
|
|
1686
|
+
}
|
|
1687
|
+
return { values: results, truncated: iteration >= maxIterations && queue.length > 0 };
|
|
1688
|
+
}
|
|
1689
|
+
function processQueueEntry(entry, segments, results, queue) {
|
|
1690
|
+
const seg = segments[entry.segmentIndex];
|
|
1691
|
+
if (seg === void 0) return;
|
|
1692
|
+
const tagResult = normalizeTag(seg.tag);
|
|
1693
|
+
if (!tagResult.ok) return;
|
|
1694
|
+
const element = resolveElement(entry.data, tagResult.value);
|
|
1695
|
+
if (element === void 0) return;
|
|
1696
|
+
const isLast = entry.segmentIndex === segments.length - 1;
|
|
1697
|
+
if (isLast) {
|
|
1698
|
+
collectLeafValues(element, results);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
enqueueSequenceItems(element, seg, entry.segmentIndex, queue);
|
|
1702
|
+
}
|
|
1703
|
+
function collectLeafValues(element, results) {
|
|
1704
|
+
if (element.Value !== void 0) {
|
|
1705
|
+
for (const v of element.Value) {
|
|
1706
|
+
results.push(v);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
function enqueueSequenceItems(element, seg, segmentIndex, queue) {
|
|
1711
|
+
const items = getSequenceItems(element);
|
|
1712
|
+
if (items === void 0) return;
|
|
1713
|
+
if (seg.isWildcard === true) {
|
|
1714
|
+
for (const item of items) {
|
|
1715
|
+
if (isPlausibleModel(item)) {
|
|
1716
|
+
queue.push({ data: item, segmentIndex: segmentIndex + 1 });
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
} else {
|
|
1720
|
+
const idx = seg.index ?? 0;
|
|
1721
|
+
const item = items[idx];
|
|
1722
|
+
if (isPlausibleModel(item)) {
|
|
1723
|
+
queue.push({ data: item, segmentIndex: segmentIndex + 1 });
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
var TAGS = {
|
|
1728
|
+
AccessionNumber: "00080050",
|
|
1729
|
+
PatientName: "00100010",
|
|
1730
|
+
PatientID: "00100020",
|
|
1731
|
+
StudyDate: "00080020",
|
|
1732
|
+
Modality: "00080060",
|
|
1733
|
+
SOPClassUID: "00080016",
|
|
1734
|
+
StudyInstanceUID: "0020000D",
|
|
1735
|
+
SeriesInstanceUID: "0020000E",
|
|
1736
|
+
SOPInstanceUID: "00080018",
|
|
1737
|
+
TransferSyntaxUID: "00020010"
|
|
1738
|
+
};
|
|
1739
|
+
var DicomDataset = class _DicomDataset {
|
|
1740
|
+
constructor(data) {
|
|
1741
|
+
__publicField(this, "data");
|
|
1742
|
+
this.data = data;
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Creates a DicomDataset from a DICOM JSON Model object.
|
|
1746
|
+
*
|
|
1747
|
+
* Performs structural validation only — verifies the input is a non-null object.
|
|
1748
|
+
*
|
|
1749
|
+
* @param json - A DICOM JSON Model object (typically from dcm2json)
|
|
1750
|
+
* @returns A Result containing the DicomDataset or an error
|
|
1751
|
+
*/
|
|
1752
|
+
static fromJson(json) {
|
|
1753
|
+
if (json === null || json === void 0 || typeof json !== "object" || Array.isArray(json)) {
|
|
1754
|
+
return err(new Error("Invalid DICOM JSON: expected a non-null, non-array object"));
|
|
1755
|
+
}
|
|
1756
|
+
const obj = json;
|
|
1757
|
+
if (!isValidDicomJsonModel(obj)) {
|
|
1758
|
+
return err(new Error('Invalid DICOM JSON: every key must be an 8-char hex tag and every value must have a "vr" string property'));
|
|
1759
|
+
}
|
|
1760
|
+
return ok(new _DicomDataset(json));
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Gets the full DICOM JSON element for a tag.
|
|
1764
|
+
*
|
|
1765
|
+
* @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
|
|
1766
|
+
* @returns Result containing the element or an error if not found
|
|
1767
|
+
*/
|
|
1768
|
+
getElement(tag) {
|
|
1769
|
+
const norm = normalizeTag(tag);
|
|
1770
|
+
if (!norm.ok) return err(norm.error);
|
|
1771
|
+
const element = resolveElement(this.data, norm.value);
|
|
1772
|
+
if (element === void 0) return err(new Error(`Tag ${tag} not found`));
|
|
1773
|
+
return ok(element);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Gets the Value array for a tag.
|
|
1777
|
+
*
|
|
1778
|
+
* @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
|
|
1779
|
+
* @returns Result containing the readonly Value array or an error
|
|
1780
|
+
*/
|
|
1781
|
+
getValue(tag) {
|
|
1782
|
+
const elemResult = this.getElement(tag);
|
|
1783
|
+
if (!elemResult.ok) return err(elemResult.error);
|
|
1784
|
+
const values = elemResult.value.Value;
|
|
1785
|
+
if (values === void 0) return err(new Error(`Tag ${tag} has no Value`));
|
|
1786
|
+
return ok(values);
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Gets the first element of the Value array for a tag.
|
|
1790
|
+
*
|
|
1791
|
+
* @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
|
|
1792
|
+
* @returns Result containing the first value or an error
|
|
1793
|
+
*/
|
|
1794
|
+
getFirstValue(tag) {
|
|
1795
|
+
const valResult = this.getValue(tag);
|
|
1796
|
+
if (!valResult.ok) return err(valResult.error);
|
|
1797
|
+
const first = valResult.value[0];
|
|
1798
|
+
if (first === void 0) return err(new Error(`Tag ${tag} Value array is empty`));
|
|
1799
|
+
return ok(first);
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Gets a tag value as a string with optional fallback.
|
|
1803
|
+
*
|
|
1804
|
+
* Returns the fallback (default empty string) if the tag is missing or has no value.
|
|
1805
|
+
* Handles PN (PersonName) values by extracting the Alphabetic component.
|
|
1806
|
+
*
|
|
1807
|
+
* @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
|
|
1808
|
+
* @param fallback - Value to return if tag is missing (default: `''`)
|
|
1809
|
+
* @returns The string value or the fallback
|
|
1810
|
+
*/
|
|
1811
|
+
getString(tag, fallback = "") {
|
|
1812
|
+
const elemResult = this.getElement(tag);
|
|
1813
|
+
if (!elemResult.ok) return fallback;
|
|
1814
|
+
const str = extractString(elemResult.value);
|
|
1815
|
+
return str.length > 0 ? str : fallback;
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Gets a tag value as a validated number.
|
|
1819
|
+
*
|
|
1820
|
+
* @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
|
|
1821
|
+
* @returns Result containing the number or an error
|
|
1822
|
+
*/
|
|
1823
|
+
getNumber(tag) {
|
|
1824
|
+
const elemResult = this.getElement(tag);
|
|
1825
|
+
if (!elemResult.ok) return err(elemResult.error);
|
|
1826
|
+
return extractNumber(elemResult.value);
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Gets all values of a tag as strings.
|
|
1830
|
+
*
|
|
1831
|
+
* Useful for multi-valued tags like CS (Code String).
|
|
1832
|
+
*
|
|
1833
|
+
* @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
|
|
1834
|
+
* @returns Result containing the readonly string array or an error
|
|
1835
|
+
*/
|
|
1836
|
+
getStrings(tag) {
|
|
1837
|
+
const elemResult = this.getElement(tag);
|
|
1838
|
+
if (!elemResult.ok) return err(elemResult.error);
|
|
1839
|
+
return ok(extractStrings(elemResult.value));
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Checks whether a tag exists in the dataset.
|
|
1843
|
+
*
|
|
1844
|
+
* @param tag - A DicomTag `(0010,0010)` or hex string `00100010`
|
|
1845
|
+
* @returns `true` if the tag is present
|
|
1846
|
+
*/
|
|
1847
|
+
hasTag(tag) {
|
|
1848
|
+
const norm = normalizeTag(tag);
|
|
1849
|
+
if (!norm.ok) return false;
|
|
1850
|
+
return resolveElement(this.data, norm.value) !== void 0;
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Gets an element by traversing a dotted tag path through sequences.
|
|
1854
|
+
*
|
|
1855
|
+
* @param path - A branded DicomTagPath, e.g. `(0040,A730)[0].(0040,A160)`
|
|
1856
|
+
* @returns Result containing the element at the path or an error
|
|
1857
|
+
*/
|
|
1858
|
+
getElementAtPath(path2) {
|
|
1859
|
+
let segments;
|
|
1860
|
+
try {
|
|
1861
|
+
segments = tagPathToSegments(path2);
|
|
1862
|
+
} catch (e) {
|
|
1863
|
+
return err(stderrLib.stderr(e));
|
|
1864
|
+
}
|
|
1865
|
+
return traversePath(this.data, segments);
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* Finds all values matching a path with wildcard `[*]` indices.
|
|
1869
|
+
*
|
|
1870
|
+
* Traverses all items in wildcard sequence positions using an iterative BFS queue.
|
|
1871
|
+
* The traversal is bounded to {@link MAX_TRAVERSAL_DEPTH} * 100 iterations (5 000).
|
|
1872
|
+
* For extremely large datasets this may truncate results silently; callers
|
|
1873
|
+
* needing completeness guarantees should verify dataset size independently.
|
|
1874
|
+
*
|
|
1875
|
+
* @param path - A branded DicomTagPath, e.g. `(0040,A730)[*].(0040,A160)`
|
|
1876
|
+
* @returns A readonly array of all matching values (may be empty)
|
|
1877
|
+
*/
|
|
1878
|
+
findValues(path2) {
|
|
1879
|
+
let segments;
|
|
1880
|
+
try {
|
|
1881
|
+
segments = tagPathToSegments(path2);
|
|
1882
|
+
} catch {
|
|
1883
|
+
return [];
|
|
1884
|
+
}
|
|
1885
|
+
const result = collectWildcard(this.data, segments);
|
|
1886
|
+
return result.values;
|
|
1887
|
+
}
|
|
1888
|
+
// -----------------------------------------------------------------------
|
|
1889
|
+
// Convenience readonly getters
|
|
1890
|
+
// -----------------------------------------------------------------------
|
|
1891
|
+
/** Accession Number (0008,0050). */
|
|
1892
|
+
get accession() {
|
|
1893
|
+
return this.getString(TAGS.AccessionNumber);
|
|
1894
|
+
}
|
|
1895
|
+
/** Patient's Name (0010,0010). */
|
|
1896
|
+
get patientName() {
|
|
1897
|
+
return this.getString(TAGS.PatientName);
|
|
1898
|
+
}
|
|
1899
|
+
/** Patient ID (0010,0020). */
|
|
1900
|
+
get patientID() {
|
|
1901
|
+
return this.getString(TAGS.PatientID);
|
|
1902
|
+
}
|
|
1903
|
+
/** Study Date (0008,0020). */
|
|
1904
|
+
get studyDate() {
|
|
1905
|
+
return this.getString(TAGS.StudyDate);
|
|
1906
|
+
}
|
|
1907
|
+
/** Modality (0008,0060). */
|
|
1908
|
+
get modality() {
|
|
1909
|
+
return this.getString(TAGS.Modality);
|
|
1910
|
+
}
|
|
1911
|
+
/** SOP Class UID (0008,0016) as a branded SOPClassUID, or undefined if missing/invalid. */
|
|
1912
|
+
get sopClassUID() {
|
|
1913
|
+
const uid = this.getString(TAGS.SOPClassUID);
|
|
1914
|
+
if (uid.length === 0) return void 0;
|
|
1915
|
+
const result = createSOPClassUID(uid);
|
|
1916
|
+
return result.ok ? result.value : void 0;
|
|
1917
|
+
}
|
|
1918
|
+
/** Study Instance UID (0020,000D). */
|
|
1919
|
+
get studyInstanceUID() {
|
|
1920
|
+
return this.getString(TAGS.StudyInstanceUID);
|
|
1921
|
+
}
|
|
1922
|
+
/** Series Instance UID (0020,000E). */
|
|
1923
|
+
get seriesInstanceUID() {
|
|
1924
|
+
return this.getString(TAGS.SeriesInstanceUID);
|
|
1925
|
+
}
|
|
1926
|
+
/** SOP Instance UID (0008,0018). */
|
|
1927
|
+
get sopInstanceUID() {
|
|
1928
|
+
return this.getString(TAGS.SOPInstanceUID);
|
|
1929
|
+
}
|
|
1930
|
+
/** Transfer Syntax UID (0002,0010). */
|
|
1931
|
+
get transferSyntaxUID() {
|
|
1932
|
+
return this.getString(TAGS.TransferSyntaxUID);
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
function killTree(pid) {
|
|
1936
|
+
try {
|
|
1937
|
+
kill__default.default(pid);
|
|
1938
|
+
} catch {
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
async function execCommand(binary, args, options) {
|
|
1942
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1943
|
+
return new Promise((resolve) => {
|
|
1944
|
+
const child = child_process.spawn(binary, [...args], {
|
|
1945
|
+
cwd: options?.cwd,
|
|
1946
|
+
windowsHide: true,
|
|
1947
|
+
signal: options?.signal
|
|
1948
|
+
});
|
|
1949
|
+
wireSpawnListeners(child, timeoutMs, resolve);
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
function wireSpawnListeners(child, timeoutMs, resolve) {
|
|
1953
|
+
let stdout = "";
|
|
1954
|
+
let stderr6 = "";
|
|
1955
|
+
let settled = false;
|
|
1956
|
+
const settle = (result) => {
|
|
1957
|
+
if (settled) return;
|
|
1958
|
+
settled = true;
|
|
1959
|
+
clearTimeout(timer);
|
|
1960
|
+
resolve(result);
|
|
1961
|
+
};
|
|
1962
|
+
const timer = setTimeout(() => {
|
|
1963
|
+
if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
|
|
1964
|
+
settle(err(new Error(`Process timed out after ${timeoutMs}ms`)));
|
|
1965
|
+
}, timeoutMs);
|
|
1966
|
+
child.stdout?.on("data", (chunk) => {
|
|
1967
|
+
stdout += String(chunk);
|
|
1968
|
+
if (stdout.length + stderr6.length > MAX_OUTPUT_BYTES) {
|
|
1969
|
+
if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
|
|
1970
|
+
settle(err(new Error(`Process output exceeded ${MAX_OUTPUT_BYTES} bytes`)));
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
child.stderr?.on("data", (chunk) => {
|
|
1974
|
+
stderr6 += String(chunk);
|
|
1975
|
+
if (stdout.length + stderr6.length > MAX_OUTPUT_BYTES) {
|
|
1976
|
+
if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
|
|
1977
|
+
settle(err(new Error(`Process output exceeded ${MAX_OUTPUT_BYTES} bytes`)));
|
|
1978
|
+
}
|
|
1979
|
+
});
|
|
1980
|
+
child.on("error", (error) => {
|
|
1981
|
+
if (child.pid !== void 0 && child.pid !== null) killTree(child.pid);
|
|
1982
|
+
settle(err(new Error(`Process error: ${error.message}`)));
|
|
1983
|
+
});
|
|
1984
|
+
child.on("close", (code) => {
|
|
1985
|
+
settle(ok({ stdout, stderr: stderr6, exitCode: code ?? 1 }));
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
async function spawnCommand(binary, args, options) {
|
|
1989
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1990
|
+
return new Promise((resolve) => {
|
|
1991
|
+
const child = child_process.spawn(binary, [...args], {
|
|
1992
|
+
cwd: options?.cwd,
|
|
1993
|
+
env: options?.env ? { ...process.env, ...options.env } : void 0,
|
|
1994
|
+
windowsHide: true,
|
|
1995
|
+
signal: options?.signal
|
|
1996
|
+
});
|
|
1997
|
+
wireSpawnListeners(child, timeoutMs, resolve);
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
var PN_REPS = ["Alphabetic", "Ideographic", "Phonetic"];
|
|
2001
|
+
var ARRAY_TAG_NAMES = /* @__PURE__ */ new Set(["DicomAttribute", "Value", "PersonName", "Item"]);
|
|
2002
|
+
var KNOWN_VR_CODES = /* @__PURE__ */ new Set([
|
|
2003
|
+
"AE",
|
|
2004
|
+
"AS",
|
|
2005
|
+
"AT",
|
|
2006
|
+
"CS",
|
|
2007
|
+
"DA",
|
|
2008
|
+
"DS",
|
|
2009
|
+
"DT",
|
|
2010
|
+
"FD",
|
|
2011
|
+
"FL",
|
|
2012
|
+
"IS",
|
|
2013
|
+
"LO",
|
|
2014
|
+
"LT",
|
|
2015
|
+
"OB",
|
|
2016
|
+
"OD",
|
|
2017
|
+
"OF",
|
|
2018
|
+
"OL",
|
|
2019
|
+
"OV",
|
|
2020
|
+
"OW",
|
|
2021
|
+
"PN",
|
|
2022
|
+
"SH",
|
|
2023
|
+
"SL",
|
|
2024
|
+
"SQ",
|
|
2025
|
+
"SS",
|
|
2026
|
+
"ST",
|
|
2027
|
+
"SV",
|
|
2028
|
+
"TM",
|
|
2029
|
+
"UC",
|
|
2030
|
+
"UI",
|
|
2031
|
+
"UL",
|
|
2032
|
+
"UN",
|
|
2033
|
+
"UR",
|
|
2034
|
+
"US",
|
|
2035
|
+
"UT",
|
|
2036
|
+
"UV"
|
|
2037
|
+
]);
|
|
2038
|
+
function buildPnString(comp) {
|
|
2039
|
+
const parts = [comp.FamilyName ?? "", comp.GivenName ?? "", comp.MiddleName ?? "", comp.NamePrefix ?? "", comp.NameSuffix ?? ""];
|
|
2040
|
+
let last = parts.length - 1;
|
|
2041
|
+
for (; last >= 0; last--) {
|
|
2042
|
+
if (parts[last] !== "") break;
|
|
2043
|
+
}
|
|
2044
|
+
return parts.slice(0, last + 1).join("^");
|
|
2045
|
+
}
|
|
2046
|
+
function toArray(val) {
|
|
2047
|
+
if (Array.isArray(val)) return val;
|
|
2048
|
+
if (val === void 0 || val === null) return [];
|
|
2049
|
+
return [val];
|
|
2050
|
+
}
|
|
2051
|
+
function convertPersonName(pnNode) {
|
|
2052
|
+
const result = {};
|
|
2053
|
+
if (typeof pnNode !== "object" || pnNode === null) return result;
|
|
2054
|
+
const pn = pnNode;
|
|
2055
|
+
for (const rep of PN_REPS) {
|
|
2056
|
+
const repNode = pn[rep];
|
|
2057
|
+
if (repNode !== void 0 && typeof repNode === "object" && repNode !== null) {
|
|
2058
|
+
const str = buildPnString(repNode);
|
|
2059
|
+
if (str.length > 0) {
|
|
2060
|
+
result[rep] = str;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
return result;
|
|
2065
|
+
}
|
|
2066
|
+
function safeString(val) {
|
|
2067
|
+
if (typeof val === "string") return val;
|
|
2068
|
+
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
|
2069
|
+
return "";
|
|
2070
|
+
}
|
|
2071
|
+
function convertInlineBinary(attr, element) {
|
|
2072
|
+
element.InlineBinary = safeString(attr.InlineBinary);
|
|
2073
|
+
}
|
|
2074
|
+
function convertBulkDataURI(attr, element) {
|
|
2075
|
+
const bulkArray = toArray(attr.BulkDataURI);
|
|
2076
|
+
const firstBulk = bulkArray[0];
|
|
2077
|
+
if (typeof firstBulk === "object" && firstBulk !== null && "@_uri" in firstBulk) {
|
|
2078
|
+
element.BulkDataURI = safeString(firstBulk["@_uri"]);
|
|
2079
|
+
} else {
|
|
2080
|
+
element.BulkDataURI = safeString(firstBulk);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
function convertPNValue(attr, element) {
|
|
2084
|
+
const pnArray = toArray(attr.PersonName);
|
|
2085
|
+
const values = [];
|
|
2086
|
+
for (const pn of pnArray) {
|
|
2087
|
+
values.push(convertPersonName(pn));
|
|
2088
|
+
}
|
|
2089
|
+
if (values.length > 0) element.Value = values;
|
|
2090
|
+
}
|
|
2091
|
+
function convertSequence(attr, element) {
|
|
2092
|
+
const items = toArray(attr.Item);
|
|
2093
|
+
const values = [];
|
|
2094
|
+
for (const item of items) {
|
|
2095
|
+
if (typeof item !== "object" || item === null) continue;
|
|
2096
|
+
values.push(convertAttributes(item));
|
|
2097
|
+
}
|
|
2098
|
+
if (values.length > 0) element.Value = values;
|
|
2099
|
+
}
|
|
2100
|
+
function convertRegularValue(attr, element) {
|
|
2101
|
+
const valArray = toArray(attr.Value);
|
|
2102
|
+
const values = [];
|
|
2103
|
+
for (const v of valArray) {
|
|
2104
|
+
if (typeof v === "object" && v !== null && "#text" in v) {
|
|
2105
|
+
values.push(v["#text"]);
|
|
2106
|
+
} else {
|
|
2107
|
+
values.push(v);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
if (values.length > 0) element.Value = values;
|
|
2111
|
+
}
|
|
2112
|
+
function convertElement(attr) {
|
|
2113
|
+
const rawVr = attr["@_vr"];
|
|
2114
|
+
const vr = KNOWN_VR_CODES.has(rawVr) ? rawVr : "UN";
|
|
2115
|
+
const element = { vr };
|
|
2116
|
+
if (attr.InlineBinary !== void 0) {
|
|
2117
|
+
convertInlineBinary(attr, element);
|
|
2118
|
+
} else if (attr.BulkDataURI !== void 0) {
|
|
2119
|
+
convertBulkDataURI(attr, element);
|
|
2120
|
+
} else if (element.vr === "PN" && attr.PersonName !== void 0) {
|
|
2121
|
+
convertPNValue(attr, element);
|
|
2122
|
+
} else if (element.vr === "SQ" && attr.Item !== void 0) {
|
|
2123
|
+
convertSequence(attr, element);
|
|
2124
|
+
} else if (attr.Value !== void 0) {
|
|
2125
|
+
convertRegularValue(attr, element);
|
|
2126
|
+
}
|
|
2127
|
+
return Object.freeze(element);
|
|
2128
|
+
}
|
|
2129
|
+
function convertAttributes(obj) {
|
|
2130
|
+
const result = {};
|
|
2131
|
+
const attrs = toArray(obj["DicomAttribute"]);
|
|
2132
|
+
for (const attr of attrs) {
|
|
2133
|
+
if (typeof attr !== "object" || attr === null) continue;
|
|
2134
|
+
const xmlAttr = attr;
|
|
2135
|
+
const tag = xmlAttr["@_tag"];
|
|
2136
|
+
if (tag === void 0) continue;
|
|
2137
|
+
result[tag] = convertElement(xmlAttr);
|
|
2138
|
+
}
|
|
2139
|
+
return result;
|
|
2140
|
+
}
|
|
2141
|
+
var parser = new fastXmlParser.XMLParser({
|
|
2142
|
+
ignoreAttributes: false,
|
|
2143
|
+
attributeNamePrefix: "@_",
|
|
2144
|
+
parseTagValue: false,
|
|
2145
|
+
isArray: (name) => ARRAY_TAG_NAMES.has(name)
|
|
2146
|
+
});
|
|
2147
|
+
function xmlToJson(xml) {
|
|
2148
|
+
try {
|
|
2149
|
+
const parsed = parser.parse(xml);
|
|
2150
|
+
const root = parsed["NativeDicomModel"];
|
|
2151
|
+
if (root === void 0) {
|
|
2152
|
+
return err(new Error("Invalid dcm2xml output: missing NativeDicomModel root element"));
|
|
2153
|
+
}
|
|
2154
|
+
if (typeof root !== "object" || root === null) {
|
|
2155
|
+
return ok({});
|
|
2156
|
+
}
|
|
2157
|
+
return ok(convertAttributes(root));
|
|
2158
|
+
} catch (error) {
|
|
2159
|
+
return err(new Error(`Failed to parse dcm2xml XML: ${stderrLib.stderr(error).message}`));
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// src/tools/_repairJson.ts
|
|
2164
|
+
var VALUE_ARRAY_PATTERN = /("Value"\s*:\s*\[)([\s\S]*?)(\])/g;
|
|
2165
|
+
var BARE_NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
2166
|
+
function quoteIfBareNumber(token) {
|
|
2167
|
+
const trimmed = token.trim();
|
|
2168
|
+
if (BARE_NUMBER.test(trimmed)) {
|
|
2169
|
+
return `"${trimmed}"`;
|
|
2170
|
+
}
|
|
2171
|
+
return trimmed;
|
|
2172
|
+
}
|
|
2173
|
+
function repairInner(inner) {
|
|
2174
|
+
const trimmed = inner.trim();
|
|
2175
|
+
if (trimmed.length === 0) return inner;
|
|
2176
|
+
const tokens = [];
|
|
2177
|
+
let current = "";
|
|
2178
|
+
let inString = false;
|
|
2179
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
2180
|
+
const ch = trimmed[i];
|
|
2181
|
+
if (ch === '"' && (i === 0 || trimmed[i - 1] !== "\\")) {
|
|
2182
|
+
inString = !inString;
|
|
2183
|
+
current += ch;
|
|
2184
|
+
} else if (ch === "," && !inString) {
|
|
2185
|
+
tokens.push(current);
|
|
2186
|
+
current = "";
|
|
2187
|
+
} else {
|
|
2188
|
+
current += ch;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
tokens.push(current);
|
|
2192
|
+
return tokens.map(quoteIfBareNumber).join(", ");
|
|
2193
|
+
}
|
|
2194
|
+
function repairJson(raw) {
|
|
2195
|
+
return raw.replace(VALUE_ARRAY_PATTERN, (_match, prefix, inner, suffix) => {
|
|
2196
|
+
return `${prefix}${repairInner(inner)}${suffix}`;
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// src/tools/dcm2json.ts
|
|
2201
|
+
var Dcm2jsonOptionsSchema = zod.z.object({
|
|
2202
|
+
timeoutMs: zod.z.number().int().positive().optional(),
|
|
2203
|
+
signal: zod.z.instanceof(AbortSignal).optional(),
|
|
2204
|
+
directOnly: zod.z.boolean().optional()
|
|
2205
|
+
}).strict().optional();
|
|
2206
|
+
async function tryXmlPath(inputPath, timeoutMs, signal) {
|
|
2207
|
+
const xmlBinary = resolveBinary("dcm2xml");
|
|
2208
|
+
if (!xmlBinary.ok) {
|
|
2209
|
+
return err(xmlBinary.error);
|
|
2210
|
+
}
|
|
2211
|
+
const xmlResult = await execCommand(xmlBinary.value, ["-nat", inputPath], { timeoutMs, signal });
|
|
2212
|
+
if (!xmlResult.ok) {
|
|
2213
|
+
return err(xmlResult.error);
|
|
2214
|
+
}
|
|
2215
|
+
if (xmlResult.value.exitCode !== 0) {
|
|
2216
|
+
return err(createToolError("dcm2xml", ["-nat", inputPath], xmlResult.value.exitCode, xmlResult.value.stderr));
|
|
2217
|
+
}
|
|
2218
|
+
const jsonResult = xmlToJson(xmlResult.value.stdout);
|
|
2219
|
+
if (!jsonResult.ok) {
|
|
2220
|
+
return err(jsonResult.error);
|
|
2221
|
+
}
|
|
2222
|
+
return ok({ data: jsonResult.value, source: "xml" });
|
|
2223
|
+
}
|
|
2224
|
+
async function tryDirectPath(inputPath, timeoutMs, signal) {
|
|
2225
|
+
const jsonBinary = resolveBinary("dcm2json");
|
|
2226
|
+
if (!jsonBinary.ok) {
|
|
2227
|
+
return err(jsonBinary.error);
|
|
2228
|
+
}
|
|
2229
|
+
const result = await execCommand(jsonBinary.value, [inputPath], { timeoutMs, signal });
|
|
2230
|
+
if (!result.ok) {
|
|
2231
|
+
return err(result.error);
|
|
2232
|
+
}
|
|
2233
|
+
if (result.value.exitCode !== 0) {
|
|
2234
|
+
return err(createToolError("dcm2json", [inputPath], result.value.exitCode, result.value.stderr));
|
|
2235
|
+
}
|
|
2236
|
+
try {
|
|
2237
|
+
const repaired = repairJson(result.value.stdout);
|
|
2238
|
+
const data = JSON.parse(repaired);
|
|
2239
|
+
return ok({ data, source: "direct" });
|
|
2240
|
+
} catch (parseError) {
|
|
2241
|
+
return err(createToolError("dcm2json", [inputPath], 1, `Parse error: ${stderrLib.stderr(parseError).message}`));
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
async function dcm2json(inputPath, options) {
|
|
2245
|
+
const validation = Dcm2jsonOptionsSchema.safeParse(options);
|
|
2246
|
+
if (!validation.success) {
|
|
2247
|
+
return err(createValidationError("dcm2json", validation.error));
|
|
2248
|
+
}
|
|
2249
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
2250
|
+
const signal = options?.signal;
|
|
2251
|
+
if (options?.directOnly === true) {
|
|
2252
|
+
return tryDirectPath(inputPath, timeoutMs, signal);
|
|
2253
|
+
}
|
|
2254
|
+
const xmlResult = await tryXmlPath(inputPath, timeoutMs, signal);
|
|
2255
|
+
if (xmlResult.ok) {
|
|
2256
|
+
return xmlResult;
|
|
2257
|
+
}
|
|
2258
|
+
return tryDirectPath(inputPath, timeoutMs, signal);
|
|
2259
|
+
}
|
|
2260
|
+
var TAG_OR_PATH_PATTERN = /^\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)(\[\d+\](\.\([0-9A-Fa-f]{4},[0-9A-Fa-f]{4}\)(\[\d+\])?)*)?$/;
|
|
2261
|
+
var TagModificationSchema = zod.z.object({
|
|
2262
|
+
tag: zod.z.string().regex(TAG_OR_PATH_PATTERN),
|
|
2263
|
+
value: zod.z.string()
|
|
2264
|
+
});
|
|
2265
|
+
var DcmodifyOptionsSchema = zod.z.object({
|
|
2266
|
+
timeoutMs: zod.z.number().int().positive().optional(),
|
|
2267
|
+
signal: zod.z.instanceof(AbortSignal).optional(),
|
|
2268
|
+
modifications: zod.z.array(TagModificationSchema).optional().default([]),
|
|
2269
|
+
erasures: zod.z.array(zod.z.string()).optional(),
|
|
2270
|
+
erasePrivateTags: zod.z.boolean().optional(),
|
|
2271
|
+
noBackup: zod.z.boolean().optional(),
|
|
2272
|
+
insertIfMissing: zod.z.boolean().optional(),
|
|
2273
|
+
ignoreMissingTags: zod.z.boolean().optional()
|
|
2274
|
+
}).strict().refine((data) => data.modifications.length > 0 || data.erasures !== void 0 && data.erasures.length > 0 || data.erasePrivateTags === true, {
|
|
2275
|
+
message: "At least one of modifications, erasures, or erasePrivateTags is required"
|
|
2276
|
+
});
|
|
2277
|
+
function buildArgs3(inputPath, options) {
|
|
2278
|
+
const args = [];
|
|
2279
|
+
if (options.noBackup !== false) {
|
|
2280
|
+
args.push("-nb");
|
|
2281
|
+
}
|
|
2282
|
+
if (options.ignoreMissingTags === true) {
|
|
2283
|
+
args.push("-imt");
|
|
2284
|
+
}
|
|
2285
|
+
const flag = options.insertIfMissing === true ? "-i" : "-m";
|
|
2286
|
+
const modifications = options.modifications ?? [];
|
|
2287
|
+
for (const mod of modifications) {
|
|
2288
|
+
args.push(flag, `${mod.tag}=${mod.value}`);
|
|
2289
|
+
}
|
|
2290
|
+
if (options.erasures !== void 0) {
|
|
2291
|
+
for (const erasure of options.erasures) {
|
|
2292
|
+
args.push("-e", erasure);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
if (options.erasePrivateTags === true) {
|
|
2296
|
+
args.push("-ep");
|
|
2297
|
+
}
|
|
2298
|
+
args.push(inputPath);
|
|
2299
|
+
return args;
|
|
2300
|
+
}
|
|
2301
|
+
async function dcmodify(inputPath, options) {
|
|
2302
|
+
const validation = DcmodifyOptionsSchema.safeParse(options);
|
|
2303
|
+
if (!validation.success) {
|
|
2304
|
+
return err(createValidationError("dcmodify", validation.error));
|
|
2305
|
+
}
|
|
2306
|
+
const binaryResult = resolveBinary("dcmodify");
|
|
2307
|
+
if (!binaryResult.ok) {
|
|
2308
|
+
return err(binaryResult.error);
|
|
2309
|
+
}
|
|
2310
|
+
const args = buildArgs3(inputPath, options);
|
|
2311
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
2312
|
+
const result = await spawnCommand(binaryResult.value, args, {
|
|
2313
|
+
timeoutMs,
|
|
2314
|
+
signal: options.signal
|
|
2315
|
+
});
|
|
2316
|
+
if (!result.ok) {
|
|
2317
|
+
return err(result.error);
|
|
2318
|
+
}
|
|
2319
|
+
if (result.value.exitCode !== 0) {
|
|
2320
|
+
return err(createToolError("dcmodify", args, result.value.exitCode, result.value.stderr));
|
|
2321
|
+
}
|
|
2322
|
+
return ok({ filePath: inputPath });
|
|
2323
|
+
}
|
|
2324
|
+
async function applyModifications(filePath, changeset, options) {
|
|
2325
|
+
const modifications = changeset.toModifications();
|
|
2326
|
+
const erasures = changeset.toErasureArgs();
|
|
2327
|
+
const hasErasures = erasures.length > 0 || changeset.erasePrivate;
|
|
2328
|
+
const result = await dcmodify(filePath, {
|
|
2329
|
+
modifications: modifications.length > 0 ? modifications : void 0,
|
|
2330
|
+
erasures: erasures.length > 0 ? erasures : void 0,
|
|
2331
|
+
erasePrivateTags: changeset.erasePrivate || void 0,
|
|
2332
|
+
insertIfMissing: true,
|
|
2333
|
+
ignoreMissingTags: hasErasures || void 0,
|
|
2334
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
2335
|
+
signal: options.signal
|
|
2336
|
+
});
|
|
2337
|
+
if (!result.ok) return err(result.error);
|
|
2338
|
+
return ok(void 0);
|
|
2339
|
+
}
|
|
2340
|
+
async function copyFileSafe(source, dest) {
|
|
2341
|
+
return stderrLib.tryCatch(
|
|
2342
|
+
() => fs.copyFile(source, dest),
|
|
2343
|
+
(e) => new Error(`Failed to copy file: ${e.message}`)
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
async function statFileSize(path2) {
|
|
2347
|
+
return stderrLib.tryCatch(
|
|
2348
|
+
async () => (await fs.stat(path2)).size,
|
|
2349
|
+
(e) => new Error(`Failed to stat file: ${e.message}`)
|
|
2350
|
+
);
|
|
2351
|
+
}
|
|
2352
|
+
async function unlinkFile(path2) {
|
|
2353
|
+
return stderrLib.tryCatch(
|
|
2354
|
+
() => fs.unlink(path2),
|
|
2355
|
+
(e) => new Error(`Failed to delete file: ${e.message}`)
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
// src/dicom/DicomInstance.ts
|
|
2360
|
+
var DicomInstance = class _DicomInstance {
|
|
2361
|
+
constructor(dataset, changes, filePath, metadata) {
|
|
2362
|
+
__publicField(this, "dicomDataset");
|
|
2363
|
+
__publicField(this, "changeSet");
|
|
2364
|
+
__publicField(this, "filepath");
|
|
2365
|
+
__publicField(this, "meta");
|
|
2366
|
+
this.dicomDataset = dataset;
|
|
2367
|
+
this.changeSet = changes;
|
|
2368
|
+
this.filepath = filePath;
|
|
2369
|
+
this.meta = metadata;
|
|
2370
|
+
}
|
|
2371
|
+
// -----------------------------------------------------------------------
|
|
2372
|
+
// Factories
|
|
2373
|
+
// -----------------------------------------------------------------------
|
|
2374
|
+
/**
|
|
2375
|
+
* Opens a DICOM file and creates a DicomInstance.
|
|
2376
|
+
*
|
|
2377
|
+
* @param path - Filesystem path to the DICOM file
|
|
2378
|
+
* @param options - Timeout and abort options
|
|
2379
|
+
* @returns A Result containing the DicomInstance or an error
|
|
2380
|
+
*/
|
|
2381
|
+
static async open(path2, options) {
|
|
2382
|
+
const filePathResult = createDicomFilePath(path2);
|
|
2383
|
+
if (!filePathResult.ok) return err(filePathResult.error);
|
|
2384
|
+
const jsonResult = await dcm2json(path2, {
|
|
2385
|
+
timeoutMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
2386
|
+
signal: options?.signal
|
|
2387
|
+
});
|
|
2388
|
+
if (!jsonResult.ok) return err(jsonResult.error);
|
|
2389
|
+
const datasetResult = DicomDataset.fromJson(jsonResult.value.data);
|
|
2390
|
+
if (!datasetResult.ok) return err(datasetResult.error);
|
|
2391
|
+
return ok(new _DicomInstance(datasetResult.value, ChangeSet.empty(), filePathResult.value, /* @__PURE__ */ new Map()));
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Creates a DicomInstance from an existing DicomDataset.
|
|
2395
|
+
*
|
|
2396
|
+
* @param dataset - The DicomDataset to wrap
|
|
2397
|
+
* @param filePath - Optional file path (e.g. if the dataset came from a file)
|
|
2398
|
+
* @returns A Result containing the DicomInstance
|
|
2399
|
+
*/
|
|
2400
|
+
static fromDataset(dataset, filePath) {
|
|
2401
|
+
let fp;
|
|
2402
|
+
if (filePath !== void 0) {
|
|
2403
|
+
const fpResult = createDicomFilePath(filePath);
|
|
2404
|
+
if (!fpResult.ok) return err(fpResult.error);
|
|
2405
|
+
fp = fpResult.value;
|
|
2406
|
+
}
|
|
2407
|
+
return ok(new _DicomInstance(dataset, ChangeSet.empty(), fp, /* @__PURE__ */ new Map()));
|
|
2408
|
+
}
|
|
2409
|
+
// -----------------------------------------------------------------------
|
|
2410
|
+
// Read accessors (delegate to DicomDataset)
|
|
2411
|
+
// -----------------------------------------------------------------------
|
|
2412
|
+
/** The underlying immutable DICOM dataset. */
|
|
2413
|
+
get dataset() {
|
|
2414
|
+
return this.dicomDataset;
|
|
2415
|
+
}
|
|
2416
|
+
/** The pending change set. */
|
|
2417
|
+
get changes() {
|
|
2418
|
+
return this.changeSet;
|
|
2419
|
+
}
|
|
2420
|
+
/** Whether there are unsaved changes. */
|
|
2421
|
+
get hasUnsavedChanges() {
|
|
2422
|
+
return !this.changeSet.isEmpty;
|
|
2423
|
+
}
|
|
2424
|
+
/** The file path, or undefined if this instance has no associated file. */
|
|
2425
|
+
get filePath() {
|
|
2426
|
+
return this.filepath;
|
|
2427
|
+
}
|
|
2428
|
+
/** Patient's Name (0010,0010). */
|
|
2429
|
+
get patientName() {
|
|
2430
|
+
return this.dicomDataset.patientName;
|
|
2431
|
+
}
|
|
2432
|
+
/** Patient ID (0010,0020). */
|
|
2433
|
+
get patientID() {
|
|
2434
|
+
return this.dicomDataset.patientID;
|
|
2435
|
+
}
|
|
2436
|
+
/** Study Date (0008,0020). */
|
|
2437
|
+
get studyDate() {
|
|
2438
|
+
return this.dicomDataset.studyDate;
|
|
2439
|
+
}
|
|
2440
|
+
/** Modality (0008,0060). */
|
|
2441
|
+
get modality() {
|
|
2442
|
+
return this.dicomDataset.modality;
|
|
2443
|
+
}
|
|
2444
|
+
/** Accession Number (0008,0050). */
|
|
2445
|
+
get accession() {
|
|
2446
|
+
return this.dicomDataset.accession;
|
|
2447
|
+
}
|
|
2448
|
+
/** SOP Class UID (0008,0016). */
|
|
2449
|
+
get sopClassUID() {
|
|
2450
|
+
return this.dicomDataset.sopClassUID;
|
|
2451
|
+
}
|
|
2452
|
+
/** Study Instance UID (0020,000D). */
|
|
2453
|
+
get studyInstanceUID() {
|
|
2454
|
+
return this.dicomDataset.studyInstanceUID;
|
|
2455
|
+
}
|
|
2456
|
+
/** Series Instance UID (0020,000E). */
|
|
2457
|
+
get seriesInstanceUID() {
|
|
2458
|
+
return this.dicomDataset.seriesInstanceUID;
|
|
2459
|
+
}
|
|
2460
|
+
/** SOP Instance UID (0008,0018). */
|
|
2461
|
+
get sopInstanceUID() {
|
|
2462
|
+
return this.dicomDataset.sopInstanceUID;
|
|
2463
|
+
}
|
|
2464
|
+
/** Transfer Syntax UID (0002,0010). */
|
|
2465
|
+
get transferSyntaxUID() {
|
|
2466
|
+
return this.dicomDataset.transferSyntaxUID;
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* Gets a tag value as a string with optional fallback.
|
|
2470
|
+
*
|
|
2471
|
+
* @param tag - A DICOM tag, e.g. `'(0010,0010)'` or `'00100010'`
|
|
2472
|
+
* @param fallback - Value to return if tag is missing (default: `''`)
|
|
2473
|
+
*/
|
|
2474
|
+
getString(tag, fallback = "") {
|
|
2475
|
+
return this.dicomDataset.getString(tag, fallback);
|
|
2476
|
+
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Gets a tag value as a number.
|
|
2479
|
+
*
|
|
2480
|
+
* @param tag - A DICOM tag, e.g. `'(0020,0013)'`
|
|
2481
|
+
*/
|
|
2482
|
+
getNumber(tag) {
|
|
2483
|
+
return this.dicomDataset.getNumber(tag);
|
|
2484
|
+
}
|
|
2485
|
+
/** Checks whether a tag exists in the dataset. */
|
|
2486
|
+
hasTag(tag) {
|
|
2487
|
+
return this.dicomDataset.hasTag(tag);
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Finds all values matching a wildcard path.
|
|
2491
|
+
*
|
|
2492
|
+
* @param path - A DicomTagPath with optional wildcard indices
|
|
2493
|
+
*/
|
|
2494
|
+
findValues(path2) {
|
|
2495
|
+
return this.dicomDataset.findValues(path2);
|
|
2496
|
+
}
|
|
2497
|
+
// -----------------------------------------------------------------------
|
|
2498
|
+
// Write methods (return new instance)
|
|
2499
|
+
// -----------------------------------------------------------------------
|
|
2500
|
+
/**
|
|
2501
|
+
* Sets a tag value, returning a new DicomInstance.
|
|
2502
|
+
*
|
|
2503
|
+
* @param path - The DICOM tag path (e.g. `'(0010,0010)'`)
|
|
2504
|
+
* @param value - The new value
|
|
2505
|
+
*/
|
|
2506
|
+
setTag(path2, value) {
|
|
2507
|
+
return new _DicomInstance(this.dicomDataset, this.changeSet.setTag(path2, value), this.filepath, this.meta);
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Erases a tag, returning a new DicomInstance.
|
|
2511
|
+
*
|
|
2512
|
+
* @param path - The DICOM tag path to erase
|
|
2513
|
+
*/
|
|
2514
|
+
eraseTag(path2) {
|
|
2515
|
+
return new _DicomInstance(this.dicomDataset, this.changeSet.eraseTag(path2), this.filepath, this.meta);
|
|
2516
|
+
}
|
|
2517
|
+
/** Erases all private tags, returning a new DicomInstance. */
|
|
2518
|
+
erasePrivateTags() {
|
|
2519
|
+
return new _DicomInstance(this.dicomDataset, this.changeSet.erasePrivateTags(), this.filepath, this.meta);
|
|
2520
|
+
}
|
|
2521
|
+
/** Sets Patient's Name (0010,0010). */
|
|
2522
|
+
setPatientName(value) {
|
|
2523
|
+
return this.setTag("(0010,0010)", value);
|
|
2524
|
+
}
|
|
2525
|
+
/** Sets Patient ID (0010,0020). */
|
|
2526
|
+
setPatientID(value) {
|
|
2527
|
+
return this.setTag("(0010,0020)", value);
|
|
2528
|
+
}
|
|
2529
|
+
/** Sets Study Date (0008,0020). */
|
|
2530
|
+
setStudyDate(value) {
|
|
2531
|
+
return this.setTag("(0008,0020)", value);
|
|
2532
|
+
}
|
|
2533
|
+
/** Sets Modality (0008,0060). */
|
|
2534
|
+
setModality(value) {
|
|
2535
|
+
return this.setTag("(0008,0060)", value);
|
|
2536
|
+
}
|
|
2537
|
+
/** Sets Accession Number (0008,0050). */
|
|
2538
|
+
setAccessionNumber(value) {
|
|
2539
|
+
return this.setTag("(0008,0050)", value);
|
|
2540
|
+
}
|
|
2541
|
+
/** Sets Study Description (0008,1030). */
|
|
2542
|
+
setStudyDescription(value) {
|
|
2543
|
+
return this.setTag("(0008,1030)", value);
|
|
2544
|
+
}
|
|
2545
|
+
/** Sets Series Description (0008,103E). */
|
|
2546
|
+
setSeriesDescription(value) {
|
|
2547
|
+
return this.setTag("(0008,103E)", value);
|
|
2548
|
+
}
|
|
2549
|
+
/** Sets Institution Name (0008,0080). */
|
|
2550
|
+
setInstitutionName(value) {
|
|
2551
|
+
return this.setTag("(0008,0080)", value);
|
|
2552
|
+
}
|
|
2553
|
+
/**
|
|
2554
|
+
* Transforms a tag value using a function.
|
|
2555
|
+
*
|
|
2556
|
+
* The function receives the current string value (or undefined if tag is missing)
|
|
2557
|
+
* and returns the new value. Returns a new DicomInstance with the modification.
|
|
2558
|
+
*
|
|
2559
|
+
* @param path - The DICOM tag path
|
|
2560
|
+
* @param fn - Transform function receiving the current value
|
|
2561
|
+
*/
|
|
2562
|
+
transformTag(path2, fn) {
|
|
2563
|
+
const current = this.dicomDataset.getString(path2);
|
|
2564
|
+
const newValue = fn(current.length > 0 ? current : void 0);
|
|
2565
|
+
return this.setTag(path2, newValue);
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Sets multiple tags at once, returning a new DicomInstance.
|
|
2569
|
+
*
|
|
2570
|
+
* @param entries - A record of tag path → value pairs
|
|
2571
|
+
*/
|
|
2572
|
+
setBatch(entries) {
|
|
2573
|
+
return new _DicomInstance(this.dicomDataset, this.changeSet.setBatch(entries), this.filepath, this.meta);
|
|
2574
|
+
}
|
|
2575
|
+
/**
|
|
2576
|
+
* Returns a new DicomInstance with the given changes merged into pending changes.
|
|
2577
|
+
*
|
|
2578
|
+
* @param changes - A ChangeSet to merge with existing pending changes
|
|
2579
|
+
* @returns A new DicomInstance with accumulated changes
|
|
2580
|
+
*/
|
|
2581
|
+
withChanges(changes) {
|
|
2582
|
+
return new _DicomInstance(this.dicomDataset, this.changeSet.merge(changes), this.filepath, this.meta);
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* Returns a new DicomInstance pointing to a different file path.
|
|
2586
|
+
*
|
|
2587
|
+
* Preserves the dataset, pending changes, and metadata.
|
|
2588
|
+
*
|
|
2589
|
+
* @param newPath - The new filesystem path (validated via createDicomFilePath)
|
|
2590
|
+
* @returns A new DicomInstance with the updated path
|
|
2591
|
+
* @throws If the path is invalid
|
|
2592
|
+
*/
|
|
2593
|
+
withFilePath(newPath) {
|
|
2594
|
+
const result = createDicomFilePath(newPath);
|
|
2595
|
+
if (!result.ok) throw result.error;
|
|
2596
|
+
return new _DicomInstance(this.dicomDataset, this.changeSet, result.value, this.meta);
|
|
2597
|
+
}
|
|
2598
|
+
// -----------------------------------------------------------------------
|
|
2599
|
+
// File I/O
|
|
2600
|
+
// -----------------------------------------------------------------------
|
|
2601
|
+
/**
|
|
2602
|
+
* Applies pending changes to the file in-place.
|
|
2603
|
+
*
|
|
2604
|
+
* Requires that the instance has an associated file path.
|
|
2605
|
+
*
|
|
2606
|
+
* @param options - Timeout and abort options
|
|
2607
|
+
*/
|
|
2608
|
+
async applyChanges(options) {
|
|
2609
|
+
if (this.filepath === void 0) return err(new Error("No file path associated with this instance"));
|
|
2610
|
+
if (this.changeSet.isEmpty) return ok(void 0);
|
|
2611
|
+
return applyModifications(this.filepath, this.changeSet, options ?? {});
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Copies the file to a new path and applies pending changes to the copy.
|
|
2615
|
+
*
|
|
2616
|
+
* Returns a new DicomInstance pointing to the output path.
|
|
2617
|
+
*
|
|
2618
|
+
* @param outputPath - Destination filesystem path
|
|
2619
|
+
* @param options - Timeout and abort options
|
|
2620
|
+
*/
|
|
2621
|
+
async writeAs(outputPath, options) {
|
|
2622
|
+
if (this.filepath === void 0) return err(new Error("No file path associated with this instance"));
|
|
2623
|
+
const outPathResult = createDicomFilePath(outputPath);
|
|
2624
|
+
if (!outPathResult.ok) return err(outPathResult.error);
|
|
2625
|
+
const copyResult = await copyFileSafe(this.filepath, outputPath);
|
|
2626
|
+
if (!copyResult.ok) return err(copyResult.error);
|
|
2627
|
+
if (!this.changeSet.isEmpty) {
|
|
2628
|
+
const applyResult = await applyModifications(outPathResult.value, this.changeSet, options ?? {});
|
|
2629
|
+
if (!applyResult.ok) {
|
|
2630
|
+
await unlinkFile(outputPath);
|
|
2631
|
+
return err(applyResult.error);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
return ok(new _DicomInstance(this.dicomDataset, ChangeSet.empty(), outPathResult.value, this.meta));
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Gets the file size in bytes.
|
|
2638
|
+
*
|
|
2639
|
+
* @returns A Result containing the size or an error
|
|
2640
|
+
*/
|
|
2641
|
+
async fileSize() {
|
|
2642
|
+
if (this.filepath === void 0) return err(new Error("No file path associated with this instance"));
|
|
2643
|
+
return statFileSize(this.filepath);
|
|
2644
|
+
}
|
|
2645
|
+
/**
|
|
2646
|
+
* Deletes the associated file from the filesystem.
|
|
2647
|
+
*
|
|
2648
|
+
* @returns A Result indicating success or failure
|
|
2649
|
+
*/
|
|
2650
|
+
async unlink() {
|
|
2651
|
+
if (this.filepath === void 0) return err(new Error("No file path associated with this instance"));
|
|
2652
|
+
return unlinkFile(this.filepath);
|
|
2653
|
+
}
|
|
2654
|
+
// -----------------------------------------------------------------------
|
|
2655
|
+
// Metadata (non-DICOM app context)
|
|
2656
|
+
// -----------------------------------------------------------------------
|
|
2657
|
+
/**
|
|
2658
|
+
* Returns a new DicomInstance with application metadata attached.
|
|
2659
|
+
*
|
|
2660
|
+
* Metadata is not stored in the DICOM file — it's for application context
|
|
2661
|
+
* (e.g. tracking source association, processing status, etc.).
|
|
2662
|
+
*
|
|
2663
|
+
* @param key - Metadata key
|
|
2664
|
+
* @param value - Metadata value
|
|
2665
|
+
*/
|
|
2666
|
+
withMetadata(key, value) {
|
|
2667
|
+
const newMeta = new Map(this.meta);
|
|
2668
|
+
newMeta.set(key, value);
|
|
2669
|
+
return new _DicomInstance(this.dicomDataset, this.changeSet, this.filepath, newMeta);
|
|
2670
|
+
}
|
|
2671
|
+
/**
|
|
2672
|
+
* Gets application metadata by key.
|
|
2673
|
+
*
|
|
2674
|
+
* @param key - Metadata key
|
|
2675
|
+
* @returns The metadata value or undefined
|
|
2676
|
+
*/
|
|
2677
|
+
getMetadata(key) {
|
|
2678
|
+
return this.meta.get(key);
|
|
2679
|
+
}
|
|
2680
|
+
};
|
|
2681
|
+
|
|
2682
|
+
// src/servers/DicomReceiver.ts
|
|
2683
|
+
var DEFAULT_MIN_POOL_SIZE = 2;
|
|
2684
|
+
var DEFAULT_MAX_POOL_SIZE = 10;
|
|
2685
|
+
var DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
|
|
2686
|
+
var CONNECTION_RETRY_INTERVAL_MS = 500;
|
|
2687
|
+
var MAX_CONNECTION_RETRIES = 200;
|
|
2688
|
+
var DicomReceiverOptionsSchema = zod.z.object({
|
|
2689
|
+
port: zod.z.number().int().min(1).max(65535),
|
|
2690
|
+
storageDir: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in storageDir" }),
|
|
2691
|
+
aeTitle: zod.z.string().min(1).max(16).refine(isValidAETitle, { message: "AE Title contains invalid characters" }).optional(),
|
|
2692
|
+
minPoolSize: zod.z.number().int().min(1).max(100).optional(),
|
|
2693
|
+
maxPoolSize: zod.z.number().int().min(1).max(100).optional(),
|
|
2694
|
+
connectionTimeoutMs: zod.z.number().int().positive().optional(),
|
|
2695
|
+
configFile: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }).optional(),
|
|
2696
|
+
configProfile: zod.z.string().min(1).optional(),
|
|
2697
|
+
signal: zod.z.instanceof(AbortSignal).optional()
|
|
2698
|
+
}).strict().refine((data) => (data.minPoolSize ?? DEFAULT_MIN_POOL_SIZE) <= (data.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE), {
|
|
2699
|
+
message: "minPoolSize must be <= maxPoolSize"
|
|
2700
|
+
});
|
|
2701
|
+
function allocatePort() {
|
|
2702
|
+
return new Promise((resolve) => {
|
|
2703
|
+
const server = net__namespace.createServer();
|
|
2704
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2705
|
+
const addr = server.address();
|
|
2706
|
+
if (addr === null || typeof addr === "string") {
|
|
2707
|
+
server.close(() => resolve(err(new Error("Failed to allocate port"))));
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
const port = addr.port;
|
|
2711
|
+
server.close(() => resolve(ok(port)));
|
|
2712
|
+
});
|
|
2713
|
+
server.on("error", (e) => {
|
|
2714
|
+
resolve(err(new Error(`Port allocation failed: ${e.message}`)));
|
|
2715
|
+
});
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
var DicomReceiver = class _DicomReceiver extends events.EventEmitter {
|
|
2719
|
+
constructor(options) {
|
|
2720
|
+
super();
|
|
2721
|
+
__publicField(this, "options");
|
|
2722
|
+
__publicField(this, "minPoolSize");
|
|
2723
|
+
__publicField(this, "maxPoolSize");
|
|
2724
|
+
__publicField(this, "connectionTimeoutMs");
|
|
2725
|
+
__publicField(this, "workers", /* @__PURE__ */ new Map());
|
|
2726
|
+
__publicField(this, "tcpServer");
|
|
2727
|
+
__publicField(this, "associationCounter", 0);
|
|
2728
|
+
__publicField(this, "started", false);
|
|
2729
|
+
__publicField(this, "stopping", false);
|
|
2730
|
+
__publicField(this, "abortHandler");
|
|
2731
|
+
this.setMaxListeners(20);
|
|
2732
|
+
this.on("error", () => {
|
|
2733
|
+
});
|
|
2734
|
+
this.options = options;
|
|
2735
|
+
this.minPoolSize = options.minPoolSize ?? DEFAULT_MIN_POOL_SIZE;
|
|
2736
|
+
this.maxPoolSize = options.maxPoolSize ?? DEFAULT_MAX_POOL_SIZE;
|
|
2737
|
+
this.connectionTimeoutMs = options.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS;
|
|
2738
|
+
}
|
|
2739
|
+
// -----------------------------------------------------------------------
|
|
2740
|
+
// Public API
|
|
2741
|
+
// -----------------------------------------------------------------------
|
|
2742
|
+
/**
|
|
2743
|
+
* Creates a new DicomReceiver instance.
|
|
2744
|
+
*
|
|
2745
|
+
* @param options - Configuration options
|
|
2746
|
+
* @returns A Result containing the instance or a validation error
|
|
2747
|
+
*/
|
|
2748
|
+
static create(options) {
|
|
2749
|
+
const validation = DicomReceiverOptionsSchema.safeParse(options);
|
|
2750
|
+
if (!validation.success) {
|
|
2751
|
+
return err(createValidationError("DicomReceiver", validation.error));
|
|
2752
|
+
}
|
|
2753
|
+
return ok(new _DicomReceiver(options));
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Starts the TCP proxy and spawns the initial worker pool.
|
|
2757
|
+
*
|
|
2758
|
+
* @returns A Result indicating success or failure
|
|
2759
|
+
*/
|
|
2760
|
+
async start() {
|
|
2761
|
+
if (this.started) {
|
|
2762
|
+
return err(new Error("DicomReceiver: already started"));
|
|
2763
|
+
}
|
|
2764
|
+
this.started = true;
|
|
2765
|
+
const storageDirResult = await ensureDirectory(this.options.storageDir);
|
|
2766
|
+
if (!storageDirResult.ok) return storageDirResult;
|
|
2767
|
+
const spawnResults = await this.spawnWorkers(this.minPoolSize);
|
|
2768
|
+
if (!spawnResults.ok) return spawnResults;
|
|
2769
|
+
const listenResult = await this.startTcpProxy();
|
|
2770
|
+
if (!listenResult.ok) return listenResult;
|
|
2771
|
+
if (this.options.signal !== void 0) {
|
|
2772
|
+
this.wireAbortSignal(this.options.signal);
|
|
2773
|
+
}
|
|
2774
|
+
return ok(void 0);
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* Stops the TCP proxy and all workers.
|
|
2778
|
+
*/
|
|
2779
|
+
async stop() {
|
|
2780
|
+
if (!this.started || this.stopping) {
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
this.stopping = true;
|
|
2784
|
+
if (this.options.signal !== void 0 && this.abortHandler !== void 0) {
|
|
2785
|
+
this.options.signal.removeEventListener("abort", this.abortHandler);
|
|
2786
|
+
}
|
|
2787
|
+
await this.closeTcpProxy();
|
|
2788
|
+
const stopPromises = [];
|
|
2789
|
+
for (const worker of this.workers.values()) {
|
|
2790
|
+
stopPromises.push(this.stopWorker(worker));
|
|
2791
|
+
}
|
|
2792
|
+
await Promise.all(stopPromises);
|
|
2793
|
+
this.workers.clear();
|
|
2794
|
+
this.started = false;
|
|
2795
|
+
this.stopping = false;
|
|
2796
|
+
}
|
|
2797
|
+
/**
|
|
2798
|
+
* Registers a typed listener for a DicomReceiver-specific event.
|
|
2799
|
+
*
|
|
2800
|
+
* @param event - The event name from DicomReceiverEventMap
|
|
2801
|
+
* @param listener - Callback receiving typed event data
|
|
2802
|
+
* @returns this for chaining
|
|
2803
|
+
*/
|
|
2804
|
+
onEvent(event, listener) {
|
|
2805
|
+
return this.on(event, listener);
|
|
2806
|
+
}
|
|
2807
|
+
/**
|
|
2808
|
+
* Registers a listener for received files.
|
|
2809
|
+
*
|
|
2810
|
+
* @param listener - Callback receiving file data
|
|
2811
|
+
* @returns this for chaining
|
|
2812
|
+
*/
|
|
2813
|
+
onFileReceived(listener) {
|
|
2814
|
+
return this.on("FILE_RECEIVED", listener);
|
|
2815
|
+
}
|
|
2816
|
+
/**
|
|
2817
|
+
* Registers a listener for completed associations.
|
|
2818
|
+
*
|
|
2819
|
+
* @param listener - Callback receiving association data
|
|
2820
|
+
* @returns this for chaining
|
|
2821
|
+
*/
|
|
2822
|
+
onAssociationComplete(listener) {
|
|
2823
|
+
return this.on("ASSOCIATION_COMPLETE", listener);
|
|
2824
|
+
}
|
|
2825
|
+
/** Current pool status. */
|
|
2826
|
+
get poolStatus() {
|
|
2827
|
+
let idle = 0;
|
|
2828
|
+
let busy = 0;
|
|
2829
|
+
for (const w of this.workers.values()) {
|
|
2830
|
+
if (w.state === "idle") idle++;
|
|
2831
|
+
else busy++;
|
|
2832
|
+
}
|
|
2833
|
+
return { idle, busy, total: this.workers.size };
|
|
2834
|
+
}
|
|
2835
|
+
// -----------------------------------------------------------------------
|
|
2836
|
+
// TCP proxy
|
|
2837
|
+
// -----------------------------------------------------------------------
|
|
2838
|
+
/** Starts the TCP proxy on the configured port. */
|
|
2839
|
+
startTcpProxy() {
|
|
2840
|
+
return new Promise((resolve) => {
|
|
2841
|
+
this.tcpServer = net__namespace.createServer((socket) => {
|
|
2842
|
+
void this.handleConnection(socket);
|
|
2843
|
+
});
|
|
2844
|
+
this.tcpServer.on("error", (e) => {
|
|
2845
|
+
if (!this.started) {
|
|
2846
|
+
resolve(err(new Error(`DicomReceiver: TCP proxy failed: ${e.message}`)));
|
|
2847
|
+
} else {
|
|
2848
|
+
this.emit("error", { error: e instanceof Error ? e : new Error(String(e)) });
|
|
2849
|
+
}
|
|
2850
|
+
});
|
|
2851
|
+
this.tcpServer.listen(this.options.port, () => {
|
|
2852
|
+
resolve(ok(void 0));
|
|
2853
|
+
});
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
/** Closes the TCP proxy server. */
|
|
2857
|
+
closeTcpProxy() {
|
|
2858
|
+
return new Promise((resolve) => {
|
|
2859
|
+
if (this.tcpServer === void 0) {
|
|
2860
|
+
resolve();
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
this.tcpServer.close(() => resolve());
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
// -----------------------------------------------------------------------
|
|
2867
|
+
// Connection routing
|
|
2868
|
+
// -----------------------------------------------------------------------
|
|
2869
|
+
/** Routes an incoming connection to an idle worker. */
|
|
2870
|
+
async handleConnection(remoteSocket) {
|
|
2871
|
+
remoteSocket.pause();
|
|
2872
|
+
const worker = await this.findIdleWorker();
|
|
2873
|
+
if (worker === void 0) {
|
|
2874
|
+
remoteSocket.destroy(new Error("DicomReceiver: no idle worker available"));
|
|
2875
|
+
this.emit("error", { error: new Error("DicomReceiver: connection rejected \u2014 pool exhausted") });
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
this.associationCounter++;
|
|
2879
|
+
const associationId = `assoc-${String(this.associationCounter)}`;
|
|
2880
|
+
const associationDir = path__namespace.join(this.options.storageDir, associationId);
|
|
2881
|
+
const mkdirResult = await ensureDirectory(associationDir);
|
|
2882
|
+
if (!mkdirResult.ok) {
|
|
2883
|
+
remoteSocket.destroy();
|
|
2884
|
+
this.emit("error", { error: mkdirResult.error });
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
worker.state = "busy";
|
|
2888
|
+
worker.associationId = associationId;
|
|
2889
|
+
worker.associationDir = associationDir;
|
|
2890
|
+
worker.files = [];
|
|
2891
|
+
worker.fileSizes = [];
|
|
2892
|
+
worker.startAt = Date.now();
|
|
2893
|
+
this.pipeConnection(worker, remoteSocket);
|
|
2894
|
+
void this.replenishPool();
|
|
2895
|
+
}
|
|
2896
|
+
/** Finds an idle worker, retrying up to connectionTimeoutMs. */
|
|
2897
|
+
async findIdleWorker() {
|
|
2898
|
+
const idle = this.getIdleWorker();
|
|
2899
|
+
if (idle !== void 0) return idle;
|
|
2900
|
+
const maxRetries = Math.min(Math.ceil(this.connectionTimeoutMs / CONNECTION_RETRY_INTERVAL_MS), MAX_CONNECTION_RETRIES);
|
|
2901
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
2902
|
+
await delay(CONNECTION_RETRY_INTERVAL_MS);
|
|
2903
|
+
const found = this.getIdleWorker();
|
|
2904
|
+
if (found !== void 0) return found;
|
|
2905
|
+
if (this.stopping) return void 0;
|
|
1152
2906
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
2907
|
+
return void 0;
|
|
2908
|
+
}
|
|
2909
|
+
/** Returns the first idle worker, or undefined. */
|
|
2910
|
+
getIdleWorker() {
|
|
2911
|
+
for (const w of this.workers.values()) {
|
|
2912
|
+
if (w.state === "idle") return w;
|
|
2913
|
+
}
|
|
2914
|
+
return void 0;
|
|
2915
|
+
}
|
|
2916
|
+
/** Pipes remote socket bidirectionally to the worker's port. */
|
|
2917
|
+
pipeConnection(worker, remoteSocket) {
|
|
2918
|
+
const workerSocket = net__namespace.createConnection({ port: worker.port, host: "127.0.0.1" });
|
|
2919
|
+
worker.remoteSocket = remoteSocket;
|
|
2920
|
+
worker.workerSocket = workerSocket;
|
|
2921
|
+
remoteSocket.pipe(workerSocket);
|
|
2922
|
+
workerSocket.pipe(remoteSocket);
|
|
2923
|
+
remoteSocket.resume();
|
|
2924
|
+
const cleanup = () => {
|
|
2925
|
+
remoteSocket.unpipe(workerSocket);
|
|
2926
|
+
workerSocket.unpipe(remoteSocket);
|
|
2927
|
+
if (!remoteSocket.destroyed) remoteSocket.destroy();
|
|
2928
|
+
if (!workerSocket.destroyed) workerSocket.destroy();
|
|
2929
|
+
};
|
|
2930
|
+
remoteSocket.on("error", cleanup);
|
|
2931
|
+
workerSocket.on("error", cleanup);
|
|
2932
|
+
remoteSocket.on("close", cleanup);
|
|
2933
|
+
workerSocket.on("close", cleanup);
|
|
2934
|
+
}
|
|
2935
|
+
// -----------------------------------------------------------------------
|
|
2936
|
+
// Worker pool management
|
|
2937
|
+
// -----------------------------------------------------------------------
|
|
2938
|
+
/** Spawns `count` new workers and adds them to the pool. */
|
|
2939
|
+
async spawnWorkers(count) {
|
|
2940
|
+
const promises = [];
|
|
2941
|
+
for (let i = 0; i < count; i++) {
|
|
2942
|
+
promises.push(this.spawnWorker());
|
|
2943
|
+
}
|
|
2944
|
+
const results = await Promise.all(promises);
|
|
2945
|
+
for (const result of results) {
|
|
2946
|
+
if (!result.ok) return err(result.error);
|
|
2947
|
+
}
|
|
2948
|
+
return ok(void 0);
|
|
2949
|
+
}
|
|
2950
|
+
/** Spawns a single Dcmrecv worker with an ephemeral port. */
|
|
2951
|
+
async spawnWorker() {
|
|
2952
|
+
const portResult = await allocatePort();
|
|
2953
|
+
if (!portResult.ok) return portResult;
|
|
2954
|
+
const port = portResult.value;
|
|
2955
|
+
const tempDir = path__namespace.join(os__namespace.tmpdir(), `dcmrecv-pool-${String(port)}-${String(Date.now())}`);
|
|
2956
|
+
const mkdirResult = await ensureDirectory(tempDir);
|
|
2957
|
+
if (!mkdirResult.ok) return mkdirResult;
|
|
2958
|
+
const createResult = Dcmrecv.create({
|
|
2959
|
+
port,
|
|
2960
|
+
aeTitle: this.options.aeTitle ?? "DCMRECV",
|
|
2961
|
+
outputDirectory: tempDir,
|
|
2962
|
+
configFile: this.options.configFile,
|
|
2963
|
+
configProfile: this.options.configProfile
|
|
2964
|
+
});
|
|
2965
|
+
if (!createResult.ok) return createResult;
|
|
2966
|
+
const dcmrecv = createResult.value;
|
|
2967
|
+
const worker = {
|
|
2968
|
+
dcmrecv,
|
|
2969
|
+
port,
|
|
2970
|
+
tempDir,
|
|
2971
|
+
state: "idle",
|
|
2972
|
+
associationId: void 0,
|
|
2973
|
+
associationDir: void 0,
|
|
2974
|
+
files: [],
|
|
2975
|
+
fileSizes: [],
|
|
2976
|
+
startAt: void 0,
|
|
2977
|
+
remoteSocket: void 0,
|
|
2978
|
+
workerSocket: void 0
|
|
2979
|
+
};
|
|
2980
|
+
this.wireWorkerEvents(worker);
|
|
2981
|
+
const startResult = await dcmrecv.start();
|
|
2982
|
+
if (!startResult.ok) {
|
|
2983
|
+
return err(new Error(`DicomReceiver: worker start failed on port ${String(port)}: ${startResult.error.message}`));
|
|
2984
|
+
}
|
|
2985
|
+
this.workers.set(port, worker);
|
|
2986
|
+
return ok(worker);
|
|
2987
|
+
}
|
|
2988
|
+
/** Stops a single worker: stop process, clean temp dir, remove from pool. */
|
|
2989
|
+
async stopWorker(worker) {
|
|
2990
|
+
if (worker.remoteSocket !== void 0 && !worker.remoteSocket.destroyed) {
|
|
2991
|
+
worker.remoteSocket.destroy();
|
|
2992
|
+
}
|
|
2993
|
+
if (worker.workerSocket !== void 0 && !worker.workerSocket.destroyed) {
|
|
2994
|
+
worker.workerSocket.destroy();
|
|
2995
|
+
}
|
|
2996
|
+
await worker.dcmrecv.stop();
|
|
2997
|
+
worker.dcmrecv[Symbol.dispose]();
|
|
2998
|
+
await removeDirSafe(worker.tempDir);
|
|
2999
|
+
this.workers.delete(worker.port);
|
|
3000
|
+
}
|
|
3001
|
+
/** Pre-emptively spawns workers to keep idle count >= minPoolSize. */
|
|
3002
|
+
async replenishPool() {
|
|
3003
|
+
const status = this.poolStatus;
|
|
3004
|
+
const needed = this.minPoolSize - status.idle;
|
|
3005
|
+
const capacity = this.maxPoolSize - status.total;
|
|
3006
|
+
const toSpawn = Math.min(needed, capacity);
|
|
3007
|
+
if (toSpawn <= 0) return;
|
|
3008
|
+
const promises = [];
|
|
3009
|
+
for (let i = 0; i < toSpawn; i++) {
|
|
3010
|
+
promises.push(this.spawnWorker());
|
|
3011
|
+
}
|
|
3012
|
+
const results = await Promise.all(promises);
|
|
3013
|
+
for (const result of results) {
|
|
3014
|
+
if (!result.ok) {
|
|
3015
|
+
this.emit("error", { error: result.error });
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
/** Stops excess idle workers when idle count > minPoolSize + 2. */
|
|
3020
|
+
async scaleDown() {
|
|
3021
|
+
const idleWorkers = [];
|
|
3022
|
+
for (const w of this.workers.values()) {
|
|
3023
|
+
if (w.state === "idle") idleWorkers.push(w);
|
|
3024
|
+
}
|
|
3025
|
+
const excess = idleWorkers.length - (this.minPoolSize + 2);
|
|
3026
|
+
if (excess <= 0) return;
|
|
3027
|
+
const toStop = idleWorkers.slice(0, excess);
|
|
3028
|
+
const promises = [];
|
|
3029
|
+
for (const w of toStop) {
|
|
3030
|
+
promises.push(this.stopWorker(w));
|
|
3031
|
+
}
|
|
3032
|
+
await Promise.all(promises);
|
|
3033
|
+
}
|
|
3034
|
+
// -----------------------------------------------------------------------
|
|
3035
|
+
// Worker event wiring
|
|
3036
|
+
// -----------------------------------------------------------------------
|
|
3037
|
+
/** Wires FILE_RECEIVED and ASSOCIATION_COMPLETE events on a worker. */
|
|
3038
|
+
wireWorkerEvents(worker) {
|
|
3039
|
+
this.wireFileReceived(worker);
|
|
3040
|
+
this.wireAssociationComplete(worker);
|
|
3041
|
+
}
|
|
3042
|
+
/** Wires FILE_RECEIVED from dcmrecv worker to handleFileReceived. */
|
|
3043
|
+
wireFileReceived(worker) {
|
|
3044
|
+
worker.dcmrecv.onFileReceived((data) => {
|
|
3045
|
+
void this.handleFileReceived(worker, data);
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
/** Moves a received file, opens it as DicomInstance, and emits FILE_RECEIVED. */
|
|
3049
|
+
async handleFileReceived(worker, data) {
|
|
3050
|
+
if (worker.associationDir === void 0 || worker.associationId === void 0) return;
|
|
3051
|
+
const srcPath = data.filePath;
|
|
3052
|
+
const destPath = path__namespace.join(worker.associationDir, path__namespace.basename(srcPath));
|
|
3053
|
+
const assocId = worker.associationId;
|
|
3054
|
+
const assocDir = worker.associationDir;
|
|
3055
|
+
const moveResult = await moveFile(srcPath, destPath);
|
|
3056
|
+
const finalPath = moveResult.ok ? destPath : srcPath;
|
|
3057
|
+
worker.files.push(finalPath);
|
|
3058
|
+
worker.fileSizes.push(await statFileSafe(finalPath));
|
|
3059
|
+
const openResult = await DicomInstance.open(finalPath);
|
|
3060
|
+
if (!openResult.ok) {
|
|
3061
|
+
this.emit("error", {
|
|
3062
|
+
error: openResult.error,
|
|
3063
|
+
filePath: finalPath,
|
|
3064
|
+
associationId: assocId,
|
|
3065
|
+
associationDir: assocDir,
|
|
3066
|
+
callingAE: data.callingAE,
|
|
3067
|
+
calledAE: data.calledAE,
|
|
3068
|
+
source: data.source
|
|
3069
|
+
});
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
this.emit("FILE_RECEIVED", {
|
|
3073
|
+
filePath: finalPath,
|
|
3074
|
+
associationId: assocId,
|
|
3075
|
+
associationDir: assocDir,
|
|
3076
|
+
callingAE: data.callingAE,
|
|
3077
|
+
calledAE: data.calledAE,
|
|
3078
|
+
source: data.source,
|
|
3079
|
+
instance: openResult.value
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
/** Returns worker to idle pool on association complete, emits summary. */
|
|
3083
|
+
wireAssociationComplete(worker) {
|
|
3084
|
+
worker.dcmrecv.onAssociationComplete((data) => {
|
|
3085
|
+
const assocId = worker.associationId ?? data.associationId;
|
|
3086
|
+
const assocDir = worker.associationDir ?? "";
|
|
3087
|
+
const files = [...worker.files];
|
|
3088
|
+
const endAt = Date.now();
|
|
3089
|
+
const startAt = worker.startAt ?? endAt;
|
|
3090
|
+
const totalBytes = sumArray(worker.fileSizes);
|
|
3091
|
+
const elapsedMs = endAt - startAt;
|
|
3092
|
+
const bytesPerSecond = elapsedMs > 0 ? Math.round(totalBytes / elapsedMs * 1e3) : 0;
|
|
3093
|
+
this.emit("ASSOCIATION_COMPLETE", {
|
|
3094
|
+
associationId: assocId,
|
|
3095
|
+
associationDir: assocDir,
|
|
3096
|
+
callingAE: data.callingAE,
|
|
3097
|
+
calledAE: data.calledAE,
|
|
3098
|
+
source: data.source,
|
|
3099
|
+
files,
|
|
3100
|
+
durationMs: data.durationMs,
|
|
3101
|
+
endReason: data.endReason,
|
|
3102
|
+
totalBytes,
|
|
3103
|
+
bytesPerSecond,
|
|
3104
|
+
startAt,
|
|
3105
|
+
endAt
|
|
3106
|
+
});
|
|
3107
|
+
worker.state = "idle";
|
|
3108
|
+
worker.associationId = void 0;
|
|
3109
|
+
worker.associationDir = void 0;
|
|
3110
|
+
worker.files = [];
|
|
3111
|
+
worker.fileSizes = [];
|
|
3112
|
+
worker.startAt = void 0;
|
|
3113
|
+
worker.remoteSocket = void 0;
|
|
3114
|
+
worker.workerSocket = void 0;
|
|
3115
|
+
void this.scaleDown();
|
|
3116
|
+
});
|
|
3117
|
+
}
|
|
3118
|
+
// -----------------------------------------------------------------------
|
|
3119
|
+
// Abort signal
|
|
3120
|
+
// -----------------------------------------------------------------------
|
|
3121
|
+
/** Wires an AbortSignal to stop the receiver. */
|
|
3122
|
+
wireAbortSignal(signal) {
|
|
3123
|
+
if (signal.aborted) {
|
|
3124
|
+
void this.stop();
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
this.abortHandler = () => {
|
|
3128
|
+
void this.stop();
|
|
3129
|
+
};
|
|
3130
|
+
signal.addEventListener("abort", this.abortHandler, { once: true });
|
|
3131
|
+
}
|
|
3132
|
+
};
|
|
3133
|
+
async function ensureDirectory(dirPath) {
|
|
3134
|
+
try {
|
|
3135
|
+
await fs__namespace.mkdir(dirPath, { recursive: true });
|
|
3136
|
+
return ok(void 0);
|
|
3137
|
+
} catch (e) {
|
|
3138
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
3139
|
+
return err(new Error(`Failed to create directory ${dirPath}: ${msg}`));
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
async function moveFile(src, dest) {
|
|
3143
|
+
try {
|
|
3144
|
+
await fs__namespace.rename(src, dest);
|
|
3145
|
+
return ok(void 0);
|
|
3146
|
+
} catch {
|
|
3147
|
+
try {
|
|
3148
|
+
await fs__namespace.copyFile(src, dest);
|
|
3149
|
+
await fs__namespace.unlink(src);
|
|
3150
|
+
return ok(void 0);
|
|
3151
|
+
} catch (e) {
|
|
3152
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
3153
|
+
return err(new Error(`Failed to move file ${src} \u2192 ${dest}: ${msg}`));
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
async function statFileSafe(filePath) {
|
|
3158
|
+
try {
|
|
3159
|
+
const stat3 = await fs__namespace.stat(filePath);
|
|
3160
|
+
return stat3.size;
|
|
3161
|
+
} catch {
|
|
3162
|
+
return 0;
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
function sumArray(arr) {
|
|
3166
|
+
let total = 0;
|
|
3167
|
+
for (let i = 0; i < arr.length; i++) {
|
|
3168
|
+
total += arr[i] ?? 0;
|
|
3169
|
+
}
|
|
3170
|
+
return total;
|
|
3171
|
+
}
|
|
3172
|
+
async function removeDirSafe(dirPath) {
|
|
3173
|
+
try {
|
|
3174
|
+
await fs__namespace.rm(dirPath, { recursive: true, force: true });
|
|
3175
|
+
} catch {
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
function delay(ms) {
|
|
3179
|
+
return new Promise((resolve) => {
|
|
3180
|
+
setTimeout(resolve, ms);
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
// src/events/dcmprscp.ts
|
|
3185
|
+
var DcmprscpEvent = {
|
|
3186
|
+
DATABASE_READY: "DATABASE_READY",
|
|
3187
|
+
ASSOCIATION_RECEIVED: "ASSOCIATION_RECEIVED",
|
|
3188
|
+
ASSOCIATION_ACKNOWLEDGED: "ASSOCIATION_ACKNOWLEDGED",
|
|
3189
|
+
ASSOCIATION_RELEASE: "ASSOCIATION_RELEASE",
|
|
3190
|
+
ASSOCIATION_ABORTED: "ASSOCIATION_ABORTED",
|
|
3191
|
+
CANNOT_START_LISTENER: "CANNOT_START_LISTENER",
|
|
3192
|
+
CONFIG_ERROR: "CONFIG_ERROR"
|
|
3193
|
+
};
|
|
3194
|
+
var DCMPRSCP_PATTERNS = [
|
|
3195
|
+
{
|
|
3196
|
+
event: DcmprscpEvent.DATABASE_READY,
|
|
3197
|
+
pattern: /Using database in directory\s+'([^']+)'/,
|
|
3198
|
+
processor: (match) => ({
|
|
3199
|
+
directory: match[1] ?? ""
|
|
3200
|
+
})
|
|
3201
|
+
},
|
|
3202
|
+
{
|
|
3203
|
+
event: DcmprscpEvent.ASSOCIATION_RECEIVED,
|
|
3204
|
+
pattern: /Association Received\s{0,20}\(([^)]+)\)/,
|
|
3205
|
+
processor: (match) => ({
|
|
3206
|
+
peerInfo: (match[1] ?? "").trim()
|
|
3207
|
+
})
|
|
3208
|
+
},
|
|
3209
|
+
{
|
|
3210
|
+
event: DcmprscpEvent.ASSOCIATION_ACKNOWLEDGED,
|
|
3211
|
+
pattern: /Association Acknowledged \(Max Send PDV:\s*(\d+)\)/,
|
|
3212
|
+
processor: (match) => ({
|
|
3213
|
+
maxSendPDV: Number(match[1])
|
|
3214
|
+
})
|
|
3215
|
+
},
|
|
3216
|
+
{
|
|
3217
|
+
event: DcmprscpEvent.ASSOCIATION_RELEASE,
|
|
3218
|
+
pattern: /Association Release/i,
|
|
3219
|
+
processor: () => void 0
|
|
3220
|
+
},
|
|
3221
|
+
{
|
|
3222
|
+
event: DcmprscpEvent.ASSOCIATION_ABORTED,
|
|
3223
|
+
pattern: /Association Abort/i,
|
|
3224
|
+
processor: () => void 0
|
|
3225
|
+
},
|
|
3226
|
+
{
|
|
3227
|
+
event: DcmprscpEvent.CANNOT_START_LISTENER,
|
|
3228
|
+
pattern: /cannot initialise network|cannot listen/i,
|
|
3229
|
+
processor: (match) => ({
|
|
3230
|
+
message: match[0] ?? ""
|
|
3231
|
+
})
|
|
3232
|
+
},
|
|
3233
|
+
{
|
|
3234
|
+
event: DcmprscpEvent.CONFIG_ERROR,
|
|
3235
|
+
pattern: /can't open configuration file|no (?:default )?print scp/i,
|
|
3236
|
+
processor: (match) => ({
|
|
3237
|
+
message: match[0] ?? ""
|
|
3238
|
+
})
|
|
3239
|
+
}
|
|
3240
|
+
];
|
|
3241
|
+
var DCMPRSCP_FATAL_EVENTS = /* @__PURE__ */ new Set([DcmprscpEvent.CANNOT_START_LISTENER, DcmprscpEvent.CONFIG_ERROR]);
|
|
3242
|
+
|
|
3243
|
+
// src/servers/DcmprsCP.ts
|
|
3244
|
+
var DcmprsCPOptionsSchema = zod.z.object({
|
|
3245
|
+
configFile: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in configFile" }),
|
|
3246
|
+
printer: zod.z.string().min(1).optional(),
|
|
3247
|
+
dump: zod.z.boolean().optional(),
|
|
3248
|
+
logLevel: zod.z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).optional(),
|
|
3249
|
+
logConfig: zod.z.string().min(1).refine(isSafePath, { message: "path traversal detected in logConfig" }).optional(),
|
|
3250
|
+
startTimeoutMs: zod.z.number().int().positive().optional(),
|
|
3251
|
+
drainTimeoutMs: zod.z.number().int().positive().optional(),
|
|
3252
|
+
signal: zod.z.instanceof(AbortSignal).optional()
|
|
3253
|
+
}).strict();
|
|
3254
|
+
function buildArgs4(options) {
|
|
3255
|
+
const args = ["--verbose", "--config", options.configFile];
|
|
3256
|
+
if (options.printer !== void 0) {
|
|
3257
|
+
args.push("--printer", options.printer);
|
|
3258
|
+
}
|
|
3259
|
+
if (options.dump === true) {
|
|
3260
|
+
args.push("--dump");
|
|
3261
|
+
}
|
|
3262
|
+
if (options.logLevel !== void 0) {
|
|
3263
|
+
args.push("--log-level", options.logLevel);
|
|
3264
|
+
}
|
|
3265
|
+
if (options.logConfig !== void 0) {
|
|
3266
|
+
args.push("--log-config", options.logConfig);
|
|
3267
|
+
}
|
|
3268
|
+
return args;
|
|
3269
|
+
}
|
|
3270
|
+
var DcmprsCP = class _DcmprsCP extends DcmtkProcess {
|
|
3271
|
+
constructor(config, parser2, signal) {
|
|
3272
|
+
super(config);
|
|
3273
|
+
__publicField(this, "parser");
|
|
3274
|
+
__publicField(this, "abortSignal");
|
|
3275
|
+
__publicField(this, "abortHandler");
|
|
3276
|
+
this.parser = parser2;
|
|
3277
|
+
this.wireParser();
|
|
3278
|
+
if (signal !== void 0) {
|
|
3279
|
+
this.wireAbortSignal(signal);
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
/**
|
|
3283
|
+
* Registers a typed listener for a dcmprscp-specific event.
|
|
3284
|
+
*
|
|
3285
|
+
* @param event - The event name from DcmprsCPEventMap
|
|
3286
|
+
* @param listener - Callback receiving typed event data
|
|
3287
|
+
* @returns this for chaining
|
|
3288
|
+
*/
|
|
3289
|
+
/** Disposes the server and its parser, preventing listener leaks. */
|
|
3290
|
+
[Symbol.dispose]() {
|
|
3291
|
+
if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
|
|
3292
|
+
this.abortSignal.removeEventListener("abort", this.abortHandler);
|
|
3293
|
+
}
|
|
3294
|
+
this.parser[Symbol.dispose]();
|
|
3295
|
+
super[Symbol.dispose]();
|
|
3296
|
+
}
|
|
3297
|
+
onEvent(event, listener) {
|
|
3298
|
+
return this.on(event, listener);
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* Registers a listener for when the database is ready.
|
|
3302
|
+
*
|
|
3303
|
+
* @param listener - Callback receiving database ready data
|
|
3304
|
+
* @returns this for chaining
|
|
3305
|
+
*/
|
|
3306
|
+
onDatabaseReady(listener) {
|
|
3307
|
+
return this.onEvent("DATABASE_READY", listener);
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* Registers a listener for incoming associations.
|
|
3311
|
+
*
|
|
3312
|
+
* @param listener - Callback receiving association data
|
|
3313
|
+
* @returns this for chaining
|
|
3314
|
+
*/
|
|
3315
|
+
onAssociationReceived(listener) {
|
|
3316
|
+
return this.onEvent("ASSOCIATION_RECEIVED", listener);
|
|
3317
|
+
}
|
|
3318
|
+
/**
|
|
3319
|
+
* Creates a new DcmprsCP server instance.
|
|
3320
|
+
*
|
|
3321
|
+
* @param options - Configuration options for the dcmprscp server
|
|
3322
|
+
* @returns A Result containing the server instance or a validation/resolution error
|
|
3323
|
+
*/
|
|
3324
|
+
static create(options) {
|
|
3325
|
+
const validation = DcmprsCPOptionsSchema.safeParse(options);
|
|
3326
|
+
if (!validation.success) {
|
|
3327
|
+
return err(createValidationError("dcmprscp", validation.error));
|
|
3328
|
+
}
|
|
3329
|
+
const binaryResult = resolveBinary("dcmprscp");
|
|
3330
|
+
if (!binaryResult.ok) {
|
|
3331
|
+
return err(binaryResult.error);
|
|
3332
|
+
}
|
|
3333
|
+
const args = buildArgs4(options);
|
|
3334
|
+
const parser2 = new LineParser();
|
|
1155
3335
|
for (const pattern of DCMPRSCP_PATTERNS) {
|
|
1156
|
-
const addResult =
|
|
3336
|
+
const addResult = parser2.addPattern(pattern);
|
|
1157
3337
|
if (!addResult.ok) {
|
|
1158
3338
|
return err(addResult.error);
|
|
1159
3339
|
}
|
|
@@ -1165,7 +3345,7 @@ var DcmprsCP = class _DcmprsCP extends DcmtkProcess {
|
|
|
1165
3345
|
drainTimeoutMs: options.drainTimeoutMs,
|
|
1166
3346
|
isStartedPredicate: (line) => /Using database in directory/i.test(line)
|
|
1167
3347
|
};
|
|
1168
|
-
return ok(new _DcmprsCP(config,
|
|
3348
|
+
return ok(new _DcmprsCP(config, parser2, options.signal));
|
|
1169
3349
|
}
|
|
1170
3350
|
/** Wires the line parser to the process output. */
|
|
1171
3351
|
wireParser() {
|
|
@@ -1177,7 +3357,7 @@ var DcmprsCP = class _DcmprsCP extends DcmtkProcess {
|
|
|
1177
3357
|
this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
|
|
1178
3358
|
void this.stop();
|
|
1179
3359
|
}
|
|
1180
|
-
this.emit(event,
|
|
3360
|
+
this.emit(event, data);
|
|
1181
3361
|
});
|
|
1182
3362
|
}
|
|
1183
3363
|
/** Wires an AbortSignal to stop the server. */
|
|
@@ -1302,7 +3482,7 @@ var DcmpsrcvOptionsSchema = zod.z.object({
|
|
|
1302
3482
|
drainTimeoutMs: zod.z.number().int().positive().optional(),
|
|
1303
3483
|
signal: zod.z.instanceof(AbortSignal).optional()
|
|
1304
3484
|
}).strict();
|
|
1305
|
-
function
|
|
3485
|
+
function buildArgs5(options) {
|
|
1306
3486
|
const args = ["--verbose"];
|
|
1307
3487
|
if (options.logLevel !== void 0) {
|
|
1308
3488
|
args.push("--log-level", options.logLevel);
|
|
@@ -1317,12 +3497,12 @@ function buildArgs4(options) {
|
|
|
1317
3497
|
return args;
|
|
1318
3498
|
}
|
|
1319
3499
|
var Dcmpsrcv = class _Dcmpsrcv extends DcmtkProcess {
|
|
1320
|
-
constructor(config,
|
|
3500
|
+
constructor(config, parser2, signal) {
|
|
1321
3501
|
super(config);
|
|
1322
3502
|
__publicField(this, "parser");
|
|
1323
3503
|
__publicField(this, "abortSignal");
|
|
1324
3504
|
__publicField(this, "abortHandler");
|
|
1325
|
-
this.parser =
|
|
3505
|
+
this.parser = parser2;
|
|
1326
3506
|
this.wireParser();
|
|
1327
3507
|
if (signal !== void 0) {
|
|
1328
3508
|
this.wireAbortSignal(signal);
|
|
@@ -1373,16 +3553,16 @@ var Dcmpsrcv = class _Dcmpsrcv extends DcmtkProcess {
|
|
|
1373
3553
|
static create(options) {
|
|
1374
3554
|
const validation = DcmpsrcvOptionsSchema.safeParse(options);
|
|
1375
3555
|
if (!validation.success) {
|
|
1376
|
-
return err(
|
|
3556
|
+
return err(createValidationError("dcmpsrcv", validation.error));
|
|
1377
3557
|
}
|
|
1378
3558
|
const binaryResult = resolveBinary("dcmpsrcv");
|
|
1379
3559
|
if (!binaryResult.ok) {
|
|
1380
3560
|
return err(binaryResult.error);
|
|
1381
3561
|
}
|
|
1382
|
-
const args =
|
|
1383
|
-
const
|
|
3562
|
+
const args = buildArgs5(options);
|
|
3563
|
+
const parser2 = new LineParser();
|
|
1384
3564
|
for (const pattern of DCMPSRCV_PATTERNS) {
|
|
1385
|
-
const addResult =
|
|
3565
|
+
const addResult = parser2.addPattern(pattern);
|
|
1386
3566
|
if (!addResult.ok) {
|
|
1387
3567
|
return err(addResult.error);
|
|
1388
3568
|
}
|
|
@@ -1394,7 +3574,7 @@ var Dcmpsrcv = class _Dcmpsrcv extends DcmtkProcess {
|
|
|
1394
3574
|
drainTimeoutMs: options.drainTimeoutMs,
|
|
1395
3575
|
isStartedPredicate: (line) => /Receiver\s+\S+\s+on port/i.test(line)
|
|
1396
3576
|
};
|
|
1397
|
-
return ok(new _Dcmpsrcv(config,
|
|
3577
|
+
return ok(new _Dcmpsrcv(config, parser2, options.signal));
|
|
1398
3578
|
}
|
|
1399
3579
|
/** Wires the line parser to the process output. */
|
|
1400
3580
|
wireParser() {
|
|
@@ -1406,7 +3586,7 @@ var Dcmpsrcv = class _Dcmpsrcv extends DcmtkProcess {
|
|
|
1406
3586
|
this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
|
|
1407
3587
|
void this.stop();
|
|
1408
3588
|
}
|
|
1409
|
-
this.emit(event,
|
|
3589
|
+
this.emit(event, data);
|
|
1410
3590
|
});
|
|
1411
3591
|
}
|
|
1412
3592
|
/** Wires an AbortSignal to stop the server. */
|
|
@@ -1522,7 +3702,7 @@ var DcmQRSCPOptionsSchema = zod.z.object({
|
|
|
1522
3702
|
drainTimeoutMs: zod.z.number().int().positive().optional(),
|
|
1523
3703
|
signal: zod.z.instanceof(AbortSignal).optional()
|
|
1524
3704
|
}).strict();
|
|
1525
|
-
function
|
|
3705
|
+
function buildArgs6(options) {
|
|
1526
3706
|
const args = [];
|
|
1527
3707
|
if (options.verbose !== false) {
|
|
1528
3708
|
args.push("--verbose");
|
|
@@ -1558,24 +3738,17 @@ function buildNetworkArgs2(args, options) {
|
|
|
1558
3738
|
}
|
|
1559
3739
|
}
|
|
1560
3740
|
var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
|
|
1561
|
-
constructor(config,
|
|
3741
|
+
constructor(config, parser2, signal) {
|
|
1562
3742
|
super(config);
|
|
1563
3743
|
__publicField(this, "parser");
|
|
1564
3744
|
__publicField(this, "abortSignal");
|
|
1565
3745
|
__publicField(this, "abortHandler");
|
|
1566
|
-
this.parser =
|
|
3746
|
+
this.parser = parser2;
|
|
1567
3747
|
this.wireParser();
|
|
1568
3748
|
if (signal !== void 0) {
|
|
1569
3749
|
this.wireAbortSignal(signal);
|
|
1570
3750
|
}
|
|
1571
3751
|
}
|
|
1572
|
-
/**
|
|
1573
|
-
* Registers a typed listener for a dcmqrscp-specific event.
|
|
1574
|
-
*
|
|
1575
|
-
* @param event - The event name from DcmQRSCPEventMap
|
|
1576
|
-
* @param listener - Callback receiving typed event data
|
|
1577
|
-
* @returns this for chaining
|
|
1578
|
-
*/
|
|
1579
3752
|
/** Disposes the server and its parser, preventing listener leaks. */
|
|
1580
3753
|
[Symbol.dispose]() {
|
|
1581
3754
|
if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
|
|
@@ -1584,6 +3757,13 @@ var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
|
|
|
1584
3757
|
this.parser[Symbol.dispose]();
|
|
1585
3758
|
super[Symbol.dispose]();
|
|
1586
3759
|
}
|
|
3760
|
+
/**
|
|
3761
|
+
* Registers a typed listener for a dcmqrscp-specific event.
|
|
3762
|
+
*
|
|
3763
|
+
* @param event - The event name from DcmQRSCPEventMap
|
|
3764
|
+
* @param listener - Callback receiving typed event data
|
|
3765
|
+
* @returns this for chaining
|
|
3766
|
+
*/
|
|
1587
3767
|
onEvent(event, listener) {
|
|
1588
3768
|
return this.on(event, listener);
|
|
1589
3769
|
}
|
|
@@ -1614,16 +3794,16 @@ var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
|
|
|
1614
3794
|
static create(options) {
|
|
1615
3795
|
const validation = DcmQRSCPOptionsSchema.safeParse(options);
|
|
1616
3796
|
if (!validation.success) {
|
|
1617
|
-
return err(
|
|
3797
|
+
return err(createValidationError("dcmqrscp", validation.error));
|
|
1618
3798
|
}
|
|
1619
3799
|
const binaryResult = resolveBinary("dcmqrscp");
|
|
1620
3800
|
if (!binaryResult.ok) {
|
|
1621
3801
|
return err(binaryResult.error);
|
|
1622
3802
|
}
|
|
1623
|
-
const args =
|
|
1624
|
-
const
|
|
3803
|
+
const args = buildArgs6(options);
|
|
3804
|
+
const parser2 = new LineParser();
|
|
1625
3805
|
for (const pattern of DCMQRSCP_PATTERNS) {
|
|
1626
|
-
const addResult =
|
|
3806
|
+
const addResult = parser2.addPattern(pattern);
|
|
1627
3807
|
if (!addResult.ok) {
|
|
1628
3808
|
return err(addResult.error);
|
|
1629
3809
|
}
|
|
@@ -1634,7 +3814,7 @@ var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
|
|
|
1634
3814
|
startTimeoutMs: options.startTimeoutMs,
|
|
1635
3815
|
drainTimeoutMs: options.drainTimeoutMs
|
|
1636
3816
|
};
|
|
1637
|
-
return ok(new _DcmQRSCP(config,
|
|
3817
|
+
return ok(new _DcmQRSCP(config, parser2, options.signal));
|
|
1638
3818
|
}
|
|
1639
3819
|
/** Wires the line parser to the process output. */
|
|
1640
3820
|
wireParser() {
|
|
@@ -1646,7 +3826,7 @@ var DcmQRSCP = class _DcmQRSCP extends DcmtkProcess {
|
|
|
1646
3826
|
this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
|
|
1647
3827
|
void this.stop();
|
|
1648
3828
|
}
|
|
1649
|
-
this.emit(event,
|
|
3829
|
+
this.emit(event, data);
|
|
1650
3830
|
});
|
|
1651
3831
|
}
|
|
1652
3832
|
/** Wires an AbortSignal to stop the server. */
|
|
@@ -1742,7 +3922,7 @@ var WlmscpfsOptionsSchema = zod.z.object({
|
|
|
1742
3922
|
drainTimeoutMs: zod.z.number().int().positive().optional(),
|
|
1743
3923
|
signal: zod.z.instanceof(AbortSignal).optional()
|
|
1744
3924
|
}).strict();
|
|
1745
|
-
function
|
|
3925
|
+
function buildArgs7(options) {
|
|
1746
3926
|
const args = [];
|
|
1747
3927
|
if (options.verbose !== false) {
|
|
1748
3928
|
args.push("--verbose");
|
|
@@ -1772,24 +3952,17 @@ function buildNetworkArgs3(args, options) {
|
|
|
1772
3952
|
}
|
|
1773
3953
|
}
|
|
1774
3954
|
var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
|
|
1775
|
-
constructor(config,
|
|
3955
|
+
constructor(config, parser2, signal) {
|
|
1776
3956
|
super(config);
|
|
1777
3957
|
__publicField(this, "parser");
|
|
1778
3958
|
__publicField(this, "abortSignal");
|
|
1779
3959
|
__publicField(this, "abortHandler");
|
|
1780
|
-
this.parser =
|
|
3960
|
+
this.parser = parser2;
|
|
1781
3961
|
this.wireParser();
|
|
1782
3962
|
if (signal !== void 0) {
|
|
1783
3963
|
this.wireAbortSignal(signal);
|
|
1784
3964
|
}
|
|
1785
3965
|
}
|
|
1786
|
-
/**
|
|
1787
|
-
* Registers a typed listener for a wlmscpfs-specific event.
|
|
1788
|
-
*
|
|
1789
|
-
* @param event - The event name from WlmscpfsEventMap
|
|
1790
|
-
* @param listener - Callback receiving typed event data
|
|
1791
|
-
* @returns this for chaining
|
|
1792
|
-
*/
|
|
1793
3966
|
/** Disposes the server and its parser, preventing listener leaks. */
|
|
1794
3967
|
[Symbol.dispose]() {
|
|
1795
3968
|
if (this.abortSignal !== void 0 && this.abortHandler !== void 0) {
|
|
@@ -1798,6 +3971,13 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
|
|
|
1798
3971
|
this.parser[Symbol.dispose]();
|
|
1799
3972
|
super[Symbol.dispose]();
|
|
1800
3973
|
}
|
|
3974
|
+
/**
|
|
3975
|
+
* Registers a typed listener for a wlmscpfs-specific event.
|
|
3976
|
+
*
|
|
3977
|
+
* @param event - The event name from WlmscpfsEventMap
|
|
3978
|
+
* @param listener - Callback receiving typed event data
|
|
3979
|
+
* @returns this for chaining
|
|
3980
|
+
*/
|
|
1801
3981
|
onEvent(event, listener) {
|
|
1802
3982
|
return this.on(event, listener);
|
|
1803
3983
|
}
|
|
@@ -1828,16 +4008,16 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
|
|
|
1828
4008
|
static create(options) {
|
|
1829
4009
|
const validation = WlmscpfsOptionsSchema.safeParse(options);
|
|
1830
4010
|
if (!validation.success) {
|
|
1831
|
-
return err(
|
|
4011
|
+
return err(createValidationError("wlmscpfs", validation.error));
|
|
1832
4012
|
}
|
|
1833
4013
|
const binaryResult = resolveBinary("wlmscpfs");
|
|
1834
4014
|
if (!binaryResult.ok) {
|
|
1835
4015
|
return err(binaryResult.error);
|
|
1836
4016
|
}
|
|
1837
|
-
const args =
|
|
1838
|
-
const
|
|
4017
|
+
const args = buildArgs7(options);
|
|
4018
|
+
const parser2 = new LineParser();
|
|
1839
4019
|
for (const pattern of WLMSCPFS_PATTERNS) {
|
|
1840
|
-
const addResult =
|
|
4020
|
+
const addResult = parser2.addPattern(pattern);
|
|
1841
4021
|
if (!addResult.ok) {
|
|
1842
4022
|
return err(addResult.error);
|
|
1843
4023
|
}
|
|
@@ -1849,7 +4029,7 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
|
|
|
1849
4029
|
drainTimeoutMs: options.drainTimeoutMs
|
|
1850
4030
|
// wlmscpfs doesn't reliably print "listening" — resolve on spawn
|
|
1851
4031
|
};
|
|
1852
|
-
return ok(new _Wlmscpfs(config,
|
|
4032
|
+
return ok(new _Wlmscpfs(config, parser2, options.signal));
|
|
1853
4033
|
}
|
|
1854
4034
|
/** Wires the line parser to the process output. */
|
|
1855
4035
|
wireParser() {
|
|
@@ -1861,7 +4041,7 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
|
|
|
1861
4041
|
this.emit("error", { error: new Error(`Fatal: ${event}`), fatal: true });
|
|
1862
4042
|
void this.stop();
|
|
1863
4043
|
}
|
|
1864
|
-
this.emit(event,
|
|
4044
|
+
this.emit(event, data);
|
|
1865
4045
|
});
|
|
1866
4046
|
}
|
|
1867
4047
|
/** Wires an AbortSignal to stop the server. */
|
|
@@ -1878,6 +4058,7 @@ var Wlmscpfs = class _Wlmscpfs extends DcmtkProcess {
|
|
|
1878
4058
|
}
|
|
1879
4059
|
};
|
|
1880
4060
|
|
|
4061
|
+
exports.AssociationTracker = AssociationTracker;
|
|
1881
4062
|
exports.DCMPRSCP_FATAL_EVENTS = DCMPRSCP_FATAL_EVENTS;
|
|
1882
4063
|
exports.DCMPRSCP_PATTERNS = DCMPRSCP_PATTERNS;
|
|
1883
4064
|
exports.DCMPSRCV_FATAL_EVENTS = DCMPSRCV_FATAL_EVENTS;
|
|
@@ -1895,6 +4076,7 @@ exports.DcmqrscpEvent = DcmqrscpEvent;
|
|
|
1895
4076
|
exports.Dcmrecv = Dcmrecv;
|
|
1896
4077
|
exports.DcmrecvEvent = DcmrecvEvent;
|
|
1897
4078
|
exports.DcmtkProcess = DcmtkProcess;
|
|
4079
|
+
exports.DicomReceiver = DicomReceiver;
|
|
1898
4080
|
exports.FilenameMode = FilenameMode;
|
|
1899
4081
|
exports.LineParser = LineParser;
|
|
1900
4082
|
exports.PreferredTransferSyntax = PreferredTransferSyntax;
|
|
@@ -1903,6 +4085,7 @@ exports.STORESCP_FATAL_EVENTS = STORESCP_FATAL_EVENTS;
|
|
|
1903
4085
|
exports.STORESCP_PATTERNS = STORESCP_PATTERNS;
|
|
1904
4086
|
exports.StorageMode = StorageMode;
|
|
1905
4087
|
exports.StoreSCP = StoreSCP;
|
|
4088
|
+
exports.StoreSCPPreset = StoreSCPPreset;
|
|
1906
4089
|
exports.StorescpEvent = StorescpEvent;
|
|
1907
4090
|
exports.SubdirectoryMode = SubdirectoryMode;
|
|
1908
4091
|
exports.WLMSCPFS_FATAL_EVENTS = WLMSCPFS_FATAL_EVENTS;
|