feat(ui): model download cards + PST import range + calendar cleanup

Settings > Hardware: the Models list is now a bordered card row per
model with active/installed badges and a real progress bar for
downloads (was plain text links + a bare "downloading NN%" string),
using the app's existing lucide icon set.

Settings > Calendar: an Import range picker (30/90/180/365 days/all
time, persisted, applied to both a manual Import and the pst_auto_sync
startup re-import) and a Clean up section (delete all / older-than-N
threshold, confirm() gated, always reports how many events were kept
because they're linked to a meeting) -- fixes the 14,686-row bloat from
importing a mailbox's entire unbounded history.
This commit is contained in:
iamdoubz
2026-07-11 00:13:02 -05:00
parent 99d50583cf
commit 13fb9984da
+258 -28
View File
@@ -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<string>(
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<string>("all");
let cleanupRunning = $state(false);
let cleanupResult = $state<{ deleted: number; protected: number } | null>(null);
let cleanupError = $state<string | null>(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 @@
<h4>Models</h4>
<ul class="models">
{#each settings.models as m (m.id)}
<li>
<span class="name">{m.label}</span>
<span class="muted">{fmtBytes(m.size_mb)}</span>
{#if m.active}<span class="badge">active</span>{/if}
{#if m.id in settings.downloadProgress}
<span class="muted"
>downloading… {progressPct(m.id) !== null ? `${progressPct(m.id)}%` : ""}</span
>
{:else if m.installed}
<button
class="link"
disabled={m.active}
onclick={() => settings.setActiveModel(m.id)}>Use</button
>
<button
class="link danger"
disabled={m.active}
onclick={() => settings.removeModel(m.id)}>Remove</button
>
{:else}
<button class="link" onclick={() => settings.downloadModel(m.id)}>Download</button>
{/if}
{@const downloading = m.id in settings.downloadProgress}
{@const pct = progressPct(m.id)}
<li class="model" class:active={m.active}>
<div class="model-info">
<span class="name">{m.label}</span>
<span class="meta">
{fmtBytes(m.size_mb)}
{#if m.active}
<span class="badge ok"><Check size={11} aria-hidden="true" /> Active</span>
{:else if m.installed}
<span class="badge">Installed</span>
{/if}
</span>
{#if downloading}
<div
class="progress"
role="progressbar"
aria-label="Downloading {m.label}"
aria-valuenow={pct ?? undefined}
aria-valuemin={0}
aria-valuemax={100}
>
<div
class="progress-fill"
class:indeterminate={pct === null}
style={pct !== null ? `width: ${pct}%` : ""}
></div>
</div>
<span class="muted progress-label"
>Downloading… {pct !== null ? `${pct}%` : ""}</span
>
{/if}
</div>
<div class="model-actions">
{#if downloading}
<!-- no actions while a download is in flight -->
{:else if m.installed}
<button
class="icon-btn"
disabled={m.active}
onclick={() => settings.setActiveModel(m.id)}
>
<Check size={13} aria-hidden="true" /> Use
</button>
<button
class="icon-btn danger"
disabled={m.active}
title={m.active ? "Can't remove the active model" : "Remove"}
onclick={() => settings.removeModel(m.id)}
>
<Trash2 size={13} aria-hidden="true" /> Remove
</button>
{:else}
<button class="icon-btn" onclick={() => settings.downloadModel(m.id)}>
<Download size={13} aria-hidden="true" /> Download
</button>
{/if}
</div>
</li>
{/each}
</ul>
@@ -830,9 +908,8 @@
</select>
</label>
<p class="muted" id="language-disabled-hint">
{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.
</p>
{/if}
</section>
@@ -889,7 +966,22 @@
>Password <em>(rarely needed)</em>
<input type="password" bind:value={pstPassword} placeholder="optional" />
</label>
<label
>Import range
<select value={importRange} onchange={onImportRangeChange}>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="180">Last 6 months</option>
<option value="365">Last 1 year</option>
<option value="all">All time</option>
</select>
</label>
</div>
<p class="muted small">
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.
</p>
<div class="actions">
<button class="primary" onclick={doImportPst} disabled={!pstPath || calendar.importing}>
{calendar.importing ? "Importing…" : "Import"}
@@ -919,6 +1011,39 @@
</p>
{/if}
<h4>Clean up</h4>
<p class="muted small">
Events already attached to a recorded meeting are always kept, no matter which option
below is picked.
</p>
<div class="actions">
<select bind:value={cleanupThreshold} disabled={cleanupRunning}>
<option value="30">Older than 30 days</option>
<option value="90">Older than 90 days</option>
<option value="180">Older than 6 months</option>
<option value="365">Older than 1 year</option>
<option value="all">Delete all</option>
</select>
<button
class="icon-btn danger"
onclick={runCleanup}
disabled={cleanupRunning || calendar.events.length === 0}
>
<Trash2 size={13} aria-hidden="true" />
{cleanupRunning ? "Cleaning up…" : "Clean up calendar"}
</button>
</div>
{#if cleanupResult}
<p class="muted small">
Deleted {cleanupResult.deleted}{cleanupResult.protected
? `, kept ${cleanupResult.protected} (linked to a meeting)`
: ""}.
</p>
{/if}
{#if cleanupError}
<p class="error">Clean up failed: {cleanupError}</p>
{/if}
<h4>Imported events</h4>
{#if calendar.events.length === 0}
<p class="muted">No events imported yet.</p>
@@ -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;