(() => { const POLL_INTERVAL_MS = 750; let activeCancelUrl = null; function translate(key, fallback) { return window.__I18N_DATA?.[key] || fallback; } function format(template, values) { return Object.entries(values).reduce( (text, [key, value]) => text.replace(`{${key}}`, String(value)), template ); } function stageLabel(stage) { const labels = { queued: ["search.progress_stage_queued", "En attente du serveur…"], preparing_graph: ["search.progress_stage_preparing", "Préparation du graphe…"], searching_descendants: ["search.progress_stage_descendants", "Recherche des descendants…"], searching_ancestors: ["search.progress_stage_ancestors", "Recherche des ascendants…"], building_result: ["search.progress_stage_result", "Construction du résultat…"], building_nodes: ["search.progress_stage_nodes", "Analyse des nœuds du résultat…"], building_relationships: ["search.progress_stage_relationships", "Chargement des relations…"], formatting_result: ["search.progress_stage_formatting", "Mise en forme du résultat…"], building_highlights: ["search.progress_stage_highlights", "Calcul des modèles importants…"], completed: ["search.progress_stage_completed", "Recherche terminée."], failed: ["search.progress_stage_failed", "La recherche a échoué."], cancelled: ["search.progress_stage_cancelled", "Recherche annulée."], }; const [key, fallback] = labels[stage] || labels.queued; return translate(key, fallback); } function findProgressPanel(form) { return form.parentElement.querySelector(".search-progress"); } function setSubmitting(form, submitting) { form.dataset.submitting = submitting ? "true" : "false"; const submitButton = form.querySelector('button[type="submit"]'); if (submitButton) submitButton.disabled = submitting; } function showError(form, progress, message) { const stage = progress.querySelector(".search-progress-stage"); const status = progress.querySelector(".search-progress-status"); const bar = progress.querySelector(".search-progress-bar"); stage.textContent = translate( "search.progress_stage_failed", "La recherche a échoué." ); status.textContent = message || translate( "search.progress_connection_error", "Impossible d’obtenir la progression depuis le serveur." ); bar.style.width = "0%"; progress.classList.remove("alert-info"); progress.classList.add("alert-danger"); setSubmitting(form, false); } function showCancelled(form, progress) { const stage = progress.querySelector(".search-progress-stage"); const status = progress.querySelector(".search-progress-status"); const note = progress.querySelector(".search-progress-note"); const bar = progress.querySelector(".search-progress-bar"); stage.textContent = stageLabel("cancelled"); status.textContent = ""; note.textContent = ""; bar.style.width = "0%"; progress.classList.remove("alert-info", "alert-danger"); progress.classList.add("alert-secondary"); setSubmitting(form, false); } function cancelActiveSearch() { if (!activeCancelUrl) return; const cancelUrl = activeCancelUrl; activeCancelUrl = null; if ( navigator.sendBeacon && navigator.sendBeacon( cancelUrl, new Blob([], { type: "text/plain" }) ) ) { return; } fetch(cancelUrl, { method: "POST", credentials: "same-origin", keepalive: true, }).catch(() => {}); } function renderStatus(progress, job) { const title = progress.querySelector(".search-progress-title"); const stage = progress.querySelector(".search-progress-stage"); const status = progress.querySelector(".search-progress-status"); const note = progress.querySelector(".search-progress-note"); const bar = progress.querySelector(".search-progress-bar"); stage.textContent = stageLabel(job.stage); if (job.status === "queued" || job.stage === "queued") { title.textContent = translate( "search.progress_queue_title", "Recherche en file d’attente" ); progress.classList.remove("alert-info", "alert-danger", "alert-secondary"); progress.classList.add("alert-warning"); const queueDetails = []; if (job.queue_position && job.queued_jobs) { queueDetails.push( format( translate( "search.progress_queue_position", "Position dans la file : {position}/{total}" ), { position: job.queue_position, total: job.queued_jobs, } ) ); const jobsAhead = job.queue_position - 1; if (jobsAhead === 0) { queueDetails.push( translate( "search.progress_queue_next", "Votre recherche est la prochaine." ) ); } else { queueDetails.push( format( translate( jobsAhead === 1 ? "search.progress_queue_ahead_one" : "search.progress_queue_ahead_many", jobsAhead === 1 ? "1 recherche devant la vôtre" : "{count} recherches devant la vôtre" ), { count: jobsAhead } ) ); } } if (job.running_jobs) { queueDetails.push( format( translate( job.running_jobs === 1 ? "search.progress_queue_running_one" : "search.progress_queue_running_many", job.running_jobs === 1 ? "1 recherche en cours" : "{count} recherches en cours" ), { count: job.running_jobs } ) ); } status.textContent = queueDetails.join(" · "); note.textContent = translate( "search.progress_queue_note", "La recherche démarrera automatiquement dès qu’une capacité sera disponible." ); bar.style.width = "0%"; bar.removeAttribute("aria-valuenow"); return; } title.textContent = translate( "search.progress_title", "Recherche en cours…" ); progress.classList.remove("alert-warning", "alert-danger", "alert-secondary"); progress.classList.add("alert-info"); const details = [ format( translate("search.progress_elapsed", "Temps écoulé : {seconds} s"), { seconds: job.elapsed_seconds } ), ]; if (job.completed_items !== null && job.total_items) { details.push( format( translate( "search.progress_items", "Éléments traités par le serveur : {completed}/{total}" ), { completed: job.completed_items, total: job.total_items, } ) ); } if (job.progress_percent !== null) { details.push( format( translate( "search.progress_server_percent", "Progression indiquée par le serveur : {percent} %" ), { percent: job.progress_percent } ) ); bar.style.width = `${job.progress_percent}%`; bar.setAttribute("aria-valuenow", String(job.progress_percent)); } else { bar.style.width = "0%"; bar.removeAttribute("aria-valuenow"); } if (job.remaining_seconds !== null) { details.push( format( translate( "search.progress_remaining_step", "Temps restant estimé pour cette étape : {seconds} s" ), { seconds: job.remaining_seconds } ) ); } status.textContent = details.join(" · "); note.textContent = translate( "search.progress_server_note", "La progression repose sur les opérations réellement terminées par le serveur." ); } async function readJson(response) { const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(body.error || `${response.status} ${response.statusText}`); } return body; } async function pollJob(form, progress, statusUrl) { try { const job = await readJson(await fetch(statusUrl, { credentials: "same-origin", cache: "no-store", })); renderStatus(progress, job); if (job.status === "cancelled") { activeCancelUrl = null; showCancelled(form, progress); return; } if (job.result_url) { activeCancelUrl = null; window.location.assign(job.result_url); return; } window.setTimeout(() => pollJob(form, progress, statusUrl), POLL_INTERVAL_MS); } catch (error) { cancelActiveSearch(); showError(form, progress, error.message); } } async function startSearch(form, progress) { const formData = new FormData(form); const endpoint = form.dataset.searchJobUrl || "/api/search-jobs"; progress.classList.remove("d-none", "alert-danger"); progress.classList.add("alert-info"); progress.querySelector(".search-progress-title").textContent = translate( "search.progress_title", "Recherche en cours…" ); renderStatus(progress, { stage: "queued", elapsed_seconds: 0, progress_percent: null, remaining_seconds: null, queue_position: null, queued_jobs: null, running_jobs: null, completed_items: null, total_items: null, }); setSubmitting(form, true); try { const job = await readJson(await fetch(endpoint, { method: "POST", body: formData, credentials: "same-origin", headers: { Accept: "application/json" }, })); activeCancelUrl = job.cancel_url; pollJob(form, progress, job.status_url); } catch (error) { showError(form, progress, error.message); } } document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll("form[data-search-progress]").forEach((form) => { form.addEventListener("submit", (event) => { event.preventDefault(); if (form.dataset.submitting === "true" || !form.reportValidity()) return; const progress = findProgressPanel(form); if (progress) startSearch(form, progress); }); }); }); window.addEventListener("pagehide", cancelActiveSearch); })();