diff --git a/services/desktop_app/README.md b/services/desktop_app/README.md index d213e54..f2eeda6 100644 --- a/services/desktop_app/README.md +++ b/services/desktop_app/README.md @@ -116,7 +116,7 @@ El instalador queda en `release/` y se publica en: `s3.builker.xyz/apps-versions/IPSExtractor/{version}/` -La fecha de release en `latest.json` / `DATA.txt` es **el día en que se publica**, salvo que pases `RELEASE_DATE=DD/MM/YYYY`. El script también actualiza `src/shared/version.json`. +La fecha de release (UI, `latest.json`, `DATA.txt`) es **el día del build/publish**, salvo que pases `RELEASE_DATE=DD/MM/YYYY`. Se escribe en `version.json` antes de `vite build` (la UI la inyecta en compile-time). Si solo quieres el `.exe` local **sin** subir a MinIO: @@ -129,9 +129,9 @@ npm run dist:local | Campo | Archivo | |-------|---------| | Número (`1.0.0`) | `package.json` → `version` | -| Fecha de release | Se escribe al publicar (`version.json`); override con `RELEASE_DATE` | +| Fecha de release | MinIO (`latest.json` / `DATA.txt` de la versión); fallback local de `version.json` | -La versión se muestra en el menú lateral y en la esquina superior derecha. +La versión se muestra en el menú lateral y en la esquina superior derecha. Al arrancar, la app lee la fecha desde MinIO para la versión instalada (si coincide con `latest.json`, o desde `{version}/DATA.txt`). ### Auto-update (MinIO) @@ -146,6 +146,7 @@ Layout en el bucket `apps-versions`: ```text IPSExtractor/ latest.json + versions.json ← catálogo de todas las versiones publicadas 1.0.1/IPSExtractor-Desktop-1.0.1-Setup.exe 1.0.1/DATA.txt ← fecha DD/MM/YYYY, ej. 06/07/2026 ``` diff --git a/services/desktop_app/electron/autoUpdate.cjs b/services/desktop_app/electron/autoUpdate.cjs index bfc79f6..c123249 100644 --- a/services/desktop_app/electron/autoUpdate.cjs +++ b/services/desktop_app/electron/autoUpdate.cjs @@ -215,10 +215,113 @@ async function downloadAndInstall(manifest, onProgress) { return { ok: true, destPath }; } +/** + * Fecha de release de la versión instalada, leída desde MinIO + * (latest.json si coincide la versión, si no DATA.txt de esa carpeta). + * @returns {Promise<{ releaseDate?: string, releaseDateDisplay?: string } | null>} + */ +async function getCurrentReleaseMeta() { + const base = LATEST_JSON_URL.replace(/\/latest\.json$/i, ""); + + try { + const { buffer } = await download(LATEST_JSON_URL); + if (buffer) { + /** @type {UpdateManifest} */ + const manifest = JSON.parse(buffer.toString("utf8")); + if (manifest?.version && compareSemver(CURRENT_VERSION, manifest.version) === 0) { + if (!manifest.releaseDateDisplay) { + try { + const dataUrl = `${base}/${CURRENT_VERSION}/DATA.txt`; + const { buffer: dataBuf } = await download(dataUrl); + const display = dataBuf?.toString("utf8").trim().split(/\r?\n/)[0]?.trim(); + if (display) { + manifest.releaseDateDisplay = display; + const iso = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (iso && !manifest.releaseDate) { + manifest.releaseDate = `${iso[3]}-${iso[2].padStart(2, "0")}-${iso[1].padStart(2, "0")}`; + } + } + } catch { + /* opcional */ + } + } + if (manifest.releaseDate || manifest.releaseDateDisplay) { + return { + releaseDate: manifest.releaseDate, + releaseDateDisplay: manifest.releaseDateDisplay, + }; + } + } + } + } catch { + /* probar DATA.txt de la versión instalada */ + } + + try { + const dataUrl = `${base}/${CURRENT_VERSION}/DATA.txt`; + const { buffer: dataBuf } = await download(dataUrl); + const display = dataBuf?.toString("utf8").trim().split(/\r?\n/)[0]?.trim(); + if (!display) return null; + const isoMatch = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + return { + releaseDateDisplay: display, + releaseDate: isoMatch + ? `${isoMatch[3]}-${isoMatch[2].padStart(2, "0")}-${isoMatch[1].padStart(2, "0")}` + : undefined, + }; + } catch { + return null; + } +} + +/** + * Lista todas las versiones publicadas (versions.json). + * Si no existe, cae a [latest.json]. + * @returns {Promise<{ currentVersion: string, versions: UpdateManifest[], error?: string }>} + */ +async function listVersions() { + const versionsUrl = LATEST_JSON_URL.replace(/\/latest\.json$/i, "/versions.json"); + try { + const { buffer } = await download(versionsUrl); + if (buffer) { + const parsed = JSON.parse(buffer.toString("utf8")); + const versions = Array.isArray(parsed?.versions) + ? parsed.versions.filter((v) => v?.version && v?.url && v?.fileName) + : []; + if (versions.length > 0) { + versions.sort((a, b) => compareSemver(b.version, a.version)); + return { currentVersion: CURRENT_VERSION, versions }; + } + } + } catch { + /* fallback latest */ + } + + try { + const { buffer } = await download(LATEST_JSON_URL); + if (!buffer) { + return { currentVersion: CURRENT_VERSION, versions: [], error: "Respuesta vacía" }; + } + const manifest = JSON.parse(buffer.toString("utf8")); + if (!manifest?.version || !manifest?.url || !manifest?.fileName) { + return { currentVersion: CURRENT_VERSION, versions: [], error: "Manifiesto inválido" }; + } + return { currentVersion: CURRENT_VERSION, versions: [manifest] }; + } catch (err) { + return { + currentVersion: CURRENT_VERSION, + versions: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + module.exports = { CURRENT_VERSION, LATEST_JSON_URL, compareSemver, checkForUpdates, downloadAndInstall, + getCurrentReleaseMeta, + listVersions, }; diff --git a/services/desktop_app/electron/main.cjs b/services/desktop_app/electron/main.cjs index 0e870d6..1be97c2 100644 --- a/services/desktop_app/electron/main.cjs +++ b/services/desktop_app/electron/main.cjs @@ -47,19 +47,20 @@ function sendUpdateProgress(payload) { } ipcMain.handle("updater:check", async () => { - if (!app.isPackaged) { - return { - updateAvailable: false, - currentVersion: appVersion, - error: "Auto-update desactivado en desarrollo", - }; - } return autoUpdate.checkForUpdates(); }); +ipcMain.handle("updater:release-meta", async () => { + return autoUpdate.getCurrentReleaseMeta(); +}); + +ipcMain.handle("updater:list-versions", async () => { + return autoUpdate.listVersions(); +}); + ipcMain.handle("updater:install", async (_event, manifest) => { if (!app.isPackaged) { - throw new Error("Auto-update solo disponible en la app instalada"); + throw new Error("Instalar actualizaciones solo está disponible en la app instalada"); } return autoUpdate.downloadAndInstall(manifest, sendUpdateProgress); }); diff --git a/services/desktop_app/electron/preload.cjs b/services/desktop_app/electron/preload.cjs index 8f36881..2a32da8 100644 --- a/services/desktop_app/electron/preload.cjs +++ b/services/desktop_app/electron/preload.cjs @@ -10,6 +10,8 @@ contextBridge.exposeInMainWorld("desktopApi", { ipcRenderer.invoke("fs:index-folder", rootPath, structureMode, docTypes, keywords), readFile: (filePath) => ipcRenderer.invoke("fs:read-file", filePath), checkForUpdates: () => ipcRenderer.invoke("updater:check"), + getReleaseMeta: () => ipcRenderer.invoke("updater:release-meta"), + listVersions: () => ipcRenderer.invoke("updater:list-versions"), installUpdate: (manifest) => ipcRenderer.invoke("updater:install", manifest), onUpdateProgress: (callback) => { const handler = (_event, payload) => callback(payload); diff --git a/services/desktop_app/package.json b/services/desktop_app/package.json index ce8675e..4d16001 100644 --- a/services/desktop_app/package.json +++ b/services/desktop_app/package.json @@ -1,12 +1,13 @@ { "name": "ipsextractor-desktop", - "version": "1.0.2", + "version": "1.0.3", "private": true, "description": "Desktop extraction client for IPSExtractor microservices.", "main": "electron/main.cjs", "scripts": { "dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && electron .\"", - "build": "tsc --noEmit && vite build", + "stamp:date": "node scripts/stamp-release-date.mjs", + "build": "npm run stamp:date && tsc --noEmit && vite build", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", diff --git a/services/desktop_app/scripts/publish-version.mjs b/services/desktop_app/scripts/publish-version.mjs index 2490a33..02215d6 100644 --- a/services/desktop_app/scripts/publish-version.mjs +++ b/services/desktop_app/scripts/publish-version.mjs @@ -1,5 +1,5 @@ /** - * Publica el instalador NSIS en MinIO y actualiza latest.json + DATA.txt. + * Publica el instalador NSIS en MinIO y actualiza latest.json, versions.json y DATA.txt. * * Uso: * set MINIO_ACCESS_KEY=... @@ -16,7 +16,7 @@ import { createHash } from "node:crypto"; import { createReadStream, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, ".."); @@ -113,6 +113,46 @@ async function upload(client, key, body, contentType) { console.log(` ↑ s3://${bucket}/${key}`); } +function compareSemver(a, b) { + const pa = String(a).replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0); + const pb = String(b).replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (x < y) return -1; + if (x > y) return 1; + } + return 0; +} + +async function streamToString(body) { + if (!body) return ""; + if (typeof body.transformToString === "function") return body.transformToString(); + const chunks = []; + for await (const chunk of body) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +/** Lee versions.json existente del bucket (o [] si no existe). */ +async function loadExistingVersions(client) { + try { + const res = await client.send( + new GetObjectCommand({ Bucket: bucket, Key: `${prefix}/versions.json` }), + ); + const text = await streamToString(res.Body); + const parsed = JSON.parse(text); + return Array.isArray(parsed?.versions) ? parsed.versions : []; + } catch (err) { + const code = err?.name || err?.Code || err?.$metadata?.httpStatusCode; + if (code === "NoSuchKey" || code === 404 || err?.$metadata?.httpStatusCode === 404) { + return []; + } + console.warn("No se pudo leer versions.json previo:", err?.message || err); + return []; + } +} + async function main() { const releaseDateDisplay = resolveReleaseDateDisplay(); const releaseDateIso = isoFromDisplay(releaseDateDisplay) || new Date().toISOString().slice(0, 10); @@ -123,9 +163,10 @@ async function main() { const objectKeyExe = `${prefix}/${version}/${setupName}`; const objectKeyData = `${prefix}/${version}/DATA.txt`; const objectKeyLatest = `${prefix}/latest.json`; + const objectKeyVersions = `${prefix}/versions.json`; const publicUrl = `${publicBase}/${version}/${setupName}`; - const latest = { + const entry = { version, releaseDate: releaseDateIso, releaseDateDisplay, @@ -152,20 +193,32 @@ async function main() { }, }); + const existing = await loadExistingVersions(client); + const byVersion = new Map(existing.map((v) => [String(v.version), v])); + byVersion.set(version, entry); + const versions = [...byVersion.values()].sort((a, b) => compareSemver(b.version, a.version)); + console.log(`Publicando v${version} → ${endpoint}/${bucket}/${prefix}/`); await upload(client, objectKeyExe, createReadStream(setupPath), "application/octet-stream"); await upload(client, objectKeyData, `${releaseDateDisplay}\n`, "text/plain; charset=utf-8"); await upload( client, objectKeyLatest, - Buffer.from(`${JSON.stringify(latest, null, 2)}\n`, "utf8"), + Buffer.from(`${JSON.stringify(entry, null, 2)}\n`, "utf8"), + "application/json", + ); + await upload( + client, + objectKeyVersions, + Buffer.from(`${JSON.stringify({ versions }, null, 2)}\n`, "utf8"), "application/json", ); console.log("\nListo."); - console.log(` latest.json: ${publicBase}/latest.json`); - console.log(` DATA.txt: ${publicBase}/${version}/DATA.txt → ${releaseDateDisplay}`); - console.log(` Setup: ${publicUrl}`); + console.log(` latest.json: ${publicBase}/latest.json`); + console.log(` versions.json: ${publicBase}/versions.json (${versions.length} versiones)`); + console.log(` DATA.txt: ${publicBase}/${version}/DATA.txt → ${releaseDateDisplay}`); + console.log(` Setup: ${publicUrl}`); } main().catch((err) => { diff --git a/services/desktop_app/scripts/stamp-release-date.mjs b/services/desktop_app/scripts/stamp-release-date.mjs new file mode 100644 index 0000000..2e85afd --- /dev/null +++ b/services/desktop_app/scripts/stamp-release-date.mjs @@ -0,0 +1,60 @@ +/** + * Escribe src/shared/version.json con la fecha de release. + * Default: hoy. Override: RELEASE_DATE=DD/MM/YYYY (env o .env.minio). + * + * Debe ejecutarse ANTES de vite build: la UI inyecta esta fecha en compile-time. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +function loadEnvFile(filePath) { + if (!existsSync(filePath)) return; + const text = readFileSync(filePath, "utf8"); + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (!(key in process.env) || process.env[key] === "") { + process.env[key] = value; + } + } +} + +loadEnvFile(join(root, ".env.minio")); +loadEnvFile(join(root, ".env")); + +function todayIso() { + const d = new Date(); + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const yyyy = d.getFullYear(); + return `${yyyy}-${mm}-${dd}`; +} + +function isoFromDisplay(display) { + const m = String(display).trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (!m) return null; + const [, dd, mm, yyyy] = m; + return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`; +} + +const releaseDateIso = process.env.RELEASE_DATE + ? isoFromDisplay(process.env.RELEASE_DATE) || todayIso() + : todayIso(); + +const outPath = join(root, "src/shared/version.json"); +writeFileSync(outPath, `${JSON.stringify({ releaseDate: releaseDateIso }, null, 2)}\n`, "utf8"); +console.log(`releaseDate → ${releaseDateIso} (${outPath})`); diff --git a/services/desktop_app/src/renderer/App.tsx b/services/desktop_app/src/renderer/App.tsx index 5bf3fd4..0e3315f 100644 --- a/services/desktop_app/src/renderer/App.tsx +++ b/services/desktop_app/src/renderer/App.tsx @@ -2,17 +2,18 @@ import { BarChart2, Briefcase, Cpu, + Download, Hash, Link2, Settings, Sparkles, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { Job } from "../shared/types"; import { APP_RELEASE_DATE, APP_VERSION, - formatReleaseDate, + formatReleaseDateFlexible, formatVersionLabel, } from "../shared/appVersion"; import { ApiClient } from "./lib/apiClient"; @@ -22,7 +23,7 @@ import { JobView } from "./views/JobView"; import { SettingsView, type SettingsSection } from "./views/SettingsView"; import type { JobSection } from "./types/jobSection"; import appIcon from "./assets/app-icon.png"; -import { UpdatePrompt, useAutoUpdateCheck } from "./components/UpdatePrompt"; +import { UpdatePrompt, CheckUpdatesButton, useAutoUpdateCheck } from "./components/UpdatePrompt"; // Registrar módulos al cargar la app import "./modules/counting/CountingModule"; @@ -47,8 +48,23 @@ function App() { const [configJson, setConfigJson] = useState(null); const [modelsJson, setModelsJson] = useState(null); const [busy, setBusy] = useState(false); + const [releaseDate, setReleaseDate] = useState(APP_RELEASE_DATE); const update = useAutoUpdateCheck(); + useEffect(() => { + const api = window.desktopApi; + if (!api?.getReleaseMeta) return; + let cancelled = false; + void api.getReleaseMeta().then((meta) => { + if (cancelled || !meta) return; + const next = meta.releaseDate || meta.releaseDateDisplay; + if (next) setReleaseDate(next); + }); + return () => { + cancelled = true; + }; + }, []); + const client = useMemo(() => new ApiClient(apiBaseUrl), [apiBaseUrl]); useEffect(() => { @@ -63,7 +79,7 @@ function App() { localStorage.setItem("apiBaseUrl", apiBaseUrl); }, [apiBaseUrl]); - async function verifyConnection() { + const verifyConnection = useCallback(async () => { setConnectionStatus("busy"); setConnectionMessage("Comprobando gateway y servicios…"); setBusy(true); @@ -85,7 +101,11 @@ function App() { } finally { setBusy(false); } - } + }, [client]); + + useEffect(() => { + void verifyConnection(); + }, [verifyConnection]); function openJob(job: Job) { setActiveJob(job); @@ -160,6 +180,14 @@ function App() { Backend + ) : isJobOpen ? ( @@ -245,19 +273,34 @@ function App() { : "Sin verificar"} -
+
v{APP_VERSION} - {formatReleaseDate(APP_RELEASE_DATE)} + {formatReleaseDateFlexible(releaseDate)}
- {formatVersionLabel()} + {formatVersionLabel(releaseDate)} + void update.checkForUpdates()} + />
- {view === "home" && } + {view === "home" && ( + { + if (activeJob?.id === jobId) { + setActiveJob(null); + setView("home"); + } + }} + /> + )} {view === "job" && activeJob && ( void update.checkForUpdates()} + onInstallVersion={(manifest) => + update.promptInstall(manifest, update.currentVersion || APP_VERSION) + } /> )}
@@ -289,13 +340,14 @@ function App() { {update.manifest && ( void update.confirmInstall()} installing={update.installing} progress={update.progress} error={update.error} + forceInstall={update.forceInstall} /> )} diff --git a/services/desktop_app/src/renderer/components/UpdatePrompt.tsx b/services/desktop_app/src/renderer/components/UpdatePrompt.tsx index 77e4da7..6d39ed1 100644 --- a/services/desktop_app/src/renderer/components/UpdatePrompt.tsx +++ b/services/desktop_app/src/renderer/components/UpdatePrompt.tsx @@ -1,5 +1,5 @@ -import { Download, Loader2, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Download, Loader2, RefreshCw, X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import type { UpdateManifest, UpdateProgress } from "../../shared/types"; type Props = { @@ -11,6 +11,8 @@ type Props = { installing: boolean; progress: UpdateProgress | null; error: string | null; + /** Si true, el título habla de instalar esa versión (no solo "nueva"). */ + forceInstall?: boolean; }; function formatBytes(n: number | undefined): string { @@ -20,6 +22,19 @@ function formatBytes(n: number | undefined): string { return `${(n / (1024 * 1024)).toFixed(1)} MB`; } +function compareSemver(a: string, b: string): number { + const pa = a.replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0); + const pb = b.replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (x < y) return -1; + if (x > y) return 1; + } + return 0; +} + export function UpdatePrompt({ open, currentVersion, @@ -29,11 +44,37 @@ export function UpdatePrompt({ installing, progress, error, + forceInstall = false, }: Props) { if (!open) return null; const dateLabel = manifest.releaseDateDisplay || manifest.releaseDate || ""; const percent = progress?.percent ?? 0; + const cmp = compareSemver(currentVersion, manifest.version); + const isSame = cmp === 0; + const isOlder = cmp > 0; + const title = forceInstall || isSame || isOlder ? "Instalar versión" : "Nueva versión disponible"; + const body = isSame ? ( + <> + Vas a reinstalar la v{manifest.version} + {dateLabel ? <> (lanzamiento {dateLabel}) : null}. + + ) : isOlder ? ( + <> + Vas a instalar una versión anterior: v{manifest.version} + {dateLabel ? <> (lanzamiento {dateLabel}) : null}. +
+ Tienes instalada la v{currentVersion}. + + ) : ( + <> + Hay una versión nueva: v{manifest.version} + {dateLabel ? <> (lanzamiento {dateLabel}) : null}. +
+ Tienes instalada la v{currentVersion}. + + ); + const phaseLabel = progress?.phase === "verifying" ? "Verificando archivo…" @@ -47,7 +88,7 @@ export function UpdatePrompt({
-

Nueva versión disponible

+

{title}

{!installing && (
-

- Hay una versión nueva: v{manifest.version} - {dateLabel ? <> (lanzamiento {dateLabel}) : null}. -
- Tienes instalada la v{currentVersion}. -

+

{body}

{manifest.size != null && manifest.size > 0 && (

Tamaño aproximado: {formatBytes(manifest.size)}

@@ -103,7 +139,9 @@ export function UpdatePrompt({ ); } -/** Comprueba actualizaciones al montar (solo si existe desktopApi). */ +export type UpdateCheckStatus = "idle" | "checking" | "up-to-date" | "available" | "error"; + +/** Comprueba actualizaciones al montar y expone check/install manual. */ export function useAutoUpdateCheck() { const [open, setOpen] = useState(false); const [currentVersion, setCurrentVersion] = useState(""); @@ -111,6 +149,58 @@ export function useAutoUpdateCheck() { const [installing, setInstalling] = useState(false); const [progress, setProgress] = useState(null); const [error, setError] = useState(null); + const [forceInstall, setForceInstall] = useState(false); + const [checkStatus, setCheckStatus] = useState("idle"); + const [checkMessage, setCheckMessage] = useState(""); + + const openInstall = useCallback((next: UpdateManifest, current: string, force = false) => { + setCurrentVersion(current); + setManifest(next); + setForceInstall(force); + setError(null); + setProgress(null); + setOpen(true); + }, []); + + const checkForUpdates = useCallback(async (opts?: { silent?: boolean }) => { + const api = window.desktopApi; + if (!api?.checkForUpdates) { + setCheckStatus("error"); + setCheckMessage("Actualizaciones no disponibles en este entorno."); + return null; + } + setCheckStatus("checking"); + setCheckMessage("Buscando actualizaciones…"); + try { + const result = await api.checkForUpdates(); + setCurrentVersion(result.currentVersion); + if (result.error && !result.manifest) { + setCheckStatus("error"); + setCheckMessage(result.error); + return result; + } + if (result.updateAvailable && result.manifest) { + setCheckStatus("available"); + setCheckMessage(`Hay una versión nueva: v${result.manifest.version}`); + if (!opts?.silent) { + openInstall(result.manifest, result.currentVersion, false); + } else { + setManifest(result.manifest); + setOpen(true); + setForceInstall(false); + } + } else { + setCheckStatus("up-to-date"); + setCheckMessage(`Ya tienes la última versión (v${result.currentVersion}).`); + } + return result; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setCheckStatus("error"); + setCheckMessage(msg); + return null; + } + }, [openInstall]); useEffect(() => { const api = window.desktopApi; @@ -124,6 +214,9 @@ export function useAutoUpdateCheck() { if (cancelled || !result.updateAvailable || !result.manifest) return; setCurrentVersion(result.currentVersion); setManifest(result.manifest); + setForceInstall(false); + setCheckStatus("available"); + setCheckMessage(`Hay una versión nueva: v${result.manifest.version}`); setOpen(true); } catch { /* silencioso */ @@ -150,13 +243,16 @@ export function useAutoUpdateCheck() { setProgress({ phase: "downloading", percent: 0 }); try { await window.desktopApi.installUpdate(manifest); - // La app debería cerrarse desde main; si no, mostramos mensaje. } catch (e) { setError(e instanceof Error ? e.message : String(e)); setInstalling(false); } } + function promptInstall(next: UpdateManifest, current: string) { + openInstall(next, current, true); + } + return { open, currentVersion, @@ -164,9 +260,31 @@ export function useAutoUpdateCheck() { installing, progress, error, + forceInstall, + checkStatus, + checkMessage, close: () => { if (!installing) setOpen(false); }, confirmInstall, + checkForUpdates, + promptInstall, }; } + +export function CheckUpdatesButton({ + onClick, + busy, + className = "btn btn-outline btn-sm", +}: { + onClick: () => void; + busy?: boolean; + className?: string; +}) { + return ( + + ); +} diff --git a/services/desktop_app/src/renderer/lib/apiClient.ts b/services/desktop_app/src/renderer/lib/apiClient.ts index b5184e1..5938bb0 100644 --- a/services/desktop_app/src/renderer/lib/apiClient.ts +++ b/services/desktop_app/src/renderer/lib/apiClient.ts @@ -185,6 +185,15 @@ export class ApiClient { return this.handle(response); } + async deleteJob(jobId: string): Promise { + const response = await fetchWithTimeout( + this.url(`/jobs/${encodeURIComponent(jobId)}`), + { method: "DELETE" }, + ); + if (response.status === 204 || response.ok) return; + await this.handle(response); + } + // ── Count Jobs ───────────────────────────────────────────────────────────── /** Envía PDFs al servicio de conteo (síncrono). */ diff --git a/services/desktop_app/src/renderer/styles.css b/services/desktop_app/src/renderer/styles.css index c727d36..56c561d 100644 --- a/services/desktop_app/src/renderer/styles.css +++ b/services/desktop_app/src/renderer/styles.css @@ -179,6 +179,7 @@ button:disabled { border-bottom: 1px solid var(--border); display: flex; flex-shrink: 0; + gap: 12px; height: 36px; justify-content: flex-end; padding: 0 20px; @@ -2356,6 +2357,17 @@ th { color: #354154; } +.btn-danger { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; +} + +.btn-danger:hover:not(:disabled) { + background: #b91c1c; + border-color: #b91c1c; +} + .btn-ghost { background: transparent; border: 1px dashed #c5cdd8; @@ -3403,6 +3415,12 @@ th { padding: 0; } +.job-list-item { + align-items: stretch; + display: flex; + gap: 8px; +} + .job-card { align-items: center; background: var(--surface); @@ -3410,11 +3428,13 @@ th { border-radius: 10px; cursor: pointer; display: flex; + flex: 1; gap: 14px; + min-width: 0; padding: 14px 16px; text-align: left; transition: box-shadow 0.15s, border-color 0.15s; - width: 100%; + width: auto; } .job-card:hover { @@ -3422,6 +3442,23 @@ th { box-shadow: 0 2px 8px rgba(0,0,0,0.08); } +.job-card-delete { + align-self: center; + border-style: solid; + color: #94a3b8; + flex-shrink: 0; + height: 36px; + justify-content: center; + padding: 0; + width: 36px; +} + +.job-card-delete:hover:not(:disabled) { + background: #fef2f2; + border-color: #fecaca; + color: #dc2626; +} + .job-card-icon { align-items: center; background: var(--primary-ghost, rgba(59,130,246,0.08)); @@ -4706,3 +4743,49 @@ th { height: 100%; transition: width 0.2s ease; } + +.updates-version-list { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; +} + +.updates-version-row { + align-items: center; + border: 1px solid var(--border); + border-radius: 8px; + display: flex; + gap: 12px; + justify-content: space-between; + padding: 10px 12px; +} + +.updates-version-row.is-current { + background: #f8fafc; + border-color: #cbd5e1; +} + +.updates-version-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.updates-version-meta strong { + align-items: center; + display: inline-flex; + gap: 8px; +} + +.updates-version-badge { + background: #e2e8f0; + border-radius: 4px; + color: #334155; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; +} diff --git a/services/desktop_app/src/renderer/test/setup.ts b/services/desktop_app/src/renderer/test/setup.ts index 87f8456..a7a1ff4 100644 --- a/services/desktop_app/src/renderer/test/setup.ts +++ b/services/desktop_app/src/renderer/test/setup.ts @@ -17,6 +17,8 @@ beforeEach(() => { updateAvailable: false, currentVersion: "1.0.0", }), + getReleaseMeta: vi.fn().mockResolvedValue(null), + listVersions: vi.fn().mockResolvedValue({ currentVersion: "1.0.0", versions: [] }), installUpdate: vi.fn().mockResolvedValue({ ok: true }), onUpdateProgress: vi.fn().mockReturnValue(() => undefined), }; diff --git a/services/desktop_app/src/renderer/views/HomeView.tsx b/services/desktop_app/src/renderer/views/HomeView.tsx index f9c7266..bfe9a25 100644 --- a/services/desktop_app/src/renderer/views/HomeView.tsx +++ b/services/desktop_app/src/renderer/views/HomeView.tsx @@ -1,5 +1,15 @@ -import { Briefcase, CalendarDays, ChevronRight, FolderOpen, Loader2, Plus, RefreshCw, X } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { + Briefcase, + CalendarDays, + ChevronRight, + FolderOpen, + Loader2, + Plus, + RefreshCw, + Trash2, + X, +} from "lucide-react"; +import { useCallback, useEffect, useState, type MouseEvent } from "react"; import { createPortal } from "react-dom"; import type { ApiClient } from "../lib/apiClient"; import { DEFAULT_API_BASE_URL } from "../lib/apiDefaults"; @@ -10,6 +20,7 @@ declare const desktopApi: import("../../shared/types").DesktopBridge; interface Props { client: ApiClient; onOpenJob: (job: Job) => void; + onJobDeleted?: (jobId: string) => void; } function formatRelative(iso: string): string { @@ -32,7 +43,7 @@ function statusLabel(s: string) { } } -export function HomeView({ client, onOpenJob }: Props) { +export function HomeView({ client, onOpenJob, onJobDeleted }: Props) { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -41,6 +52,9 @@ export function HomeView({ client, onOpenJob }: Props) { const [formFolder, setFormFolder] = useState(""); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); + const [jobToDelete, setJobToDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const loadJobs = useCallback(async () => { setLoading(true); @@ -64,18 +78,17 @@ export function HomeView({ client, onOpenJob }: Props) { useEffect(() => { loadJobs(); }, [loadJobs]); useEffect(() => { - if (!showForm) return; + if (!showForm && !jobToDelete) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; - }, [showForm]); + }, [showForm, jobToDelete]); async function selectFolder() { const dir = await desktopApi.selectDirectory(); if (dir) { setFormFolder(dir); if (!formTitle) { - // Usar último segmento del path como título sugerido const parts = dir.replace(/\\/g, "/").split("/").filter(Boolean); setFormTitle(parts[parts.length - 1] ?? ""); } @@ -102,6 +115,29 @@ export function HomeView({ client, onOpenJob }: Props) { } } + function openDeleteConfirm(job: Job, e: MouseEvent) { + e.stopPropagation(); + setDeleteError(null); + setJobToDelete(job); + } + + async function handleDelete() { + if (!jobToDelete) return; + setDeleting(true); + setDeleteError(null); + try { + await client.deleteJob(jobToDelete.id); + const deletedId = jobToDelete.id; + setJobs((prev) => prev.filter((j) => j.id !== deletedId)); + setJobToDelete(null); + onJobDeleted?.(deletedId); + } catch (e) { + setDeleteError(e instanceof Error ? e.message : String(e)); + } finally { + setDeleting(false); + } + } + return (
@@ -114,7 +150,6 @@ export function HomeView({ client, onOpenJob }: Props) {
- {/* ── Modal crear trabajo (portal: evita que fixed quede anclado a .home-view) ── */} {showForm && createPortal(
setShowForm(false)}>
e.stopPropagation()}> @@ -189,7 +224,60 @@ export function HomeView({ client, onOpenJob }: Props) { document.body, )} - {/* ── Lista de trabajos ── */} + {jobToDelete && createPortal( +
{ if (!deleting) setJobToDelete(null); }} + > +
e.stopPropagation()} role="dialog" aria-labelledby="delete-job-title"> +
+

Eliminar trabajo

+ +
+
+

+ ¿Eliminar {jobToDelete.title ?? "Sin título"}? +

+

+ Se borrarán de forma permanente los conteos, extracciones, limpiezas y resultados + asociados. Los archivos en disco no se eliminan. +

+ {deleteError && ( +
{deleteError}
+ )} +
+
+ + +
+
+
, + document.body, + )} +
{loading ? (
@@ -216,7 +304,7 @@ export function HomeView({ client, onOpenJob }: Props) { ) : (
    {jobs.map((job) => ( -
  • +
  • +
  • ))}
diff --git a/services/desktop_app/src/renderer/views/SettingsView.tsx b/services/desktop_app/src/renderer/views/SettingsView.tsx index 251c3c4..28979de 100644 --- a/services/desktop_app/src/renderer/views/SettingsView.tsx +++ b/services/desktop_app/src/renderer/views/SettingsView.tsx @@ -1,14 +1,18 @@ import { CheckCircle2, Database, + Download, Loader2, Plus, + RefreshCw, RotateCcw, Server, Trash2, XCircle, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import type { UpdateManifest } from "../../shared/types"; +import { APP_VERSION, formatReleaseDateFlexible } from "../../shared/appVersion"; import { DEFAULT_API_BASE_URL } from "../lib/apiDefaults"; import { type CopModConfig, @@ -16,8 +20,9 @@ import { loadCopModConfig, saveCopModConfig, } from "../lib/cleaningSettings"; +import type { UpdateCheckStatus } from "../components/UpdatePrompt"; -export type SettingsSection = "connection" | "cleaning" | "backend"; +export type SettingsSection = "connection" | "cleaning" | "backend" | "updates"; type Status = "idle" | "ok" | "error" | "busy"; type BackendTab = "config" | "models"; @@ -32,6 +37,12 @@ type SettingsViewProps = { busy: boolean; configJson: unknown; modelsJson: unknown; + releaseDate: string; + updateCheckStatus: UpdateCheckStatus; + updateCheckMessage: string; + updateInstalling: boolean; + onCheckUpdates: () => void; + onInstallVersion: (manifest: UpdateManifest) => void; }; const SERVICE_LABELS: Record = { @@ -71,6 +82,12 @@ export function SettingsView({ busy, configJson, modelsJson, + releaseDate, + updateCheckStatus, + updateCheckMessage, + updateInstalling, + onCheckUpdates, + onInstallVersion, }: SettingsViewProps) { if (section === "connection") { return ( @@ -88,6 +105,18 @@ export function SettingsView({ if (section === "cleaning") { return ; } + if (section === "updates") { + return ( + + ); + } return ( void; + onInstallVersion: (manifest: UpdateManifest) => void; +}) { + const [versions, setVersions] = useState([]); + const [listBusy, setListBusy] = useState(false); + const [listError, setListError] = useState(null); + + const loadVersions = useCallback(async () => { + const api = window.desktopApi; + if (!api?.listVersions) { + setListError("Listado de versiones no disponible."); + return; + } + setListBusy(true); + setListError(null); + try { + const result = await api.listVersions(); + if (result.error && result.versions.length === 0) { + setListError(result.error); + } + setVersions(result.versions); + } catch (e) { + setListError(e instanceof Error ? e.message : String(e)); + } finally { + setListBusy(false); + } + }, []); + + useEffect(() => { + void loadVersions(); + }, [loadVersions]); + + const statusTone = + checkStatus === "available" + ? "ok" + : checkStatus === "error" + ? "error" + : checkStatus === "checking" + ? "busy" + : checkStatus === "up-to-date" + ? "ok" + : "idle"; + + return ( +
+
+
+
+

Actualizaciones

+

Busca versiones nuevas o instala una publicada en el servidor.

+
+
+ +
+
+
+ +
+
+
+ {checkStatus === "checking" ? ( + + ) : checkStatus === "error" ? ( + + ) : checkStatus === "available" || checkStatus === "up-to-date" ? ( + + ) : ( + + )} +
+
+ + Instalada: v{APP_VERSION} + {releaseDate ? ` · ${formatReleaseDateFlexible(releaseDate)}` : ""} + + + {checkMessage || + (checkStatus === "idle" + ? "Pulsa «Buscar actualización» para comprobar si hay una versión nueva." + : "")} + +
+
+ +
+
+

+ Versiones publicadas +

+ +
+ + {listError &&
{listError}
} + + {!listError && versions.length === 0 && !listBusy && ( +

+ No hay catálogo aún. Tras el próximo npm run publish:minio aparecerá{" "} + versions.json en MinIO. +

+ )} + + {versions.length > 0 && ( +
    + {versions.map((v) => { + const isCurrent = v.version === APP_VERSION; + const dateLabel = v.releaseDateDisplay || v.releaseDate || ""; + return ( +
  • +
    + v{v.version} + {isCurrent && Actual} + + {dateLabel ? formatReleaseDateFlexible(dateLabel) : "Sin fecha"} + {" · "} + {formatBytes(v.size)} + +
    + +
  • + ); + })} +
+ )} +
+
+
+ ); +} + function ConnectionSettings({ apiBaseUrl, onApiBaseUrlChange, diff --git a/services/desktop_app/src/shared/appVersion.ts b/services/desktop_app/src/shared/appVersion.ts index 502aabe..d03270b 100644 --- a/services/desktop_app/src/shared/appVersion.ts +++ b/services/desktop_app/src/shared/appVersion.ts @@ -11,6 +11,18 @@ export function formatReleaseDate(isoDate: string): string { }); } -export function formatVersionLabel(): string { - return `v${APP_VERSION} · ${formatReleaseDate(APP_RELEASE_DATE)}`; +/** Acepta ISO (YYYY-MM-DD) o display (DD/MM/YYYY). */ +export function formatReleaseDateFlexible(value: string): string { + const trimmed = value.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return formatReleaseDate(trimmed); + const m = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (m) { + const iso = `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`; + return formatReleaseDate(iso); + } + return trimmed; +} + +export function formatVersionLabel(releaseDate: string = APP_RELEASE_DATE): string { + return `v${APP_VERSION} · ${formatReleaseDateFlexible(releaseDate)}`; } diff --git a/services/desktop_app/src/shared/types.ts b/services/desktop_app/src/shared/types.ts index 5873b12..cf9ad41 100644 --- a/services/desktop_app/src/shared/types.ts +++ b/services/desktop_app/src/shared/types.ts @@ -284,6 +284,18 @@ export interface UpdateCheckResult { error?: string; } +/** Fecha de la versión instalada según MinIO (latest.json / DATA.txt). */ +export interface ReleaseMeta { + releaseDate?: string; + releaseDateDisplay?: string; +} + +export interface VersionsListResult { + currentVersion: string; + versions: UpdateManifest[]; + error?: string; +} + export interface UpdateProgress { phase: "downloading" | "verifying" | "installing" | string; received?: number; @@ -305,6 +317,8 @@ export interface DesktopBridge { ): Promise; readFile(filePath: string): Promise; checkForUpdates(): Promise; + getReleaseMeta(): Promise; + listVersions(): Promise; installUpdate(manifest: UpdateManifest): Promise<{ ok: boolean; destPath?: string }>; onUpdateProgress(callback: (progress: UpdateProgress) => void): () => void; } diff --git a/services/extraction/api/main.py b/services/extraction/api/main.py index 5d3eb7c..a94031b 100644 --- a/services/extraction/api/main.py +++ b/services/extraction/api/main.py @@ -17,7 +17,7 @@ except ImportError: from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile from pydantic import BaseModel, Field, model_validator -from sqlalchemy import select +from sqlalchemy import delete, select from api.desktop_ingest import mark_task_completed_if_done, process_desktop_ingest @@ -28,6 +28,8 @@ from domain.estimator import Estimator from domain.service import InvoiceProcessingService from domain.strategies import OpenAICompatibleExtractor from infrastructure.database import ( + CleanedRow, + CleaningJob, CountJobFile, ExtractionBatch, ExtractionRow, @@ -1044,6 +1046,53 @@ def get_job_tasks(job_id: str) -> list[dict[str, Any]]: return [serialize_task(t) for t in tasks] +@app.delete("/jobs/{job_id}", status_code=204) +def delete_job(job_id: str) -> None: + """Elimina un job y todos sus resultados (tasks, conteos, extracciones, limpiezas).""" + with session_scope() as session: + job = session.get(Job, job_id) + if job is None: + raise HTTPException(status_code=404, detail="Job no encontrado") + + tasks = session.scalars(select(Task).where(Task.job_id == job_id)).all() + task_ids = [t.id for t in tasks] + + batch_ids: list[str] = [] + if task_ids: + batch_ids = list( + session.scalars( + select(ExtractionBatch.id).where(ExtractionBatch.job_module_id.in_(task_ids)) + ).all() + ) + + cleaning_ids = list( + session.scalars(select(CleaningJob.id).where(CleaningJob.job_id == job_id)).all() + ) + if batch_ids: + cleaning_ids.extend( + session.scalars( + select(CleaningJob.id).where(CleaningJob.extraction_batch_id.in_(batch_ids)) + ).all() + ) + cleaning_ids = list(dict.fromkeys(cleaning_ids)) + + if cleaning_ids: + session.execute(delete(CleanedRow).where(CleanedRow.cleaning_job_id.in_(cleaning_ids))) + session.execute(delete(CleaningJob).where(CleaningJob.id.in_(cleaning_ids))) + + if batch_ids: + session.execute(delete(CleanedRow).where(CleanedRow.extraction_batch_id.in_(batch_ids))) + session.execute(delete(ExtractionRow).where(ExtractionRow.batch_id.in_(batch_ids))) + session.execute(delete(ExtractionBatch).where(ExtractionBatch.id.in_(batch_ids))) + + if task_ids: + session.execute(delete(CountJobFile).where(CountJobFile.job_module_id.in_(task_ids))) + session.execute(delete(Task).where(Task.id.in_(task_ids))) + + session.delete(job) + session.flush() + + # ═══════════════════════════════════════════════════════════════════════════════ # Count Jobs — persistir y gestionar resultados del servicio de conteo # ═══════════════════════════════════════════════════════════════════════════════