Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(git log:*)",
"Bash(gh pr:*)",
"Bash(mvn compile:*)",
"Bash(mvn test:*)"
]
}
}
5 changes: 5 additions & 0 deletions frontend/src/locale/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,11 @@
"ENCOUNTER_ANNOTATION": "Begegnungsanmerkung",
"COCO_FORMAT": "COCO-Format",
"COCO_FORMAT_DESCRIPTION": "Exportieren Sie Annotationen im COCO-Format für KI/ML-Training. Enthält Bilder und Begrenzungsrahmen.",
"COCO_PHASE_PREPARING": "Preparing...",
"COCO_PHASE_IMAGES": "Downloading images",
"COCO_PHASE_MANIFEST": "Building manifest...",
"COCO_PHASE_PACKAGING": "Packaging ZIP...",
"COCO_RETRY_DOWNLOAD": "Download wiederholen",
"EXPORT_ZIP_FILE": "ZIP-Datei exportieren",
"THIS_ENCOUNTER": "Diese Begegnung",
"CLICK_ANNOTATION_TO_SEE_MATCH_RESULTS": "Klicken Sie auf eine Anmerkung, um die Übereinstimmungsergebnisse zu sehen.",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,11 @@
"ENCOUNTER_ANNOTATION": "Encounter Annotation",
"COCO_FORMAT": "COCO Format",
"COCO_FORMAT_DESCRIPTION": "Export annotations in COCO format for AI/ML training. Includes images and bounding boxes.",
"COCO_PHASE_PREPARING": "Preparing...",
"COCO_PHASE_IMAGES": "Downloading images",
"COCO_PHASE_MANIFEST": "Building manifest...",
"COCO_PHASE_PACKAGING": "Packaging ZIP...",
"COCO_RETRY_DOWNLOAD": "Retry Download",
"EXPORT_ZIP_FILE": "Export ZIP File",
"THIS_ENCOUNTER": "This Encounter",
"CLICK_ANNOTATION_TO_SEE_MATCH_RESULTS": "Click on an annotation to view match results",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/locale/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,11 @@
"ENCOUNTER_ANNOTATION": "Anotación de encuentro",
"COCO_FORMAT": "Formato COCO",
"COCO_FORMAT_DESCRIPTION": "Exportar anotaciones en formato COCO para entrenamiento de IA/ML. Incluye imágenes y cuadros delimitadores.",
"COCO_PHASE_PREPARING": "Preparing...",
"COCO_PHASE_IMAGES": "Downloading images",
"COCO_PHASE_MANIFEST": "Building manifest...",
"COCO_PHASE_PACKAGING": "Packaging ZIP...",
"COCO_RETRY_DOWNLOAD": "Reintentar descarga",
"EXPORT_ZIP_FILE": "Exportar archivo ZIP",
"THIS_ENCOUNTER": "Este encuentro",
"CLICK_ANNOTATION_TO_SEE_MATCH_RESULTS": "Haz clic en la anotación para ver los resultados de coincidencia",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/locale/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,11 @@
"ENCOUNTER_ANNOTATION": "Annotation de rencontre",
"COCO_FORMAT": "Format COCO",
"COCO_FORMAT_DESCRIPTION": "Exporter les annotations au format COCO pour l'entraînement IA/ML. Inclut les images et les boîtes englobantes.",
"COCO_PHASE_PREPARING": "Preparing...",
"COCO_PHASE_IMAGES": "Downloading images",
"COCO_PHASE_MANIFEST": "Building manifest...",
"COCO_PHASE_PACKAGING": "Packaging ZIP...",
"COCO_RETRY_DOWNLOAD": "Réessayer le téléchargement",
"EXPORT_ZIP_FILE": "Exporter le fichier ZIP",
"THIS_ENCOUNTER": "Cette rencontre",
"CLICK_ANNOTATION_TO_SEE_MATCH_RESULTS": "Cliquez sur l’annotation pour voir les résultats de correspondance",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/locale/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,11 @@
"ENCOUNTER_ANNOTATION": "Annotazione dell'incontro",
"COCO_FORMAT": "Formato COCO",
"COCO_FORMAT_DESCRIPTION": "Esporta le annotazioni in formato COCO per l'addestramento AI/ML. Include immagini e bounding box.",
"COCO_PHASE_PREPARING": "Preparing...",
"COCO_PHASE_IMAGES": "Downloading images",
"COCO_PHASE_MANIFEST": "Building manifest...",
"COCO_PHASE_PACKAGING": "Packaging ZIP...",
"COCO_RETRY_DOWNLOAD": "Riprova download",
"EXPORT_ZIP_FILE": "Esporta file ZIP",
"THIS_ENCOUNTER": "Questo incontro",
"CLICK_ANNOTATION_TO_SEE_MATCH_RESULTS": "Clicca sull'annotazione per vedere i risultati della corrispondenza",
Expand Down
166 changes: 157 additions & 9 deletions frontend/src/pages/SearchPages/components/ExportModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
Spinner,
Alert,
} from "react-bootstrap";
import { useState } from "react";
import { useState, useRef, useCallback, useEffect } from "react";
import { FormattedMessage } from "react-intl";

