feat(vocab): bulk import for profile terms
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Settings → Vocabulary gets a "Bulk add from a list…" disclosure under the
single-term row. Expanding reveals a textarea; paste newline- or
comma-separated terms, hit Import, and the page loops addTerm for each
entry the active profile doesn't already have.

Dedupes case-insensitively against the existing term list so pasting the
same block twice is a no-op. Skipped + failed counts surface via toast;
persistent errors (any failing term) also land in vocabularyError so the
inline panel explains what went wrong.

Covers OpenWhispr issue #460 — one-at-a-time entry becomes friction past
roughly ten terms. No backend changes; addTerm is already in profilesStore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 09:40:18 +01:00
parent 1f5309c8f5
commit 42335c04c5

View File

@@ -12,6 +12,7 @@
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte"; import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
import { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts"; import { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
import { toasts } from "$lib/stores/toasts.svelte.js";
import { clampTextLines } from "$lib/utils/textMeasure.js"; import { clampTextLines } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
import { Check, ChevronRight } from "lucide-svelte"; import { Check, ChevronRight } from "lucide-svelte";
@@ -152,6 +153,9 @@
let vocabularyError = $state(null); let vocabularyError = $state(null);
let newVocabTerm = $state(""); let newVocabTerm = $state("");
let newVocabNote = $state(""); let newVocabNote = $state("");
let showBulkVocab = $state(false);
let bulkVocabText = $state("");
let bulkVocabBusy = $state(false);
// Draft held locally so the textarea doesn't thrash the store on every // Draft held locally so the textarea doesn't thrash the store on every
// keystroke. Saved on blur or explicit save. // keystroke. Saved on blur or explicit save.
@@ -189,6 +193,61 @@
} }
} }
async function addBulkVocabTerms() {
const raw = bulkVocabText;
// Accept newline-separated OR comma-separated (or mixed) — whichever the
// user pasted. Trim each entry, drop empties, dedupe within the input.
const candidates = Array.from(
new Set(
raw
.split(/[\n,]+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
),
);
if (candidates.length === 0) {
vocabularyError = "Nothing to import — paste one term per line or separated by commas.";
return;
}
// Skip terms the profile already has (case-insensitive — Whisper prompts
// don't care about case, and duplicates pollute the Terms list).
const existing = new Set(vocabulary.map((row) => row.term.toLowerCase()));
const toAdd = candidates.filter((term) => !existing.has(term.toLowerCase()));
const skipped = candidates.length - toAdd.length;
bulkVocabBusy = true;
vocabularyError = null;
const failed = [];
try {
for (const term of toAdd) {
try {
await profilesStore.addTerm(profilesStore.activeProfileId, term, "");
} catch (err) {
failed.push({ term, message: err?.message || String(err) });
}
}
} finally {
bulkVocabBusy = false;
}
await refreshVocabulary();
bulkVocabText = "";
showBulkVocab = false;
const addedCount = toAdd.length - failed.length;
const parts = [];
if (addedCount > 0) parts.push(`Added ${addedCount}`);
if (skipped > 0) parts.push(`skipped ${skipped} duplicate${skipped === 1 ? "" : "s"}`);
if (failed.length > 0) parts.push(`${failed.length} failed`);
if (failed.length > 0) {
vocabularyError = `Some terms failed: ${failed.map((f) => f.term).join(", ")}`;
}
if (parts.length > 0) {
toasts.info("Vocabulary import", parts.join(" · "));
}
}
async function deleteVocabTerm(id) { async function deleteVocabTerm(id) {
try { try {
await profilesStore.deleteTerm(id); await profilesStore.deleteTerm(id);
@@ -952,6 +1011,43 @@
>Add</button> >Add</button>
</div> </div>
<!-- Bulk import — one term per line or comma-separated. -->
<div class="mb-4">
{#if !showBulkVocab}
<button
type="button"
class="text-[11px] text-text-tertiary hover:text-accent underline"
onclick={() => { showBulkVocab = true; }}
>Bulk add from a list…</button>
{:else}
<div class="p-3 bg-bg-input border border-border-subtle rounded-lg animate-fade-in">
<textarea
bind:value={bulkVocabText}
placeholder="Paste terms one per line, or separated by commas. Duplicates are skipped automatically."
rows="4"
disabled={bulkVocabBusy}
class="w-full bg-bg border border-border rounded-lg px-3 py-2 text-[13px] text-text font-mono
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]
disabled:opacity-50 resize-y"
></textarea>
<div class="flex items-center gap-2 mt-2">
<button
type="button"
onclick={addBulkVocabTerms}
disabled={bulkVocabBusy || !bulkVocabText.trim()}
class="px-3 py-1.5 text-[12px] bg-accent text-bg rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent-hover"
>{bulkVocabBusy ? "Importing…" : "Import"}</button>
<button
type="button"
onclick={() => { showBulkVocab = false; bulkVocabText = ""; }}
disabled={bulkVocabBusy}
class="px-3 py-1.5 text-[12px] text-text-tertiary hover:text-text"
>Cancel</button>
</div>
</div>
{/if}
</div>
{#if vocabularyError} {#if vocabularyError}
<p class="text-[11px] text-error mb-3">{vocabularyError}</p> <p class="text-[11px] text-error mb-3">{vocabularyError}</p>
{/if} {/if}