Spaces:
Running
Running
| (() => { | |
| 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); | |
| })(); | |