From 6473c2f24a69f623a3412aa07807c6d0b81c07c7 Mon Sep 17 00:00:00 2001
From: aaron
Date: Wed, 14 Jan 2026 21:48:08 -0300
Subject: [PATCH 01/16] Import/export fix & expansion
---
...tControls.tsx => ExportImportPatients.tsx} | 141 ++-
.../patients/ExportImportSessions.tsx | 263 +++++
src/components/patients/PatientDetails.tsx | 32 +-
src/components/patients/PatientList.tsx | 4 +-
src/i18n/locales/es.json | 17 +-
src/lib/export/dird-exporter.ts | 139 ++-
src/lib/export/dird-importer.ts | 911 ++++++++++++++++--
7 files changed, 1324 insertions(+), 183 deletions(-)
rename src/components/patients/{ExportImportControls.tsx => ExportImportPatients.tsx} (60%)
create mode 100644 src/components/patients/ExportImportSessions.tsx
diff --git a/src/components/patients/ExportImportControls.tsx b/src/components/patients/ExportImportPatients.tsx
similarity index 60%
rename from src/components/patients/ExportImportControls.tsx
rename to src/components/patients/ExportImportPatients.tsx
index 39b89ea..52926b3 100644
--- a/src/components/patients/ExportImportControls.tsx
+++ b/src/components/patients/ExportImportPatients.tsx
@@ -3,6 +3,7 @@ import { Download, Upload, Database } 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 && (
-
-
- {t('export.patient')}
-
- )}
-
-
-
- {t('export.all')}
+
+
+
+ {exporting ? t('export.exporting') : t('export.all')}
setShowImportDialog(true)} size="sm">
- {t('export.import')}
+ {t('import.patientTitle')}
{/* 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..33ee72a
--- /dev/null
+++ b/src/components/patients/ExportImportSessions.tsx
@@ -0,0 +1,263 @@
+import React, { useState, useRef } from 'react';
+import { Download, Upload, Database } 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;
+ patientName?: string;
+ onImportComplete?: () => void;
+}
+
+const ExportImportSessions: React.FC = ({
+ patientId,
+ patientName,
+ 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 && (
+
+
+ {isExporting ? t('export.exporting') : t('export.patient')}
+
+ )}
+
+ setShowImportDialog(true)}>
+
+ {t('import.sessionTitle')}
+
+
+
+ {/* 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..773b096 100644
--- a/src/components/patients/PatientDetails.tsx
+++ b/src/components/patients/PatientDetails.tsx
@@ -10,7 +10,7 @@ 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 */}} />
+
{
setPatientToEdit(patient);
setShowPatientForm(true);
@@ -185,14 +171,6 @@ const PatientDetails: React.FC = () => {
{t('patients.edit')}
-
-
- {isExporting ? t('export.exporting') : t('export.patient')}
-
diff --git a/src/components/patients/PatientList.tsx b/src/components/patients/PatientList.tsx
index 5d65993..2ca68fc 100644
--- a/src/components/patients/PatientList.tsx
+++ b/src/components/patients/PatientList.tsx
@@ -9,7 +9,7 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import PatientCard, { PatientReportStatus } from './PatientCard';
import PatientForm from './PatientForm';
-import ExportImportControls from './ExportImportControls';
+import ExportImportPatients from './ExportImportPatients';
import { db, Patient } from '@/lib/db/schema';
import { deletePatient } from '@/lib/db/actions';
import { useConfirm } from '@/hooks/useConfirm';
@@ -122,7 +122,7 @@ const PatientList: React.FC = () => {
-
{/* Refresh handled by useLiveQuery */}} />
+ {/* Refresh handled by useLiveQuery */}} />
{
setPatientToEdit(undefined);
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/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
From c57128906132a66fa558ec6c064feee704897234 Mon Sep 17 00:00:00 2001
From: aaron
Date: Thu, 15 Jan 2026 17:47:10 -0300
Subject: [PATCH 02/16] fix: ImageGallery correct To x eye display
---
src/components/patients/ExportImportSessions.tsx | 4 +---
src/components/patients/PatientDetails.tsx | 4 ++--
src/components/upload/ImageGallery.tsx | 4 ++--
src/components/upload/SessionView.tsx | 2 +-
4 files changed, 6 insertions(+), 8 deletions(-)
diff --git a/src/components/patients/ExportImportSessions.tsx b/src/components/patients/ExportImportSessions.tsx
index 33ee72a..6bee77b 100644
--- a/src/components/patients/ExportImportSessions.tsx
+++ b/src/components/patients/ExportImportSessions.tsx
@@ -1,5 +1,5 @@
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';
@@ -21,13 +21,11 @@ import type { ImportResult } from '@/lib/export/dird-importer';
interface ExportImportSessionsProps {
patientId?: number;
- patientName?: string;
onImportComplete?: () => void;
}
const ExportImportSessions: React.FC = ({
patientId,
- patientName,
onImportComplete,
}) => {
const { t } = useTranslation();
diff --git a/src/components/patients/PatientDetails.tsx b/src/components/patients/PatientDetails.tsx
index 773b096..a20fef6 100644
--- a/src/components/patients/PatientDetails.tsx
+++ b/src/components/patients/PatientDetails.tsx
@@ -4,7 +4,7 @@ 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';
@@ -159,7 +159,7 @@ const PatientDetails: React.FC = () => {
-
{/* Refresh handled by useLiveQuery */}} />
+ {/* Refresh handled by useLiveQuery */}} />
{
diff --git a/src/components/upload/ImageGallery.tsx b/src/components/upload/ImageGallery.tsx
index c072282..03b1469 100644
--- a/src/components/upload/ImageGallery.tsx
+++ b/src/components/upload/ImageGallery.tsx
@@ -232,13 +232,13 @@ const DraggableImageCard = React.forwardRef
{image.eyeType === 'OI' ? (
<>
+
{t('upload.gallery.moveToRight')}
-
>
) : (
<>
-
{t('upload.gallery.moveToLeft')}
+
>
)}
diff --git a/src/components/upload/SessionView.tsx b/src/components/upload/SessionView.tsx
index 35f3835..dc4c2b4 100644
--- a/src/components/upload/SessionView.tsx
+++ b/src/components/upload/SessionView.tsx
@@ -278,7 +278,7 @@ const SessionView: React.FC = () => {
)}
- {showLargeDropzone && (
+ {(
Date: Thu, 15 Jan 2026 19:54:09 -0300
Subject: [PATCH 03/16] fix: dragging across the same eye type fixed, cross eye
dragging is not possible anymore, extra image field added to schema.ts to
control dragging
---
src/components/upload/ImageGallery.tsx | 67 ++++++++++++++++++--------
src/lib/db/schema.ts | 1 +
2 files changed, 49 insertions(+), 19 deletions(-)
diff --git a/src/components/upload/ImageGallery.tsx b/src/components/upload/ImageGallery.tsx
index 03b1469..8b435e9 100644
--- a/src/components/upload/ImageGallery.tsx
+++ b/src/components/upload/ImageGallery.tsx
@@ -12,7 +12,7 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
-import { SortableContext, useSortable, rectSortingStrategy } from '@dnd-kit/sortable';
+import { SortableContext, useSortable, rectSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Trash2, Brain, GripVertical, ArrowRight, ArrowLeft, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next';
import { inferenceService } from '@/lib/ai/inference-service';
import { db } from '@/lib/db/schema';
+
interface ImageGalleryProps {
images: ImageType[];
patientId: string;
@@ -58,6 +59,7 @@ const DraggableImageCard = React.forwardRef {
if (image.id) {
@@ -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/lib/db/schema.ts b/src/lib/db/schema.ts
index 8ad3d3e..8fe12da 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;
From 53cb44322c4d02c17f499a3a2caa3f6146e8321f Mon Sep 17 00:00:00 2001
From: aaron
Date: Fri, 16 Jan 2026 19:14:17 -0300
Subject: [PATCH 04/16] fix: campo order en image ahora no es obligatorio para
no romper demo
---
src/lib/db/schema.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 8fe12da..6ce406c 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -42,7 +42,7 @@ export interface Image {
sessionId: number;
filename: string;
eyeType: 'OI' | 'OD';
- order: number; // Ordenar imagenes y actualizar su orden en una sesion
+ order?: number; // Ordenar imagenes y actualizar su orden en una sesion
originalBlob: Blob;
processedBlob?: Blob;
width: number;
From 2372826537dda2c997228986700f01b74eb26f0a Mon Sep 17 00:00:00 2001
From: aaron
Date: Fri, 16 Jan 2026 19:15:48 -0300
Subject: [PATCH 05/16] =?UTF-8?q?fix:=20eliminacion=20de=20boton=20(a?=
=?UTF-8?q?=C3=B1adir=20imagenes)=20en=20favor=20de=20dropzone?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/patients/ExportImportPatients.tsx | 2 +-
src/components/upload/ImageDropzone.tsx | 14 +++++++-------
src/components/upload/SessionView.tsx | 16 +++++++---------
3 files changed, 15 insertions(+), 17 deletions(-)
diff --git a/src/components/patients/ExportImportPatients.tsx b/src/components/patients/ExportImportPatients.tsx
index 52926b3..a11ac00 100644
--- a/src/components/patients/ExportImportPatients.tsx
+++ b/src/components/patients/ExportImportPatients.tsx
@@ -1,5 +1,5 @@
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';
diff --git a/src/components/upload/ImageDropzone.tsx b/src/components/upload/ImageDropzone.tsx
index 2451beb..492d061 100644
--- a/src/components/upload/ImageDropzone.tsx
+++ b/src/components/upload/ImageDropzone.tsx
@@ -73,19 +73,19 @@ const ImageDropzone: React.FC = ({ sessionId, onUploadComple
{t('upload.selectEye')}
- setSelectedEye('OI')}
- disabled={isLimitReached}
- >
- {t('upload.eye.left')}
-
setSelectedEye('OD')}
disabled={isLimitReached}
>
{t('upload.eye.right')}
+
+ setSelectedEye('OI')}
+ disabled={isLimitReached}
+ >
+ {t('upload.eye.left')}
diff --git a/src/components/upload/SessionView.tsx b/src/components/upload/SessionView.tsx
index dc4c2b4..6573299 100644
--- a/src/components/upload/SessionView.tsx
+++ b/src/components/upload/SessionView.tsx
@@ -231,7 +231,7 @@ const SessionView: React.FC = () => {
return new Date(date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' });
};
- const showLargeDropzone = (images.length === 0 || isUploading) && !session.locked;
+ const showLargeDropzone = !session.locked;
return (
<>
@@ -259,14 +259,10 @@ const SessionView: React.FC = () => {
{isExporting ? t('export.exporting') : t('export.session')}
- {session.locked ? (
+ {session.locked && (
{t('sessions.locked')}
- ) : (
-
- setRefreshKey((prev) => prev + 1)} />
-
)}
@@ -278,7 +274,7 @@ const SessionView: React.FC = () => {
)}
- {(
+ {showLargeDropzone && (
{
{t('sessions.galleryTitle')}
- {images.length > 0 && !session.locked && (
+
+ {/*images.length > 0 && !session.locked && (
- )}
+ )*/}
+
{images.length > 0 && !session.locked && (
Date: Fri, 16 Jan 2026 19:32:27 -0300
Subject: [PATCH 06/16] fix: no se podia subir la misma imagen dos veces
---
src/hooks/useImageUploader.tsx | 3 +++
1 file changed, 3 insertions(+)
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 = () => ({
From d726b9b3b4b709648db74e25943bb31524fe2fd6 Mon Sep 17 00:00:00 2001
From: aaron
Date: Fri, 16 Jan 2026 19:40:36 -0300
Subject: [PATCH 07/16] =?UTF-8?q?enhancement:=20cambiar=20a=20pesta=C3=B1a?=
=?UTF-8?q?=20de=20imagenes=20cuando=20se=20sube=20una=20imagen=20estando?=
=?UTF-8?q?=20en=20otra=20pesta=C3=B1a?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/upload/SessionView.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/components/upload/SessionView.tsx b/src/components/upload/SessionView.tsx
index 6573299..6b4fe44 100644
--- a/src/components/upload/SessionView.tsx
+++ b/src/components/upload/SessionView.tsx
@@ -152,6 +152,7 @@ const SessionView: React.FC = () => {
const handleUploadComplete = () => {
setIsUploading(false);
setRefreshKey((prev) => prev + 1);
+ setSessionViewTab('images');
};
const handleUploadStart = () => {
From 82997e56a19e9a893cf8281ab0b00dd34f4d090f Mon Sep 17 00:00:00 2001
From: aaron
Date: Fri, 16 Jan 2026 20:53:38 -0300
Subject: [PATCH 08/16] fix: ya no depende del paciente demo
---
src/App.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/App.tsx b/src/App.tsx
index 5188951..771bfd5 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -120,7 +120,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);
From 57dd9b4edf8063bc28e2951d11aa8c7ab1466982 Mon Sep 17 00:00:00 2001
From: aaron
Date: Mon, 26 Jan 2026 19:31:53 -0300
Subject: [PATCH 09/16] fix: contribuciones no se podian subir por una
conversion de string
---
src/components/contribution/ContributionMenu.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/components/contribution/ContributionMenu.tsx b/src/components/contribution/ContributionMenu.tsx
index cd1c68d..eff0d72 100644
--- a/src/components/contribution/ContributionMenu.tsx
+++ b/src/components/contribution/ContributionMenu.tsx
@@ -245,7 +245,8 @@ 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());
+ console.log(session.date.toString());
}
formData.append('image', img.originalBlob, img.filename);
@@ -263,6 +264,7 @@ const ContributionMenu: React.FC = () => {
await db.pendingContributions.update(contrib.id!, { status: 'submitted' });
successCount++;
} catch (err) {
+ console.log(err);
errorCount++;
}
@@ -292,6 +294,7 @@ const ContributionMenu: React.FC = () => {
await db.pendingContributions.update(contrib.id!, { status: 'submitted' });
successCount++;
} catch (err) {
+ console.log(err);
errorCount++;
}
@@ -323,6 +326,7 @@ const ContributionMenu: React.FC = () => {
successCount++;
} catch (err) {
errorCount++;
+ console.log(err);
}
currentProgress++;
From 3c0fa4f6ec4174f8ae6d47d26c3fffd5b2602231 Mon Sep 17 00:00:00 2001
From: aaron
Date: Mon, 26 Jan 2026 19:33:35 -0300
Subject: [PATCH 10/16] empty console.log
---
src/components/contribution/ContributionMenu.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/components/contribution/ContributionMenu.tsx b/src/components/contribution/ContributionMenu.tsx
index eff0d72..74b39d1 100644
--- a/src/components/contribution/ContributionMenu.tsx
+++ b/src/components/contribution/ContributionMenu.tsx
@@ -246,7 +246,6 @@ const ContributionMenu: React.FC = () => {
formData.append('session_id', session.id!.toString());
formData.append('session_name', session.name || `Session ${session.sessionNumber}`);
formData.append('session_date', session.date.toString());
- console.log(session.date.toString());
}
formData.append('image', img.originalBlob, img.filename);
From 95b6369b84dfd703b6fda2ff2e85dbdfc6b8055b Mon Sep 17 00:00:00 2001
From: aaron
Date: Wed, 28 Jan 2026 17:37:56 -0300
Subject: [PATCH 11/16] fix: descargar imagenes de admin
---
src/components/admin/ContributionsList.tsx | 30 +++++++++++++++-------
src/lib/api/admin-service.ts | 4 ++-
2 files changed, 24 insertions(+), 10 deletions(-)
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/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);
From 22ec3f0b4ce6847028bf4fad47e4874cc2eac617 Mon Sep 17 00:00:00 2001
From: aaron
Date: Thu, 29 Jan 2026 17:18:03 -0300
Subject: [PATCH 12/16] fix: imagenes enviadas a contribucion no podian ser
enviadas nuevamente, inclusive si habian nuevas contribuciones en la imagen
---
src/components/contribution/ContributionMenu.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/contribution/ContributionMenu.tsx b/src/components/contribution/ContributionMenu.tsx
index 74b39d1..4caf183 100644
--- a/src/components/contribution/ContributionMenu.tsx
+++ b/src/components/contribution/ContributionMenu.tsx
@@ -260,7 +260,7 @@ 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);
From 6d307062b1566961bb2d06f0d9cbc4ce91687b8e Mon Sep 17 00:00:00 2001
From: aaron
Date: Thu, 29 Jan 2026 18:52:34 -0300
Subject: [PATCH 13/16] readme
---
README.md | 58 +++++++++++++++++++++++++++++++++++++++++--------------
1 file changed, 43 insertions(+), 15 deletions(-)
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.
From 624436cb1355cc75fda76d31fba99aafb11b0513 Mon Sep 17 00:00:00 2001
From: aaron
Date: Thu, 19 Feb 2026 17:30:26 -0300
Subject: [PATCH 14/16] fix build
---
src/App.tsx | 26 +---------
src/components/upload/SessionView.tsx | 75 ++-------------------------
2 files changed, 5 insertions(+), 96 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index 771bfd5..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();
diff --git a/src/components/upload/SessionView.tsx b/src/components/upload/SessionView.tsx
index 6b4fe44..38f1140 100644
--- a/src/components/upload/SessionView.tsx
+++ b/src/components/upload/SessionView.tsx
@@ -1,7 +1,7 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState } 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 } from 'lucide-react';
import { toast } from 'sonner';
import { useConfirm } from '@/hooks/useConfirm';
import { useTranslation } from 'react-i18next';
@@ -15,74 +15,12 @@ import ImageGallery from './ImageGallery';
import ReportGenerator from '../reports/ReportGenerator';
import ReportsList from '../reports/ReportsList';
import AnalysisView from './AnalysisView';
-import UploadProgressModal from './UploadProgressModal';
import { db } from '@/lib/db/schema';
import { exportSession, downloadDirdFile } from '@/lib/export/dird-exporter';
import { inferenceService } from '@/lib/ai/inference-service';
-import { useImageUploader } from '@/hooks/useImageUploader';
import { usePatientStore } from '@/stores/patient-store';
-const CompactUploader: React.FC<{ sessionId: number; onUploadComplete: () => void; onUploadStart: () => void }> = ({ sessionId, onUploadComplete, onUploadStart }) => {
- const {
- selectedEye,
- setSelectedEye,
- uploadingFiles,
- clearUploadState,
- triggerFileDialog,
- getHiddenInput,
- } = useImageUploader({ sessionId, onUploadComplete, onUploadStart });
- const { t } = useTranslation();
- const [isLimitReached, setIsLimitReached] = useState(false);
-
- // Check image count on component mount and when sessionId changes
- useEffect(() => {
- const fetchImageCount = async () => {
- const count = await db.images.where('sessionId').equals(sessionId).count();
- setIsLimitReached(count >= 20);
- };
-
- fetchImageCount();
- }, [sessionId]);
-
- return (
- <>
-
- {getHiddenInput()}
-
- setSelectedEye('OI')}
- className="text-xs"
- disabled={isLimitReached}
- >
- OI
-
- setSelectedEye('OD')}
- className="text-xs"
- disabled={isLimitReached}
- >
- OD
-
-
-
-
- {isLimitReached ? t('upload.photoLimitExceeded', { limit: 20 }) : t('upload.addImage')}
-
-
-
- >
- );
-};
-
const SessionView: React.FC = () => {
const { patientId, sessionId } = useParams<{ patientId: string; sessionId: string }>();
const navigate = useNavigate();
@@ -157,6 +95,7 @@ const SessionView: React.FC = () => {
const handleUploadStart = () => {
setIsUploading(true);
+ return isUploading;
};
const handleProcessWithAI = async () => {
@@ -296,14 +235,6 @@ const SessionView: React.FC = () => {
{t('sessions.galleryTitle')}
-
- {/*images.length > 0 && !session.locked && (
-
- )*/}
{images.length > 0 && !session.locked && (
Date: Thu, 19 Feb 2026 20:07:53 -0300
Subject: [PATCH 15/16] arreglo de forms de pacientes, reposicionado de caja de
upload
---
src/components/patients/PatientForm.tsx | 2 +-
src/components/ui/dialog.tsx | 10 ++-
src/components/upload/ImageDropzone.tsx | 4 +-
src/components/upload/SessionView.tsx | 91 ++++++++++++++++++++++---
4 files changed, 92 insertions(+), 15 deletions(-)
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')}
-
+
= ({ open, onOpenChange, children }) => {
if (!open) return null;
return (
-
+
onOpenChange(false)}
@@ -27,13 +27,17 @@ const DialogContent = React.forwardRef
- {children}
+
+ {children}
+
+
)
);
diff --git a/src/components/upload/ImageDropzone.tsx b/src/components/upload/ImageDropzone.tsx
index 492d061..02a6d08 100644
--- a/src/components/upload/ImageDropzone.tsx
+++ b/src/components/upload/ImageDropzone.tsx
@@ -67,7 +67,7 @@ const ImageDropzone: React.FC
= ({ sessionId, onUploadComple
};
return (
-
+
{getHiddenInput()}
@@ -99,7 +99,7 @@ const ImageDropzone: React.FC = ({ sessionId, onUploadComple
)}
-
void; onUploadStart: () => void }> = ({ sessionId, onUploadComplete, onUploadStart }) => {
+ const {
+ selectedEye,
+ setSelectedEye,
+ uploadingFiles,
+ clearUploadState,
+ triggerFileDialog,
+ getHiddenInput,
+ } = useImageUploader({ sessionId, onUploadComplete, onUploadStart });
+ const { t } = useTranslation();
+ const [isLimitReached, setIsLimitReached] = useState(false);
+
+ // Check image count on component mount and when sessionId changes
+ useEffect(() => {
+ const fetchImageCount = async () => {
+ const count = await db.images.where('sessionId').equals(sessionId).count();
+ setIsLimitReached(count >= 20);
+ };
+
+ fetchImageCount();
+ }, [sessionId]);
+
+ return (
+ <>
+
+ {getHiddenInput()}
+
+
+ setSelectedEye('OD')}
+ className="text-xs"
+ disabled={isLimitReached}
+ >
+ OD
+
+ setSelectedEye('OI')}
+ className="text-xs"
+ disabled={isLimitReached}
+ >
+ OI
+
+
+
+
+ {isLimitReached ? t('upload.photoLimitExceeded', { limit: 20 }) : t('upload.addImage')}
+
+
+
+ >
+ );
+};
+
const SessionView: React.FC = () => {
const { patientId, sessionId } = useParams<{ patientId: string; sessionId: string }>();
const navigate = useNavigate();
@@ -214,13 +277,6 @@ const SessionView: React.FC = () => {
)}
- {showLargeDropzone && (
-
- )}
@@ -235,6 +291,14 @@ const SessionView: React.FC = () => {
{t('sessions.galleryTitle')}
+
+ {images.length > 0 && !session.locked && (
+
+ )}
{images.length > 0 && !session.locked && (
{
)}
+
+ {showLargeDropzone && images.length < 1 && (
+
+ )}
+
Date: Sun, 22 Feb 2026 16:47:27 -0300
Subject: [PATCH 16/16] =?UTF-8?q?boton=20de=20edicion=20de=20sesiones=20a?=
=?UTF-8?q?=C3=B1adido?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/upload/SessionView.tsx | 35 +++++++++++++++++++++++++--
1 file changed, 33 insertions(+), 2 deletions(-)
diff --git a/src/components/upload/SessionView.tsx b/src/components/upload/SessionView.tsx
index 2f1b7e2..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';
@@ -95,6 +96,8 @@ const SessionView: React.FC = () => {
const [isProcessing, setIsProcessing] = useState(false);
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 });
const [isUploading, setIsUploading] = useState(false);
+ const [sessionToEdit, setSessionToEdit] = useState();
+ const [showSessionForm, setShowSessionForm] = useState(false);
const session = useLiveQuery(
() => (sessionId ? db.sessions.get(parseInt(sessionId)) : undefined),
@@ -126,6 +129,11 @@ const SessionView: React.FC = () => {
}
};
+ const handleEditSession = (session: Session) => {
+ setSessionToEdit(session);
+ setShowSessionForm(true);
+ };
+
const handleDeleteImage = async (imageId: number) => {
const confirmed = await confirm({
title: t('confirmations.deleteImageTitle') || t('upload.deleteImage'),
@@ -262,6 +270,18 @@ const SessionView: React.FC = () => {
{isExporting ? t('export.exporting') : t('export.session')}
+
+ {!session.locked && ( // Solo mostrar editar y eliminar si no está bloqueado
+ <>
+ {
+ e.stopPropagation();
+ handleEditSession(session);}}
+ className="flex-1 md:flex-none">
+
+ {t('sessions.edit')}
+
+ >
+ )}
{session.locked && (
{t('sessions.locked')}
@@ -404,6 +424,17 @@ const SessionView: React.FC = () => {
+
+
{
+ setShowSessionForm(false);
+ setSessionToEdit(undefined);
+ }}
+ />
{ConfirmDialogComponent}
>
);