diff --git a/README.md b/README.md index 4bfc94b..e9c7ec3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ DIRD+ is a privacy-first, edge-computing web application for ophthalmological im - **Offline-First**: PWA with IndexedDB persistence, .dird export format (ZIP-based) - **Internationalization**: Spanish base, extensible i18n architecture +# Table of Contents * [INSTALLATION](#installation) * [APP INSTALLATION](#app-installation) @@ -18,24 +19,18 @@ DIRD+ is a privacy-first, edge-computing web application for ophthalmological im * [CREATING AND MANAGING PATIENTS](#creating-and-managing-patients) * [CREATING AND MANAGING SESSIONS](#creating-and-managing-sessions) * [WORKING INSIDE A SESSION](#working-inside-a-session) - * [ANALYZE AN IMAGE](#analyze-images) - * [MOVING AND MANAGING IMAGES](#moving-and-managing-images) - * [VIEW SESSION AND IMAGE STATISTICS](#view-session-and-image-statistics) * [CREATING AND MANAGING REPORTS](#creating-and-managing-reports) * [EXPORT AND IMPORT](#exports-and-imports) + * [IMAGE VIEWER](#image-viewer) * [CONFIGURATION] - * [] -* [CONTRIBUTIONS] - * [ANNOTATIONS] - * [DONATIONS (KO-FI)] +* [CONTRIBUTIONS](#contributions) +* [DONATIONS](#donations) # Usage ## Creating and managing patients -When opening the app, you are taken to the Patients view. This is the main page of the system. - -Here you can **create, edit or archive** patients. +When opening the app, the main page of the system will be the patient's list. Press **Create a patient** to create a patient, and fill up the necessary fields. @@ -93,7 +88,7 @@ can be viewed by pressing the **AI ANALYSIS** tab which will redirect to the sta ## Creating and managing reports Reports are generated for one session, and are pdf's. They can be generated while inside a session, on the **reports** tab, by pressing the **Generate Report** button. -After pressing the button, we'll see a survey with "Additional Notes", and the button to generate the report. After adding any notes deeemed necessary, press **Generate Preview** to generate a preview report. +After pressing the button, a survey with "Additional Notes" and the button to generate the report will be shown. After adding any notes deeemed necessary, press **Generate Preview** to generate a preview report. Reports can be **regenerated** by pressing the "Generate Report" again, and then pressing the left button **regenerate preview report**. Report notes can be edited this way as well. @@ -115,10 +110,43 @@ can be hidden by going to the **configuration** tab on top of the screen, then g ## Exports and imports **Patients** and individual **Patient Sessions** can be imported and exported. All exports and imports use a **.dird file**. -Patient files contain every information used on a patient instance, such as images, sessions, etc. -Session files contain information specific to the session, like session images, session reports, etc. -To export a **patient**, click on a patient and click on **Export Patient**. This will download a **.dird file** which can then be used to **import a patient** on the patient page by clicking **import .dird**. +* Patient files contain every information used on a patient instance, such as images, sessions, etc. +* Session files contain information specific to the session, like session images, session reports, etc. + +To **export a patient**, click on a patient and click on **Export Patient**. This will download a **.dird file** which can then be used to **import a patient** on the patient page by clicking **import .dird**. + +To **export a session**, click on one of the sessions inside a patient, then click on **Export Session**. This will download a **.dird file**, which can then be used to **import a session** inside a patient page by clicking **Import Session**. + + +## Image viewer + + +# CONTRIBUTIONS + +### What are contributions? +Contributions help improve the YOLO identification model, by marking what the model didn't already identify. + +Contributions, as of now, are manual annotations made inside the image viewer. Manual annotations are either detection boxes, or landmarks. It's important to note that every contribution marked, includes every single annotation, both manual and AI. Every contribution is made anonymously. + +### Mark an image + +To mark an image for contribution, press the star icon on the top right. This button will appear once any manual annotation is made. You can either mark the whole session or the singular image. + +### Unmark an image + +To unmark a contribution, press on the star button and click "Don't contribute". + +It's worth noting that to reflect any changes in manual annotations inside contributions, re-marking will be necessary. + +### Send Contribution + +To send a contribution, navigate to the "Contribute" tab on the dashboard at the top of the page. + +You can choose to mark all un-marked images that had some form of manual annotations, or go with the ones already marked. Accept the terms and click on send. + +# DONATIONS -To export a **session**, click on one of the sessions inside a patient, then click on **Export Session**. This will download a **.dird file**, which can then be used to **import a session** inside a patient page by clicking **Import Session**. +We have our own [ko-fi page](https://ko-fi.com/tecmedhub) if you wish to support us! +Alternatively, link to our ko-fi page is located under the "Contribute" tab. diff --git a/src/App.tsx b/src/App.tsx index 5188951..8a049f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ import SessionComparison from '@/components/patients/SessionComparison'; import ContributionMenu from '@/components/contribution/ContributionMenu'; import AcademyView from '@/components/academy/AcademyView'; import { db } from '@/lib/db/schema'; -import { initializeDemoPatient, demoPatientExists, type LoadingProgress } from '@/lib/db/demoPatient'; +import {type LoadingProgress } from '@/lib/db/demoPatient'; import { DemoLoadingScreen } from '@/components/demo/DemoLoadingScreen'; import { useTokenStore } from '@/stores/token-store'; import { fetchTokens } from '@/lib/api/token-service'; @@ -34,7 +34,7 @@ function App() { ? (import.meta.env.BASE_URL || '/dird') : '/'; const [isInitializing, setIsInitializing] = useState(true); - const [loadingProgress, setLoadingProgress] = useState({ + const [loadingProgress] = useState({ step: 'init', current: 0, total: 1, @@ -63,28 +63,6 @@ function App() { db.on('blocked', handleDbBlocked); - const setupDemoPatient = async () => { - try { - const exists = await demoPatientExists(); - - if (cancelled) return; // Si el componente se desmontó, salir - - if (!exists) { - await initializeDemoPatient((progress) => { - if (!cancelled) { - setLoadingProgress(progress); - } - }); - } - } catch (error) { - console.error('❌ Error initializing demo patient:', error); - } finally { - if (!cancelled) { - setIsInitializing(false); - } - } - }; - const loadTokens = async () => { try { const tokenCount = await fetchTokens(); @@ -120,7 +98,11 @@ function App() { }; // Ejecutar en paralelo - Promise.all([setupDemoPatient(), loadTokens(), loadModelMetadata(), initOpenCV()]).catch((error) => { + Promise.all([loadTokens(), loadModelMetadata(), initOpenCV()]).then(() => { + if (!cancelled) { + setIsInitializing(false); + } + }).catch((error) => { console.error('❌ Error al inicializar aplicación:', error); if (!cancelled) { setIsInitializing(false); diff --git a/src/components/admin/ContributionsList.tsx b/src/components/admin/ContributionsList.tsx index 0e34681..61cf476 100644 --- a/src/components/admin/ContributionsList.tsx +++ b/src/components/admin/ContributionsList.tsx @@ -47,15 +47,27 @@ export function ContributionsList() { loadContributions(); }, []); - const handleDownload = (url: string, filename: string) => { - const fullUrl = `${API_BASE_URL}${url}`; - const link = document.createElement('a'); - link.href = fullUrl; - link.download = filename; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const handleDownload = async (url: string, filename: string) => { + try { + const fullUrl = `${API_BASE_URL}${url}`; + const response = await fetch(fullUrl, {method: 'GET'}); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + + } catch (error) { + console.error('Download error:', error); + } }; const formatDate = (dateString: string) => { diff --git a/src/components/contribution/ContributionMenu.tsx b/src/components/contribution/ContributionMenu.tsx index cd1c68d..4caf183 100644 --- a/src/components/contribution/ContributionMenu.tsx +++ b/src/components/contribution/ContributionMenu.tsx @@ -245,7 +245,7 @@ const ContributionMenu: React.FC = () => { if (session) { formData.append('session_id', session.id!.toString()); formData.append('session_name', session.name || `Session ${session.sessionNumber}`); - formData.append('session_date', session.date.toISOString()); + formData.append('session_date', session.date.toString()); } formData.append('image', img.originalBlob, img.filename); @@ -260,9 +260,10 @@ const ContributionMenu: React.FC = () => { if (!response.ok) throw new Error(`Failed to upload ${img.filename}`); - await db.pendingContributions.update(contrib.id!, { status: 'submitted' }); + await db.pendingContributions.delete(contrib.id) successCount++; } catch (err) { + console.log(err); errorCount++; } @@ -292,6 +293,7 @@ const ContributionMenu: React.FC = () => { await db.pendingContributions.update(contrib.id!, { status: 'submitted' }); successCount++; } catch (err) { + console.log(err); errorCount++; } @@ -323,6 +325,7 @@ const ContributionMenu: React.FC = () => { successCount++; } catch (err) { errorCount++; + console.log(err); } currentProgress++; diff --git a/src/components/patients/ExportImportControls.tsx b/src/components/patients/ExportImportPatients.tsx similarity index 59% rename from src/components/patients/ExportImportControls.tsx rename to src/components/patients/ExportImportPatients.tsx index 39b89ea..a11ac00 100644 --- a/src/components/patients/ExportImportControls.tsx +++ b/src/components/patients/ExportImportPatients.tsx @@ -1,8 +1,9 @@ import React, { useState, useRef } from 'react'; -import { Download, Upload, Database } from 'lucide-react'; +import { Download, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; + import { Dialog, DialogContent, @@ -10,48 +11,38 @@ import { DialogTitle, DialogDescription, } from '@/components/ui/dialog'; -import { exportPatient, exportAllData, downloadDirdFile } from '@/lib/export/dird-exporter'; -import { importDirdFile } from '@/lib/export/dird-importer'; + +import { exportAllData, downloadDirdFile } from '@/lib/export/dird-exporter'; +import { importDirdFile, importDirdType } from '@/lib/export/dird-importer'; import type { ImportResult } from '@/lib/export/dird-importer'; -interface ExportImportControlsProps { - patientId?: number; - patientName?: string; +interface ExportImportPatientsProps { onImportComplete?: () => void; } -const ExportImportControls: React.FC = ({ - patientId, - patientName, +const ExportImportPatients: React.FC = ({ onImportComplete, }) => { const { t } = useTranslation(); const [showImportDialog, setShowImportDialog] = useState(false); + const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); const [importResult, setImportResult] = useState(null); const fileInputRef = useRef(null); + const closeTimeoutRef = useRef(null); - const handleExportPatient = async () => { - if (!patientId) return; - - try { - const blob = await exportPatient(patientId); - const filename = `paciente_${patientName?.replace(/\s/g, '_')}_${Date.now()}`; - downloadDirdFile(blob, filename); - } catch (error) { - console.error('Error exporting patient:', error); - toast.error(t('export.errorPatient')); - } - }; const handleExportAll = async () => { + setExporting(true); try { const blob = await exportAllData(); - const filename = `dird_backup_${Date.now()}`; - downloadDirdFile(blob, filename); + downloadDirdFile(blob, `dird_backup_${Date.now()}`); + toast.success(t('export.fullSuccess')); } catch (error) { console.error('Error exporting data:', error); toast.error(t('export.errorData')); + } finally { + setExporting(false); } }; @@ -60,15 +51,37 @@ const ExportImportControls: React.FC = ({ setImportResult(null); try { + const type = await importDirdType(file); + + if (type === 'session') { + setImporting(false); + + setImportResult({ + success: false, + error: t('import.invalidFile'), + patientsImported: 0, + sessionsImported: 0, + imagesImported: 0, + detectionsImported: 0, + segmentationsImported: 0, + measurementsImported: 0, + reportsImported: 0 + }); + return; + } + const result = await importDirdFile(file); setImportResult(result); if (result.success) { - setTimeout(() => { + closeTimeoutRef.current = window.setTimeout(() => { setShowImportDialog(false); + setImporting(false); + setImportResult(null); onImportComplete?.(); - }, 3000); + }, 5000); } + } catch (error) { console.error('Error importing file:', error); setImportResult({ @@ -77,6 +90,9 @@ const ExportImportControls: React.FC = ({ sessionsImported: 0, imagesImported: 0, detectionsImported: 0, + segmentationsImported: 0, + measurementsImported: 0, + reportsImported: 0 }); } finally { setImporting(false); @@ -90,34 +106,66 @@ const ExportImportControls: React.FC = ({ } }; + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + if (file) { + handleImport(file); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + return ( <>
- {patientId && ( - - )} - -
{/* Import Dialog */} - + { + setShowImportDialog(open); + + if (!open) { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + + setImportResult(null); + setImporting(false); + } + + if (fileInputRef.current) {fileInputRef.current.value = '';} + }} + > + - {t('import.title')} + {t('import.patientTitle')} - {t('import.description')} + {t('import.patientDescription')} @@ -133,6 +181,8 @@ const ExportImportControls: React.FC = ({ />
fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDrop={handleDrop} className="border-2 border-dashed border-coal-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-400 transition-colors" > @@ -166,10 +216,21 @@ const ExportImportControls: React.FC = ({ {t('import.successTitle')}
-

{t('import.patientLabel')}{importResult.patient?.name}

+ + {importResult.import_type === 'patient' && ( +

{t('import.patientLabel')}{importResult.patient?.name}

+ )} + + {importResult.import_type === 'full' && ( +

{t('import.patientsImported')}{importResult.patientsImported}

+ )} +

{t('import.sessionsImported')}{importResult.sessionsImported}

{t('import.imagesImported')}{importResult.imagesImported}

+

{t('import.reportsImported')}{importResult.reportsImported}

{t('import.detectionsImported')}{importResult.detectionsImported}

+

{t('import.segmentationsImported')}{importResult.segmentationsImported}

+

{t('import.measurementsImported')}{importResult.measurementsImported}

) : ( @@ -187,4 +248,4 @@ const ExportImportControls: React.FC = ({ ); }; -export default ExportImportControls; +export default ExportImportPatients; diff --git a/src/components/patients/ExportImportSessions.tsx b/src/components/patients/ExportImportSessions.tsx new file mode 100644 index 0000000..6bee77b --- /dev/null +++ b/src/components/patients/ExportImportSessions.tsx @@ -0,0 +1,261 @@ +import React, { useState, useRef } from 'react'; +import { Download, Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; + +import { db } from '@/lib/db/schema'; + +import { useLiveQuery } from 'dexie-react-hooks'; +import { exportPatient, downloadDirdFile } from '@/lib/export/dird-exporter'; +import { importDirdFile, importDirdType } from '@/lib/export/dird-importer'; +import type { ImportResult } from '@/lib/export/dird-importer'; + +interface ExportImportSessionsProps { + patientId?: number; + onImportComplete?: () => void; +} + +const ExportImportSessions: React.FC = ({ + patientId, + onImportComplete, +}) => { + const { t } = useTranslation(); + const [showImportDialog, setShowImportDialog] = useState(false); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + const [isExporting, setIsExporting] = useState(false); + const fileInputRef = useRef(null); + const closeTimeoutRef = useRef(null); + + const patient = useLiveQuery( + () => (patientId ? db.patients.get(patientId) : undefined), + [patientId] + ); + + const handleExportPatient = async () => { + if (!patient) return; + setIsExporting(true); + + try { + const blob = await exportPatient(patientId!); + downloadDirdFile(blob, `dird_export_patient_${patient.patientId}`); + toast.success(t('export.patientSuccess')); + } catch (error) { + console.error('Error exporting patient:', error); + toast.error(t('export.errorPatient')); + } finally { + setIsExporting(false); + } + }; + + const handleImport = async (file: File) => { + setImporting(true); + setImportResult(null); + + try { + const type = await importDirdType(file); + + if (type === 'full') { + setImporting(false); + + setImportResult({ + success: false, + error: t('import.invalidFile'), + sessionsImported: 0, + imagesImported: 0, + detectionsImported: 0, + segmentationsImported: 0, + measurementsImported: 0, + reportsImported: 0 + }); + return; + } + + const result = await importDirdFile(file, patientId); + setImportResult(result); + + if (result.success) { + closeTimeoutRef.current = window.setTimeout(() => { + setShowImportDialog(false); + setImporting(false); + setImportResult(null); + onImportComplete?.(); + }, 5000); + } + + } catch (error) { + console.error('Error importing file:', error); + setImportResult({ + success: false, + error: t('import.processError'), + sessionsImported: 0, + imagesImported: 0, + detectionsImported: 0, + segmentationsImported: 0, + measurementsImported: 0, + reportsImported: 0 + }); + } finally { + setImporting(false); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + handleImport(file); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + if (file) { + handleImport(file); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + + return ( + <> +
+ {patientId && ( + + )} + + +
+ + {/* Import Dialog */} + { + setShowImportDialog(open); + + if (!open) { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + + setImportResult(null); + setImporting(false); + } + + if (fileInputRef.current) {fileInputRef.current.value = '';} + }} + > + + + + {t('import.sessionTitle')} + + {t('import.sessionDescription')} + + + +
+ {!importing && !importResult && ( + <> + +
fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDrop={handleDrop} + className="border-2 border-dashed border-coal-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-400 transition-colors" + > + +

+ {t('import.selectFile')} +

+

{t('export.dragAndDrop')}

+
+ + )} + + {importing && ( +
+
+

{t('import.importing')}

+

{t('import.waitMessage')}

+
+ )} + + {importResult && ( +
+ {importResult.success ? ( +
+

+ {t('import.successTitle')} +

+
+ + {importResult.import_type === 'patient' && ( +

{t('import.patientLabel')}{importResult.patient?.name}

+ )} + + {importResult.import_type === 'full' && ( +

{t('import.patientsImported')}{importResult.patientsImported}

+ )} + +

{t('import.sessionsImported')}{importResult.sessionsImported}

+

{t('import.imagesImported')}{importResult.imagesImported}

+

{t('import.reportsImported')}{importResult.reportsImported}

+

{t('import.detectionsImported')}{importResult.detectionsImported}

+

{t('import.segmentationsImported')}{importResult.segmentationsImported}

+

{t('import.measurementsImported')}{importResult.measurementsImported}

+
+
+ ) : ( +
+

{t('import.errorTitle')}

+

{importResult.error}

+
+ )} +
+ )} +
+
+
+ + ); +}; + +export default ExportImportSessions; diff --git a/src/components/patients/PatientDetails.tsx b/src/components/patients/PatientDetails.tsx index a59c7ca..a20fef6 100644 --- a/src/components/patients/PatientDetails.tsx +++ b/src/components/patients/PatientDetails.tsx @@ -4,13 +4,13 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { useConfirm } from '@/hooks/useConfirm'; import { useLiveQuery } from 'dexie-react-hooks'; -import { ArrowLeft, Plus, Calendar, Lock, Unlock, Download, Pencil, Trash2, Copy, ArrowRightLeft, CheckSquare } from 'lucide-react'; +import { ArrowLeft, Plus, Calendar, Lock, Unlock, Pencil, Trash2, Copy, ArrowRightLeft, CheckSquare } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import SessionForm from './SessionForm'; import PatientForm from './PatientForm'; import { db, Session, Patient } from '@/lib/db/schema'; -import { exportPatient, downloadDirdFile } from '@/lib/export/dird-exporter'; +import ExportImportSessions from './ExportImportSessions'; import { duplicateSession } from '@/lib/db/actions'; import { createCombinedSession } from '@/lib/db/combinedSessions'; @@ -21,7 +21,6 @@ const PatientDetails: React.FC = () => { const { confirm, ConfirmDialogComponent } = useConfirm(); const [showSessionForm, setShowSessionForm] = useState(false); const [showPatientForm, setShowPatientForm] = useState(false); - const [isExporting, setIsExporting] = useState(false); const [isDuplicating, setIsDuplicating] = useState(null); const [sessionToEdit, setSessionToEdit] = useState(); const [patientToEdit, setPatientToEdit] = useState(); @@ -40,21 +39,6 @@ const PatientDetails: React.FC = () => { [patientId] ); - const handleExportPatient = async () => { - if (!patient) return; - setIsExporting(true); - try { - const blob = await exportPatient(patient.id!); - downloadDirdFile(blob, `dird_export_patient_${patient.patientId}`); - toast.success(t('export.patientSuccess')); - } catch (error) { - console.error('Error exporting patient:', error); - toast.error(t('errors.exportPatient')); - } finally { - setIsExporting(false); - } - }; - const handleDeleteSession = async (sessionId: number) => { const confirmed = await confirm({ title: t('confirmations.deleteSessionTitle') || t('sessions.delete'), @@ -173,9 +157,11 @@ const PatientDetails: React.FC = () => {

{t('patients.idLabel')}{patient.patientId}

-
+
+ + {/* Refresh handled by useLiveQuery */}} /> + -
diff --git a/src/components/patients/PatientForm.tsx b/src/components/patients/PatientForm.tsx index 7a012fa..20afba4 100644 --- a/src/components/patients/PatientForm.tsx +++ b/src/components/patients/PatientForm.tsx @@ -187,7 +187,7 @@ const PatientForm: React.FC = ({

{t('patients.form.medicalHistory')}

-
+
{

- {/* Refresh handled by useLiveQuery */}} /> + {/* Refresh handled by useLiveQuery */}} /> +
@@ -99,7 +99,7 @@ const ImageDropzone: React.FC = ({ sessionId, onUploadComple
)} -
{ if (image.id) { @@ -232,13 +234,13 @@ const DraggableImageCard = React.forwardRef {image.eyeType === 'OI' ? ( <> + {t('upload.gallery.moveToRight')} - ) : ( <> - {t('upload.gallery.moveToLeft')} + )} @@ -297,6 +299,8 @@ const SortableImageCard: React.FC = (props) => { const ImageGallery: React.FC = ({ images, patientId, sessionId, onDelete, isLocked, refreshKey }) => { const [thumbnails, setThumbnails] = useState>(new Map()); const [activeImage, setActiveImage] = useState(null); + const [oiOrder, setOiOrder] = useState([]); + const [odOrder, setOdOrder] = useState([]); const sensors = useSensors( useSensor(PointerSensor, { @@ -306,6 +310,21 @@ const ImageGallery: React.FC = ({ images, patientId, sessionI }) ); const { t } = useTranslation(); + + useEffect(() => { + const oi = images + .filter(i => i.eyeType === 'OI') + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map(i => i.id!); + + const od = images + .filter(i => i.eyeType === 'OD') + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map(i => i.id!); + + setOiOrder(oi); + setOdOrder(od); + }, [images]); useEffect(() => { const newThumbnails = new Map(); @@ -375,10 +394,12 @@ const ImageGallery: React.FC = ({ images, patientId, sessionI }, [images]); const { oiImages, odImages } = useMemo(() => { - const oi = images.filter(img => img.eyeType === 'OI'); - const od = images.filter(img => img.eyeType === 'OD'); - return { oiImages: oi, odImages: od }; - }, [images]); + const byId = new Map(images.map(i => [i.id!, i])); + return { + oiImages: oiOrder.map(id => byId.get(id)!).filter(Boolean), + odImages: odOrder.map(id => byId.get(id)!).filter(Boolean), + }; + }, [images, oiOrder, odOrder]); const handleDragStart = (event: DragStartEvent) => { const { active } = event; @@ -397,25 +418,33 @@ const ImageGallery: React.FC = ({ images, patientId, sessionI setActiveImage(null); const { active, over } = event; - if (!over) { - return; - } + if (!over || active.id === over.id) return; const activeImage = images.find(img => img.id === active.id); - if (!activeImage) { - return; - } + const overImg = images.find(img => img.id === over.id); + if (!activeImage || !overImg) return; - let targetEye: 'OI' | 'OD' | null = null; - if (over.id === 'OI' || oiImages.some(i => i.id === over.id)) { - targetEye = 'OI'; - } else if (over.id === 'OD' || odImages.some(i => i.id === over.id)) { - targetEye = 'OD'; - } + const isOI = activeImage.eyeType === 'OI'; + + const currentOrder = isOI ? oiOrder: odOrder; + const from = currentOrder.indexOf(active.id as number); + const to = currentOrder.indexOf(over.id as number); + if (from === -1 || to === -1) return; + + const nextOrder = arrayMove(currentOrder, from, to); - if (targetEye && activeImage.eyeType !== targetEye) { - handleMove(activeImage.id!, targetEye); + if (isOI) { + setOiOrder(nextOrder); + } else { + setOdOrder(nextOrder); } + + await db.transaction('rw', db.images, async () => { + + for (let i = 0; i < nextOrder.length; i++) { + await db.images.update(nextOrder[i], {order: i}); + } + }); }; const DroppableColumn: React.FC<{ id: 'OI' | 'OD'; title: string; imageList: ImageType[]; className: string; }> = ({ id, title, imageList, className }) => { diff --git a/src/components/upload/SessionView.tsx b/src/components/upload/SessionView.tsx index 35f3835..5fa2cfd 100644 --- a/src/components/upload/SessionView.tsx +++ b/src/components/upload/SessionView.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useLiveQuery } from 'dexie-react-hooks'; -import { ArrowLeft, Play, Lock, ChevronRight, Download, Plus } from 'lucide-react'; +import { ArrowLeft, Play, Lock, ChevronRight, Download, Plus, Pencil } from 'lucide-react'; import { toast } from 'sonner'; import { useConfirm } from '@/hooks/useConfirm'; import { useTranslation } from 'react-i18next'; @@ -15,8 +15,9 @@ import ImageGallery from './ImageGallery'; import ReportGenerator from '../reports/ReportGenerator'; import ReportsList from '../reports/ReportsList'; import AnalysisView from './AnalysisView'; +import SessionForm from '@/components/patients/SessionForm'; import UploadProgressModal from './UploadProgressModal'; -import { db } from '@/lib/db/schema'; +import { db, Session } from '@/lib/db/schema'; import { exportSession, downloadDirdFile } from '@/lib/export/dird-exporter'; import { inferenceService } from '@/lib/ai/inference-service'; import { useImageUploader } from '@/hooks/useImageUploader'; @@ -50,23 +51,24 @@ const CompactUploader: React.FC<{ sessionId: number; onUploadComplete: () => voi
{getHiddenInput()}
+ + OD +
- {session.locked ? ( + + {!session.locked && ( // Solo mostrar editar y eliminar si no está bloqueado + <> + + + )} + {session.locked && ( {t('sessions.locked')} - ) : ( -
- setRefreshKey((prev) => prev + 1)} /> -
)}
@@ -278,13 +297,6 @@ const SessionView: React.FC = () => { )} - {showLargeDropzone && ( - - )} @@ -299,6 +311,7 @@ const SessionView: React.FC = () => {
{t('sessions.galleryTitle')}
+ {images.length > 0 && !session.locked && ( { onUploadStart={handleUploadStart} /> )} + {images.length > 0 && !session.locked && (
+ + {showLargeDropzone && images.length < 1 && ( + + )} + {
+ + { + setShowSessionForm(false); + setSessionToEdit(undefined); + }} + /> {ConfirmDialogComponent} ); diff --git a/src/hooks/useImageUploader.tsx b/src/hooks/useImageUploader.tsx index dc8f023..5d63b5f 100644 --- a/src/hooks/useImageUploader.tsx +++ b/src/hooks/useImageUploader.tsx @@ -250,6 +250,9 @@ export const useImageUploader = ({ sessionId, onUploadComplete: _onUploadComplet const clearUploadState = useCallback(() => { setUploadingFiles([]); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } }, []); const getRootProps = () => ({ diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5678b81..4cc7f7f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -665,21 +665,30 @@ "errorPatient": "Error al exportar el paciente", "errorData": "Error al exportar los datos", "patientSuccess": "Paciente exportado exitosamente", - "sessionSuccess": "Estudio exportado exitosamente" + "sessionSuccess": "Estudio exportado exitosamente", + "fullSuccess": "Todos los datos exportados exitosamente", + "sessionExport" : "sesion" }, "import": { - "title": "Importar .dird", - "description": "Importa un archivo .dird para restaurar datos de pacientes", + "patientTitle": "Importar paciente(s)", + "sessionTitle": "Importar estudio(s)", + "patientDescription": "Importa un archivo .dird para cargar un paciente o pacientes", + "sessionDescription": "Importa un archivo .dird para cargar un estudio o estudios", "selectFile": "Haz clic para seleccionar un archivo .dird", "importing": "Importando datos...", "waitMessage": "Esto puede tardar unos momentos", "successTitle": "Importación Exitosa", "patientLabel": "Paciente: ", + "patientsImported": "Pacientes importados: ", "sessionsImported": "Estudios importados: ", + "reportsImported": "Informes importados: ", "imagesImported": "Imágenes importadas: ", "detectionsImported": "Detecciones importadas: ", + "segmentationsImported": "Segmentaciones importadas: ", + "measurementsImported": "Mediciones importadas: ", "errorTitle": "Error de Importación", - "processError": "Error al procesar el archivo" + "processError": "Error al procesar el archivo", + "invalidFile": "Archivo inválido. Por favor seleccione un archivo de paciente(s) .dird válido." }, "languages": { "es": "Español", diff --git a/src/lib/api/admin-service.ts b/src/lib/api/admin-service.ts index 81e1c28..ce4b891 100644 --- a/src/lib/api/admin-service.ts +++ b/src/lib/api/admin-service.ts @@ -407,9 +407,11 @@ export async function downloadTixPackage( const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; + link.download = `dird_contributions_${ - installationToken ? installationToken.substring(0, 8) : 'all' + installationToken ? installationToken.substring(0, 8) : 'all' }_${new Date().toISOString().split('T')[0]}.tix`; + document.body.appendChild(link); link.click(); document.body.removeChild(link); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 8ad3d3e..6ce406c 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -42,6 +42,7 @@ export interface Image { sessionId: number; filename: string; eyeType: 'OI' | 'OD'; + order?: number; // Ordenar imagenes y actualizar su orden en una sesion originalBlob: Blob; processedBlob?: Blob; width: number; diff --git a/src/lib/export/dird-exporter.ts b/src/lib/export/dird-exporter.ts index c557381..f38becb 100644 --- a/src/lib/export/dird-exporter.ts +++ b/src/lib/export/dird-exporter.ts @@ -1,12 +1,14 @@ import JSZip from 'jszip'; import { db } from '@/lib/db/schema'; -import type { Patient, Session, Detection, Segmentation } from '@/lib/db/schema'; +import type { Patient, Session, Detection, Segmentation, Measurement } from '@/lib/db/schema'; export interface DirdExportMetadata { export_version: string; exported_at: string; - patient: Patient; - sessions: Session[]; + export_type: 'full' | 'patient' | 'session'; + patient?: Patient; + sessions?: Session[]; + } export async function exportPatient(patientId: number): Promise { @@ -23,9 +25,10 @@ export async function exportPatient(patientId: number): Promise { const metadata: DirdExportMetadata = { export_version: '1.0.1', exported_at: new Date().toISOString(), + export_type: 'patient', patient, - sessions, - }; + sessions + }; zip.file('metadata.json', JSON.stringify(metadata, null, 2)); @@ -45,18 +48,31 @@ export async function exportPatient(patientId: number): Promise { } } - // Get detections for all images in this session + // Image metadata + const imagesMetadata = images.map(img => ({ + id: img.id, + filename: img.filename, + eyeType: img.eyeType, + })) + + sessionFolder.file('images_metadata.json', JSON.stringify(imagesMetadata, null, 2)); + + // Get detections / segmentations / measurements for all images in this session const allDetections: Detection[] = []; const allSegmentations: Segmentation[] = []; + const allMeasurements: Measurement[] = []; + for (const image of images) { const detections = await db.detections.where('imageId').equals(image.id!).toArray(); const segmentations = await db.segmentations.where('imageId').equals(image.id!).toArray(); + const measurements = await db.measurements.where('imageId').equals(image.id!).toArray(); allDetections.push(...detections); allSegmentations.push(...segmentations); + allMeasurements.push(...measurements); } - // Save detections and segmentations as JSON + // Save detections, measurements and segmentations as JSON if (allDetections.length > 0) { sessionFolder.file('detections.json', JSON.stringify(allDetections, null, 2)); } @@ -65,6 +81,10 @@ export async function exportPatient(patientId: number): Promise { sessionFolder.file('segmentations.json', JSON.stringify(allSegmentations, null, 2)); } + if (allMeasurements.length > 0) { + sessionFolder.file('measurements.json', JSON.stringify(allMeasurements, null, 2)); + } + // Get reports for this session const reports = await db.reports.where('sessionId').equals(session.id!).toArray(); if (reports.length > 0) { @@ -75,6 +95,8 @@ export async function exportPatient(patientId: number): Promise { reportsFolder.file(reportName, report.pdfBlob); } } + } else { + console.log('no reports found for session', session.id); } } @@ -89,14 +111,11 @@ export async function exportSession(sessionId: number): Promise { const session = await db.sessions.get(sessionId); if (!session) throw new Error('Session not found'); - const patient = await db.patients.get(session.patientId); - if (!patient) throw new Error('Patient not found for session'); - const metadata: DirdExportMetadata = { export_version: '1.0.1', exported_at: new Date().toISOString(), - patient, - sessions: [session], + export_type: 'session', + sessions: [session] }; zip.file('metadata.json', JSON.stringify(metadata, null, 2)); @@ -109,13 +128,25 @@ export async function exportSession(sessionId: number): Promise { } } + // Image metadata + const imagesMetadata = images.map(img => ({ + id: img.id, + filename: img.filename, + eyeType: img.eyeType, + })) + + zip.file('images_metadata.json', JSON.stringify(imagesMetadata, null, 2)); + const allDetections: Detection[] = []; const allSegmentations: Segmentation[] = []; + const allMeasurements: Measurement[] = []; for (const image of images) { const detections = await db.detections.where('imageId').equals(image.id!).toArray(); allDetections.push(...detections); const segmentations = await db.segmentations.where('imageId').equals(image.id!).toArray(); allSegmentations.push(...segmentations); + const measurements = await db.measurements.where('imageId').equals(image.id!).toArray(); + allMeasurements.push(...measurements); } if (allDetections.length > 0) { @@ -124,6 +155,9 @@ export async function exportSession(sessionId: number): Promise { if (allSegmentations.length > 0) { zip.file('segmentations.json', JSON.stringify(allSegmentations, null, 2)); } + if (allMeasurements.length > 0) { + zip.file('measurements.json', JSON.stringify(allMeasurements, null, 2)); + } const reports = await db.reports.where('sessionId').equals(sessionId).toArray(); if (reports.length > 0) { @@ -142,27 +176,78 @@ export async function exportSession(sessionId: number): Promise { export async function exportAllData(): Promise { const zip = new JSZip(); + const metadata: DirdExportMetadata = { + export_version: '1.0.1', + exported_at: new Date().toISOString(), + export_type: 'full', + }; + + + zip.file('metadata.json', JSON.stringify(metadata, null, 2)); + // Get all patients const patients = await db.patients.toArray(); for (const patient of patients) { - const patientBlob = await exportPatient(patient.id!); - const patientZip = await JSZip.loadAsync(patientBlob); - - // Add to main zip with patient folder - const patientFolderName = `paciente_${patient.patientId}`; - const patientFolder = zip.folder(patientFolderName); - - if (patientFolder) { - // Copy all files from patient zip to main zip - const files = Object.keys(patientZip.files); - for (const filename of files) { - const file = patientZip.files[filename]; - if (!file.dir) { - const content = await file.async('blob'); - patientFolder.file(filename, content); + const patientFolder = zip.folder(`paciente_${patient.patientId}`); + if (!patientFolder) continue; + + const sessions = await db.sessions.where('patientId').equals(patient.id!).toArray(); + + const patientMetadata: DirdExportMetadata = { + export_version: '1.0.1', + exported_at: new Date().toISOString(), + export_type: 'patient', + patient, sessions + }; + + patientFolder.file('metadata.json', JSON.stringify(patientMetadata, null, 2)); + + for (const session of sessions) { + const sessionFolder = patientFolder.folder(`sessions/session_${String(session.sessionNumber).padStart(3, '0')}`); + if (!sessionFolder) continue; + + const images = await db.images.where('sessionId').equals(session.id!).toArray(); + + const imagesFolder = sessionFolder.folder('images'); + if (imagesFolder) { + for (const image of images) { + imagesFolder.file(image.filename, image.originalBlob); } } + + const imagesMetadata = images.map(img => ({ + id: img.id, + filename: img.filename, + eyeType: img.eyeType + })); + + sessionFolder.file('images_metadata.json', JSON.stringify(imagesMetadata, null, 2)); + + const allDetections: Detection[] = []; + const allSegmentations: Segmentation[] = []; + const allMeasurements: Measurement[] = []; + + for (const image of images) { + allDetections.push(...(await db.detections.where('imageId').equals(image.id!).toArray())); + allSegmentations.push(...(await db.segmentations.where('imageId').equals(image.id!).toArray())); + allMeasurements.push(...(await db.measurements.where('imageId').equals(image.id!).toArray())); + } + + if (allDetections.length > 0) {sessionFolder.file('detections.json', JSON.stringify(allDetections, null, 2));} + if (allSegmentations.length > 0) {sessionFolder.file('segmentations.json', JSON.stringify(allSegmentations, null, 2));} + if (allMeasurements.length > 0) {sessionFolder.file('measurements.json', JSON.stringify(allMeasurements, null, 2));} + + const reports = await db.reports.where('sessionId').equals(session.id!).toArray(); + if (reports.length > 0) { + const reportsFolder = sessionFolder.folder('reports'); + if (reportsFolder) { + for (const report of reports) { + reportsFolder.file(`report_${report.type}.pdf`, report.pdfBlob); + } + } + } + } } diff --git a/src/lib/export/dird-importer.ts b/src/lib/export/dird-importer.ts index ed015de..46e2f75 100644 --- a/src/lib/export/dird-importer.ts +++ b/src/lib/export/dird-importer.ts @@ -1,36 +1,456 @@ import JSZip from 'jszip'; import { db } from '@/lib/db/schema'; -import type { Patient, Detection, Segmentation } from '@/lib/db/schema'; +import type { Patient, Detection, Segmentation, Measurement } from '@/lib/db/schema'; import type { DirdExportMetadata } from './dird-exporter'; export interface ImportResult { success: boolean; patient?: Patient; - sessionsImported: number; + sessionsImported?: number; imagesImported: number; detectionsImported: number; + segmentationsImported: number; + measurementsImported: number; + reportsImported: number; + patientsImported?: number; error?: string; + import_type?: 'patient' | 'session' | 'full' } -export async function importDirdFile(file: File): Promise { + + + +async function deletePatientData(patientId: number) { + // Get all sessions + const sessions = await db.sessions.where('patientId').equals(patientId).toArray(); + + for (const session of sessions) { + // Get all images + const images = await db.images.where('sessionId').equals(session.id!).toArray(); + + for (const image of images) { + // Delete detections and segmentations + await db.detections.where('imageId').equals(image.id!).delete(); + await db.segmentations.where('imageId').equals(image.id!).delete(); + await db.measurements.where('imageId').equals(image.id!).delete(); + } + + // Delete images + await db.images.where('sessionId').equals(session.id!).delete(); + + // Delete reports + await db.reports.where('sessionId').equals(session.id!).delete(); + } + + // Delete sessions + await db.sessions.where('patientId').equals(patientId).delete(); + + // Delete patient + await db.patients.delete(patientId); +} + +async function deleteSessionData(sessionId: number) { + // Get all images + const images = await db.images.where('sessionId').equals(sessionId!).toArray(); + + for (const image of images) { + // Delete detections and segmentations + await db.detections.where('imageId').equals(image.id!).delete(); + await db.segmentations.where('imageId').equals(image.id!).delete(); + await db.measurements.where('imageId').equals(image.id!).delete(); + } + + // Delete images + await db.images.where('sessionId').equals(sessionId).delete(); + + // Delete reports + await db.reports.where('sessionId').equals(sessionId).delete(); + + // Delete session + await db.sessions.where('id').equals(sessionId).delete(); +} + +function loadImageFromBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(blob); + + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image')); + }; + + img.src = url; + }); +} + +async function importSessionZip(zip: JSZip, metadata: DirdExportMetadata, targetPatientId: number): Promise { + try{ + + if (!metadata.sessions || metadata.sessions.length !== 1 ) { + throw new Error('Invalid session export: expected exactly one session'); + } + + // Check if session already exists + const sessionData = metadata.sessions[0]; + const existingSession = await db.sessions.where('patientId').equals(targetPatientId).and(s => s.sessionNumber === sessionData.sessionNumber).first(); + + if (existingSession) { + const overwrite = confirm(`La sesion ${metadata.sessions[0].name} (${metadata.sessions[0].sessionNumber}) ya existe. ¿Deseas sobrescribirlo?`); + if (!overwrite) { + return { + success: false, + error: 'Importación cancelada por el usuario', + imagesImported: 0, + detectionsImported: 0, + segmentationsImported: 0, + reportsImported: 0, + measurementsImported: 0 + }; + } + + // Delete existing session and all related data + await deleteSessionData(existingSession.id!); + } + + let sessionsImported = 0; + let imagesImported = 0; + let detectionsImported = 0; + let segmentationsImported = 0; + let measurementsImported = 0; + let reportsImported = 0; + + const sessionId = await db.sessions.add({ + ...sessionData, + id: undefined, + patientId: targetPatientId, + }); + sessionsImported++; + + + // Import images + const imagesMetaFile = zip.file(`images_metadata.json`); + if (!imagesMetaFile) { + throw new Error('Missing image.json'); + } + const imagesMeta = JSON.parse(await imagesMetaFile.async('text')); + + const imageIdMap = new Map(); + + + for (const imgMeta of imagesMeta) { + const blob = await zip.file(`images/${imgMeta.filename}`)!.async('blob'); + const img = await loadImageFromBlob(blob); + + const newImageId = await db.images.add({ + sessionId: sessionId as number, + filename: imgMeta.filename, + eyeType: 'OI', + originalBlob: blob, + width: img.width, + height: img.height, + uploadedAt: new Date(), + }); + + imageIdMap.set(imgMeta.id, newImageId as number); + imagesImported++; + } + + // Import detections + const detectionsFile = zip.file(`detections.json`); + if (detectionsFile) { + const detections: Detection[] = JSON.parse( + await detectionsFile.async('text') + ); + + for (const detection of detections) { + const newImageId = imageIdMap.get(detection.imageId as number); + + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } + + await db.detections.add({ + ...detection, + id: undefined, + imageId: newImageId, + }); + detectionsImported++; + } + } + + // Import segmentations + const segmentationsFile = zip.file(`segmentations.json`); + if (segmentationsFile) { + const segmentationsContent = await segmentationsFile.async('text'); + const segmentations: Segmentation[] = JSON.parse(segmentationsContent); + + for (const segmentation of segmentations) { + const newImageId = imageIdMap.get(segmentation.imageId as number); + + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } + + await db.segmentations.add({ + ...segmentation, + id: undefined, + imageId: newImageId, + }); + segmentationsImported++; + } + } + + // Import reports + const reportFiles = Object.keys(zip.files).filter( + (name) => name.startsWith(`report/`) && name.endsWith('.pdf') + ); + + for (const reportPath of reportFiles) { + const reportFile = zip.files[reportPath]; + const blob = await reportFile.async('blob'); + const reportType = reportPath.includes('final') ? 'final' : 'preview'; + + await db.reports.add({ + sessionId: sessionId as number, + type: reportType as 'preview' | 'final', + reportCategory: 'single', + pdfBlob: blob, + evaluatorNotes: '', + areasOfInterest: [], + generatedAt: new Date(), + }); + reportsImported++; + } + + // Import measurements + const measurementsFile = zip.file(`measurements.json`); + if (measurementsFile) { + const measurementsContent = await measurementsFile.async('text'); + const measurements: Measurement[] = JSON.parse(measurementsContent); + + for (const measurement of measurements) { + const newImageId = imageIdMap.get(measurement.imageId as number); + + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } + + await db.measurements.add({ + ...measurement, + id: undefined, + imageId:newImageId, + }); + } + } + + return { + success: true, + sessionsImported, + imagesImported, + detectionsImported, + segmentationsImported, + measurementsImported, + reportsImported, + import_type: 'session' + }; + + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Error desconocido', + sessionsImported: 0, + imagesImported: 0, + detectionsImported: 0, + segmentationsImported: 0, + measurementsImported: 0, + reportsImported: 0, + import_type: 'session' + }; + } +} + +async function importSessionsToPatient(zip: JSZip, metadata: DirdExportMetadata, targetPatientId: number): Promise { try { - // Load ZIP file - const zip = await JSZip.loadAsync(file); + if (!metadata.sessions || metadata.sessions.length === 0) { + throw new Error('Invalid session import: no sessions found'); + } - // Read metadata - const metadataFile = zip.file('metadata.json'); - if (!metadataFile) { - throw new Error('Invalid .dird file: metadata.json not found'); + let sessionsImported = 0; + let imagesImported = 0; + let detectionsImported = 0; + let segmentationsImported = 0; + let measurementsImported = 0; + let reportsImported = 0; + + // import sessions + for (const sessionData of metadata.sessions) { + const sessionFolderPath = `sessions/session_${String(sessionData.sessionNumber).padStart(3, '0')}`; + const sessionFolder = zip.folder(sessionFolderPath); + if (!sessionFolder) continue; + + // overwrite check + const existingSession = await db.sessions.where('patientId').equals(targetPatientId).and(s => s.sessionNumber === sessionData.sessionNumber).first(); + + if (existingSession) { + const overwrite = confirm(`La sesión ${sessionData.name} (${sessionData.sessionNumber}) ya existe. ¿Deseas sobrescribirla?`); + if (!overwrite) continue; + await deleteSessionData(existingSession.id!); + } + + const newSessionId = await db.sessions.add({ + ...sessionData, + id: undefined, + patientId: targetPatientId + }) + sessionsImported++; + + // import images + const imagesMetaFile = zip.file(`${sessionFolderPath}/images_metadata.json`); + if (!imagesMetaFile) continue; + + const imagesMeta = JSON.parse(await imagesMetaFile.async('text')); + const imageIdMap = new Map(); + + for (const imgMeta of imagesMeta) { + const imageFile = zip.file(`${sessionFolderPath}/images/${imgMeta.filename}`); + if (!imageFile) continue; + + const blob = await imageFile.async('blob'); + const img = await loadImageFromBlob(blob); + + const newImageId = await db.images.add({ + sessionId: newSessionId as number, + filename: imgMeta.filename, + eyeType: imgMeta.eyeType, + originalBlob: blob, + width: img.width, + height: img.height, + uploadedAt: new Date() + }); + + imageIdMap.set(imgMeta.id, newImageId as number); + imagesImported++; + } + + // import detections + const detectionsFile = zip.file(`${sessionFolderPath}/detections.json`); + if (detectionsFile) { + const detections: Detection[] = JSON.parse(await detectionsFile.async('text')); + for (const detection of detections) { + const newImageId = imageIdMap.get(detection.imageId as number); + if (!newImageId) continue; + + await db.detections.add({ + ...detection, + id: undefined, + imageId: newImageId + }); + detectionsImported++; + } + } + + // import segmentations + const segmentationsFile = zip.file(`${sessionFolderPath}/segmentations.json`); + if (segmentationsFile) { + const segmentations: Segmentation[] = JSON.parse(await segmentationsFile.async('text')); + for (const segmentation of segmentations) { + const newImageId = imageIdMap.get(segmentation.imageId as number); + if (!newImageId) continue; + + await db.segmentations.add({ + ...segmentation, + id: undefined, + imageId: newImageId + }); + segmentationsImported++; + } + } + + // measurements + const measurementsFile = zip.file(`${sessionFolderPath}/measurements.json`); + if (measurementsFile) { + const measurements: Measurement[] = JSON.parse(await measurementsFile.async('text')); + for (const measurement of measurements) { + const newImageId = imageIdMap.get(measurement.imageId as number); + if (!newImageId) continue; + + await db.measurements.add({ + ...measurement, + id: undefined, + imageId: newImageId + }); + measurementsImported++; + } + } + + // import reports + const reportFiles = Object.keys(zip.files).filter( + name => + name.startsWith(`${sessionFolderPath}/reports/`) && + name.endsWith('.pdf') + ); + + for (const reportPath of reportFiles) { + const blob = await zip.files[reportPath].async('blob'); + const reportType = reportPath.includes('final') ? 'final' : 'preview'; + + await db.reports.add({ + sessionId: newSessionId as number, + type: reportType as 'preview' | 'final', + reportCategory: 'single', + pdfBlob: blob, + evaluatorNotes: '', + areasOfInterest: [], + generatedAt: new Date() + }); + + reportsImported++; + } } - const metadataContent = await metadataFile.async('text'); - const metadata: DirdExportMetadata = JSON.parse(metadataContent); + return { + success: true, + sessionsImported, + imagesImported, + detectionsImported, + segmentationsImported, + measurementsImported, + reportsImported, + import_type: 'session' + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Error desconocido', + sessionsImported: 0, + imagesImported: 0, + detectionsImported: 0, + segmentationsImported: 0, + measurementsImported: 0, + reportsImported: 0, + import_type: 'session' + }; + } +} + +async function importPatientZip(zip: JSZip, metadata: DirdExportMetadata): Promise { + try { + + if (!metadata.patient || !metadata.sessions) { + throw new Error('Invalid patient export: missing patient or sessions'); + } // Check if patient already exists const existingPatient = await db.patients - .where('patientId') - .equals(metadata.patient.patientId) - .first(); + .where('patientId') + .equals(metadata.patient.patientId) + .first(); if (existingPatient) { const overwrite = confirm( @@ -43,9 +463,12 @@ export async function importDirdFile(file: File): Promise { sessionsImported: 0, imagesImported: 0, detectionsImported: 0, + segmentationsImported: 0, + reportsImported: 0, + measurementsImported: 0 }; } - + // Delete existing patient and all related data await deletePatientData(existingPatient.id!); } @@ -56,9 +479,13 @@ export async function importDirdFile(file: File): Promise { id: undefined, // Let database assign new ID }); + const importedPatient = await db.patients.get(patientId as number); let sessionsImported = 0; let imagesImported = 0; let detectionsImported = 0; + let segmentationsImported = 0; + let measurementsImported = 0; + let reportsImported = 0; // Import sessions for (const sessionData of metadata.sessions) { @@ -74,37 +501,35 @@ export async function importDirdFile(file: File): Promise { sessionsImported++; // Import images - const imagesFolder = zip.folder(`${sessionFolderName}/images`); - if (imagesFolder) { - const imageFiles = Object.keys(zip.files).filter( - (name) => name.startsWith(`${sessionFolderName}/images/`) && !zip.files[name].dir - ); - // Map old image IDs to new ones - const imageIdMap = new Map(); - let imageIndex = 0; + const imagesMetaFile = zip.file(`${sessionFolderName}/images_metadata.json`); + if (!imagesMetaFile) { + throw new Error('Missing image.json'); + } + const imagesMeta = JSON.parse(await imagesMetaFile.async('text')); - for (const imagePath of imageFiles) { - const imageFile = zip.files[imagePath]; - const filename = imagePath.split('/').pop()!; - const blob = await imageFile.async('blob'); - // Load image to get dimensions - const img = await loadImageFromBlob(blob); + // Map old image IDs to new ones + const imageIdMap = new Map(); - const newImageId = await db.images.add({ - sessionId: sessionId as number, - filename, - eyeType: 'OI', // Default value for imported images - originalBlob: blob, - width: img.width, - height: img.height, - uploadedAt: new Date(), - }); + for (const imgMeta of imagesMeta) { + const blob = await zip.file(`${sessionFolderName}/images/${imgMeta.filename}`)!.async('blob'); + + // Load image to get dimensions + const img = await loadImageFromBlob(blob); + + const newImageId = await db.images.add({ + sessionId: sessionId as number, + filename: imgMeta.filename, + eyeType: imgMeta.eyeType, // Default value for imported images + originalBlob: blob, + width: img.width, + height: img.height, + uploadedAt: new Date(), + }); - imageIdMap.set(imageIndex, newImageId as number); - imageIndex++; - imagesImported++; + imageIdMap.set(imgMeta.id, newImageId as number); + imagesImported++; } // Import detections @@ -115,9 +540,11 @@ export async function importDirdFile(file: File): Promise { for (const detection of detections) { // Map old imageId to new imageId - const newImageId = Array.from(imageIdMap.values())[ - detections.indexOf(detection) % imageIdMap.size - ]; + const newImageId = imageIdMap.get(detection.imageId as number); + + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } await db.detections.add({ ...detection, @@ -135,21 +562,46 @@ export async function importDirdFile(file: File): Promise { const segmentations: Segmentation[] = JSON.parse(segmentationsContent); for (const segmentation of segmentations) { - const newImageId = Array.from(imageIdMap.values())[ - segmentations.indexOf(segmentation) % imageIdMap.size - ]; + const newImageId = imageIdMap.get(segmentation.imageId as number); + + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } await db.segmentations.add({ ...segmentation, id: undefined, imageId: newImageId, }); + segmentationsImported++; + } + } + + // Import measurements + const measurementsFile = zip.file(`${sessionFolderName}/measurements.json`); + if (measurementsFile) { + const measurementsContent = await measurementsFile.async('text'); + const measurements: Measurement[] = JSON.parse(measurementsContent); + + for (const measurement of measurements) { + const newImageId = imageIdMap.get(measurement.imageId as number); + + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } + + await db.measurements.add({ + ...measurement, + id: undefined, + imageId: newImageId, + }); + measurementsImported++; } } // Import reports const reportFiles = Object.keys(zip.files).filter( - (name) => name.startsWith(`${sessionFolderName}/report_`) && name.endsWith('.pdf') + (name) => name.startsWith(`${sessionFolderName}/reports/`) && name.endsWith('.pdf') ); for (const reportPath of reportFiles) { @@ -166,11 +618,9 @@ export async function importDirdFile(file: File): Promise { areasOfInterest: [], generatedAt: new Date(), }); + reportsImported++; } } - } - - const importedPatient = await db.patients.get(patientId as number); return { success: true, @@ -178,6 +628,10 @@ export async function importDirdFile(file: File): Promise { sessionsImported, imagesImported, detectionsImported, + segmentationsImported, + measurementsImported, + reportsImported, + import_type: 'patient' }; } catch (error) { console.error('Error importing .dird file:', error); @@ -187,53 +641,344 @@ export async function importDirdFile(file: File): Promise { sessionsImported: 0, imagesImported: 0, detectionsImported: 0, + segmentationsImported: 0, + measurementsImported: 0, + reportsImported: 0, + import_type: 'patient' }; } } -async function deletePatientData(patientId: number) { - // Get all sessions - const sessions = await db.sessions.where('patientId').equals(patientId).toArray(); +async function importPatientFromFolder(zip: JSZip, basePath: string): Promise { + + const metadataFile = zip.file(`${basePath}/metadata.json`); + if (!metadataFile) { + throw new Error(`Missing metadata.json in ${basePath}`); + } - for (const session of sessions) { - // Get all images - const images = await db.images.where('sessionId').equals(session.id!).toArray(); + const metadata: DirdExportMetadata = JSON.parse( + await metadataFile.async('text') + ); - for (const image of images) { - // Delete detections and segmentations - await db.detections.where('imageId').equals(image.id!).delete(); - await db.segmentations.where('imageId').equals(image.id!).delete(); + if (!metadata.patient || !metadata.sessions) { + throw new Error('Invalid patient export'); + } + + // overwrite check + const existingPatient = await db.patients + .where('patientId') + .equals(metadata.patient.patientId) + .first(); + + if (existingPatient) { + const overwrite = confirm( + `El paciente ${metadata.patient.name} (${metadata.patient.patientId}) ya existe. ¿Deseas sobrescribirlo?` + ); + if (!overwrite) { + return { + success: false, + error: 'Importación cancelada por el usuario', + sessionsImported: 0, + imagesImported: 0, + detectionsImported: 0, + segmentationsImported: 0, + reportsImported: 0, + measurementsImported: 0 + }; } + await deletePatientData(existingPatient.id!); + } - // Delete images - await db.images.where('sessionId').equals(session.id!).delete(); + const patientId = await db.patients.add({ + ...metadata.patient, + id: undefined, + }); - // Delete reports - await db.reports.where('sessionId').equals(session.id!).delete(); + let sessionsImported = 0; + let imagesImported = 0; + let detectionsImported = 0; + let segmentationsImported = 0; + let measurementsImported = 0; + let reportsImported = 0; + + // Import sessions + for (const sessionData of metadata.sessions) { + const sessionFolderName = `sessions/session_${String(sessionData.sessionNumber).padStart(3, '0')}`; + const sessionBasePath = `${basePath}/${sessionFolderName}`; + + // Import session + const sessionId = await db.sessions.add({ + ...sessionData, + id: undefined, + patientId: patientId as number, + }); + sessionsImported++; + + // Import images + + const imagesMetaFile = zip.file(`${sessionBasePath}/images_metadata.json`); + if (!imagesMetaFile) { + throw new Error('Missing image.json'); + } + const imagesMeta = JSON.parse(await imagesMetaFile.async('text')); + + // Map old image IDs to new ones + const imageIdMap = new Map(); + + for (const imgMeta of imagesMeta) { + const imageFile = zip.file(`${sessionBasePath}/images/${imgMeta.filename}`); + if (!imageFile) continue; + const blob = await imageFile.async('blob'); + + // Load image to get dimensions + const img = await loadImageFromBlob(blob); + + const newImageId = await db.images.add({ + sessionId: sessionId as number, + filename: imgMeta.filename, + eyeType: imgMeta.eyeType, // Default value for imported images + originalBlob: blob, + width: img.width, + height: img.height, + uploadedAt: new Date(imgMeta.uploadedAt), + }); + + imageIdMap.set(imgMeta.id, newImageId as number); + imagesImported++; + } + + // Import detections + const detectionsFile = zip.file(`${sessionBasePath}/detections.json`); + if (detectionsFile) { + const detections: Detection[] = JSON.parse(await detectionsFile.async('text')); + + for (const detection of detections) { + const newImageId = imageIdMap.get(detection.imageId as number); + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } + + await db.detections.add({ + ...detection, + id: undefined, + imageId: newImageId, + }); + detectionsImported++; + } + } + + // Import segmentations + const segmentationsFile = zip.file(`${sessionBasePath}/segmentations.json`); + if (segmentationsFile) { + const segmentations: Segmentation[] = JSON.parse( + await segmentationsFile.async('text') + ); + + for (const segmentation of segmentations) { + const newImageId = imageIdMap.get(segmentation.imageId as number); + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } + await db.segmentations.add({ + ...segmentation, + id: undefined, + imageId: newImageId, + }); + segmentationsImported++; + } + } + + // Import reports + const reportFiles = Object.keys(zip.files).filter( + name => name.startsWith(`${sessionBasePath}/reports/`) && name.endsWith('.pdf') + ); + + for (const reportPath of reportFiles) { + const blob = await zip.files[reportPath].async('blob'); + const reportType = reportPath.includes('final') ? 'final' : 'preview'; + + await db.reports.add({ + sessionId: sessionId as number, + type: reportType as 'preview' | 'final', + reportCategory: 'single', + pdfBlob: blob, + evaluatorNotes: '', + areasOfInterest: [], + generatedAt: new Date(), + }); + reportsImported++; + } + + // Import measurements + const measurementsFile = zip.file(`${sessionBasePath}/measurements.json`); + if (measurementsFile) { + const measurements: Measurement[] = JSON.parse( + await measurementsFile.async('text') + ); + + for (const measurement of measurements) { + const newImageId = imageIdMap.get(measurement.imageId as number); + + if (!newImageId) { + throw new Error('Missing image mapping for imageId ${detection.imageId}') + } + await db.measurements.add({ + ...measurement, + id: undefined, + imageId: newImageId, + }); + measurementsImported++; + } + } } - // Delete sessions - await db.sessions.where('patientId').equals(patientId).delete(); + return { + success: true, + sessionsImported, + imagesImported, + detectionsImported, + segmentationsImported, + measurementsImported, + reportsImported, + patient: await db.patients.get(patientId as number), + }; +} - // Delete patient - await db.patients.delete(patientId); + +async function importFullZip(zip: JSZip, _metadata: DirdExportMetadata): Promise { + let totalSessions = 0; + let totalImages = 0; + let totalDetections = 0; + let totalPatients = 0; + let totalSegmentations = 0; + let totalReports = 0; + let totalMeasurements = 0; + + // Only treat real folders (ending with /) as patient folders + const patientFolders = Object.keys(zip.files).filter(name => { + if (!zip.files[name].dir) return false; + if (!name.startsWith('paciente_')) return false; + + // remove trailing slash and check depth + const trimmed = name.replace(/\/$/, ''); + return !trimmed.includes('/'); + }); + + + for (const folder of patientFolders) { + try { + const result = await importPatientFromFolder(zip, folder.replace(/\/$/, '')); + + if (!result.success) { + return result; + } + + totalPatients++; + totalSessions += result.sessionsImported || 0; + totalImages += result.imagesImported; + totalDetections += result.detectionsImported; + totalSegmentations += result.segmentationsImported; + totalReports += result.reportsImported; + totalMeasurements += result.measurementsImported; + + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Error importing patient folder', + sessionsImported: totalSessions, + imagesImported: totalImages, + detectionsImported: totalDetections, + segmentationsImported: totalSegmentations, + measurementsImported: totalMeasurements, + reportsImported: totalReports, + patientsImported: totalPatients, + import_type: 'full' + }; + } + } + + return { + success: true, + sessionsImported: totalSessions, + imagesImported: totalImages, + detectionsImported: totalDetections, + patientsImported: totalPatients, + segmentationsImported: totalSegmentations, + measurementsImported: totalMeasurements, + reportsImported: totalReports, + import_type: 'full' + }; } -function loadImageFromBlob(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - const url = URL.createObjectURL(blob); +export async function importDirdFile(file: File, targetPatientId?: number): Promise { + try { + // Load ZIP file + const zip = await JSZip.loadAsync(file); - img.onload = () => { - URL.revokeObjectURL(url); - resolve(img); - }; + // Read metadata + const metadataFile = zip.file('metadata.json'); + if (!metadataFile) { + throw new Error('Invalid .dird file: metadata.json not found'); + } - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Failed to load image')); - }; + const metadataContent = await metadataFile.async('text'); + const metadata: DirdExportMetadata = JSON.parse(metadataContent); - img.src = url; - }); + switch (metadata.export_type) { + case 'patient': + if (!targetPatientId) return importPatientZip(zip, metadata); + return importSessionsToPatient(zip, metadata, targetPatientId); + + case 'session': + if (!targetPatientId) { + throw new Error('Target patient ID is required for session import'); + } + return importSessionZip(zip, metadata, targetPatientId); + + case 'full': + return importFullZip(zip, metadata); + + default: + throw new Error('Unsupported export type'); + } + + } catch (error) { + + return { + success: false, + error: error instanceof Error ? error.message : 'Error desconocido', + sessionsImported: 0, + imagesImported: 0, + detectionsImported: 0, + segmentationsImported: 0, + measurementsImported: 0, + reportsImported: 0 + }; + } } + +export async function importDirdType(file: File): Promise { + try { + // Load ZIP file + const zip = await JSZip.loadAsync(file); + + // Read metadata + const metadataFile = zip.file('metadata.json'); + if (!metadataFile) { + throw new Error('Invalid .dird file: metadata.json not found'); + } + + const metadataContent = await metadataFile.async('text'); + const metadata: DirdExportMetadata = JSON.parse(metadataContent); + + return metadata.export_type; + + } catch (error) { + console.error('Error determining .dird file type:', error); + return null; + + } +} \ No newline at end of file