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 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user