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.

This commit is contained in:
erik
2026-07-11 01:02:00 -05:00
parent c8853de808
commit f355a954a3
17 changed files with 917 additions and 56 deletions
+4 -3
View File
@@ -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
```
@@ -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,
};
+9 -8
View File
@@ -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);
});
@@ -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);
+3 -2
View File
@@ -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",
@@ -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) => {
@@ -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})`);
+62 -10
View File
@@ -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<unknown>(null);
const [modelsJson, setModelsJson] = useState<unknown>(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() {
<Cpu size={17} />
<span>Backend</span>
</button>
<button
type="button"
className={settingsSection === "updates" ? "active" : ""}
onClick={() => setSettingsSection("updates")}
>
<Download size={17} />
<span>Actualizaciones</span>
</button>
</nav>
</>
) : isJobOpen ? (
@@ -245,19 +273,34 @@ function App() {
: "Sin verificar"}
</span>
</div>
<div className="sidebar-version" title={formatVersionLabel()}>
<div className="sidebar-version" title={formatVersionLabel(releaseDate)}>
<span>v{APP_VERSION}</span>
<span>{formatReleaseDate(APP_RELEASE_DATE)}</span>
<span>{formatReleaseDateFlexible(releaseDate)}</span>
</div>
</div>
</aside>
<section className={`workspace workspace--full${isSettingsOpen ? " workspace--settings" : ""}`}>
<header className="app-topbar">
<span className="app-version-badge">{formatVersionLabel()}</span>
<span className="app-version-badge">{formatVersionLabel(releaseDate)}</span>
<CheckUpdatesButton
busy={update.checkStatus === "checking" || update.installing}
onClick={() => void update.checkForUpdates()}
/>
</header>
{view === "home" && <HomeView client={client} onOpenJob={openJob} />}
{view === "home" && (
<HomeView
client={client}
onOpenJob={openJob}
onJobDeleted={(jobId) => {
if (activeJob?.id === jobId) {
setActiveJob(null);
setView("home");
}
}}
/>
)}
{view === "job" && activeJob && (
<JobView
@@ -282,6 +325,14 @@ function App() {
busy={busy}
configJson={configJson}
modelsJson={modelsJson}
releaseDate={releaseDate}
updateCheckStatus={update.checkStatus}
updateCheckMessage={update.checkMessage}
updateInstalling={update.installing}
onCheckUpdates={() => void update.checkForUpdates()}
onInstallVersion={(manifest) =>
update.promptInstall(manifest, update.currentVersion || APP_VERSION)
}
/>
)}
</section>
@@ -289,13 +340,14 @@ function App() {
{update.manifest && (
<UpdatePrompt
open={update.open}
currentVersion={update.currentVersion}
currentVersion={update.currentVersion || APP_VERSION}
manifest={update.manifest}
onClose={update.close}
onConfirm={() => void update.confirmInstall()}
installing={update.installing}
progress={update.progress}
error={update.error}
forceInstall={update.forceInstall}
/>
)}
</main>
@@ -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 <strong>v{manifest.version}</strong>
{dateLabel ? <> (lanzamiento {dateLabel})</> : null}.
</>
) : isOlder ? (
<>
Vas a instalar una versión anterior: <strong>v{manifest.version}</strong>
{dateLabel ? <> (lanzamiento {dateLabel})</> : null}.
<br />
Tienes instalada la <strong>v{currentVersion}</strong>.
</>
) : (
<>
Hay una versión nueva: <strong>v{manifest.version}</strong>
{dateLabel ? <> (lanzamiento {dateLabel})</> : null}.
<br />
Tienes instalada la <strong>v{currentVersion}</strong>.
</>
);
const phaseLabel =
progress?.phase === "verifying"
? "Verificando archivo…"
@@ -47,7 +88,7 @@ export function UpdatePrompt({
<div className="update-modal-backdrop" role="presentation">
<div className="update-modal" role="dialog" aria-labelledby="update-modal-title">
<div className="update-modal-header">
<h2 id="update-modal-title">Nueva versión disponible</h2>
<h2 id="update-modal-title">{title}</h2>
{!installing && (
<button type="button" className="btn btn-sm btn-ghost" onClick={onClose} aria-label="Cerrar">
<X size={16} />
@@ -55,12 +96,7 @@ export function UpdatePrompt({
)}
</div>
<p className="update-modal-body">
Hay una versión nueva: <strong>v{manifest.version}</strong>
{dateLabel ? <> (lanzamiento {dateLabel})</> : null}.
<br />
Tienes instalada la <strong>v{currentVersion}</strong>.
</p>
<p className="update-modal-body">{body}</p>
{manifest.size != null && manifest.size > 0 && (
<p className="form-hint">Tamaño aproximado: {formatBytes(manifest.size)}</p>
@@ -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<UpdateProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [forceInstall, setForceInstall] = useState(false);
const [checkStatus, setCheckStatus] = useState<UpdateCheckStatus>("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 (
<button type="button" className={className} onClick={onClick} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
{busy ? "Buscando…" : "Buscar actualización"}
</button>
);
}
@@ -185,6 +185,15 @@ export class ApiClient {
return this.handle<Job>(response);
}
async deleteJob(jobId: string): Promise<void> {
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). */
+84 -1
View File
@@ -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;
}
@@ -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),
};
@@ -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<Job[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -41,6 +52,9 @@ export function HomeView({ client, onOpenJob }: Props) {
const [formFolder, setFormFolder] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [jobToDelete, setJobToDelete] = useState<Job | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(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 (
<div className="home-view">
<header className="home-header">
@@ -114,7 +150,6 @@ export function HomeView({ client, onOpenJob }: Props) {
</button>
</header>
{/* ── Modal crear trabajo (portal: evita que fixed quede anclado a .home-view) ── */}
{showForm && createPortal(
<div className="modal-backdrop" onClick={() => setShowForm(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
@@ -189,7 +224,60 @@ export function HomeView({ client, onOpenJob }: Props) {
document.body,
)}
{/* ── Lista de trabajos ── */}
{jobToDelete && createPortal(
<div
className="modal-backdrop"
onClick={() => { if (!deleting) setJobToDelete(null); }}
>
<div className="modal-card" onClick={(e) => e.stopPropagation()} role="dialog" aria-labelledby="delete-job-title">
<div className="modal-header">
<h2 id="delete-job-title" className="modal-title">Eliminar trabajo</h2>
<button
type="button"
className="modal-close"
onClick={() => setJobToDelete(null)}
disabled={deleting}
aria-label="Cerrar"
>
<X size={16} />
</button>
</div>
<div className="modal-body">
<p>
¿Eliminar <strong>{jobToDelete.title ?? "Sin título"}</strong>?
</p>
<p className="form-hint">
Se borrarán de forma permanente los conteos, extracciones, limpiezas y resultados
asociados. Los archivos en disco no se eliminan.
</p>
{deleteError && (
<div className="wiz-toast wiz-toast--error">{deleteError}</div>
)}
</div>
<div className="modal-footer">
<button
type="button"
className="btn"
onClick={() => setJobToDelete(null)}
disabled={deleting}
>
Cancelar
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => void handleDelete()}
disabled={deleting}
>
{deleting ? <Loader2 size={14} className="spin" /> : <Trash2 size={14} />}
{deleting ? "Eliminando…" : "Eliminar"}
</button>
</div>
</div>
</div>,
document.body,
)}
<div className="home-body">
{loading ? (
<div className="home-loading">
@@ -216,7 +304,7 @@ export function HomeView({ client, onOpenJob }: Props) {
) : (
<ul className="job-list">
{jobs.map((job) => (
<li key={job.id}>
<li key={job.id} className="job-list-item">
<button
type="button"
className="job-card"
@@ -245,6 +333,15 @@ export function HomeView({ client, onOpenJob }: Props) {
</div>
<ChevronRight size={18} className="job-card-arrow" />
</button>
<button
type="button"
className="btn btn-sm btn-ghost job-card-delete"
aria-label={`Eliminar ${job.title ?? "trabajo"}`}
title="Eliminar trabajo"
onClick={(e) => openDeleteConfirm(job, e)}
>
<Trash2 size={15} />
</button>
</li>
))}
</ul>
@@ -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<string, string> = {
@@ -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 <CleaningSettings />;
}
if (section === "updates") {
return (
<UpdatesSettings
releaseDate={releaseDate}
checkStatus={updateCheckStatus}
checkMessage={updateCheckMessage}
installing={updateInstalling}
onCheckUpdates={onCheckUpdates}
onInstallVersion={onInstallVersion}
/>
);
}
return (
<BackendSettings
connectionStatus={connectionStatus}
@@ -99,6 +128,181 @@ export function SettingsView({
);
}
function formatBytes(n: number | undefined): string {
if (!n || n <= 0) return "—";
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function UpdatesSettings({
releaseDate,
checkStatus,
checkMessage,
installing,
onCheckUpdates,
onInstallVersion,
}: {
releaseDate: string;
checkStatus: UpdateCheckStatus;
checkMessage: string;
installing: boolean;
onCheckUpdates: () => void;
onInstallVersion: (manifest: UpdateManifest) => void;
}) {
const [versions, setVersions] = useState<UpdateManifest[]>([]);
const [listBusy, setListBusy] = useState(false);
const [listError, setListError] = useState<string | null>(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 (
<div className="settings-page">
<header className="settings-page-header">
<div className="settings-page-header-row">
<div>
<h2>Actualizaciones</h2>
<p>Busca versiones nuevas o instala una publicada en el servidor.</p>
</div>
<div className="settings-page-actions">
<button
type="button"
className="btn btn-primary"
onClick={onCheckUpdates}
disabled={checkStatus === "checking" || installing}
>
{checkStatus === "checking" ? (
<Loader2 size={15} className="spin" />
) : (
<RefreshCw size={15} />
)}
Buscar actualización
</button>
</div>
</div>
</header>
<div className="settings-page-body">
<div className={`settings-status-card settings-status-card--${statusTone}`}>
<div className={`settings-status-icon settings-status-icon--${statusTone}`}>
{checkStatus === "checking" ? (
<Loader2 size={20} className="spin" />
) : checkStatus === "error" ? (
<XCircle size={20} />
) : checkStatus === "available" || checkStatus === "up-to-date" ? (
<CheckCircle2 size={20} />
) : (
<Download size={20} />
)}
</div>
<div className="settings-status-text">
<strong>
Instalada: v{APP_VERSION}
{releaseDate ? ` · ${formatReleaseDateFlexible(releaseDate)}` : ""}
</strong>
<span>
{checkMessage ||
(checkStatus === "idle"
? "Pulsa «Buscar actualización» para comprobar si hay una versión nueva."
: "")}
</span>
</div>
</div>
<div className="settings-panel">
<div className="settings-page-header-row" style={{ marginBottom: 12 }}>
<h3 className="wiz-field-label" style={{ margin: 0 }}>
Versiones publicadas
</h3>
<button
type="button"
className="btn btn-sm btn-outline"
onClick={() => void loadVersions()}
disabled={listBusy || installing}
>
{listBusy ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
Actualizar lista
</button>
</div>
{listError && <div className="wiz-toast wiz-toast--error">{listError}</div>}
{!listError && versions.length === 0 && !listBusy && (
<p className="form-hint">
No hay catálogo aún. Tras el próximo <code>npm run publish:minio</code> aparecerá{" "}
<code>versions.json</code> en MinIO.
</p>
)}
{versions.length > 0 && (
<ul className="updates-version-list">
{versions.map((v) => {
const isCurrent = v.version === APP_VERSION;
const dateLabel = v.releaseDateDisplay || v.releaseDate || "";
return (
<li key={v.version} className={`updates-version-row${isCurrent ? " is-current" : ""}`}>
<div className="updates-version-meta">
<strong>v{v.version}</strong>
{isCurrent && <span className="updates-version-badge">Actual</span>}
<span className="form-hint">
{dateLabel ? formatReleaseDateFlexible(dateLabel) : "Sin fecha"}
{" · "}
{formatBytes(v.size)}
</span>
</div>
<button
type="button"
className="btn btn-sm btn-outline"
disabled={installing}
onClick={() => onInstallVersion(v)}
>
<Download size={14} />
{isCurrent ? "Reinstalar" : "Descargar e instalar"}
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
);
}
function ConnectionSettings({
apiBaseUrl,
onApiBaseUrlChange,
+14 -2
View File
@@ -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)}`;
}
+14
View File
@@ -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<ManifestEntry[]>;
readFile(filePath: string): Promise<FileUploadData>;
checkForUpdates(): Promise<UpdateCheckResult>;
getReleaseMeta(): Promise<ReleaseMeta | null>;
listVersions(): Promise<VersionsListResult>;
installUpdate(manifest: UpdateManifest): Promise<{ ok: boolean; destPath?: string }>;
onUpdateProgress(callback: (progress: UpdateProgress) => void): () => void;
}
+50 -1
View File
@@ -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
# ═══════════════════════════════════════════════════════════════════════════════