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