const downloadFunction = async (url, setLoading) => {
Expand Down Expand Up @@ -75,6 +75,26 @@ export default function ExportDialog({ open, setOpen, searchQueryId }) {
});

const [error, setError] = useState(null);
const [cocoProgress, setCocoProgress] = useState(null);
const [cocoJobId, setCocoJobId] = useState(null);
const cocoPollingRef = useRef(null);

// Clean up polling interval on unmount (e.g., modal close)
useEffect(() => {
return () => {
if (cocoPollingRef.current) {
clearInterval(cocoPollingRef.current);
cocoPollingRef.current = null;
}
};
}, []);

// Auto-expire the retry button after 1 hour (matches server-side job TTL)
useEffect(() => {
if (!cocoJobId) return;
const timer = setTimeout(() => setCocoJobId(null), 60 * 60 * 1000);
return () => clearTimeout(timer);
}, [cocoJobId]);

const setLoading = (key, value) => {
setLoadingStates((prev) => ({ ...prev, [key]: value }));
Expand All @@ -90,6 +110,117 @@ export default function ExportDialog({ open, setOpen, searchQueryId }) {
}
};

const handleCocoExport = useCallback(async () => {
setError(null);
setLoading("cocoFormat", true);
setCocoProgress(null);

try {
// Start the async export job
const startUrl = `/EncounterSearchExportCOCO?action=start&searchQueryId=${searchQueryId}&regularQuery=true`;
const startResp = await fetch(startUrl);
const startData = await startResp.json();
if (!startResp.ok || !startData.jobId) {
throw new Error(startData.error || "Failed to start export");
}
const { jobId } = startData;

// Poll for progress (timeout after 2 hours for very large exports)
const MAX_POLL_MS = 2 * 60 * 60 * 1000;
const pollStart = Date.now();
let consecutiveErrors = 0;
const result = await new Promise((resolve, reject) => {
cocoPollingRef.current = setInterval(async () => {
try {
if (Date.now() - pollStart > MAX_POLL_MS) {
clearInterval(cocoPollingRef.current);
cocoPollingRef.current = null;
reject(new Error("Export timed out after 2 hours"));
return;
}

const statusResp = await fetch(
`/EncounterSearchExportCOCO?action=status&jobId=${jobId}`,
);
if (!statusResp.ok) {
throw new Error(`Status check failed (HTTP ${statusResp.status})`);
}
const status = await statusResp.json();
consecutiveErrors = 0;

if (status.totalImages > 0 || status.phase) {
setCocoProgress(status);
}

if (status.status === "complete") {
clearInterval(cocoPollingRef.current);
cocoPollingRef.current = null;
resolve(jobId);
} else if (status.status === "error") {
clearInterval(cocoPollingRef.current);
cocoPollingRef.current = null;
reject(new Error(status.error || "Export failed"));
}
} catch (e) {
consecutiveErrors++;
// Tolerate up to 3 transient network errors before giving up
if (consecutiveErrors >= 3) {
clearInterval(cocoPollingRef.current);
cocoPollingRef.current = null;
reject(e);
}
}
}, 3000);
});

// Trigger native browser download — no buffering in JS memory.
// The Content-Disposition: attachment header tells the browser to
// save the file without navigating away from the page.
setCocoJobId(result);
triggerCocoDownload(result);
} catch (err) {
console.error("COCO export error:", err);
setError(`Failed to export: ${err.message}`);
} finally {
if (cocoPollingRef.current) {
clearInterval(cocoPollingRef.current);
cocoPollingRef.current = null;
}
setLoading("cocoFormat", false);
setCocoProgress(null);
}
}, [searchQueryId]);

