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:
+258
-28
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user