diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index e41729f..1c19abf 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -32,6 +32,8 @@ Eye, EyeOff, Globe, + Download, + Trash2, } from "@lucide/svelte"; import { OLLAMA_OPTIONS, @@ -295,15 +297,55 @@ const path = await open({ filters: [{ name: "Outlook data file", extensions: ["pst"] }] }); if (typeof path === "string") pstPath = path; } + // "all" imports the full mailbox history; otherwise days of history from + // now. Persisted immediately on change so pst_auto_sync's startup + // re-import (lib.rs) applies the same range as the last manual import. + let importRange = $state( + settings.settings.pst_import_range_days == null + ? "all" + : String(settings.settings.pst_import_range_days), + ); + function onImportRangeChange(e: Event) { + importRange = (e.target as HTMLSelectElement).value; + settings.patch({ + pst_import_range_days: importRange === "all" ? null : Number(importRange), + }); + } async function doImportPst() { if (!pstPath) return; - await calendar.importPst(pstPath, pstPassword || undefined); + const rangeDays = importRange === "all" ? undefined : Number(importRange); + await calendar.importPst(pstPath, pstPassword || undefined, rangeDays); pstPassword = ""; if (!calendar.importError) await settings.patch({ pst_last_path: pstPath }); } function onToggleAutoSync(e: Event) { settings.patch({ pst_auto_sync: (e.target as HTMLInputElement).checked }); } + + // ---- Calendar cleanup (dedup/bloat fix follow-up) ---- + let cleanupThreshold = $state("all"); + let cleanupRunning = $state(false); + let cleanupResult = $state<{ deleted: number; protected: number } | null>(null); + let cleanupError = $state(null); + async function runCleanup() { + const isDeleteAll = cleanupThreshold === "all"; + const olderThanDays = isDeleteAll ? undefined : Number(cleanupThreshold); + const warning = isDeleteAll + ? "Delete ALL imported calendar events? Events already attached to a recorded meeting are always kept." + : `Delete calendar events older than ${cleanupThreshold} days? Events already attached to a recorded meeting are always kept.`; + if (!confirm(warning)) return; + cleanupRunning = true; + cleanupError = null; + cleanupResult = null; + try { + cleanupResult = await api.cleanupCalendarEvents(olderThanDays); + await calendar.load(); + } catch (e) { + cleanupError = errorMessage(e); + } finally { + cleanupRunning = false; + } + } function formatEventDate(unixSecs: number | null): string { if (!unixSecs) return ""; return new Date(unixSecs * 1000).toLocaleString(undefined, { @@ -775,28 +817,64 @@

Models

    {#each settings.models as m (m.id)} -
  • - {m.label} - {fmtBytes(m.size_mb)} - {#if m.active}active{/if} - {#if m.id in settings.downloadProgress} - downloading… {progressPct(m.id) !== null ? `${progressPct(m.id)}%` : ""} - {:else if m.installed} - - - {:else} - - {/if} + {@const downloading = m.id in settings.downloadProgress} + {@const pct = progressPct(m.id)} +
  • +
    + {m.label} + + {fmtBytes(m.size_mb)} + {#if m.active} + + {:else if m.installed} + Installed + {/if} + + {#if downloading} +
    +
    +
    + Downloading… {pct !== null ? `${pct}%` : ""} + {/if} +
    +
    + {#if downloading} + + {:else if m.installed} + + + {:else} + + {/if} +
  • {/each}
@@ -830,9 +908,8 @@

- {activeModel - ? `"${activeModel.label}" is English-only.` - : "No model selected."} Switch to a multilingual model above to choose a language. + {activeModel ? `"${activeModel.label}" is English-only.` : "No model selected."} Switch to + a multilingual model above to choose a language.

{/if} @@ -889,7 +966,22 @@ >Password (rarely needed) + +

+ A long-lived mailbox can hold years of recurring/holiday entries — narrowing the range + keeps the imported calendar to what's actually relevant. Applies to both this Import + button and automatic re-import on launch. +

+
+ {#if cleanupResult} +

+ Deleted {cleanupResult.deleted}{cleanupResult.protected + ? `, kept ${cleanupResult.protected} (linked to a meeting)` + : ""}. +

+ {/if} + {#if cleanupError} +

Clean up failed: {cleanupError}

+ {/if} +

Imported events

{#if calendar.events.length === 0}

No events imported yet.

@@ -1888,7 +2013,6 @@ padding: 0; margin: 0.3rem 0; } - ul.models li, ul.events li { display: flex; align-items: center; @@ -1911,9 +2035,115 @@ input.grow { flex: 1; } - ul.models .name { + + /* Model download list (T3.7, FR-MODEL-1) — a card row per model, not a + bare text row: clearer active/installed state, and a real progress bar + for in-flight downloads instead of a lone percentage number. */ + ul.models li.model { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.7rem; + margin-bottom: 0.4rem; + border: 1px solid var(--border); + border-left: 2px solid transparent; + border-radius: var(--radius-md, 8px); + font-size: 0.85rem; + } + ul.models li.model.active { + background: var(--accent-soft); + border-left-color: var(--accent); + } + .model-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; flex: 1; } + .model-info .name { + font-weight: 600; + } + .model-info .meta { + display: flex; + align-items: center; + gap: 0.4rem; + color: var(--muted); + font-size: 0.78rem; + } + .model-actions { + display: flex; + align-items: center; + gap: 0.4rem; + flex: none; + } + .icon-btn { + display: flex; + align-items: center; + gap: 0.3rem; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm, 5px); + padding: 0.3rem 0.55rem; + font-size: 0.78rem; + color: var(--fg); + cursor: pointer; + transition: background-color 120ms ease-out; + } + .icon-btn:hover:not(:disabled) { + background: var(--bg-hover); + } + .icon-btn:disabled { + opacity: 0.5; + cursor: default; + } + .icon-btn.danger { + color: var(--danger); + border-color: var(--danger); + } + .icon-btn.danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 12%, transparent); + } + /* Real progress bar — a lone "NN%" string doesn't read as motion/progress + at a glance the way a filling bar does (ux: loading-states). */ + .progress { + height: 6px; + border-radius: var(--radius-full, 999px); + background: var(--border); + overflow: hidden; + } + .progress-fill { + height: 100%; + background: var(--accent); + border-radius: inherit; + transition: width 200ms ease-out; + } + /* Unknown total (no content-length yet) — an animated indeterminate sweep + instead of a bar stuck at 0%, so it still reads as "in progress". */ + .progress-fill.indeterminate { + width: 40%; + animation: progress-sweep 1.1s ease-in-out infinite; + } + @keyframes progress-sweep { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(250%); + } + } + @media (prefers-reduced-motion: reduce) { + .progress-fill.indeterminate { + animation: none; + width: 100%; + opacity: 0.5; + } + } + .progress-label { + font-size: 0.75rem; + } + ul.targets li { display: flex; align-items: center;