const triggerCocoDownload = useCallback(async (jobId) => {
// Pre-flight check: verify the job is still available before triggering
// the native download (which can't surface HTTP errors in-app).
try {
const checkResp = await fetch(
`/EncounterSearchExportCOCO?action=status&jobId=${jobId}`,
);
if (!checkResp.ok) {
setCocoJobId(null);
setError("Export is no longer available. Please run a new export.");
return;
}
const checkData = await checkResp.json();
if (checkData.status !== "complete") {
setCocoJobId(null);
setError("Export is no longer available. Please run a new export.");
return;
}
} catch {
// Network error on pre-flight — try the download anyway
}

const a = document.createElement("a");
a.href = `/EncounterSearchExportCOCO?action=download&jobId=${jobId}`;
a.download = "wildbook-coco-export.zip";
document.body.appendChild(a);
a.click();
a.remove();
}, []);

const scrollToSection = (sectionId) => {
setActiveSection(sectionId);
const element = document.getElementById(sectionId);
Expand Down Expand Up @@ -249,17 +380,12 @@ export default function ExportDialog({ open, setOpen, searchQueryId }) {
<Card.Text className="text-muted small">
<FormattedMessage id="COCO_FORMAT_DESCRIPTION" />
</Card.Text>
<div className="d-flex gap-2">
<div className="d-flex gap-2 align-items-center">
<Button
className="my-3"
variant="outline-primary"
size="sm"
onClick={() =>
handleDownload(
`/EncounterSearchExportCOCO?searchQueryId=${searchQueryId}&regularQuery=true`,
"cocoFormat",
)
}
onClick={handleCocoExport}
disabled={loadingStates.cocoFormat}
>
{loadingStates.cocoFormat ? (
Expand All @@ -272,12 +398,34 @@ export default function ExportDialog({ open, setOpen, searchQueryId }) {
aria-hidden="true"
className="me-2"
/>
<FormattedMessage id="EXPORTING" />
{cocoProgress?.phase === "images" && cocoProgress.totalImages > 0
? <>
<FormattedMessage id="COCO_PHASE_IMAGES" />
{` ${cocoProgress.processedImages} / ${cocoProgress.totalImages}`}
</>
: cocoProgress?.phase === "manifest"
? <FormattedMessage id="COCO_PHASE_MANIFEST" />
: cocoProgress?.phase === "packaging"
? <FormattedMessage id="COCO_PHASE_PACKAGING" />
: cocoProgress?.phase === "preparing"
? <FormattedMessage id="COCO_PHASE_PREPARING" />
: <FormattedMessage id="EXPORTING" />
}
</>
) : (
<FormattedMessage id="EXPORT_ZIP_FILE" />
)}
</Button>
{cocoJobId && !loadingStates.cocoFormat && (
<Button
className="my-3"
variant="primary"
size="sm"
onClick={() => triggerCocoDownload(cocoJobId)}
>
<FormattedMessage id="COCO_RETRY_DOWNLOAD" />
</Button>
)}
</div>
</Card.Body>
</Card>
Expand Down
Loading
Loading