From f355a954a3a71f4a0bd72193c0ba7961b2fc3be4 Mon Sep 17 00:00:00 2001 From: erik Date: Sat, 11 Jul 2026 01:02:00 -0500 Subject: [PATCH] chore: Bump version to 1.0.3 and enhance update handling in the desktop application. Introduce release date stamping before build, add version listing functionality, and improve update prompts with detailed version information. Update README and scripts for clarity on release date management and version publishing. --- services/desktop_app/README.md | 7 +- services/desktop_app/electron/autoUpdate.cjs | 103 +++++++++ services/desktop_app/electron/main.cjs | 17 +- services/desktop_app/electron/preload.cjs | 2 + services/desktop_app/package.json | 5 +- .../desktop_app/scripts/publish-version.mjs | 67 +++++- .../scripts/stamp-release-date.mjs | 60 +++++ services/desktop_app/src/renderer/App.tsx | 72 +++++- .../src/renderer/components/UpdatePrompt.tsx | 140 +++++++++++- .../desktop_app/src/renderer/lib/apiClient.ts | 9 + services/desktop_app/src/renderer/styles.css | 85 ++++++- .../desktop_app/src/renderer/test/setup.ts | 2 + .../src/renderer/views/HomeView.tsx | 115 +++++++++- .../src/renderer/views/SettingsView.tsx | 208 +++++++++++++++++- services/desktop_app/src/shared/appVersion.ts | 16 +- services/desktop_app/src/shared/types.ts | 14 ++ services/extraction/api/main.py | 51 ++++- 17 files changed, 917 insertions(+), 56 deletions(-) create mode 100644 services/desktop_app/scripts/stamp-release-date.mjs 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 # ═══════════════════════════════════════════════════════════════════════════════