feat(vocab): bulk import for profile terms
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:
@@ -12,6 +12,7 @@
|
||||
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
|
||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||
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 { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
|
||||
import { Check, ChevronRight } from "lucide-svelte";
|
||||
@@ -152,6 +153,9 @@
|
||||
let vocabularyError = $state(null);
|
||||
let newVocabTerm = $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
|
||||
// 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) {
|
||||
try {
|
||||
await profilesStore.deleteTerm(id);
|
||||
@@ -952,6 +1011,43 @@
|
||||
>Add</button>
|
||||
</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}
|
||||
<p class="text-[11px] text-error mb-3">{vocabularyError}</p>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user