From 1eb82ccaa1bd736c1235607b9bc5df1f7611ebff Mon Sep 17 00:00:00 2001 From: Salim Kanoun Date: Fri, 13 Sep 2024 22:35:40 +0200 Subject: [PATCH 01/10] wip --- src/content/patients/EditPatient.tsx | 8 +-- src/content/patients/PatientEditForm.tsx | 92 ++++++++++++------------ src/content/studies/EditStudy.tsx | 10 +-- src/content/studies/StudyEditForm.tsx | 69 +++++++++++++----- src/services/orthanc.ts | 10 ++- src/utils/types.ts | 8 ++- 6 files changed, 120 insertions(+), 77 deletions(-) diff --git a/src/content/patients/EditPatient.tsx b/src/content/patients/EditPatient.tsx index c8b6fa5f..084755b1 100644 --- a/src/content/patients/EditPatient.tsx +++ b/src/content/patients/EditPatient.tsx @@ -2,7 +2,7 @@ import React from "react"; import Patient from "../../model/Patient"; import { modifyPatient } from "../../services"; import { useCustomMutation, useCustomToast } from "../../utils"; -import { PatientPayload, OrthancResponse } from "../../utils/types"; +import { PatientModifyPayload, OrthancResponse } from "../../utils/types"; import PatientEditForm from './PatientEditForm'; import { Modal } from "../../ui"; @@ -16,7 +16,7 @@ type EditPatientProps = { const EditPatient: React.FC = ({ patient, onEditPatient, onClose, show }) => { const { toastSuccess, toastError } = useCustomToast(); - const { mutateAsync: mutatePatient } = useCustomMutation( + const { mutateAsync: mutatePatient } = useCustomMutation( ({ id, payload }) => modifyPatient(id, payload), [['jobs']], { @@ -31,7 +31,7 @@ const EditPatient: React.FC = ({ patient, onEditPatient, onClo } ); - const handleSubmit = ({ id, payload }: { id: string; payload: PatientPayload }) => { + const handleSubmit = ({ id, payload }: { id: string; payload: PatientModifyPayload }) => { mutatePatient({ id, payload }); }; @@ -39,7 +39,7 @@ const EditPatient: React.FC = ({ patient, onEditPatient, onClo Edit patient - + ); diff --git a/src/content/patients/PatientEditForm.tsx b/src/content/patients/PatientEditForm.tsx index 8471a9f3..33ef63fe 100644 --- a/src/content/patients/PatientEditForm.tsx +++ b/src/content/patients/PatientEditForm.tsx @@ -1,53 +1,54 @@ import React, { ChangeEvent, useState, useEffect } from "react"; import { Button, Input, Spinner } from "../../ui"; import Patient from "../../model/Patient"; -import { PatientMainDicomTags, PatientPayload } from "../../utils/types"; +import { PatientMainDicomTags, PatientModifyPayload } from "../../utils/types"; import CheckBox from "../../ui/Checkbox"; import { Colors } from "../../utils"; import InputWithDelete from "../../ui/InputWithDelete"; type PatientEditFormProps = { patient: Patient; - onSubmit: (data: { id: string; payload: PatientPayload }) => void; - onCancel: () => void; + onSubmit: (data: { id: string; payload: PatientModifyPayload }) => void; }; - const PatientEditForm = ({ patient, onSubmit, onCancel }: PatientEditFormProps) => { - const [patientId, setPatientId] = useState(patient?.patientId ?? ""); - const [patientName, setPatientName] = useState(patient?.patientName ?? null); - const [patientBirthDate, setPatientBirthDate] = useState(patient?.patientBirthDate ?? null); - const [patientSex, setPatientSex] = useState(patient?.patientSex ?? null); - const [removePrivateTags, setRemovePrivateTags] = useState(false); - const [keepSource, setKeepSource] = useState(false); - const [fieldsToRemove, setFieldsToRemove] = useState([]); - - if (!patient) return ; - - const handleFieldRemoval = (field: string, checked: boolean) => { - setFieldsToRemove((prev) => - checked ? [...prev, field] : prev.filter((item) => item !== field) - ); - }; - - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - const replace: Partial = {}; - - if (patientName !== patient.patientName) replace.patientName = patientName; - if (patientBirthDate !== patient.patientBirthDate) replace.patientBirthDate = patientBirthDate; - if (patientSex !== patient.patientSex) replace.patientSex = patientSex; - - const payload: PatientPayload = { - replace, - remove: fieldsToRemove, - removePrivateTags, - keepSource, - force: true, - synchronous: false, - }; - onSubmit({ id: patientId, payload }); +const PatientEditForm = ({ patient, onSubmit }: PatientEditFormProps) => { + const [patientId, setPatientId] = useState(patient?.patientId ?? ""); + const [patientName, setPatientName] = useState(patient?.patientName ?? null); + const [patientBirthDate, setPatientBirthDate] = useState(patient?.patientBirthDate ?? null); + const [patientSex, setPatientSex] = useState(patient?.patientSex ?? null); + const [removePrivateTags, setRemovePrivateTags] = useState(false); + const [keepSource, setKeepSource] = useState(false); + const [fieldsToRemove, setFieldsToRemove] = useState([]); + const [keepUIDs, setKeepUIDs] = useState(false) + + if (!patient) return ; + + const handleFieldRemoval = (field: string, checked: boolean) => { + setFieldsToRemove((prev) => + checked ? [...prev, field] : prev.filter((item) => item !== field) + ); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const replace: Partial = {}; + + if (patientName !== patient.patientName) replace.patientName = patientName; + if (patientBirthDate !== patient.patientBirthDate) replace.patientBirthDate = patientBirthDate; + if (patientSex !== patient.patientSex) replace.patientSex = patientSex; + + const payload: PatientModifyPayload = { + replace, + remove: fieldsToRemove, + removePrivateTags, + keepSource, + force: true, + synchronous: false, + keep: keepUIDs ? ['StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID'] : [], }; - + onSubmit({ id: patientId, payload }); + }; + return (
@@ -91,9 +92,9 @@ type PatientEditFormProps = { placeholder="Enter patient sex" /> -
+
) => setRemovePrivateTags(event.target.checked)} bordered={false} @@ -104,11 +105,14 @@ type PatientEditFormProps = { onChange={(event: ChangeEvent) => setKeepSource(event.target.checked)} bordered={false} /> + ) => setKeepUIDs(e.target.checked)} + bordered={false} + />
-
- +
diff --git a/src/content/studies/EditStudy.tsx b/src/content/studies/EditStudy.tsx index 4d72d1a0..33021542 100644 --- a/src/content/studies/EditStudy.tsx +++ b/src/content/studies/EditStudy.tsx @@ -3,7 +3,7 @@ */ import React, { useState } from "react"; -import { Study, StudyPayload } from "../../utils/types"; +import { Study, StudyModifyPayload } from "../../utils/types"; import { getStudy, modifyStudy } from "../../services/orthanc"; import { useCustomMutation, useCustomQuery, useCustomToast } from "../../utils"; import StudyEditForm from './StudyEditForm'; @@ -20,8 +20,7 @@ const EditStudy: React.FC = ({ studyId, onStudyUpdated, onClose, const { toastSuccess, toastError } = useCustomToast(); const [jobId, setJobId] = useState(null); - console.log("EditStudyProps", studyId, onStudyUpdated, onClose, show); - const { mutateAsync: mutateStudy } = useCustomMutation( + const { mutateAsync: mutateStudy } = useCustomMutation( ({ id, payload }) => modifyStudy(id, payload), [['studies'], ['jobs']], { @@ -31,6 +30,7 @@ const EditStudy: React.FC = ({ studyId, onStudyUpdated, onClose, // onClose(); }, onError: (error) => { + console.log(error) toastError("Failed to update study: " + error ); }, } @@ -46,7 +46,7 @@ const EditStudy: React.FC = ({ studyId, onStudyUpdated, onClose, } ); - const handleSubmit = ({ id, payload }: { id: string; payload: StudyPayload }) => { + const handleSubmit = ({ id, payload }: { id: string; payload: StudyModifyPayload }) => { mutateStudy({ id, payload }); }; @@ -71,7 +71,7 @@ const EditStudy: React.FC = ({ studyId, onStudyUpdated, onClose, {editingStudyDetails && ( void; + data: Study; + onSubmit: (params: { id: string; payload: StudyModifyPayload }) => void; jobId?: string; onJobCompleted?: (jobState: string) => void; }; const StudyEditForm = ({ data, onSubmit, jobId, onJobCompleted }: StudyEditFormProps) => { - const [accessionNumber, setAccessionNumber] = useState(data?.accessionNumber ?? null); - const [studyDate, setStudyDate] = useState(data?.studyDate ?? null); - const [studyDescription, setStudyDescription] = useState(data?.studyDescription ?? null); - const [studyTime, setStudyTime] = useState(data?.studyTime ?? null); - const [studyId, setStudyId] = useState(data?.studyId ?? null); + const [patientName, setPatientName] = useState(data?.patientMainDicomTags?.patientName ?? null); + const [patientId, setPatientId] = useState(data?.patientMainDicomTags?.patientId ?? null); + + const [accessionNumber, setAccessionNumber] = useState(data?.mainDicomTags?.accessionNumber ?? null); + const [studyDate, setStudyDate] = useState(data?.mainDicomTags?.studyDate ?? null); + const [studyDescription, setStudyDescription] = useState(data?.mainDicomTags?.studyDescription ?? null); + const [studyTime, setStudyTime] = useState(data?.mainDicomTags?.studyTime ?? null); + const [studyId, setStudyId] = useState(data?.mainDicomTags?.studyId ?? null); const [removePrivateTags, setRemovePrivateTags] = useState(false); const [keepSource, setKeepSource] = useState(false); const [fieldsToRemove, setFieldsToRemove] = useState([]); + const [keepUIDs, setKeepUIDs] = useState(false) + const handleFieldRemoval = (field: string, checked: boolean) => { setFieldsToRemove((prev) => checked ? [...prev, field] : prev.filter((item) => item !== field) @@ -29,28 +35,46 @@ const StudyEditForm = ({ data, onSubmit, jobId, onJobCompleted }: StudyEditFormP }; const handleSubmit = (event: React.FormEvent | React.MouseEvent) => { event.preventDefault(); - const replace: Partial = {}; + const replace: Partial = {}; - if (accessionNumber !== data.accessionNumber) replace.accessionNumber = accessionNumber; - if (studyDate !== data.studyDate) replace.studyDate = studyDate; - if (studyDescription !== data.studyDescription) replace.studyDescription = studyDescription; - if (studyId !== data.studyId) replace.studyId = studyId; - if (studyTime !== data.studyTime) replace.studyTime = studyTime; + if (patientId !== data?.patientMainDicomTags?.patientId) replace.patientId = patientId; + if (patientName !== data?.patientMainDicomTags?.patientName) replace.patientName = patientName; + if (accessionNumber !== data?.mainDicomTags?.accessionNumber) replace.accessionNumber = accessionNumber; + if (studyDate !== data?.mainDicomTags?.studyDate) replace.studyDate = studyDate; + if (studyDescription !== data?.mainDicomTags?.studyDescription) replace.studyDescription = studyDescription; + if (studyId !== data?.mainDicomTags?.studyId) replace.studyId = studyId; + if (studyTime !== data?.mainDicomTags?.studyTime) replace.studyTime = studyTime; - const payload: StudyPayload = { + const payload: StudyModifyPayload = { replace, remove: fieldsToRemove, removePrivateTags, + keep: keepUIDs ? ['StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID'] : [], keepSource, force: true, synchronous: false, }; + onSubmit({ id: data.id, payload }); }; return ( +
+ ) => setPatientName(e.target.value)} + fieldName="patientName" + /> + ) => setPatientId(e.target.value)} + fieldName="patientID" + /> +
-
+
) => setRemovePrivateTags(e.target.checked)} bordered={false} @@ -110,6 +134,15 @@ const StudyEditForm = ({ data, onSubmit, jobId, onJobCompleted }: StudyEditFormP onChange={(e: ChangeEvent) => setKeepSource(e.target.checked)} bordered={false} /> + ) => setKeepUIDs(e.target.checked)} + bordered={false} + /> +
+
+
{jobId && ( diff --git a/src/services/orthanc.ts b/src/services/orthanc.ts index 7a205bf7..df2ae49d 100644 --- a/src/services/orthanc.ts +++ b/src/services/orthanc.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { Patient, Study, Series, PatientPayload, OrthancResponse, StudyPayload, SeriesPayload, Instances } from '../utils/types'; +import { Patient, Study, Series, PatientModifyPayload, OrthancResponse, StudyModifyPayload, SeriesPayload, Instances } from '../utils/types'; export const getOrthancSystem = (): Promise => { return axios.get("/api/system").then(response => response.data) @@ -212,7 +212,7 @@ export const getInstancesOfSeries = (seriesId: string) => { } -export const modifyPatient = (patientId: string, patient: PatientPayload): Promise => { +export const modifyPatient = (patientId: string, patient: PatientModifyPayload): Promise => { const patientPayloadUpdate = { Replace: { PatientID: patient.replace.patientId, @@ -220,6 +220,7 @@ export const modifyPatient = (patientId: string, patient: PatientPayload): Promi PatientBirthDate: patient.replace.patientBirthDate, PatientSex: patient.replace.patientSex }, + Keep: patient.keep, Remove: patient.remove.map(field => field.charAt(0).toUpperCase() + field.slice(1)), RemovePrivateTags: patient.removePrivateTags, Force: true, @@ -242,9 +243,11 @@ export const modifyPatient = (patientId: string, patient: PatientPayload): Promi }); }; -export const modifyStudy = (studyId: string, study: StudyPayload): Promise => { +export const modifyStudy = (studyId: string, study: StudyModifyPayload): Promise => { const studyPayloadUpdate = { Replace: { + PatientID : study.replace.patientId, + PatientName : study.replace.patientName, AccessionNumber: study.replace.accessionNumber, StudyDate: study.replace.studyDate, StudyDescription: study.replace.studyDescription, @@ -252,6 +255,7 @@ export const modifyStudy = (studyId: string, study: StudyPayload): Promise field.charAt(0).toUpperCase() + field.slice(1)), RemovePrivateTags: study.removePrivateTags, + Keep : study.keep, Force: true, Synchronous: false, KeepSource: study.keepSource diff --git a/src/utils/types.ts b/src/utils/types.ts index 13738593..a87978c6 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -335,13 +335,14 @@ export type Patient = { type: string; }; -export type PatientPayload = { +export type PatientModifyPayload = { replace: Partial; remove: string[]; removePrivateTags: boolean; force: boolean; synchronous: boolean; keepSource: boolean; + keep : string[]; }; export type OrthancResponse = { @@ -361,11 +362,12 @@ export type Study = { type: string; }; -export type StudyPayload = { - replace: Partial; +export type StudyModifyPayload = { + replace: Partial; remove: string[]; removePrivateTags: boolean; force: boolean; + keep : string[]; synchronous: boolean; keepSource: boolean; }; From 54b265602fcb785a150307232cd98639c7434d62 Mon Sep 17 00:00:00 2001 From: Salim Kanoun Date: Fri, 13 Sep 2024 23:08:10 +0200 Subject: [PATCH 02/10] wip --- docker-compose.yml | 2 ++ src/content/ContentRoot.tsx | 1 + src/content/patients/EditPatient.tsx | 26 ++++++++++------ src/content/patients/PatientEditForm.tsx | 19 ++++++++++-- src/content/studies/EditStudy.tsx | 2 -- src/content/studies/StudyEditForm.tsx | 6 +++- src/ui/Checkbox.tsx | 5 +-- src/ui/InputWithDelete.tsx | 39 ++++++++++-------------- 8 files changed, 61 insertions(+), 39 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b8c830cb..c486fb20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,7 @@ services: PYTHON_PLUGIN_ENABLED: "true" TRANSFERS_PLUGIN_ENABLED: "true" WORKLISTS_PLUGIN_ENABLED: "true" + ORTHANC__OVERWRITE_INSTANCES: "true" ORTHANC__DICOM_WEB__ENABLE: "true" ORTHANC__DICOM_WEB__ROOT: "/dicom-web/" ORTHANC__DICOM_WEB__ENABLEWADO: "true" @@ -70,6 +71,7 @@ services: ORTHANC__DICOM_WEB__SSL: "true" ORTHANC__DICOM_WEB__STUDIES_METADATA: "MainDicomTags" ORTHANC__DICOM_WEB__SERIES_METADATA: "Full" + volumes: orthanc-flow: diff --git a/src/content/ContentRoot.tsx b/src/content/ContentRoot.tsx index 883ab6be..d7c5a66f 100644 --- a/src/content/ContentRoot.tsx +++ b/src/content/ContentRoot.tsx @@ -95,6 +95,7 @@ const ContentRoot: React.FC = () => { return (
= ({ patient, onEditPatient, onClose, show }) => { - const { toastSuccess, toastError } = useCustomToast(); + const { toastError } = useCustomToast(); + const [jobId, setJobId] = useState(null); const { mutateAsync: mutatePatient } = useCustomMutation( ({ id, payload }) => modifyPatient(id, payload), [['jobs']], { - onSuccess: async () => { - toastSuccess(`Patient updated successfully`); - onEditPatient(patient); - onClose(); + onSuccess: async (data) => { + setJobId(data.id); }, - onError: (error: any) => { - toastError(`Failed to update patient: ${error}`); + onError: () => { + toastError(`Failed to update patient`); } } ); @@ -35,11 +34,20 @@ const EditPatient: React.FC = ({ patient, onEditPatient, onClo mutatePatient({ id, payload }); }; + const handleJobCompletion = (job: string) => { + if (job === "Success") { + onEditPatient(patient); + onClose(); + } else if (job === "Failure") { + toastError(`Failed to update Study `); + } + }; + return ( Edit patient - + ); diff --git a/src/content/patients/PatientEditForm.tsx b/src/content/patients/PatientEditForm.tsx index 33ef63fe..67c540fc 100644 --- a/src/content/patients/PatientEditForm.tsx +++ b/src/content/patients/PatientEditForm.tsx @@ -5,13 +5,16 @@ import { PatientMainDicomTags, PatientModifyPayload } from "../../utils/types"; import CheckBox from "../../ui/Checkbox"; import { Colors } from "../../utils"; import InputWithDelete from "../../ui/InputWithDelete"; +import ProgressJobs from "../../query/ProgressJobs"; type PatientEditFormProps = { + jobId: string | null; patient: Patient; onSubmit: (data: { id: string; payload: PatientModifyPayload }) => void; + onJobCompleted: (jobStatus :string) => void; }; -const PatientEditForm = ({ patient, onSubmit }: PatientEditFormProps) => { +const PatientEditForm = ({ patient, jobId, onSubmit, onJobCompleted }: PatientEditFormProps) => { const [patientId, setPatientId] = useState(patient?.patientId ?? ""); const [patientName, setPatientName] = useState(patient?.patientName ?? null); const [patientBirthDate, setPatientBirthDate] = useState(patient?.patientBirthDate ?? null); @@ -23,6 +26,10 @@ const PatientEditForm = ({ patient, onSubmit }: PatientEditFormProps) => { if (!patient) return ; + useEffect(() => { + if (keepUIDs) setKeepSource(true) + }, [keepUIDs]) + const handleFieldRemoval = (field: string, checked: boolean) => { setFieldsToRemove((prev) => checked ? [...prev, field] : prev.filter((item) => item !== field) @@ -34,6 +41,7 @@ const PatientEditForm = ({ patient, onSubmit }: PatientEditFormProps) => { const replace: Partial = {}; if (patientName !== patient.patientName) replace.patientName = patientName; + if (patientId !== patient.patientId) replace.patientId = patientId; if (patientBirthDate !== patient.patientBirthDate) replace.patientBirthDate = patientBirthDate; if (patientSex !== patient.patientSex) replace.patientSex = patientSex; @@ -46,7 +54,7 @@ const PatientEditForm = ({ patient, onSubmit }: PatientEditFormProps) => { synchronous: false, keep: keepUIDs ? ['StudyInstanceUID', 'SeriesInstanceUID', 'SOPInstanceUID'] : [], }; - onSubmit({ id: patientId, payload }); + onSubmit({ id: patient.id, payload }); }; @@ -116,6 +124,13 @@ const PatientEditForm = ({ patient, onSubmit }: PatientEditFormProps) => { + {jobId && + ( +
+ +
+ ) + }
); diff --git a/src/content/studies/EditStudy.tsx b/src/content/studies/EditStudy.tsx index 33021542..136756e1 100644 --- a/src/content/studies/EditStudy.tsx +++ b/src/content/studies/EditStudy.tsx @@ -25,9 +25,7 @@ const EditStudy: React.FC = ({ studyId, onStudyUpdated, onClose, [['studies'], ['jobs']], { onSuccess: (data) => { - // toastSuccess(`Study ${data.id} updated successfully`); setJobId(data.id); - // onClose(); }, onError: (error) => { console.log(error) diff --git a/src/content/studies/StudyEditForm.tsx b/src/content/studies/StudyEditForm.tsx index 1b63de3e..d324fa54 100644 --- a/src/content/studies/StudyEditForm.tsx +++ b/src/content/studies/StudyEditForm.tsx @@ -1,6 +1,6 @@ -import React, { ChangeEvent, useState } from "react"; +import React, { ChangeEvent, useEffect, useState } from "react"; import { StudyModifyPayload, StudyMainDicomTags, Study, PatientMainDicomTags } from '../../utils/types'; import { Button, CheckBox, Input, InputWithDelete } from "../../ui"; @@ -28,6 +28,10 @@ const StudyEditForm = ({ data, onSubmit, jobId, onJobCompleted }: StudyEditFormP const [fieldsToRemove, setFieldsToRemove] = useState([]); const [keepUIDs, setKeepUIDs] = useState(false) + useEffect(() => { + if (keepUIDs) setKeepSource(true) + }, [keepUIDs]) + const handleFieldRemoval = (field: string, checked: boolean) => { setFieldsToRemove((prev) => checked ? [...prev, field] : prev.filter((item) => item !== field) diff --git a/src/ui/Checkbox.tsx b/src/ui/Checkbox.tsx index 609593dc..f6b380b7 100644 --- a/src/ui/Checkbox.tsx +++ b/src/ui/Checkbox.tsx @@ -8,7 +8,7 @@ type CheckBoxProps = { [key: string]: any; }; -const CheckBox: React.FC = ({label, checked, onChange ,bordered=true }) => { +const CheckBox: React.FC = ({label, checked, onChange ,bordered=true, ...props}) => { const id = useId() return (
@@ -18,7 +18,8 @@ const CheckBox: React.FC = ({label, checked, onChange ,bordered=t checked={checked} onChange={onChange} className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" - />{ + {...props} + />{ label&& diff --git a/src/ui/InputWithDelete.tsx b/src/ui/InputWithDelete.tsx index 726ed092..c4c93983 100644 --- a/src/ui/InputWithDelete.tsx +++ b/src/ui/InputWithDelete.tsx @@ -28,33 +28,26 @@ const InputWithDelete: React.FC = ({ const isMarkedForRemoval = fieldsToRemove.includes(fieldName); return ( -
-
- - } - value={value || ""} - onChange={onChange} - readOnly={readOnly || isMarkedForRemoval} - required={required} - placeholder={placeholder} - disabled={isMarkedForRemoval} - className={` +
+ + } + value={value || ""} + onChange={onChange} + readOnly={readOnly || isMarkedForRemoval} + required={required} + placeholder={placeholder} + disabled={isMarkedForRemoval} + className={` w-full ${isMarkedForRemoval ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : ''} `} - /> -
- } - checked={isMarkedForRemoval} - onChange={(e: ChangeEvent) => onRemove(fieldName, e.target.checked)} - bordered={false} /> + onRemove(fieldName, !isMarkedForRemoval)} />
); }; From a812b413326609193d2d346e478043025f5a501b Mon Sep 17 00:00:00 2001 From: Salim Kanoun Date: Fri, 13 Sep 2024 23:22:49 +0200 Subject: [PATCH 03/10] series working --- src/content/series/EditSeries.tsx | 7 +- src/content/series/SeriesEditForm.tsx | 39 ++-- src/services/orthanc.ts | 276 +++++++++++++++----------- src/utils/index.ts | 4 +- src/utils/types.ts | 3 +- 5 files changed, 194 insertions(+), 135 deletions(-) diff --git a/src/content/series/EditSeries.tsx b/src/content/series/EditSeries.tsx index a42ef190..5b972962 100644 --- a/src/content/series/EditSeries.tsx +++ b/src/content/series/EditSeries.tsx @@ -5,9 +5,10 @@ import React, { useState } from "react"; import { modifySeries } from "../../services/orthanc"; -import { useCustomMutation, useCustomToast, Series, SeriesPayload } from "../../utils"; +import { useCustomMutation, useCustomToast, Series } from "../../utils"; import SeriesEditForm from './SeriesEditForm'; import { Modal } from "../../ui"; +import { SeriesModifyPayload } from "../../utils/types"; type EditSeriesProps = { series: Series; @@ -21,7 +22,7 @@ const EditSeries: React.FC = ({ series, onEditSeries, onClose, const [jobId, setJobId] = useState(null); - const { mutateAsync: mutateSeries } = useCustomMutation( + const { mutateAsync: mutateSeries } = useCustomMutation( ({ id, payload }) => modifySeries(id, payload), [['series'], ['jobs']], { @@ -34,7 +35,7 @@ const EditSeries: React.FC = ({ series, onEditSeries, onClose, } ); - const handleSubmit = ({ id, payload }: { id: string; payload: SeriesPayload }) => { + const handleSubmit = ({ id, payload }: { id: string; payload: SeriesModifyPayload }) => { mutateSeries({ id, payload }); }; diff --git a/src/content/series/SeriesEditForm.tsx b/src/content/series/SeriesEditForm.tsx index b5e45f85..8b6c2a5b 100644 --- a/src/content/series/SeriesEditForm.tsx +++ b/src/content/series/SeriesEditForm.tsx @@ -1,5 +1,5 @@ -import React, { ChangeEvent, useState } from "react"; -import { Series, SeriesPayload, SeriesMainDicomTags } from '../../utils/types'; +import React, { ChangeEvent, useEffect, useState } from "react"; +import { Series, SeriesModifyPayload, SeriesMainDicomTags } from '../../utils/types'; import { InputWithDelete, CheckBox, Button } from "../../ui"; import ProgressJobs from "../../query/ProgressJobs"; @@ -7,7 +7,7 @@ import { Colors } from "../../utils"; type SeriesEditFormProps = { data: Series; - onSubmit: (data: { id: string; payload: SeriesPayload }) => void; + onSubmit: (data: { id: string; payload: SeriesModifyPayload }) => void; jobId?: string; onJobCompleted?: (jobState: string) => void; } @@ -21,6 +21,11 @@ const SeriesEditForm = ({ data, onSubmit, jobId, onJobCompleted }: SeriesEditFor const [removePrivateTags, setRemovePrivateTags] = useState(false); const [keepSource, setKeepSource] = useState(false); const [fieldsToRemove, setFieldsToRemove] = useState([]); + const [keepUIDs, setKeepUIDs] = useState(false) + + useEffect(() => { + if (keepUIDs) setKeepSource(true) + }, [keepUIDs]) const handleFieldRemoval = (field: string, checked: boolean) => { setFieldsToRemove((prev) => @@ -40,13 +45,14 @@ const SeriesEditForm = ({ data, onSubmit, jobId, onJobCompleted }: SeriesEditFor if (seriesDate !== data.mainDicomTags.seriesDate) replace.seriesDate = seriesDate; if (seriesTime !== data.mainDicomTags.seriesTime) replace.seriesTime = seriesTime; - const payload: SeriesPayload = { + const payload: SeriesModifyPayload = { replace, remove: fieldsToRemove, removePrivateTags, keepSource, force: true, synchronous: false, + keep: keepUIDs ? ['SeriesInstanceUID', 'SOPInstanceUID'] : [], }; onSubmit({ id: data.id, payload }); @@ -107,7 +113,7 @@ const SeriesEditForm = ({ data, onSubmit, jobId, onJobCompleted }: SeriesEditFor fieldsToRemove={fieldsToRemove} />
-
+
) => setKeepSource(e.target.checked)} bordered={false} /> + ) => setKeepUIDs(e.target.checked)} + bordered={false} + />
-
+
+ { + jobId && ( +
+ +
+ ) + }
- { - jobId && ( -
- -
- ) - } + ); }; diff --git a/src/services/orthanc.ts b/src/services/orthanc.ts index df2ae49d..bbae6e48 100644 --- a/src/services/orthanc.ts +++ b/src/services/orthanc.ts @@ -1,8 +1,19 @@ import axios from "axios"; -import { Patient, Study, Series, PatientModifyPayload, OrthancResponse, StudyModifyPayload, SeriesPayload, Instances } from '../utils/types'; +import { + Patient, + Study, + Series, + PatientModifyPayload, + OrthancResponse, + StudyModifyPayload, + Instances, + SeriesModifyPayload, +} from "../utils/types"; export const getOrthancSystem = (): Promise => { - return axios.get("/api/system").then(response => response.data) + return axios + .get("/api/system") + .then((response) => response.data) .catch(function (error) { if (error.response) { throw error.response; @@ -12,7 +23,8 @@ export const getOrthancSystem = (): Promise => { }; export const orthancReset = (): Promise => { - return axios.post("/api/tools/reset") + return axios + .post("/api/tools/reset") .then(function () { return null; }) @@ -22,10 +34,11 @@ export const orthancReset = (): Promise => { } throw error; }); -} +}; export const orthancShutdown = (): Promise => { - return axios.post("/api/tools/shutdown") + return axios + .post("/api/tools/shutdown") .then(function (response) { return response.data; }) @@ -35,11 +48,13 @@ export const orthancShutdown = (): Promise => { } throw error; }); -} - +}; export const updateVerbosity = (level: string): Promise => { - return axios.put("/api/tools/log-level", level, { headers: { "Content-Type": "text/plain" } }) + return axios + .put("/api/tools/log-level", level, { + headers: { "Content-Type": "text/plain" }, + }) .then(function () { return null; }) @@ -49,10 +64,11 @@ export const updateVerbosity = (level: string): Promise => { } throw error; }); -} +}; export const getVerbosity = (): Promise => { - return axios.get("/api/tools/log-level") + return axios + .get("/api/tools/log-level") .then(function (response) { return response.data; }) @@ -62,12 +78,13 @@ export const getVerbosity = (): Promise => { } throw error; }); -} +}; export const getSeries = (seriesId: string): Promise => { - return axios.get("/api/series/" + seriesId + '?expand') - .then(response => { - const data = response.data + return axios + .get("/api/series/" + seriesId + "?expand") + .then((response) => { + const data = response.data; console.log("Series data:", data); return { expectedNumberOfInstances: data.ExpectedNumberOfInstances, @@ -87,12 +104,12 @@ export const getSeries = (seriesId: string): Promise => { seriesNumber: data.MainDicomTags.SeriesNumber, stationName: data.MainDicomTags.StationName, seriesDate: data.MainDicomTags.SeriesDate, - seriesTime: data.MainDicomTags.seriesTime + seriesTime: data.MainDicomTags.seriesTime, }, parentStudy: data.ParentStudy, status: data.Status, - type: data.Type - } + type: data.Type, + }; }) .catch(function (error) { if (error.response) { @@ -103,9 +120,10 @@ export const getSeries = (seriesId: string): Promise => { }; export const getStudy = (studyId: string): Promise => { - return axios.get("/api/studies/" + studyId + '?expand') + return axios + .get("/api/studies/" + studyId + "?expand") .then((response): Study => { - const data = response.data + const data = response.data; return { id: data.ID, isStable: data.IsStable, @@ -119,18 +137,18 @@ export const getStudy = (studyId: string): Promise => { studyDescription: data.MainDicomTags.StudyDescription, studyId: data.MainDicomTags.StudyID, studyInstanceUID: data.MainDicomTags.StudyInstanceUID, - studyTime: data.MainDicomTags.StudyTime + studyTime: data.MainDicomTags.StudyTime, }, patientMainDicomTags: { patientBirthDate: data.PatientMainDicomTags.PatientBirthDate, patientId: data.PatientMainDicomTags.PatientID, patientName: data.PatientMainDicomTags.PatientName, - patientSex: data.PatientMainDicomTags.PatientSex + patientSex: data.PatientMainDicomTags.PatientSex, }, parentPatient: data.ParentPatient, series: data.Series, - type: data.Type - } + type: data.Type, + }; }) .catch(function (error) { if (error.response) { @@ -141,34 +159,38 @@ export const getStudy = (studyId: string): Promise => { }; export const getSeriesOfStudy = (studyId: string): Promise => { - return axios.get(`/api/studies/${studyId}/series?expand`) + return axios + .get(`/api/studies/${studyId}/series?expand`) .then((response: any): Series[] => { - const mappedData = response.data.map((data: any): Series => ({ - expectedNumberOfInstances: data.ExpectedNumberOfInstances, - id: data.ID, - instances: data.Instances, - isStable: data.IsStable, - labels: data.Labels, - lastUpdate: data.LastUpdate, - mainDicomTags: { - imageOrientationPatient: data.MainDicomTags.ImageOrientationPatient, - manufacturer: data.MainDicomTags.Manufacturer, - modality: data.MainDicomTags.Modality, - operatorsName: data.MainDicomTags.OperatorsName, - protocolName: data.MainDicomTags.ProtocolName, - seriesDescription: data.MainDicomTags.SeriesDescription, - seriesInstanceUID: data.MainDicomTags.SeriesInstanceUID, - seriesNumber: data.MainDicomTags.SeriesNumber, - stationName: data.MainDicomTags.StationName, - seriesDate: data.MainDicomTags.SeriesDate, - seriesTime: data.MainDicomTags.SeriesTime - }, - parentStudy: data.ParentStudy, - status: data.Status, - type: data.Type - })); + const mappedData = response.data.map( + (data: any): Series => ({ + expectedNumberOfInstances: data.ExpectedNumberOfInstances, + id: data.ID, + instances: data.Instances, + isStable: data.IsStable, + labels: data.Labels, + lastUpdate: data.LastUpdate, + mainDicomTags: { + imageOrientationPatient: data.MainDicomTags.ImageOrientationPatient, + manufacturer: data.MainDicomTags.Manufacturer, + modality: data.MainDicomTags.Modality, + operatorsName: data.MainDicomTags.OperatorsName, + protocolName: data.MainDicomTags.ProtocolName, + seriesDescription: data.MainDicomTags.SeriesDescription, + seriesInstanceUID: data.MainDicomTags.SeriesInstanceUID, + seriesNumber: data.MainDicomTags.SeriesNumber, + stationName: data.MainDicomTags.StationName, + seriesDate: data.MainDicomTags.SeriesDate, + seriesTime: data.MainDicomTags.SeriesTime, + }, + parentStudy: data.ParentStudy, + status: data.Status, + type: data.Type, + }) + ); return mappedData; - }).catch((error: any) => { + }) + .catch((error: any) => { if (error.response) { console.error("Error response:", error.response); throw error.response; @@ -179,29 +201,33 @@ export const getSeriesOfStudy = (studyId: string): Promise => { }; export const getInstancesOfSeries = (seriesId: string) => { - return axios.get(`/api/series/${seriesId}/instances`) + return axios + .get(`/api/series/${seriesId}/instances`) .then((response: any): Instances[] => { - const mappedData = response.data.map((data: any) : Instances => ({ - fileSize: data.FileSize, - fileUuid: data.FileUuid, - id: data.ID, - indexInSeries: data.IndexInSeries, - labels: data.Labels, - mainDicomTags: { - acquisitionNumber: data.MainDicomTags.AcquisitionNumber, - imageComments: data.MainDicomTags.ImageComments, - imageOrientationPatient: data.MainDicomTags.ImageOrientationPatient, - imagePositionPatient: data.MainDicomTags.ImagePositionPatient, - instanceCreationDate: data.MainDicomTags.InstanceCreationDate, - instanceCreationTime: data.MainDicomTags.InstanceCreationTime, - instanceNumber: data.MainDicomTags.InstanceNumber, - sopInstanceUID: data.MainDicomTags.SopInstanceUID - }, - parentSeries: data.ParentSeries, - type: data.Type - })); + const mappedData = response.data.map( + (data: any): Instances => ({ + fileSize: data.FileSize, + fileUuid: data.FileUuid, + id: data.ID, + indexInSeries: data.IndexInSeries, + labels: data.Labels, + mainDicomTags: { + acquisitionNumber: data.MainDicomTags.AcquisitionNumber, + imageComments: data.MainDicomTags.ImageComments, + imageOrientationPatient: data.MainDicomTags.ImageOrientationPatient, + imagePositionPatient: data.MainDicomTags.ImagePositionPatient, + instanceCreationDate: data.MainDicomTags.InstanceCreationDate, + instanceCreationTime: data.MainDicomTags.InstanceCreationTime, + instanceNumber: data.MainDicomTags.InstanceNumber, + sopInstanceUID: data.MainDicomTags.SopInstanceUID, + }, + parentSeries: data.ParentSeries, + type: data.Type, + }) + ); return mappedData; - }).catch((error: any) => { + }) + .catch((error: any) => { if (error.response) { console.error("Error response:", error.response); throw error.response; @@ -209,31 +235,36 @@ export const getInstancesOfSeries = (seriesId: string) => { console.error("Error:", error); throw error; }); -} - +}; -export const modifyPatient = (patientId: string, patient: PatientModifyPayload): Promise => { +export const modifyPatient = ( + patientId: string, + patient: PatientModifyPayload +): Promise => { const patientPayloadUpdate = { Replace: { PatientID: patient.replace.patientId, PatientName: patient.replace.patientName, PatientBirthDate: patient.replace.patientBirthDate, - PatientSex: patient.replace.patientSex + PatientSex: patient.replace.patientSex, }, Keep: patient.keep, - Remove: patient.remove.map(field => field.charAt(0).toUpperCase() + field.slice(1)), + Remove: patient.remove.map( + (field) => field.charAt(0).toUpperCase() + field.slice(1) + ), RemovePrivateTags: patient.removePrivateTags, Force: true, Synchronous: false, - KeepSource: patient.keepSource + KeepSource: patient.keepSource, }; - return axios.post(`/api/patients/${patientId}/modify`, patientPayloadUpdate) + return axios + .post(`/api/patients/${patientId}/modify`, patientPayloadUpdate) .then((response: any): OrthancResponse => { - const data = response.data + const data = response.data; return { id: data.ID, - path: data.Path - } + path: data.Path, + }; }) .catch(function (error) { if (error.response) { @@ -243,41 +274,49 @@ export const modifyPatient = (patientId: string, patient: PatientModifyPayload): }); }; -export const modifyStudy = (studyId: string, study: StudyModifyPayload): Promise => { +export const modifyStudy = ( + studyId: string, + study: StudyModifyPayload +): Promise => { const studyPayloadUpdate = { Replace: { - PatientID : study.replace.patientId, - PatientName : study.replace.patientName, + PatientID: study.replace.patientId, + PatientName: study.replace.patientName, AccessionNumber: study.replace.accessionNumber, StudyDate: study.replace.studyDate, StudyDescription: study.replace.studyDescription, StudyID: study.replace.studyId, }, - Remove: study.remove.map(field => field.charAt(0).toUpperCase() + field.slice(1)), + Remove: study.remove.map( + (field) => field.charAt(0).toUpperCase() + field.slice(1) + ), RemovePrivateTags: study.removePrivateTags, - Keep : study.keep, + Keep: study.keep, Force: true, Synchronous: false, - KeepSource: study.keepSource + KeepSource: study.keepSource, }; - return axios.post(`/api/studies/${studyId}/modify`, studyPayloadUpdate) + return axios + .post(`/api/studies/${studyId}/modify`, studyPayloadUpdate) .then((response: any): OrthancResponse => { - const data = response.data + const data = response.data; return { id: data.ID, - path: data.Path - } - } - ) + path: data.Path, + }; + }) .catch(function (error) { if (error.response) { throw error.response; } throw error; }); -} +}; -export const modifySeries = (seriesId: string, series: SeriesPayload): Promise => { +export const modifySeries = ( + seriesId: string, + series: SeriesModifyPayload +): Promise => { const seriesPayloadUpdate = { Replace: { ImageOrientationPatient: series.replace.imageOrientationPatient, @@ -290,35 +329,39 @@ export const modifySeries = (seriesId: string, series: SeriesPayload): Promise field.charAt(0).toUpperCase() + field.slice(1)), + Remove: series.remove.map( + (field) => field.charAt(0).toUpperCase() + field.slice(1) + ), RemovePrivateTags: series.removePrivateTags, Force: true, Synchronous: false, - KeepSource: series.keepSource + KeepSource: series.keepSource, + Keep: series.keep, }; - return axios.post(`/api/series/${seriesId}/modify`, seriesPayloadUpdate) + return axios + .post(`/api/series/${seriesId}/modify`, seriesPayloadUpdate) .then((response: any): OrthancResponse => { - const data = response.data + const data = response.data; return { id: data.ID, - path: data.Path - } - } - ) + path: data.Path, + }; + }) .catch(function (error) { if (error.response) { throw error.response; } throw error; }); -} +}; export const getPatient = (patientId: string): Promise => { - return axios.get("/api/patients/" + patientId) + return axios + .get("/api/patients/" + patientId) .then((response): Patient => { - const data = response.data + const data = response.data; return { id: data.ID, isStable: data.IsStable, @@ -328,11 +371,11 @@ export const getPatient = (patientId: string): Promise => { patientBirthDate: data.MainDicomTags.PatientBirthDate, patientId: data.MainDicomTags.PatientID, patientName: data.MainDicomTags.PatientName, - patientSex: data.MainDicomTags.PatientSex + patientSex: data.MainDicomTags.PatientSex, }, studies: data.Studies, - type: data.Type - } + type: data.Type, + }; }) .catch(function (error) { if (error.response) { @@ -343,9 +386,10 @@ export const getPatient = (patientId: string): Promise => { }; export const deletePatient = (patientId: string): Promise => { - return axios.delete("/api/patients/" + patientId) + return axios + .delete("/api/patients/" + patientId) .then(() => { - return undefined + return undefined; }) .catch(function (error) { if (error.response) { @@ -356,9 +400,10 @@ export const deletePatient = (patientId: string): Promise => { }; export const deleteStudy = (studyId: string): Promise => { - return axios.delete("/api/studies/" + studyId) + return axios + .delete("/api/studies/" + studyId) .then(() => { - return undefined + return undefined; }) .catch(function (error) { if (error.response) { @@ -369,9 +414,10 @@ export const deleteStudy = (studyId: string): Promise => { }; export const deleteSeries = (seriesId: string): Promise => { - return axios.delete("/api/series/" + seriesId) + return axios + .delete("/api/series/" + seriesId) .then(() => { - return undefined + return undefined; }) .catch(function (error) { if (error.response) { diff --git a/src/utils/index.ts b/src/utils/index.ts index 7f89f5db..1cdf1022 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -25,7 +25,6 @@ import type { Option, QueryPayload, Series, - SeriesPayload, Study, } from "./types"; import { useCustomToast } from "./toastify"; @@ -52,6 +51,5 @@ export { User, UserPayload, UserUpdatePayload, - Series, - SeriesPayload + Series }; diff --git a/src/utils/types.ts b/src/utils/types.ts index a87978c6..3f0e635c 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -372,11 +372,12 @@ export type StudyModifyPayload = { keepSource: boolean; }; -export type SeriesPayload = { +export type SeriesModifyPayload = { replace: Partial; remove: string[]; removePrivateTags: boolean; keepSource: boolean; force: boolean; synchronous: boolean; + keep : string[]; }; From fb4cf310c5a1d3ed5bd9e0e8d176bf03e0325b0b Mon Sep 17 00:00:00 2001 From: Salim Kanoun Date: Fri, 13 Sep 2024 23:50:50 +0200 Subject: [PATCH 04/10] init tags --- src/content/series/SeriesActions.tsx | 6 ++++ src/content/series/SeriesRoot.tsx | 20 +++++++++++++ src/content/series/Tags.tsx | 43 ++++++++++++++++++++++++++++ src/services/instances.ts | 24 ++++++++++++++-- 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 src/content/series/Tags.tsx diff --git a/src/content/series/SeriesActions.tsx b/src/content/series/SeriesActions.tsx index 2b38139a..53d709ff 100644 --- a/src/content/series/SeriesActions.tsx +++ b/src/content/series/SeriesActions.tsx @@ -23,6 +23,12 @@ const SeriesActions: React.FC = ({ series, onActionClick }) color: 'red', action: () => onActionClick('delete', series) }, + { + label: 'Metadata', + icon: , + color: 'green', + action: () => onActionClick('metadata', series), + }, { label: 'Preview Series', icon: , diff --git a/src/content/series/SeriesRoot.tsx b/src/content/series/SeriesRoot.tsx index ca16e370..c5aa0297 100644 --- a/src/content/series/SeriesRoot.tsx +++ b/src/content/series/SeriesRoot.tsx @@ -12,6 +12,7 @@ import PreviewSeries from './PreviewSeries'; import { useConfirm } from '../../services/ConfirmContextProvider'; import { useCustomToast } from '../../utils/toastify'; import { Modal, Spinner } from '../../ui'; +import Tags from './Tags'; interface SeriesRootProps { studyId: string; @@ -20,6 +21,7 @@ interface SeriesRootProps { const SeriesRoot: React.FC = ({ studyId }) => { const [editingSeries, setEditingSeries] = useState(null); const [previewSeries, setPreviewSeries] = useState(null); + const [previewMetadata, setPreviewMetadata] = useState(null); const { confirm } = useConfirm(); const { toastSuccess, toastError } = useCustomToast(); @@ -55,6 +57,10 @@ const SeriesRoot: React.FC = ({ studyId }) => { setPreviewSeries(series); } + const handleMetadataPreview = (series: Series) => { + setPreviewMetadata(series) + } + const handleDeleteSeries = async (seriesId: string) => { const confirmContent = (
@@ -79,6 +85,9 @@ const SeriesRoot: React.FC = ({ studyId }) => { case 'preview': handlePreviewSeries(series); break; + case 'metadata': + setPreviewMetadata(series); + break; default: console.log(`Unhandled action: ${action}`); } @@ -119,6 +128,17 @@ const SeriesRoot: React.FC = ({ studyId }) => { )} + + {previewMetadata && ( + + setPreviewMetadata(null)} > + Preview Metadata + + + + + + )}
); }; diff --git a/src/content/series/Tags.tsx b/src/content/series/Tags.tsx new file mode 100644 index 00000000..a087010f --- /dev/null +++ b/src/content/series/Tags.tsx @@ -0,0 +1,43 @@ +import { useState } from "react" +import { getInstancesOfSeries } from "../../services/orthanc" +import { Input, Spinner } from "../../ui" +import { useCustomQuery } from "../../utils" +import { instanceHeader, instanceTags } from "../../services/instances" + +type TagsProps = { + seriesId: string +} +const Tags = ({ seriesId }: TagsProps) => { + + const { data: instances } = useCustomQuery(['series', seriesId, 'instances'], () => getInstancesOfSeries(seriesId)) + const [instanceNumber, setInstanceNumber] = useState(null) + + const currentInstanceId = instanceNumber!=null ? instances[instanceNumber].id : null + + const {data : header} = useCustomQuery( + ['instances', currentInstanceId, 'metadata'], + () => instanceHeader(instances[instanceNumber].id),{ + enabled : (instanceNumber !== null) + } + ) + + const {data : tags} = useCustomQuery( + ['instances', currentInstanceId, 'tags'], + () => instanceTags(instances[instanceNumber].id),{ + enabled : (instanceNumber !== null) + } + ) + + if (!instances) return + return ( + <> + setInstanceNumber(Number(event.target?.value))} /> +
+                {JSON.stringify(header, null, 2)}
+                {JSON.stringify(tags, null, 2)}
+            
+ + ) +} + +export default Tags \ No newline at end of file diff --git a/src/services/instances.ts b/src/services/instances.ts index 9194bee8..679e7599 100644 --- a/src/services/instances.ts +++ b/src/services/instances.ts @@ -37,9 +37,29 @@ export const createDicom = (content: string[], tags: object = {}, parentOrthancI } -export const previewInstance = (instanceUID : string) : Promise=> { +export const previewInstance = (instanceId : string) : Promise=> { - return axios.get('/api/instances/'+instanceUID+'/preview', {responseType : "blob"}) + return axios.get('/api/instances/'+instanceId+'/preview', {responseType : "blob"}) + .then((response) => { + return response.data + }).catch(error => { + console.error(error) + }) +} + +export const instanceTags = (instanceId : string) : Promise=> { + + return axios.get('/api/instances/'+instanceId+'/tags') + .then((response) => { + return response.data + }).catch(error => { + console.error(error) + }) +} + +export const instanceHeader = (instanceId : string) : Promise=> { + + return axios.get('/api/instances/'+instanceId+'/header') .then((response) => { return response.data }).catch(error => { From 9dcb729aa9265c382b35c9f81559709137d11803 Mon Sep 17 00:00:00 2001 From: Salim Kanoun Date: Fri, 13 Sep 2024 23:51:48 +0200 Subject: [PATCH 05/10] update tags --- src/content/series/Tags.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/content/series/Tags.tsx b/src/content/series/Tags.tsx index a087010f..098e468b 100644 --- a/src/content/series/Tags.tsx +++ b/src/content/series/Tags.tsx @@ -12,26 +12,28 @@ const Tags = ({ seriesId }: TagsProps) => { const { data: instances } = useCustomQuery(['series', seriesId, 'instances'], () => getInstancesOfSeries(seriesId)) const [instanceNumber, setInstanceNumber] = useState(null) - const currentInstanceId = instanceNumber!=null ? instances[instanceNumber].id : null + const currentInstanceId = instanceNumber != null ? instances[instanceNumber].id : null - const {data : header} = useCustomQuery( + const { data: header } = useCustomQuery( ['instances', currentInstanceId, 'metadata'], - () => instanceHeader(instances[instanceNumber].id),{ - enabled : (instanceNumber !== null) + () => instanceHeader(instances[instanceNumber].id), + { + enabled: (instanceNumber !== null) } ) - const {data : tags} = useCustomQuery( + const { data: tags } = useCustomQuery( ['instances', currentInstanceId, 'tags'], - () => instanceTags(instances[instanceNumber].id),{ - enabled : (instanceNumber !== null) + () => instanceTags(instances[instanceNumber].id), + { + enabled: (instanceNumber !== null) } ) if (!instances) return return ( <> - setInstanceNumber(Number(event.target?.value))} /> + setInstanceNumber(Number(event.target?.value))} />
                 {JSON.stringify(header, null, 2)}
                 {JSON.stringify(tags, null, 2)}

From 9dc9f30b91a1ce8551837b3e4d8bb9a1fb2ec67a Mon Sep 17 00:00:00 2001
From: Salim Kanoun 
Date: Sat, 14 Sep 2024 00:51:52 +0200
Subject: [PATCH 06/10] wip

---
 src/content/studies/StudyEditForm.tsx | 23 +++++++++++++++++++----
 src/services/orthanc.ts               |  2 ++
 2 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/src/content/studies/StudyEditForm.tsx b/src/content/studies/StudyEditForm.tsx
index d324fa54..c2200ec9 100644
--- a/src/content/studies/StudyEditForm.tsx
+++ b/src/content/studies/StudyEditForm.tsx
@@ -17,6 +17,8 @@ type StudyEditFormProps = {
 const StudyEditForm = ({ data, onSubmit, jobId, onJobCompleted }: StudyEditFormProps) => {
     const [patientName, setPatientName] = useState(data?.patientMainDicomTags?.patientName ?? null);
     const [patientId, setPatientId] = useState(data?.patientMainDicomTags?.patientId ?? null);
+    const [patientBirthDate, setPatientBirthDate] = useState(data?.patientMainDicomTags?.patientBirthDate ?? null);
+    const [patientSex, setPatientSex] = useState(data?.patientMainDicomTags?.patientSex ?? null);
 
     const [accessionNumber, setAccessionNumber] = useState(data?.mainDicomTags?.accessionNumber ?? null);
     const [studyDate, setStudyDate] = useState(data?.mainDicomTags?.studyDate ?? null);
@@ -28,10 +30,6 @@ const StudyEditForm = ({ data, onSubmit, jobId, onJobCompleted }: StudyEditFormP
     const [fieldsToRemove, setFieldsToRemove] = useState([]);
     const [keepUIDs, setKeepUIDs] = useState(false)
 
-    useEffect(() => {
-        if (keepUIDs) setKeepSource(true)
-    }, [keepUIDs])
-
     const handleFieldRemoval = (field: string, checked: boolean) => {
         setFieldsToRemove((prev) =>
             checked ? [...prev, field] : prev.filter((item) => item !== field)
@@ -43,6 +41,11 @@ const StudyEditForm = ({ data, onSubmit, jobId, onJobCompleted }: StudyEditFormP
 
         if (patientId !== data?.patientMainDicomTags?.patientId) replace.patientId = patientId;
         if (patientName !== data?.patientMainDicomTags?.patientName) replace.patientName = patientName;
+        if (replace.patientId || replace.patientName) {
+            replace.patientBirthDate = patientBirthDate;
+            replace.patientSex = patientSex;
+        }
+
         if (accessionNumber !== data?.mainDicomTags?.accessionNumber) replace.accessionNumber = accessionNumber;
         if (studyDate !== data?.mainDicomTags?.studyDate) replace.studyDate = studyDate;
         if (studyDescription !== data?.mainDicomTags?.studyDescription) replace.studyDescription = studyDescription;
@@ -78,6 +81,18 @@ const StudyEditForm = ({ data, onSubmit, jobId, onJobCompleted }: StudyEditFormP
                     onChange={(e: ChangeEvent) => setPatientId(e.target.value)}
                     fieldName="patientID"
                 />
+                ) => setPatientBirthDate(e.target.value)}
+                    fieldName="patientBirthdate"
+                />
+                ) => setPatientSex(e.target.value)}
+                    fieldName="patientSex"
+                />
             
Date: Sat, 14 Sep 2024 01:14:52 +0200 Subject: [PATCH 07/10] wip --- src/content/series/PreviewSeries.tsx | 1 - src/content/series/Tags.tsx | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/content/series/PreviewSeries.tsx b/src/content/series/PreviewSeries.tsx index af4a84d4..614eb046 100644 --- a/src/content/series/PreviewSeries.tsx +++ b/src/content/series/PreviewSeries.tsx @@ -40,7 +40,6 @@ const PreviewSeries: React.FC = ({ seriesId}) => { if (!instanceUIDs) return null const start = Math.max(imageIndex, 0) const end = Math.min(start + (pageSize - 1), instanceUIDs.length - 1) - console.log(start, end) const selectedUIDs = [] for (let i = start; i <= end; i++) { selectedUIDs.push(instanceUIDs[i]) diff --git a/src/content/series/Tags.tsx b/src/content/series/Tags.tsx index 098e468b..bbe981e6 100644 --- a/src/content/series/Tags.tsx +++ b/src/content/series/Tags.tsx @@ -10,30 +10,30 @@ type TagsProps = { const Tags = ({ seriesId }: TagsProps) => { const { data: instances } = useCustomQuery(['series', seriesId, 'instances'], () => getInstancesOfSeries(seriesId)) - const [instanceNumber, setInstanceNumber] = useState(null) + const [instanceNumber, setInstanceNumber] = useState(1) - const currentInstanceId = instanceNumber != null ? instances[instanceNumber].id : null + const currentInstanceId = (instanceNumber != null && instances != null) ? instances[instanceNumber - 1].id : null const { data: header } = useCustomQuery( ['instances', currentInstanceId, 'metadata'], - () => instanceHeader(instances[instanceNumber].id), + () => instanceHeader(currentInstanceId), { - enabled: (instanceNumber !== null) + enabled: (currentInstanceId !== null) } ) const { data: tags } = useCustomQuery( ['instances', currentInstanceId, 'tags'], - () => instanceTags(instances[instanceNumber].id), + () => instanceTags(currentInstanceId), { - enabled: (instanceNumber !== null) + enabled: (currentInstanceId !== null) } ) if (!instances) return return ( <> - setInstanceNumber(Number(event.target?.value))} /> + setInstanceNumber(Number(event.target?.value))} />
                 {JSON.stringify(header, null, 2)}
                 {JSON.stringify(tags, null, 2)}

From f1882b844d428ac44b003d200ebbe194ce4df99e Mon Sep 17 00:00:00 2001
From: Salim Kanoun 
Date: Sat, 14 Sep 2024 12:00:04 +0200
Subject: [PATCH 08/10] try implement download

---
 package.json                              |  1 +
 src/content/patients/AccordionPatient.tsx |  7 +++
 src/content/studies/StudyActions.tsx      |  6 ++
 src/content/studies/StudyRoot.tsx         | 10 +++-
 src/services/export.ts                    | 73 +++++++++++++++++++++++
 src/services/instances.ts                 |  2 +-
 yarn.lock                                 | 37 ++++++++++++
 7 files changed, 134 insertions(+), 2 deletions(-)
 create mode 100644 src/services/export.ts

diff --git a/package.json b/package.json
index 6ad9f12b..f8dc8969 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
     "i18next": "^23.12.2",
     "jwt-decode": "^4.0.0",
     "moment": "^2.30.1",
+    "native-file-system-adapter": "^3.0.1",
     "preline": "^2.4.1",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
diff --git a/src/content/patients/AccordionPatient.tsx b/src/content/patients/AccordionPatient.tsx
index 710c431d..81369c37 100644
--- a/src/content/patients/AccordionPatient.tsx
+++ b/src/content/patients/AccordionPatient.tsx
@@ -8,6 +8,7 @@ import Patient from "../../model/Patient";
 import StudyRoot from "../studies/StudyRoot";
 import SeriesRoot from "../series/SeriesRoot";
 import { AccordionHeader } from "../../ui/Accordion";
+import { exportRessource } from "../../services/export";
 
 type AccordionPatientProps = {
     patient: Patient;
@@ -33,6 +34,11 @@ const AccordionPatient: React.FC = ({ patient, onEditPati
         onDeletePatient(patient); 
     }
 
+    const handleSaveClick = (event:  React.MouseEvent) => {
+        event.stopPropagation();
+        exportRessource("patients", patient.id)
+    }
+
     return (
         <>
              = ({ patient, onEditPati
                             
+
diff --git a/src/content/studies/StudyActions.tsx b/src/content/studies/StudyActions.tsx index 4b5f8194..3917a9d7 100644 --- a/src/content/studies/StudyActions.tsx +++ b/src/content/studies/StudyActions.tsx @@ -50,6 +50,12 @@ const StudyActions: React.FC = ({ study, onActionClick }) => color: 'red', action: () => onActionClick('delete', study.id) }, + { + label: 'Download', + icon: , + color: 'orange', + action: () => onActionClick('download', study.id) + }, ]; diff --git a/src/content/studies/StudyRoot.tsx b/src/content/studies/StudyRoot.tsx index 355c969f..bafd9644 100644 --- a/src/content/studies/StudyRoot.tsx +++ b/src/content/studies/StudyRoot.tsx @@ -8,6 +8,7 @@ import { useConfirm } from '../../services/ConfirmContextProvider'; import { useCustomToast } from '../../utils/toastify'; import Patient from '../../model/Patient'; import AiStudy from './AiStudy'; +import { exportRessource } from '../../services/export'; type StudyRootProps = { patient: Patient; @@ -62,10 +63,14 @@ const StudyRoot: React.FC = ({ patient, onStudyUpdated, onStudyS setPreviewStudyId(studyId); } - const handleAIStudy=(studyId : string) => { + const handleAIStudy = (studyId: string) => { setAIStudyId(studyId) } + const handleDownloadStudy = (studyId: string) => { + exportRessource('studies', studyId) + } + const handleStudyAction = (action: string, studyId: string) => { switch (action) { case 'edit': @@ -80,6 +85,9 @@ const StudyRoot: React.FC = ({ patient, onStudyUpdated, onStudyS case 'ai': handleAIStudy(studyId); break; + case 'download': + handleDownloadStudy(studyId); + break; default: break; } diff --git a/src/services/export.ts b/src/services/export.ts new file mode 100644 index 00000000..810694d3 --- /dev/null +++ b/src/services/export.ts @@ -0,0 +1,73 @@ +import { getToken } from "./axios"; +import { showSaveFilePicker } from "native-file-system-adapter"; + +const getContentType = (headers: any) => { + const contentType = headers.get("Content-Type"); + const parts = contentType?.split(","); + return parts?.[0]; +}; + +const getContentDispositionFilename = (headers: any) => { + const contentDisposition = headers.get("Content-Disposition"); + const parts = contentDisposition?.split(";"); + let filename = parts[1].split("=")[1]; + return filename; +}; + +export const exportFileThroughFilesystemAPI = async ( + readableStream: ReadableStream | null, + mimeType: string, + suggestedName: string +) => { + if (!readableStream) return; + let acceptedTypes = []; + + let extension = suggestedName.split(".").pop(); + acceptedTypes.push({ accept: { [mimeType]: ["." + extension] } }); + + const fileHandle = await showSaveFilePicker({ + _preferPolyfill: true, + suggestedName: suggestedName, + types: acceptedTypes, + excludeAcceptAllOption: false, // default + }).catch((err: any) => console.log(err)); + + let writableStream = await ( + fileHandle as FileSystemFileHandle + ).createWritable(); + + await readableStream.pipeTo(writableStream); +}; + +export const exportRessource = ( + level: "studies"|"patients"|"series", + studyId: string, + transferSyntax: string | undefined = undefined +): Promise => { + const body = { + Asynchronous: false, + Transcode: transferSyntax, + }; + + return fetch("/api/"+level+"/" + studyId + "/archive", { + method: "POST", + headers: { + Authorization: "Bearer " + getToken(), + "Content-Type": "application/json", + Accept: "application/zip", + }, + body: JSON.stringify(body), + //signal: abortController.signal + }) + .then((answer) => { + if (!answer.ok) throw answer; + const readableStream = answer.body; + let contentType = getContentType(answer.headers); + let filename = getContentDispositionFilename(answer.headers); + exportFileThroughFilesystemAPI(readableStream, contentType, filename); + return true; + }) + .catch((error) => { + throw error; + }); +}; diff --git a/src/services/instances.ts b/src/services/instances.ts index 679e7599..6be106ee 100644 --- a/src/services/instances.ts +++ b/src/services/instances.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios from './axios'; import { OrthancImportDicom } from "../utils/types"; export const sendDicom = (payload: Uint8Array): Promise => { diff --git a/yarn.lock b/yarn.lock index 3d72bd24..670a3e4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5504,6 +5504,16 @@ __metadata: languageName: node linkType: hard +"fetch-blob@npm:^3.2.0": + version: 3.2.0 + resolution: "fetch-blob@npm:3.2.0" + dependencies: + node-domexception: "npm:^1.0.0" + web-streams-polyfill: "npm:^3.0.3" + checksum: 10c0/60054bf47bfa10fb0ba6cb7742acec2f37c1f56344f79a70bb8b1c48d77675927c720ff3191fa546410a0442c998d27ab05e9144c32d530d8a52fbe68f843b69 + languageName: node + linkType: hard + "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -5824,6 +5834,7 @@ __metadata: i18next: "npm:^23.12.2" jwt-decode: "npm:^4.0.0" moment: "npm:^2.30.1" + native-file-system-adapter: "npm:^3.0.1" postcss: "npm:^8.4.41" preline: "npm:^2.4.1" prettier: "npm:^3.3.3" @@ -7438,6 +7449,18 @@ __metadata: languageName: node linkType: hard +"native-file-system-adapter@npm:^3.0.1": + version: 3.0.1 + resolution: "native-file-system-adapter@npm:3.0.1" + dependencies: + fetch-blob: "npm:^3.2.0" + dependenciesMeta: + fetch-blob: + optional: true + checksum: 10c0/2a60cd7fa6b92a85b1fc9dbbda91135f66af424e240e36c7c3708b987330844fb689e29a92a8a472d7216f685423e863af8acfb4b9a7bd73c0f6093fff4bf2fd + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -7478,6 +7501,13 @@ __metadata: languageName: node linkType: hard +"node-domexception@npm:^1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: 10c0/5e5d63cda29856402df9472335af4bb13875e1927ad3be861dc5ebde38917aecbf9ae337923777af52a48c426b70148815e890a5d72760f1b4d758cc671b1a2b + languageName: node + linkType: hard + "node-fetch-native@npm:^1.6.3": version: 1.6.4 resolution: "node-fetch-native@npm:1.6.4" @@ -10124,6 +10154,13 @@ __metadata: languageName: node linkType: hard +"web-streams-polyfill@npm:^3.0.3": + version: 3.3.3 + resolution: "web-streams-polyfill@npm:3.3.3" + checksum: 10c0/64e855c47f6c8330b5436147db1c75cb7e7474d924166800e8e2aab5eb6c76aac4981a84261dd2982b3e754490900b99791c80ae1407a9fa0dcff74f82ea3a7f + languageName: node + linkType: hard + "webpack-sources@npm:^3.2.3": version: 3.2.3 resolution: "webpack-sources@npm:3.2.3" From 6c9843d3248813f33ff519b0fee27beb7fbe09ce Mon Sep 17 00:00:00 2001 From: Salim Kanoun Date: Sat, 14 Sep 2024 15:06:58 +0200 Subject: [PATCH 09/10] download working without feedback --- package.json | 2 ++ src/content/patients/AccordionPatient.tsx | 5 ++--- src/content/series/SeriesActions.tsx | 19 +++++++++++++------ src/content/series/SeriesRoot.tsx | 8 ++++++++ src/content/studies/StudyActions.tsx | 14 +++++++------- src/services/export.ts | 8 +++++--- src/ui/DownloadButton.tsx | 18 ++++++++++++++++++ src/ui/index.ts | 2 ++ yarn.lock | 11 ++++++++++- 9 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 src/ui/DownloadButton.tsx diff --git a/package.json b/package.json index f8dc8969..499b6de7 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "axios": "^1.7.3", "i18next": "^23.12.2", "jwt-decode": "^4.0.0", + "mime-types": "^2.1.35", "moment": "^2.30.1", "native-file-system-adapter": "^3.0.1", "preline": "^2.4.1", @@ -54,6 +55,7 @@ "@storybook/theming": "^8.2.8", "@tanstack/eslint-plugin-query": "^5.51.15", "@types/jwt-decode": "^3.1.0", + "@types/mime-types": "^2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/react-query": "^1.2.9", diff --git a/src/content/patients/AccordionPatient.tsx b/src/content/patients/AccordionPatient.tsx index 81369c37..f2327fa1 100644 --- a/src/content/patients/AccordionPatient.tsx +++ b/src/content/patients/AccordionPatient.tsx @@ -1,10 +1,9 @@ import React, { useState } from "react"; -import { Accordion, DeleteButton, EditButton } from "../../ui"; +import { Accordion, DeleteButton, DownloadButton, EditButton } from "../../ui"; import Patient from "../../model/Patient"; - import StudyRoot from "../studies/StudyRoot"; import SeriesRoot from "../series/SeriesRoot"; import { AccordionHeader } from "../../ui/Accordion"; @@ -50,8 +49,8 @@ const AccordionPatient: React.FC = ({ patient, onEditPati Nb of Studies: {patient.getStudies().length}
+ -
diff --git a/src/content/series/SeriesActions.tsx b/src/content/series/SeriesActions.tsx index 53d709ff..e2bdf167 100644 --- a/src/content/series/SeriesActions.tsx +++ b/src/content/series/SeriesActions.tsx @@ -1,6 +1,7 @@ // SeriesActions.tsx import React from 'react'; import { FaEdit, FaEye, FaTrash } from "react-icons/fa"; +import { RiDownload2Line as DownloadIcon } from "react-icons/ri"; import { Series } from "../../utils/types"; import DropdownButton from '../../ui/menu/DropDownButton'; @@ -17,12 +18,6 @@ const SeriesActions: React.FC = ({ series, onActionClick }) color: 'orange', action: () => onActionClick('edit', series) }, - { - label: 'Delete', - icon: , - color: 'red', - action: () => onActionClick('delete', series) - }, { label: 'Metadata', icon: , @@ -35,6 +30,18 @@ const SeriesActions: React.FC = ({ series, onActionClick }) color: 'green', action: () => onActionClick('preview', series), }, + { + label: 'Download', + icon: , + color: 'green', + action: () => onActionClick('download', series) + }, + { + label: 'Delete', + icon: , + color: 'red', + action: () => onActionClick('delete', series) + }, ]; const handleClick = (e: React.MouseEvent) => { diff --git a/src/content/series/SeriesRoot.tsx b/src/content/series/SeriesRoot.tsx index c5aa0297..9b4fee8c 100644 --- a/src/content/series/SeriesRoot.tsx +++ b/src/content/series/SeriesRoot.tsx @@ -13,6 +13,7 @@ import { useConfirm } from '../../services/ConfirmContextProvider'; import { useCustomToast } from '../../utils/toastify'; import { Modal, Spinner } from '../../ui'; import Tags from './Tags'; +import { exportRessource } from '../../services/export'; interface SeriesRootProps { studyId: string; @@ -57,6 +58,10 @@ const SeriesRoot: React.FC = ({ studyId }) => { setPreviewSeries(series); } + const handleDownloadSeries = (series: Series) => { + exportRessource('series', series.id) + } + const handleMetadataPreview = (series: Series) => { setPreviewMetadata(series) } @@ -88,6 +93,9 @@ const SeriesRoot: React.FC = ({ studyId }) => { case 'metadata': setPreviewMetadata(series); break; + case 'download': + handleDownloadSeries(series); + break; default: console.log(`Unhandled action: ${action}`); } diff --git a/src/content/studies/StudyActions.tsx b/src/content/studies/StudyActions.tsx index 3917a9d7..ffe89f19 100644 --- a/src/content/studies/StudyActions.tsx +++ b/src/content/studies/StudyActions.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { FaEdit as EditIcon, FaEye as EyeIcon, FaTrash as TrashIcon } from "react-icons/fa"; import { GiBrain as BrainIcon } from 'react-icons/gi' +import { RiDownload2Line as DownloadIcon } from "react-icons/ri"; import { StudyMainDicomTags } from "../../utils/types"; import DropdownButton from '../../ui/menu/DropDownButton'; import OhifViewerLink from '../OhifViewerLink'; @@ -44,19 +45,18 @@ const StudyActions: React.FC = ({ study, onActionClick }) => color: 'green', action: () => onActionClick('preview', study.id) }, + { + label: 'Download', + icon: , + color: 'green', + action: () => onActionClick('download', study.id) + }, { label: 'Delete', icon: , color: 'red', action: () => onActionClick('delete', study.id) }, - { - label: 'Download', - icon: , - color: 'orange', - action: () => onActionClick('download', study.id) - }, - ]; const handleClick = (e: React.MouseEvent) => { diff --git a/src/services/export.ts b/src/services/export.ts index 810694d3..95c95bcf 100644 --- a/src/services/export.ts +++ b/src/services/export.ts @@ -1,5 +1,6 @@ import { getToken } from "./axios"; import { showSaveFilePicker } from "native-file-system-adapter"; +import mime from 'mime-types' const getContentType = (headers: any) => { const contentType = headers.get("Content-Type"); @@ -10,6 +11,7 @@ const getContentType = (headers: any) => { const getContentDispositionFilename = (headers: any) => { const contentDisposition = headers.get("Content-Disposition"); const parts = contentDisposition?.split(";"); + if(!parts) return null let filename = parts[1].split("=")[1]; return filename; }; @@ -17,17 +19,17 @@ const getContentDispositionFilename = (headers: any) => { export const exportFileThroughFilesystemAPI = async ( readableStream: ReadableStream | null, mimeType: string, - suggestedName: string + suggestedName: string|null ) => { if (!readableStream) return; let acceptedTypes = []; - let extension = suggestedName.split(".").pop(); + let extension = mime.extension(mimeType); acceptedTypes.push({ accept: { [mimeType]: ["." + extension] } }); const fileHandle = await showSaveFilePicker({ _preferPolyfill: true, - suggestedName: suggestedName, + suggestedName: suggestedName ?? "download."+ extension, types: acceptedTypes, excludeAcceptAllOption: false, // default }).catch((err: any) => console.log(err)); diff --git a/src/ui/DownloadButton.tsx b/src/ui/DownloadButton.tsx new file mode 100644 index 00000000..3201c4d1 --- /dev/null +++ b/src/ui/DownloadButton.tsx @@ -0,0 +1,18 @@ +import { RiDownload2Line as Download } from "react-icons/ri"; + +type DownloadButtonProps = { + onClick: (e: React.MouseEvent) => void; +} + +const DownloadButtonProps = ({ onClick }: DownloadButtonProps) => ( + { + e.stopPropagation(); + onClick(e); + }} + /> +); + +export default DownloadButtonProps; diff --git a/src/ui/index.ts b/src/ui/index.ts index 3a35ea7b..2e165657 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -4,6 +4,7 @@ import Button from "./Button"; import CheckBox from "./Checkbox"; import EditButton from "./EditButton"; import DeleteButton from "./DeleteButton"; +import DownloadButton from "./DownloadButton" import BooleanIcon from "./BooleanIcon"; import CloseButton from "./CloseButton"; import RetrieveButton from "../query/RetrieveButton"; @@ -49,6 +50,7 @@ export { ConfirmModal, CheckBox, CloseButton, + DownloadButton, DropDown, EditButton, DeleteButton, diff --git a/yarn.lock b/yarn.lock index 670a3e4b..e83f1b4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3146,6 +3146,13 @@ __metadata: languageName: node linkType: hard +"@types/mime-types@npm:^2": + version: 2.1.4 + resolution: "@types/mime-types@npm:2.1.4" + checksum: 10c0/a10d57881d14a053556b3d09292de467968d965b0a06d06732c748da39b3aa569270b5b9f32529fd0e9ac1e5f3b91abb894f5b1996373254a65cb87903c86622 + languageName: node + linkType: hard + "@types/mime@npm:^1": version: 1.3.5 resolution: "@types/mime@npm:1.3.5" @@ -5816,6 +5823,7 @@ __metadata: "@tanstack/react-query-devtools": "npm:^5.51.23" "@tanstack/react-table": "npm:^8.20.1" "@types/jwt-decode": "npm:^3.1.0" + "@types/mime-types": "npm:^2" "@types/react": "npm:^18.3.3" "@types/react-dom": "npm:^18.3.0" "@types/react-query": "npm:^1.2.9" @@ -5833,6 +5841,7 @@ __metadata: eslint-plugin-tailwindcss: "npm:^3.17.4" i18next: "npm:^23.12.2" jwt-decode: "npm:^4.0.0" + mime-types: "npm:^2.1.35" moment: "npm:^2.30.1" native-file-system-adapter: "npm:^3.0.1" postcss: "npm:^8.4.41" @@ -7214,7 +7223,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: From 16370299c3fe8da1f0adbf9dd09a05018781da35 Mon Sep 17 00:00:00 2001 From: Salim Kanoun Date: Sat, 14 Sep 2024 15:14:56 +0200 Subject: [PATCH 10/10] wip --- src/content/patients/AccordionPatient.tsx | 5 +++- src/content/series/SeriesRoot.tsx | 3 +- src/content/studies/StudyRoot.tsx | 3 +- src/services/export.ts | 35 ++++++++++++++++------- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/content/patients/AccordionPatient.tsx b/src/content/patients/AccordionPatient.tsx index f2327fa1..c8c97bae 100644 --- a/src/content/patients/AccordionPatient.tsx +++ b/src/content/patients/AccordionPatient.tsx @@ -8,6 +8,7 @@ import StudyRoot from "../studies/StudyRoot"; import SeriesRoot from "../series/SeriesRoot"; import { AccordionHeader } from "../../ui/Accordion"; import { exportRessource } from "../../services/export"; +import { useCustomToast } from "../../utils"; type AccordionPatientProps = { patient: Patient; @@ -17,6 +18,7 @@ type AccordionPatientProps = { }; const AccordionPatient: React.FC = ({ patient, onEditPatient, onDeletePatient, onStudyUpdated }) => { + const {toastSuccess} = useCustomToast() const [selectedStudyId, setSelectedStudyId] = useState(null); const handleStudySelected = (studyId: string) => { @@ -35,7 +37,8 @@ const AccordionPatient: React.FC = ({ patient, onEditPati const handleSaveClick = (event: React.MouseEvent) => { event.stopPropagation(); - exportRessource("patients", patient.id) + toastSuccess("Download started, follow progression in console") + exportRessource("patients", patient.id, (mb)=>{console.log(mb + "mb")}) } return ( diff --git a/src/content/series/SeriesRoot.tsx b/src/content/series/SeriesRoot.tsx index 9b4fee8c..b34881b7 100644 --- a/src/content/series/SeriesRoot.tsx +++ b/src/content/series/SeriesRoot.tsx @@ -59,7 +59,8 @@ const SeriesRoot: React.FC = ({ studyId }) => { } const handleDownloadSeries = (series: Series) => { - exportRessource('series', series.id) + toastSuccess("Download started, follow progression in console") + exportRessource('series', series.id, (mb)=>{console.log(mb+ "mb")}) } const handleMetadataPreview = (series: Series) => { diff --git a/src/content/studies/StudyRoot.tsx b/src/content/studies/StudyRoot.tsx index bafd9644..b6ab43ec 100644 --- a/src/content/studies/StudyRoot.tsx +++ b/src/content/studies/StudyRoot.tsx @@ -68,7 +68,8 @@ const StudyRoot: React.FC = ({ patient, onStudyUpdated, onStudyS } const handleDownloadStudy = (studyId: string) => { - exportRessource('studies', studyId) + toastSuccess("Download started, follow progression in console") + exportRessource('studies', studyId, (mb)=>{console.log(mb+ "mb")}) } const handleStudyAction = (action: string, studyId: string) => { diff --git a/src/services/export.ts b/src/services/export.ts index 95c95bcf..75f36a51 100644 --- a/src/services/export.ts +++ b/src/services/export.ts @@ -1,6 +1,6 @@ import { getToken } from "./axios"; import { showSaveFilePicker } from "native-file-system-adapter"; -import mime from 'mime-types' +import mime from "mime-types"; const getContentType = (headers: any) => { const contentType = headers.get("Content-Type"); @@ -11,7 +11,7 @@ const getContentType = (headers: any) => { const getContentDispositionFilename = (headers: any) => { const contentDisposition = headers.get("Content-Disposition"); const parts = contentDisposition?.split(";"); - if(!parts) return null + if (!parts) return null; let filename = parts[1].split("=")[1]; return filename; }; @@ -19,7 +19,8 @@ const getContentDispositionFilename = (headers: any) => { export const exportFileThroughFilesystemAPI = async ( readableStream: ReadableStream | null, mimeType: string, - suggestedName: string|null + suggestedName: string | null, + onProgress?: (mb: number) => void ) => { if (!readableStream) return; let acceptedTypes = []; @@ -29,7 +30,7 @@ export const exportFileThroughFilesystemAPI = async ( const fileHandle = await showSaveFilePicker({ _preferPolyfill: true, - suggestedName: suggestedName ?? "download."+ extension, + suggestedName: suggestedName ?? "download." + extension, types: acceptedTypes, excludeAcceptAllOption: false, // default }).catch((err: any) => console.log(err)); @@ -38,20 +39,34 @@ export const exportFileThroughFilesystemAPI = async ( fileHandle as FileSystemFileHandle ).createWritable(); - await readableStream.pipeTo(writableStream); + let loaded = 0; + let progress = new TransformStream({ + transform(chunk, controller) { + loaded += chunk.length; + let progressMb = Math.round(loaded / 1000000); + if (progressMb > 1) { + if (onProgress) onProgress(progressMb); + } + controller.enqueue(chunk); + }, + }); + + await readableStream.pipeThrough(progress).pipeTo(writableStream); }; export const exportRessource = ( - level: "studies"|"patients"|"series", + level: "studies" | "patients" | "series", studyId: string, - transferSyntax: string | undefined = undefined + onProgress = (_mb: number) => {}, + abortController = new AbortController(), + transferSyntax: string | undefined = undefined, ): Promise => { const body = { Asynchronous: false, Transcode: transferSyntax, }; - return fetch("/api/"+level+"/" + studyId + "/archive", { + return fetch("/api/" + level + "/" + studyId + "/archive", { method: "POST", headers: { Authorization: "Bearer " + getToken(), @@ -59,14 +74,14 @@ export const exportRessource = ( Accept: "application/zip", }, body: JSON.stringify(body), - //signal: abortController.signal + signal: abortController.signal }) .then((answer) => { if (!answer.ok) throw answer; const readableStream = answer.body; let contentType = getContentType(answer.headers); let filename = getContentDispositionFilename(answer.headers); - exportFileThroughFilesystemAPI(readableStream, contentType, filename); + exportFileThroughFilesystemAPI(readableStream, contentType, filename, onProgress); return true; }) .catch((error) => {