${group.title}
${selectedCount} selected
import { html, reactive } from "/assets/vendor/arrow-core.js"; const REQUEST_TIMEOUT_MS = 15000; const ACTIVE_POLL_INTERVAL_MS = 1000; const IDLE_POLL_INTERVAL_MS = 4000; const state = reactive({ actionBusy: false, catalogLoaded: false, catalogSections: [], error: "", fetched: false, loadingCatalog: true, loadingStatus: true, savingSchedule: false, savingSelection: false, scheduleDraft: "2", scheduleSaved: "2", scheduleOptions: [], search: "", selectedDraft: [], selectedSaved: [], status: { cancelling: false, downloading: false, hasSelection: false, isOnroad: false, lastUpdate: "Never", mapsPresent: false, scheduleLabel: "Monthly", selectedCount: 0, storageBytes: 0, downloadProgress: { active: false, cancelled: false, completed: false, downloadedBytes: 0, downloadedFiles: 0, estimatedDownloadBytes: 0, estimateSource: "", etaSeconds: 0, percent: 0, phase: "idle", primaryLocation: "", totalFiles: 0, }, }, tokenLabels: {}, }); let pollingHandle = null; let statusInFlight = false; function notify(message, variant = "success") { if (typeof showSnackbar === "function") { showSnackbar(message, variant); } else if (variant === "error") { console.error(message); } else { console.log(message); } } function isMapsRouteActive() { return window.location.pathname === "/manage_maps"; } function withTimeout(promise, timeoutMs, label) { return new Promise((resolve, reject) => { const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); promise.then((value) => { clearTimeout(timerId); resolve(value); }).catch((error) => { clearTimeout(timerId); reject(error); }); }); } async function fetchJson(url, options = {}) { const response = await withTimeout(fetch(url, options), REQUEST_TIMEOUT_MS, `${url} request`); const payload = await withTimeout(response.json(), REQUEST_TIMEOUT_MS, `${url} JSON parse`); if (!response.ok) { throw new Error(payload?.error || payload?.message || `Request failed (${response.status})`); } return payload; } function formatBytes(bytes) { const value = Number(bytes || 0); if (value <= 0) return "0 MB"; const units = ["B", "KB", "MB", "GB", "TB"]; const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); const scaled = value / (1024 ** index); return `${scaled >= 10 || index === 0 ? scaled.toFixed(0) : scaled.toFixed(2)} ${units[index]}`; } function formatDuration(seconds) { const value = Math.max(0, Math.round(Number(seconds || 0))); if (value < 60) return `${value}s`; const minutes = Math.floor(value / 60); const remainingSeconds = value % 60; if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; const hours = Math.floor(minutes / 60); return `${hours}h ${minutes % 60}m`; } function normalizeDownloadProgress(progress) { const value = progress && typeof progress === "object" ? progress : {}; return { active: Boolean(value.active), cancelled: Boolean(value.cancelled), completed: Boolean(value.completed), downloadedBytes: Number(value.downloadedBytes || 0), downloadedFiles: Number(value.downloadedFiles || 0), estimatedDownloadBytes: Number(value.estimatedDownloadBytes || 0), estimateSource: String(value.estimateSource || ""), etaSeconds: Number(value.etaSeconds || 0), percent: Math.max(0, Math.min(100, Number(value.percent || 0))), phase: String(value.phase || "idle"), primaryLocation: String(value.primaryLocation || ""), totalFiles: Number(value.totalFiles || 0), }; } function uniqueSorted(values) { return [...new Set(values)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); } function arraysEqual(a, b) { if (a.length !== b.length) return false; return a.every((value, index) => value === b[index]); } function selectionDirty() { return !arraysEqual(state.selectedDraft, state.selectedSaved); } function scheduleDirty() { return state.scheduleDraft !== state.scheduleSaved; } function getTokenLabel(token) { return state.tokenLabels[token] || token; } function setDraftSelection(nextSelection) { state.selectedDraft = uniqueSorted(nextSelection); } function toggleToken(token, enabled) { const next = new Set(state.selectedDraft); if (enabled) { next.add(token); } else { next.delete(token); } setDraftSelection([...next]); } function setGroup(group, enabled) { const next = new Set(state.selectedDraft); for (const region of group.regions || []) { if (enabled) { next.add(region.token); } else { next.delete(region.token); } } setDraftSelection([...next]); } function applyStatus(payload) { const hadSelectionChanges = selectionDirty(); const hadScheduleChanges = scheduleDirty(); const selectedLocations = Array.isArray(payload.selectedLocations) ? uniqueSorted(payload.selectedLocations) : []; state.status = { cancelling: Boolean(payload.cancelling), downloading: Boolean(payload.downloading), hasSelection: Boolean(payload.hasSelection), isOnroad: Boolean(payload.isOnroad), lastUpdate: payload.lastUpdate || "Never", mapsPresent: Boolean(payload.mapsPresent), scheduleLabel: payload.scheduleLabel || "Monthly", selectedCount: Number(payload.selectedCount || 0), storageBytes: Number(payload.storageBytes || 0), downloadProgress: normalizeDownloadProgress(payload.downloadProgress), }; state.selectedSaved = selectedLocations; if (!hadSelectionChanges) { state.selectedDraft = selectedLocations; } const scheduleValue = String(payload.scheduleValue ?? "2"); state.scheduleSaved = scheduleValue; if (!hadScheduleChanges) { state.scheduleDraft = scheduleValue; } } async function fetchCatalog() { const payload = await fetchJson("/api/maps/catalog"); state.catalogSections = Array.isArray(payload.sections) ? payload.sections : []; state.scheduleOptions = Array.isArray(payload.scheduleOptions) ? payload.scheduleOptions : []; const tokenLabels = {}; for (const section of state.catalogSections) { for (const group of section.groups || []) { for (const region of group.regions || []) { tokenLabels[region.token] = region.label; } } } state.tokenLabels = tokenLabels; state.catalogLoaded = true; state.loadingCatalog = false; } async function fetchStatus() { if (statusInFlight) return; statusInFlight = true; try { const payload = await fetchJson("/api/maps/status"); applyStatus(payload); state.error = ""; } catch (error) { state.error = error?.message || String(error); } finally { state.loadingStatus = false; statusInFlight = false; } } async function initializeMaps() { state.loadingCatalog = true; state.loadingStatus = true; try { await fetchCatalog(); await fetchStatus(); } catch (error) { state.error = error?.message || String(error); state.loadingCatalog = false; state.loadingStatus = false; } } function ensurePolling() { if (pollingHandle) return; const poll = async () => { if (!isMapsRouteActive()) { pollingHandle = null; return; } try { await fetchStatus(); } finally { const nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; pollingHandle = setTimeout(poll, nextDelay); } }; pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS); } async function saveSelection() { if (state.savingSelection || !selectionDirty()) return; state.savingSelection = true; try { const payload = await fetchJson("/api/maps/selection", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ selectedLocations: state.selectedDraft }), }); if (payload.status) { applyStatus(payload.status); } notify(payload.message || "Map selection saved."); } catch (error) { notify(error?.message || "Failed to save map selection.", "error"); } finally { state.savingSelection = false; } } async function saveSchedule() { if (state.savingSchedule || !scheduleDirty()) return; state.savingSchedule = true; try { const payload = await fetchJson("/api/maps/schedule", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schedule: state.scheduleDraft }), }); if (payload.status) { applyStatus(payload.status); } notify(payload.message || "Map schedule updated."); } catch (error) { notify(error?.message || "Failed to update map schedule.", "error"); } finally { state.savingSchedule = false; } } async function startDownload() { if (state.actionBusy || state.status.downloading || state.status.isOnroad || state.selectedDraft.length === 0) { return; } state.actionBusy = true; try { const payload = await fetchJson("/api/maps/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ selectedLocations: state.selectedDraft, schedule: state.scheduleDraft, }), }); if (payload.status) { applyStatus(payload.status); } notify(payload.message || "Map download started."); } catch (error) { notify(error?.message || "Failed to start map download.", "error"); } finally { state.actionBusy = false; } } async function cancelDownload() { if (state.actionBusy || !state.status.downloading) return; state.actionBusy = true; try { const payload = await fetchJson("/api/maps/cancel", { method: "POST" }); if (payload.status) { applyStatus(payload.status); } notify(payload.message || "Map download cancellation requested."); } catch (error) { notify(error?.message || "Failed to cancel map download.", "error"); } finally { state.actionBusy = false; } } async function removeMaps() { if (state.actionBusy || state.status.downloading || state.status.isOnroad || !state.status.mapsPresent) { return; } state.actionBusy = true; try { const payload = await fetchJson("/api/maps/remove", { method: "POST" }); if (payload.status) { applyStatus(payload.status); } notify(payload.message || "Maps removed."); } catch (error) { notify(error?.message || "Failed to remove maps.", "error"); } finally { state.actionBusy = false; } } function resetDraftSelection() { state.selectedDraft = [...state.selectedSaved]; } function clearDraftSelection() { state.selectedDraft = []; } function getVisibleRegions(group) { const query = state.search.trim().toLowerCase(); if (!query) return group.regions || []; return (group.regions || []).filter((region) => { const haystack = `${region.label} ${region.code} ${region.token}`.toLowerCase(); return haystack.includes(query); }); } function getVisibleSections() { return state.catalogSections.map((section) => { const groups = (section.groups || []) .map((group) => ({ ...group, visibleRegions: getVisibleRegions(group) })) .filter((group) => group.visibleRegions.length > 0); return { ...section, groups }; }).filter((section) => section.groups.length > 0); } function selectedCountForGroup(group) { const groupTokens = new Set((group.regions || []).map((region) => region.token)); return state.selectedDraft.filter((token) => groupTokens.has(token)).length; } function selectedCountForSection(section) { const sectionTokens = new Set(section.groups.flatMap((group) => (group.regions || []).map((region) => region.token))); return state.selectedDraft.filter((token) => sectionTokens.has(token)).length; } function renderSelectedSummary() { if (state.selectedDraft.length === 0) { return html`
No regions selected.
`; } return html`Current region: ${progress.primaryLocation}
` : ""}${sourceLabel}
${selectedCount} selected
Select regions, start downloads, and manage offline maps entirely from Galaxy.
${state.error}
` : ""} ${() => state.status.isOnroad ? html`Map downloads and removal are blocked while driving.
` : ""} ${() => selectionDirty() ? html`You have unsaved region changes. Downloading now will use the current Galaxy selection.
` : ""} ${() => scheduleDirty() ? html`You have an unsaved schedule change. Downloading now will also apply it.
` : ""} ${() => renderDownloadProgress()}Loading map catalog...
No regions match the current search.
${selectedCountForSection(section)} selected