Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f300ee177 | ||
|
|
52d3fcbf3b | ||
|
|
2181aa5b0e | ||
|
|
4e87cab3ad | ||
|
|
84ab88b565 | ||
|
|
ae13944563 | ||
|
|
8fc5d72576 | ||
|
|
681933c8cf | ||
|
|
cfe73e7d80 | ||
|
|
505387ba49 | ||
|
|
fa8ce62f63 | ||
|
|
7527da92c5 | ||
|
|
ef933c3786 | ||
|
|
f6f336d79f | ||
|
|
1f25833e74 | ||
|
|
0d8bcf3b17 | ||
|
|
27a1f98051 | ||
|
|
0ecd6db12e | ||
|
|
d2f35c64b1 | ||
|
|
5967fd6997 | ||
|
|
a0ad1564a8 | ||
|
|
377a91d1b8 | ||
|
|
7796c5b62d | ||
|
|
698fb26b0a | ||
|
|
802e30e9c6 | ||
|
|
f1683af01c | ||
|
|
12388b8bd9 | ||
|
|
b295e1ee83 | ||
|
|
c98c14bbc7 | ||
|
|
c0b7587298 | ||
|
|
d5f0d028ac | ||
|
|
2f925de9a5 | ||
|
|
486767587e | ||
|
|
88969019b3 | ||
|
|
2b13de4ef2 | ||
|
|
a112b81d6e | ||
|
|
1da127a61c | ||
|
|
57d3aca386 | ||
|
|
aac88bfbc5 | ||
|
|
e5fdb2baea | ||
|
|
fc89f76ac8 | ||
|
|
7b8a651730 | ||
|
|
e1e2808d9c | ||
|
|
77234e697d | ||
|
|
f5c521df0d | ||
|
|
3f04ec6167 | ||
|
|
030b06329a | ||
|
|
a7af623753 | ||
|
|
b98c258c13 | ||
|
|
8ca6cf4f89 | ||
|
|
dbe845e923 | ||
|
|
d5aef2966f | ||
|
|
2389fb05a5 | ||
|
|
96d02cc220 | ||
|
|
c3da6cf0f3 | ||
|
|
5564c9c9a6 | ||
|
|
d25c297257 | ||
|
|
23cfca7ea2 | ||
|
|
6129866ac5 | ||
|
|
2e57ccfab5 | ||
|
|
4dff7a58b5 | ||
|
|
25204c0235 | ||
|
|
2b2e120f12 | ||
|
|
70eb182eb4 | ||
|
|
836547596b | ||
|
|
58215076cb | ||
|
|
862ab86860 | ||
|
|
a0faaa94b4 | ||
|
|
9c74f84c5b |
@@ -21,6 +21,13 @@ Every memory operation in this session goes through MEMANTO. There is no excepti
|
||||
|
||||
These are not suggestions. Follow each one on every turn.
|
||||
|
||||
0. **Activate the `whispassist` agent at the start of every session, before any memory op.** Run
|
||||
`memanto agent activate whispassist` first thing. This machine hosts multiple projects and the
|
||||
session-start sync may activate a *different* project's agent (e.g. `whispassist`), so the
|
||||
auto-synced `MEMORY.md` can belong to the wrong project — do not trust it as LastERP context
|
||||
until you've activated `whispassist` and re-synced. Confirm with `memanto agent list` (the
|
||||
active one is marked). All `recall`/`remember`/`answer` calls read and write the *active*
|
||||
agent's store, so getting this wrong silently pollutes or mis-reads another project's memory.
|
||||
1. **Read `MEMORY.md` before doing anything.** It is auto-synced at session start and holds
|
||||
the user's preferences, facts, goals, instructions, decisions, and commitments from every
|
||||
prior session. You MUST honor what is written there. If you act against it, you are
|
||||
|
||||
@@ -23,6 +23,13 @@ Default root: `%LOCALAPPDATA%\WhispAssist\` (user-configurable, FR-STORE-2).
|
||||
└── <brief_id>.json # agent-ready spec served via the MCP `get_feature_brief` tool
|
||||
```
|
||||
|
||||
A **bundle export** (`export_meeting` / `bulk_export_meetings` with `format: "bundle"`, FR-STORE-4)
|
||||
copies a meeting's `audio.wav`, `transcript.json`, `notes.md`, and `summary.json` (all decrypted)
|
||||
into a destination folder plus a `meeting.json` manifest (the `MeetingBundle`: title, timestamps,
|
||||
duration, language/backend/model, tags, speakers, and confirmed action items). `import_meeting_bundle`
|
||||
reconstructs each such folder under a fresh meeting id — the portable format for moving recordings
|
||||
between computers.
|
||||
|
||||
Rule: while a meeting is in progress a working WAV is the source of truth for crash recovery. On
|
||||
finalize, it is **kept** as `audio.wav` if "Record this meeting" was on, or **deleted** if not
|
||||
(FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json` and
|
||||
|
||||
@@ -63,9 +63,21 @@ map_speaker_to_participant(input: { meetingId: MeetingId; label: string; partici
|
||||
// yet at local-desktop meeting counts) and gained from/to date filters, per
|
||||
// FR-SEARCH-2's "filter by date, tag, or participant".
|
||||
list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[]
|
||||
// Meeting also carries `action_items: ActionItem[]` — table-backed (the confirmed/edited source
|
||||
// of truth), falling back to summary.json drafts until any are saved (FR-LLM-3). TranscriptSegment
|
||||
// keeps start_ms/end_ms; the UI renders these as per-line timestamps.
|
||||
get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated)
|
||||
delete_meeting(input: { meetingId: MeetingId }): void
|
||||
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string
|
||||
// format "bundle" writes a portable folder: audio.wav + transcript.json + notes.md + summary.json
|
||||
// (all decrypted) + meeting.json (the MeetingBundle manifest), re-importable on another machine.
|
||||
// format "obsidian" writes one self-contained vault note (dest is a .md file path, no audio):
|
||||
// YAML frontmatter (title/date/duration/participants/tags/source) + notes + summary + decisions
|
||||
// + action items + timestamped transcript. For dropping a meeting into an Obsidian vault.
|
||||
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" | "obsidian" }): string
|
||||
// Edit commands that change an uploaded artifact — update_notes, set_tags, generate_summary,
|
||||
// reprocess_transcript, confirm_action_items — auto-resync when sync is enabled (FR-SYNC-5):
|
||||
// they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an
|
||||
// edit that didn't alter a file uploads nothing.
|
||||
update_notes(input: { meetingId: MeetingId; markdown: string }): void
|
||||
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
|
||||
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
|
||||
@@ -77,6 +89,10 @@ list_note_templates(): NoteTemplate[]
|
||||
// Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle
|
||||
// folder) per meeting under destDir. Returns the count actually exported.
|
||||
bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number
|
||||
// Import bundle(s) (FR-STORE-4): `dir` is a single bundle folder (has meeting.json) or a parent
|
||||
// folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original
|
||||
// title/date/duration/speakers/tags/action items preserved). Returns the count imported.
|
||||
import_meeting_bundle(input: { dir: string }): number
|
||||
|
||||
// ---- LLM / AI provider (ADR-0007/0011) ----
|
||||
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# i18n migration tracking
|
||||
|
||||
Working doc for the incremental UI-string translation effort. The i18n **mechanism**
|
||||
is done; this tracks moving the app's remaining hardcoded strings into the translation
|
||||
files, one batch at a time. Tick boxes as views are converted.
|
||||
|
||||
> **Status (2026-07-12): migration complete.** All views and components (Batches A–F) are
|
||||
> converted; `en.json` holds ~506 keys. Every user-facing English string flows through
|
||||
> `t()`. What remains is intentionally-untranslated data (see the "Not translated" section)
|
||||
> — plus the actual work of adding a second language, which is now just translating
|
||||
> `en.json` into a new `<code>.json`.
|
||||
|
||||
- **Engine:** hand-rolled, zero-dependency. `src/lib/i18n/index.svelte.ts`
|
||||
- **Baseline dictionary:** `src/lib/i18n/en.json` (English is the source-of-truth key set
|
||||
**and** the fallback for any missing key)
|
||||
- **Selector:** Settings → Language (`section === "language"`)
|
||||
- **Scope decision:** display language is separate from the transcription `whisper_language`
|
||||
setting — don't conflate them.
|
||||
|
||||
## What we translate (and what we never do)
|
||||
|
||||
**Translate: UI chrome only** — fixed labels, buttons, headings, placeholders,
|
||||
empty/loading/status states, tooltips, and app-generated default *labels*.
|
||||
|
||||
**Never translate: user-authored content.** Meeting titles, tag names, notes, transcript
|
||||
text, search snippets, dates — these are bound straight from data (`item.title`, tag
|
||||
values, `s.text`, …) and stay exactly as the user wrote them. If a string comes from the
|
||||
user or the recording, it does not get a key.
|
||||
|
||||
So "convert view X" always means "key its fixed labels", never "touch its content". Most
|
||||
views are mostly content with a thin shell of labels — MeetingsList, for example, is ~10
|
||||
fixed labels (search placeholder, empty states, filter/bulk-export labels) wrapped around
|
||||
a list whose rows are pure user data.
|
||||
|
||||
> Caveat — the `"Untitled meeting"` **default title is generated in the Rust backend**, not
|
||||
> the frontend, so it never reaches `t()`. Localizing app-generated defaults is a separate
|
||||
> backend decision, out of scope for this frontend effort.
|
||||
|
||||
## How to convert a string (the pattern)
|
||||
|
||||
1. Add a key to `en.json`. Naming: `<area>.<subarea>.<name>`, dotted, grouped by view —
|
||||
e.g. `nav.recording`, `settings.transcription.title`, `meetings.empty`.
|
||||
2. Replace the literal in markup with `{t("key")}` (import `t` from `../i18n/index.svelte`).
|
||||
3. Dynamic bits use placeholders: `t("meetings.count", { n })` against
|
||||
`"meetings.count": "{n} meetings"`. Handle plurals with a caller-side ternary for now
|
||||
(`n === 1 ? t("...one") : t("...many")`) — the engine is intentionally simple.
|
||||
4. Attributes translate the same way: `title={t("...")}`, `aria-label={t("...")}`,
|
||||
`placeholder={t("...")}`.
|
||||
5. `npm run check` must stay at 0 errors/warnings.
|
||||
|
||||
> When adding a **new language** (not covered by this doc's batches): copy `en.json` to
|
||||
> `<code>.json`, translate it, and register it in `DICTS` + `LOCALES` in
|
||||
> `index.svelte.ts`. That one file is the whole job.
|
||||
|
||||
## Done
|
||||
|
||||
- [x] i18n engine + `en.json` baseline + Settings language selector (branch
|
||||
`feature_chore_bug_007`)
|
||||
- [x] `Settings.svelte` — nav labels (`nav.*`), the Language section
|
||||
(`settings.language.*`), and the Transcription Language section
|
||||
(`settings.transcription.*`)
|
||||
|
||||
- [x] `src/App.svelte` — app shell chrome (`app.*`): header (tagline, template
|
||||
picker, record/stop/cancel/add-meeting, recording status, retention, backend,
|
||||
device-notice), Settings button, consent + vault-unlock dialogs, pane toggles,
|
||||
splitter labels, and JS strings (discard confirm, SR announcements, vault error).
|
||||
- [x] `src/lib/views/MeetingsList.svelte` — labels only (`meetings.*`): search/filter/
|
||||
bulk-export controls, empty/loading/status states, status badges (via
|
||||
`statusLabel`), resume + delete, delete-confirm, and the pluralized export result.
|
||||
List rows (titles/tags/dates/snippets) left as user data.
|
||||
- [x] `src/lib/views/TranscriptNotes.svelte` — transcript + notes chrome (`transcript.*`,
|
||||
`notes.*`): pane headings/toggles, reprocess controls, notes toolbar (bold/heading/
|
||||
list/preview), export button tooltips + dialog filter names, empty states, segment
|
||||
tooltips + note placeholders. Transcript/notes/speaker text left as user data.
|
||||
- [x] `src/lib/views/SummaryPanel.svelte` — all panel chrome (`summary.*`): section
|
||||
headings (Recording/Sync/Tags/Summary/Briefs/Action items/Calendar/Participants/
|
||||
Speakers), buttons, placeholders, empty states, provider labels (via `providerLabel`;
|
||||
brand names kept literal), action-item + brief + speaker controls. Summary text,
|
||||
tags, brief content, participant/speaker names left as user data.
|
||||
- [x] `src/lib/views/Settings.svelte` — **all sections** done (`settings.*`): the modal
|
||||
shell (dialog title, Close), plus recording, hardware (incl. models/diarization),
|
||||
storage (+ export/import status), calendar (.pst import/cleanup/events), sync
|
||||
(targets + WebDAV/OAuth forms), AI (provider + Ollama advanced params), MCP
|
||||
(transport/scope/token/access log), privacy (egress self-check + vault), about.
|
||||
OLLAMA_OPTIONS param catalog labels/help left as data (config catalog, like model
|
||||
ids); example URLs/model-id placeholders left literal.
|
||||
|
||||
- [x] Components (Batch F): `ConsentNotice` + `HostedAiBanner` (legal/consent copy,
|
||||
`consent.*` / `hosted.*`), `ThemeToggle` (`theme.*`), `ImportMeeting` (`import.*`),
|
||||
`TagChip` (`tagchip.*`), `LevelMeter` (`levelmeter.*`). `Splitter`'s `label` is
|
||||
caller-supplied and already translated by the parent; no strings of its own.
|
||||
|
||||
## Outstanding — suggested batches
|
||||
|
||||
_None — all batches complete._ The section below is kept as a record of the plan.
|
||||
|
||||
Ordered roughly by user-visibility ÷ effort. Sizes are rough (line count / labeled
|
||||
attributes) to help portion the work, not exact string counts. A file isn't "done" until
|
||||
its visible text **and** its `title`/`aria-label`/`placeholder` attributes are keyed.
|
||||
|
||||
### ~~Batch A — app shell~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch B — meetings list~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch C — transcript & notes~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch D — summary panel~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch E — Settings, all sections~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
All nine sections + the modal shell converted, one commit per section. Moved to the Done
|
||||
list above.
|
||||
|
||||
### ~~Batch F — components~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
## Not translated (intentional)
|
||||
|
||||
- **User-authored content** — meeting titles, tag names, notes, transcript text, search
|
||||
snippets. Bound from data; stays as the user wrote it (see "What we translate" above).
|
||||
- App-generated defaults created in the backend (e.g. `"Untitled meeting"`) — a separate
|
||||
backend concern; the frontend `t()` never sees them.
|
||||
- Backend / Rust error messages surfaced via `WaError` — out of scope for the frontend
|
||||
`t()`; revisit only if we localize command errors.
|
||||
- Provider/proper names (Ollama, Anthropic, OpenAI, Obsidian, WebDAV, WhispAssist),
|
||||
model ids, ISO language codes.
|
||||
- Console/`tracing` logs.
|
||||
|
||||
## Batch log
|
||||
|
||||
Record each landed batch here (date / branch / commit) so progress is auditable.
|
||||
|
||||
| Date | Batch | Branch / commit | Notes |
|
||||
|------|-------|-----------------|-------|
|
||||
| 2026-07-12 | Infra + Settings nav/language/transcription | `feature_chore_bug_007` | engine + en.json + selector |
|
||||
| 2026-07-12 | Batch A (App shell) + Batch B (MeetingsList) | `feature_chore_bug_007` | +~60 keys; `app.*`, `meetings.*` |
|
||||
| 2026-07-12 | Batch C (TranscriptNotes) + Batch D (SummaryPanel) | `feature_chore_bug_007` | +~120 keys; `transcript.*`, `notes.*`, `summary.*`; en.json now 198 keys |
|
||||
| 2026-07-12 | Batch E (Settings, all 9 sections + shell) | `feature_chore_bug_007` | +~270 keys; `settings.*`; en.json now 470 keys; one commit per section |
|
||||
| 2026-07-12 | Batch F (components) | `feature_chore_bug_007` | +~36 keys; `consent.*`/`hosted.*`/`theme.*`/`import.*`/`tagchip.*`/`levelmeter.*`; en.json now 506 keys — migration complete |
|
||||
+377
-2
@@ -1732,6 +1732,7 @@ pub async fn reprocess_transcript(
|
||||
template.as_ref(),
|
||||
);
|
||||
let _ = state.store.update_notes(&meeting_id, ¬es_md).await;
|
||||
spawn_auto_sync(&app, state.store.clone(), meeting_id.clone());
|
||||
|
||||
let _ = app.emit(
|
||||
"transcript://finalized",
|
||||
@@ -2044,6 +2045,7 @@ pub async fn list_meetings(
|
||||
/// Replaces a meeting's complete tag set (Phase 8, FR-SEARCH-2).
|
||||
#[tauri::command]
|
||||
pub async fn set_tags(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
tags: Vec<String>,
|
||||
@@ -2052,7 +2054,9 @@ pub async fn set_tags(
|
||||
.store
|
||||
.set_tags(&meeting_id, &tags)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
spawn_auto_sync(&app, state.store.clone(), meeting_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All known tag names, sorted, for filter/autocomplete UI (Phase 8, FR-SEARCH-2).
|
||||
@@ -2250,6 +2254,7 @@ pub(crate) fn cleanup_playback_temp() {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_notes(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
markdown: String,
|
||||
@@ -2258,7 +2263,9 @@ pub async fn update_notes(
|
||||
.store
|
||||
.update_notes(&meeting_id, &markdown)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
spawn_auto_sync(&app, state.store.clone(), meeting_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `format` ∈ `md | pdf | docx | bundle`. `dest` is a file path for
|
||||
@@ -2320,6 +2327,154 @@ pub async fn bulk_export_meetings(
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Import meeting bundle(s) exported with `format: "bundle"` (FR-STORE-4) — the
|
||||
/// other half of moving recordings between computers. `dir` is either a single
|
||||
/// bundle folder (contains `meeting.json`) or a parent folder of bundles (from
|
||||
/// a bulk export); every bundle found is reconstructed under a fresh meeting id
|
||||
/// so re-importing never collides with existing meetings. Returns the count
|
||||
/// imported.
|
||||
#[tauri::command]
|
||||
pub async fn import_meeting_bundle(
|
||||
state: State<'_, AppState>,
|
||||
dir: String,
|
||||
) -> WaResult<u32> {
|
||||
let root = PathBuf::from(&dir);
|
||||
let mut bundles: Vec<PathBuf> = Vec::new();
|
||||
if root.join("meeting.json").exists() {
|
||||
bundles.push(root.clone());
|
||||
} else {
|
||||
let entries =
|
||||
std::fs::read_dir(&root).map_err(|e| WaError::new("import", e.to_string()))?;
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() && p.join("meeting.json").exists() {
|
||||
bundles.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if bundles.is_empty() {
|
||||
return Err(WaError::new(
|
||||
"import",
|
||||
"no meeting.json found — pick a folder exported with \"Bundle\"",
|
||||
));
|
||||
}
|
||||
bundles.sort();
|
||||
let mut count = 0u32;
|
||||
for b in &bundles {
|
||||
match import_one_bundle(&state, b).await {
|
||||
Ok(_) => count += 1,
|
||||
Err(e) => tracing::warn!("bundle import skipped {}: {}", b.display(), e.message),
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Reconstruct one exported bundle folder into a new meeting. Order matters:
|
||||
/// create → finalize (writes transcript.json/speakers/status) → restore dates →
|
||||
/// copy audio/summary → notes → tags → action items.
|
||||
async fn import_one_bundle(state: &State<'_, AppState>, dir: &Path) -> WaResult<MeetingId> {
|
||||
let manifest = std::fs::read_to_string(dir.join("meeting.json"))
|
||||
.map_err(|e| WaError::new("import", e.to_string()))?;
|
||||
let bundle: crate::models::MeetingBundle = serde_json::from_str(&manifest)
|
||||
.map_err(|e| WaError::new("import", format!("bad meeting.json: {e}")))?;
|
||||
|
||||
// Segments live in transcript.json (plaintext in an export); pull just the
|
||||
// array rather than depending on storage's private TranscriptFile shape.
|
||||
let segments: Vec<crate::models::TranscriptSegment> =
|
||||
std::fs::read_to_string(dir.join("transcript.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| serde_json::from_value(v.get("segments")?.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let id = state
|
||||
.store
|
||||
.create_meeting(NewMeeting {
|
||||
title: bundle.title.clone(),
|
||||
calendar_event_id: None,
|
||||
template_id: bundle.template_id.clone(),
|
||||
language: bundle.language.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
|
||||
state
|
||||
.store
|
||||
.finalize_meeting(
|
||||
&id,
|
||||
FinalizeMeeting {
|
||||
segments,
|
||||
speakers: bundle.speakers.clone(),
|
||||
duration_secs: bundle.duration_secs.unwrap_or(0),
|
||||
recorded: bundle.recorded,
|
||||
language: bundle.language.clone(),
|
||||
backend_used: bundle.backend_used.clone(),
|
||||
model_used: bundle.model_used.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
|
||||
state
|
||||
.store
|
||||
.set_meeting_times(&id, bundle.started_at, bundle.ended_at)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
|
||||
let dest_dir = meeting_dir(&id);
|
||||
// Audio: copy byte-for-byte, mirroring the export's copy-as-is. ponytail:
|
||||
// if the source machine sealed audio at rest under a different vault key,
|
||||
// it won't decrypt here — cross-machine vault key transfer is out of scope;
|
||||
// upgrade path is decrypting audio into the bundle on export.
|
||||
let audio_src = dir.join("audio.wav");
|
||||
if audio_src.exists() {
|
||||
std::fs::copy(&audio_src, dest_dir.join("audio.wav"))
|
||||
.map_err(|e| WaError::new("import", e.to_string()))?;
|
||||
}
|
||||
// notes.md
|
||||
if let Ok(notes) = std::fs::read_to_string(dir.join("notes.md")) {
|
||||
state
|
||||
.store
|
||||
.update_notes(&id, ¬es)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
}
|
||||
// summary.json — reseal at rest when the vault is unlocked (T8.8), same as
|
||||
// generate_summary's write path.
|
||||
let summary_src = dir.join("summary.json");
|
||||
if summary_src.exists() {
|
||||
if let Ok(raw) = std::fs::read(&summary_src) {
|
||||
if let Ok(sealed) = crate::vault::seal(&raw) {
|
||||
let _ = std::fs::write(dest_dir.join("summary.json"), sealed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !bundle.tags.is_empty() {
|
||||
state
|
||||
.store
|
||||
.set_tags(&id, &bundle.tags)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
}
|
||||
if !bundle.action_items.is_empty() {
|
||||
// Drop source ids so each inserts fresh under the new meeting id.
|
||||
let items: Vec<ActionItem> = bundle
|
||||
.action_items
|
||||
.iter()
|
||||
.map(|i| ActionItem {
|
||||
id: None,
|
||||
..i.clone()
|
||||
})
|
||||
.collect();
|
||||
state
|
||||
.store
|
||||
.save_action_items(&id, &items)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// A filesystem-safe, collision-resistant filename stem: sanitized title +
|
||||
/// an id prefix, since meeting titles are very often duplicates ("Untitled
|
||||
/// meeting") and would otherwise silently overwrite each other in a batch.
|
||||
@@ -2402,6 +2557,45 @@ async fn export_meeting_to(
|
||||
}
|
||||
std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown)
|
||||
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||
// summary.json may be vault-sealed; export the decrypted content so
|
||||
// the bundle is portable (T8.8), same as transcript.json above.
|
||||
let summary_src = source_dir.join("summary.json");
|
||||
if summary_src.exists() {
|
||||
let bytes = std::fs::read(&summary_src)
|
||||
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||
let plain =
|
||||
crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?;
|
||||
std::fs::write(dest_path.join("summary.json"), plain)
|
||||
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||
}
|
||||
// meeting.json — the portable manifest that makes a bundle
|
||||
// re-importable on another machine (FR-STORE-4). meeting.action_items
|
||||
// is table-backed (Feature 3), so confirmed items travel too.
|
||||
let bundle = crate::models::MeetingBundle {
|
||||
schema: 1,
|
||||
title: meeting.title.clone(),
|
||||
started_at: meeting.started_at,
|
||||
ended_at: meeting.ended_at,
|
||||
duration_secs: meeting.duration_secs,
|
||||
language: meeting.language.clone(),
|
||||
backend_used: meeting.backend_used.clone(),
|
||||
model_used: meeting.model_used.clone(),
|
||||
recorded: meeting.recorded,
|
||||
template_id: meeting.template_id.clone(),
|
||||
tags: meeting.tags.clone(),
|
||||
speakers: meeting.speakers.clone(),
|
||||
action_items: meeting.action_items.clone(),
|
||||
};
|
||||
let manifest = serde_json::to_string_pretty(&bundle)
|
||||
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||
std::fs::write(dest_path.join("meeting.json"), manifest)
|
||||
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||
}
|
||||
"obsidian" => {
|
||||
// One self-contained vault note — no audio (FR-STORE-4). Everything
|
||||
// is already on `meeting`, so this is pure formatting.
|
||||
std::fs::write(dest_path, build_obsidian_note(&meeting))
|
||||
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||
}
|
||||
other => {
|
||||
return Err(WaError::new(
|
||||
@@ -2413,6 +2607,139 @@ async fn export_meeting_to(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a meeting as a single portable Obsidian note (FR-STORE-4): YAML
|
||||
/// frontmatter + notes + summary + decisions + action items + a timestamped
|
||||
/// transcript, deliberately without audio. All fields come off the
|
||||
/// already-loaded `Meeting`, so this does no I/O of its own.
|
||||
fn build_obsidian_note(meeting: &crate::storage::Meeting) -> String {
|
||||
use std::fmt::Write as _;
|
||||
// Resolve a segment/participant speaker label to its display name, mirroring
|
||||
// the frontend's `speakerName`.
|
||||
let name_of = |label: &str| -> String {
|
||||
meeting
|
||||
.speakers
|
||||
.iter()
|
||||
.find(|sp| sp.label == label)
|
||||
.and_then(|sp| sp.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
};
|
||||
|
||||
let mut md = String::new();
|
||||
md.push_str("---\n");
|
||||
let _ = writeln!(md, "title: {}", yaml_str(&meeting.title));
|
||||
let _ = writeln!(md, "date: {}", fmt_date(meeting.started_at));
|
||||
if let Some(secs) = meeting.duration_secs {
|
||||
let _ = writeln!(md, "duration: {}", fmt_duration(secs));
|
||||
}
|
||||
let participants: Vec<String> = meeting.speakers.iter().map(|s| name_of(&s.label)).collect();
|
||||
if !participants.is_empty() {
|
||||
let joined = participants
|
||||
.iter()
|
||||
.map(|p| yaml_str(p))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let _ = writeln!(md, "participants: [{joined}]");
|
||||
}
|
||||
if !meeting.tags.is_empty() {
|
||||
let joined = meeting
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| yaml_str(t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let _ = writeln!(md, "tags: [{joined}]");
|
||||
}
|
||||
md.push_str("source: WhispAssist\n---\n\n");
|
||||
|
||||
if !meeting.notes_markdown.trim().is_empty() {
|
||||
let _ = write!(md, "## Notes\n\n{}\n\n", meeting.notes_markdown.trim());
|
||||
}
|
||||
if let Some(sum) = &meeting.summary {
|
||||
if !sum.summary_md.trim().is_empty() {
|
||||
let _ = write!(md, "## Summary\n\n{}\n\n", sum.summary_md.trim());
|
||||
}
|
||||
if !sum.decisions.is_empty() {
|
||||
md.push_str("## Decisions\n\n");
|
||||
for d in &sum.decisions {
|
||||
let _ = writeln!(md, "- {d}");
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
}
|
||||
if !meeting.action_items.is_empty() {
|
||||
md.push_str("## Action items\n\n");
|
||||
for a in &meeting.action_items {
|
||||
let check = if a.confirmed { "x" } else { " " };
|
||||
let owner = a
|
||||
.owner
|
||||
.as_deref()
|
||||
.map(|o| format!(" — {o}"))
|
||||
.unwrap_or_default();
|
||||
let due = a
|
||||
.due_at
|
||||
.map(|d| format!(" (due {})", fmt_date(d)))
|
||||
.unwrap_or_default();
|
||||
let _ = writeln!(md, "- [{check}] {}{owner}{due}", a.text);
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
if !meeting.segments.is_empty() {
|
||||
md.push_str("## Transcript\n\n");
|
||||
for seg in &meeting.segments {
|
||||
let _ = writeln!(
|
||||
md,
|
||||
"**{}** {}: {}",
|
||||
fmt_ts(seg.start_ms),
|
||||
name_of(&seg.speaker),
|
||||
seg.text.trim()
|
||||
);
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
/// Quote a value for a YAML frontmatter scalar — always double-quoted and
|
||||
/// escaped, so titles/tags containing `:`/`"`/`#` can't break the block.
|
||||
fn yaml_str(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
/// Unix seconds → `YYYY-MM-DD` in local time (matches how the UI shows dates).
|
||||
fn fmt_date(unix: i64) -> String {
|
||||
use chrono::TimeZone as _;
|
||||
chrono::Local
|
||||
.timestamp_opt(unix, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Seconds → a compact `1h 5m` / `32m` / `48s` duration.
|
||||
fn fmt_duration(secs: i64) -> String {
|
||||
let secs = secs.max(0);
|
||||
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||
if h > 0 {
|
||||
format!("{h}h {m}m")
|
||||
} else if m > 0 {
|
||||
format!("{m}m")
|
||||
} else {
|
||||
format!("{s}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// Milliseconds → `m:ss` (or `h:mm:ss` past an hour), matching the transcript
|
||||
/// timestamp prefix shown in the UI.
|
||||
fn fmt_ts(ms: u64) -> String {
|
||||
let total = ms / 1000;
|
||||
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- LLM (Phase 5) ----
|
||||
|
||||
/// Builds the configured `LlmProvider`, or `None` if LLM integration is off
|
||||
@@ -2710,6 +3037,7 @@ pub async fn generate_summary(
|
||||
if let Err(e) = state.store.reindex_fts(&meeting_id).await {
|
||||
tracing::warn!("failed to reindex search after summary generation: {e}");
|
||||
}
|
||||
spawn_auto_sync(&app, state.store.clone(), meeting_id.clone());
|
||||
|
||||
let _ = app.emit(
|
||||
"llm://done",
|
||||
@@ -2723,10 +3051,28 @@ pub async fn generate_summary(
|
||||
/// T8.6, FR-CAL-5).
|
||||
#[tauri::command]
|
||||
pub async fn confirm_action_items(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
items: Vec<ActionItem>,
|
||||
) -> WaResult<()> {
|
||||
// Cancel reminders for items the user deleted (present in the table but no
|
||||
// longer in the incoming list) — save_action_items drops the rows, but the
|
||||
// scheduled OS reminder is separate state (T8.6, FR-CAL-5).
|
||||
let existing = state
|
||||
.store
|
||||
.list_action_items(&meeting_id)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
let keep: std::collections::HashSet<&str> =
|
||||
items.iter().filter_map(|i| i.id.as_deref()).collect();
|
||||
for old in &existing {
|
||||
if let Some(id) = &old.id {
|
||||
if !keep.contains(id.as_str()) {
|
||||
crate::reminders::cancel(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let saved = state
|
||||
.store
|
||||
.save_action_items(&meeting_id, &items)
|
||||
@@ -2741,6 +3087,7 @@ pub async fn confirm_action_items(
|
||||
_ => crate::reminders::cancel(id),
|
||||
}
|
||||
}
|
||||
spawn_auto_sync(&app, state.store.clone(), meeting_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3672,6 +4019,33 @@ pub(crate) async fn enqueue_meeting_sync(
|
||||
Ok(queued)
|
||||
}
|
||||
|
||||
/// Re-sync a meeting's artifacts after an edit (notes / summary / transcript /
|
||||
/// tags / action items), mirroring sync-on-finalize (T9.5, FR-SYNC-5). A no-op
|
||||
/// unless sync is enabled; only finalize-trigger targets are touched and
|
||||
/// unchanged files are deduped by SHA-256, so a save that didn't alter an
|
||||
/// uploaded artifact queues (and uploads) nothing. Fire-and-forget so the edit
|
||||
/// command returns immediately.
|
||||
///
|
||||
/// ponytail: fires on every (debounced) save; if WebDAV PUT volume from live
|
||||
/// note-typing ever matters, coarsen the debounce or trigger on blur instead.
|
||||
pub(crate) fn spawn_auto_sync(
|
||||
app: &AppHandle,
|
||||
store: std::sync::Arc<dyn crate::storage::Store>,
|
||||
meeting_id: MeetingId,
|
||||
) {
|
||||
if !load_settings().sync_enabled {
|
||||
return;
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match enqueue_meeting_sync(store.as_ref(), &meeting_id, None, true).await {
|
||||
Ok(0) => {} // nothing changed
|
||||
Ok(_) => pump_sync(&app, store.as_ref()).await, // upload the changes
|
||||
Err(e) => tracing::warn!("auto-sync enqueue failed: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Upload one job's file: ensure the remote dir, PUT, report bytes sent.
|
||||
async fn upload_job(
|
||||
target: &dyn crate::sync::SyncTarget,
|
||||
@@ -4415,6 +4789,7 @@ mod tests {
|
||||
calendar_event_id: None,
|
||||
tags: Vec::new(),
|
||||
template_id: None,
|
||||
action_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@ pub fn run() {
|
||||
commands::update_notes,
|
||||
commands::export_meeting,
|
||||
commands::bulk_export_meetings,
|
||||
commands::import_meeting_bundle,
|
||||
commands::rename_speaker,
|
||||
commands::merge_speakers,
|
||||
commands::map_speaker_to_participant,
|
||||
|
||||
@@ -193,6 +193,29 @@ pub struct ActionItem {
|
||||
pub reminder_set: bool,
|
||||
}
|
||||
|
||||
/// Portable meeting export manifest — the `meeting.json` inside an export
|
||||
/// bundle folder (FR-STORE-4). Carries everything needed to reconstruct a
|
||||
/// meeting on another machine alongside the bundle's files (`audio.wav`,
|
||||
/// `transcript.json`, `notes.md`, `summary.json`). Deliberately excludes the
|
||||
/// meeting id (a fresh one is minted on import to avoid collisions) and the
|
||||
/// calendar-event link (event ids are machine-local).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingBundle {
|
||||
pub schema: u32,
|
||||
pub title: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub duration_secs: Option<i64>,
|
||||
pub language: Option<String>,
|
||||
pub backend_used: Option<String>,
|
||||
pub model_used: Option<String>,
|
||||
pub recorded: bool,
|
||||
pub template_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub speakers: Vec<SpeakerInfo>,
|
||||
pub action_items: Vec<ActionItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalendarEvent {
|
||||
pub id: String,
|
||||
|
||||
@@ -104,6 +104,12 @@ pub struct Meeting {
|
||||
/// Note-template id (Phase 8, T8.1, FR-NOTE-5), if one was picked at
|
||||
/// creation — resolved against `notes::templates::catalog()`.
|
||||
pub template_id: Option<String>,
|
||||
/// Confirmed/edited action items from the `action_items` table — the
|
||||
/// source of truth the user manages directly (add/edit/delete via
|
||||
/// `confirm_action_items`, FR-LLM-3). Falls back to the drafts parsed
|
||||
/// into `summary.json` when the table has none yet, so a freshly
|
||||
/// generated summary still shows its suggestions.
|
||||
pub action_items: Vec<ActionItem>,
|
||||
}
|
||||
|
||||
/// A calendar event with its attendees (T6.3/T6.4, FR-CAL-1/3) — the
|
||||
@@ -337,6 +343,16 @@ pub trait Store: Send + Sync {
|
||||
/// Manually rename a meeting — recordings otherwise default to "Untitled
|
||||
/// meeting" with no other way to change that.
|
||||
async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError>;
|
||||
/// Overwrite a meeting's start/end timestamps — used by bundle import
|
||||
/// (FR-STORE-4) so a moved recording keeps its original date rather than
|
||||
/// showing the import time (`create_meeting`/`finalize_meeting` both stamp
|
||||
/// "now").
|
||||
async fn set_meeting_times(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
) -> Result<(), StoreError>;
|
||||
/// Names a speaker AND links them to a known `Participant` (T6.5/T6.6,
|
||||
/// FR-SPK-4): the display name comes from the participant record, and
|
||||
/// the shared `participant_id` is what gives naming "continuity" across
|
||||
@@ -892,6 +908,14 @@ impl Store for SqliteStore {
|
||||
let summary = read_artifact(&folder.join("summary.json"))
|
||||
.and_then(|s| serde_json::from_str::<SummaryFile>(&s).ok());
|
||||
let tags = self.tags_for_meeting(id).await?;
|
||||
// Table is the source of truth for confirmed/edited items; fall back
|
||||
// to the summary's parsed drafts only when nothing's been saved yet.
|
||||
let mut action_items = self.list_action_items(id).await?;
|
||||
if action_items.is_empty() {
|
||||
if let Some(s) = &summary {
|
||||
action_items = s.action_items.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Meeting {
|
||||
id: row.get("id"),
|
||||
@@ -911,6 +935,7 @@ impl Store for SqliteStore {
|
||||
calendar_event_id: row.get("calendar_event_id"),
|
||||
tags,
|
||||
template_id: row.get("template_id"),
|
||||
action_items,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1196,6 +1221,22 @@ impl Store for SqliteStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_meeting_times(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
started_at: i64,
|
||||
ended_at: Option<i64>,
|
||||
) -> Result<(), StoreError> {
|
||||
sqlx::query("UPDATE meetings SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(started_at)
|
||||
.bind(ended_at)
|
||||
.bind(now_unix())
|
||||
.bind(meeting_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn map_speaker_to_participant(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
@@ -1254,6 +1295,26 @@ impl Store for SqliteStore {
|
||||
items: &[ActionItem],
|
||||
) -> Result<Vec<ActionItem>, StoreError> {
|
||||
let now = now_unix();
|
||||
// Reconcile deletes: any row previously saved for this meeting that the
|
||||
// caller no longer includes was removed in the UI (FR-LLM-3). Action
|
||||
// items per meeting number in the low tens, so a per-row delete loop is
|
||||
// fine. ponytail: O(n) delete scan, batch it only if n ever gets large.
|
||||
let keep: std::collections::HashSet<&str> =
|
||||
items.iter().filter_map(|i| i.id.as_deref()).collect();
|
||||
let existing_ids: Vec<String> =
|
||||
sqlx::query_scalar("SELECT id FROM action_items WHERE meeting_id = ?")
|
||||
.bind(id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for eid in &existing_ids {
|
||||
if !keep.contains(eid.as_str()) {
|
||||
sqlx::query("DELETE FROM action_items WHERE id = ? AND meeting_id = ?")
|
||||
.bind(eid)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let mut saved = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let item_id = match &item.id {
|
||||
|
||||
+49
-48
@@ -18,6 +18,7 @@
|
||||
import ThemeToggle from "./lib/components/ThemeToggle.svelte";
|
||||
import Splitter from "./lib/components/Splitter.svelte";
|
||||
import { layout, clamp } from "./lib/stores/layout.svelte";
|
||||
import { t } from "./lib/i18n/index.svelte";
|
||||
import {
|
||||
Circle,
|
||||
Square,
|
||||
@@ -62,7 +63,7 @@
|
||||
vaultLocked = false;
|
||||
meetings.load(); // refresh now that encrypted content is readable
|
||||
} catch {
|
||||
vaultErr = "Incorrect password";
|
||||
vaultErr = t("app.vault_incorrect");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +135,7 @@
|
||||
checkVault();
|
||||
api
|
||||
.listNoteTemplates()
|
||||
.then((t) => (noteTemplates = t))
|
||||
.then((tpls) => (noteTemplates = tpls))
|
||||
.catch(() => (noteTemplates = []));
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -160,7 +161,7 @@
|
||||
}
|
||||
|
||||
async function cancelRecording() {
|
||||
if (!confirm("Discard this recording? Its audio and transcript will be deleted.")) return;
|
||||
if (!confirm(t("app.discard_confirm"))) return;
|
||||
await recording.cancel();
|
||||
meetings.deselect();
|
||||
}
|
||||
@@ -186,9 +187,9 @@
|
||||
$effect(() => {
|
||||
const current = recording.state;
|
||||
if (current !== previousRecordingState) {
|
||||
if (current === "recording") recordingAnnouncement = "Recording started";
|
||||
else if (current === "paused") recordingAnnouncement = "Recording paused";
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = "Recording stopped";
|
||||
if (current === "recording") recordingAnnouncement = t("app.announce_started");
|
||||
else if (current === "paused") recordingAnnouncement = t("app.announce_paused");
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = t("app.announce_stopped");
|
||||
previousRecordingState = current;
|
||||
}
|
||||
});
|
||||
@@ -232,58 +233,54 @@
|
||||
<div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div>
|
||||
<header class="bar">
|
||||
<strong>WhispAssist</strong>
|
||||
<span class="muted">local · private</span>
|
||||
<span class="muted">{t("app.tagline")}</span>
|
||||
<div class="spacer"></div>
|
||||
{#if recording.state === "idle"}
|
||||
<select
|
||||
class="theme-select"
|
||||
bind:value={selectedTemplateId}
|
||||
aria-label="Note template"
|
||||
title="Note template"
|
||||
aria-label={t("app.note_template")}
|
||||
title={t("app.note_template")}
|
||||
>
|
||||
<option value="">No template</option>
|
||||
{#each noteTemplates as t (t.id)}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
<option value="">{t("app.no_template")}</option>
|
||||
{#each noteTemplates as tpl (tpl.id)}
|
||||
<option value={tpl.id}>{tpl.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="import-btn"
|
||||
onclick={() => (showImport = true)}
|
||||
title="Add a meeting from a file or URL"
|
||||
title={t("app.add_meeting_title")}
|
||||
>
|
||||
<FilePlus size={13} aria-hidden="true" />
|
||||
Add meeting
|
||||
{t("app.add_meeting")}
|
||||
</button>
|
||||
<button
|
||||
class="record-btn"
|
||||
onclick={startRecording}
|
||||
title="Start recording (Ctrl+Shift+R)"
|
||||
title={t("app.record_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Circle size={11} fill="currentColor" aria-hidden="true" />
|
||||
Record
|
||||
{t("app.record")}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="stop-btn"
|
||||
onclick={() => recording.stop()}
|
||||
title="Stop recording (Ctrl+Shift+R)"
|
||||
title={t("app.stop_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Square size={11} fill="currentColor" aria-hidden="true" />
|
||||
Stop
|
||||
{t("app.stop")}
|
||||
</button>
|
||||
<button
|
||||
class="cancel-btn"
|
||||
onclick={cancelRecording}
|
||||
title="Discard this recording and delete it"
|
||||
>
|
||||
<button class="cancel-btn" onclick={cancelRecording} title={t("app.cancel_title")}>
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
Cancel
|
||||
{t("app.cancel")}
|
||||
</button>
|
||||
<span class="rec">
|
||||
<span class="rec-dot" aria-hidden="true"></span>
|
||||
Recording…
|
||||
{t("app.recording")}
|
||||
</span>
|
||||
<LevelMeter
|
||||
rms={recording.levelRms}
|
||||
@@ -293,16 +290,16 @@
|
||||
showMic={settings.settings.microphone_enabled}
|
||||
/>
|
||||
{#if settings.hardware}
|
||||
<span class="backend" title="Active transcription backend">{settings.hardware.active}</span>
|
||||
<span class="backend" title={t("app.backend_title")}>{settings.hardware.active}</span>
|
||||
{/if}
|
||||
<label class="retention" title="Save audio as .wav for this meeting">
|
||||
<label class="retention" title={t("app.retention_title")}>
|
||||
<input type="checkbox" checked={recording.retention} onchange={onToggleRetention} />
|
||||
<span>{recording.retention ? "saving" : "not saved"}</span>
|
||||
<span>{recording.retention ? t("app.saving") : t("app.not_saved")}</span>
|
||||
</label>
|
||||
{#if recording.deviceNotice}
|
||||
<span class="device-notice" role="status" title={recording.deviceNotice}>
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
reconnecting audio device…
|
||||
{t("app.reconnecting")}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -312,8 +309,8 @@
|
||||
/>
|
||||
<button
|
||||
class="icon"
|
||||
aria-label="Settings"
|
||||
title="Settings (Ctrl+,)"
|
||||
aria-label={t("app.settings")}
|
||||
title={t("app.settings_title")}
|
||||
aria-keyshortcuts="Control+,"
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
>
|
||||
@@ -326,7 +323,7 @@
|
||||
class="consent-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Recording consent"
|
||||
aria-label={t("app.consent_dialog")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
@@ -347,23 +344,27 @@
|
||||
class="vault-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Unlock encryption vault"
|
||||
aria-label={t("app.vault_dialog")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="vault-card">
|
||||
<h2>Unlock encryption vault</h2>
|
||||
<p class="muted">Your meetings are encrypted at rest. Enter your password to read them.</p>
|
||||
<h2>{t("app.vault_title")}</h2>
|
||||
<p class="muted">{t("app.vault_desc")}</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Vault password"
|
||||
placeholder={t("app.vault_password")}
|
||||
bind:value={vaultPw}
|
||||
onkeydown={(e) => e.key === "Enter" && submitUnlock()}
|
||||
/>
|
||||
{#if vaultErr}<p class="vault-err">{vaultErr}</p>{/if}
|
||||
<div class="vault-actions">
|
||||
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}>Unlock</button>
|
||||
<button class="link" onclick={() => (vaultLocked = false)}>Continue locked</button>
|
||||
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}
|
||||
>{t("app.unlock")}</button
|
||||
>
|
||||
<button class="link" onclick={() => (vaultLocked = false)}
|
||||
>{t("app.continue_locked")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,8 +386,8 @@
|
||||
layout.leftCollapsed = false;
|
||||
layout.persist();
|
||||
}}
|
||||
title="Show meetings list"
|
||||
aria-label="Show meetings list"
|
||||
title={t("app.show_meetings")}
|
||||
aria-label={t("app.show_meetings")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -397,8 +398,8 @@
|
||||
layout.leftCollapsed = true;
|
||||
layout.persist();
|
||||
}}
|
||||
title="Hide meetings list"
|
||||
aria-label="Hide meetings list"
|
||||
title={t("app.hide_meetings")}
|
||||
aria-label={t("app.hide_meetings")}
|
||||
>
|
||||
<PanelLeftClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -407,7 +408,7 @@
|
||||
</aside>
|
||||
{#if !layout.leftCollapsed}
|
||||
<Splitter
|
||||
label="Resize meetings list"
|
||||
label={t("app.resize_meetings")}
|
||||
onResize={(d) => (layout.leftWidth = clamp(layout.leftWidth + d, 200, 480))}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
@@ -417,7 +418,7 @@
|
||||
<section class="center"><TranscriptNotes /></section>
|
||||
{#if !layout.rightCollapsed}
|
||||
<Splitter
|
||||
label="Resize summary panel"
|
||||
label={t("app.resize_summary")}
|
||||
onResize={(d) => (layout.rightWidth = clamp(layout.rightWidth - d, 240, 560))}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
@@ -432,8 +433,8 @@
|
||||
layout.rightCollapsed = false;
|
||||
layout.persist();
|
||||
}}
|
||||
title="Show summary panel"
|
||||
aria-label="Show summary panel"
|
||||
title={t("app.show_summary")}
|
||||
aria-label={t("app.show_summary")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -444,8 +445,8 @@
|
||||
layout.rightCollapsed = true;
|
||||
layout.persist();
|
||||
}}
|
||||
title="Hide summary panel"
|
||||
aria-label="Hide summary panel"
|
||||
title={t("app.hide_summary")}
|
||||
aria-label={t("app.hide_summary")}
|
||||
>
|
||||
<PanelRightClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
+15
-3
@@ -172,6 +172,10 @@ export interface Meeting {
|
||||
speakers: SpeakerInfo[];
|
||||
notes_markdown: string;
|
||||
summary: SummaryFile | null;
|
||||
// Confirmed/edited action items (table-backed source of truth) — falls back
|
||||
// to summary.action_items drafts until anything is saved. Manage via
|
||||
// confirmActionItems (add/edit/delete).
|
||||
action_items: ActionItem[];
|
||||
calendar_event_id: string | null;
|
||||
tags: string[];
|
||||
template_id: string | null;
|
||||
@@ -456,9 +460,13 @@ export const api = {
|
||||
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
|
||||
updateNotes: (meetingId: MeetingId, markdown: string) =>
|
||||
invoke<void>("update_notes", { meetingId, markdown }),
|
||||
// dest is a file path for md/pdf/docx, a folder for bundle.
|
||||
exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "pdf" | "docx" | "bundle") =>
|
||||
invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||
// dest is a file path for md/pdf/docx/obsidian, a folder for bundle.
|
||||
// "obsidian" writes one self-contained vault note (no audio) — FR-STORE-4.
|
||||
exportMeeting: (
|
||||
meetingId: MeetingId,
|
||||
dest: string,
|
||||
format: "md" | "pdf" | "docx" | "bundle" | "obsidian",
|
||||
) => invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||
// Every meeting matching tag/date filters, one file (or bundle folder) per
|
||||
// meeting under destDir. Returns the count actually exported (T8.5, FR-STORE-4).
|
||||
bulkExportMeetings: (
|
||||
@@ -473,6 +481,10 @@ export const api = {
|
||||
from: filter?.from,
|
||||
to: filter?.to,
|
||||
}),
|
||||
// Import bundle(s) exported with format "bundle" — dir is a single bundle
|
||||
// folder or a parent folder of them. Reconstructs each under a fresh id and
|
||||
// returns the count imported (FR-STORE-4).
|
||||
importMeetingBundle: (dir: string) => invoke<number>("import_meeting_bundle", { dir }),
|
||||
|
||||
llmStatus: () => invoke<LlmStatus>("llm_status"),
|
||||
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
// Settings "Record by default" toggle and the mid-meeting retention toggle
|
||||
// so both gate on the same copy and the same acknowledgment call.
|
||||
import { ShieldAlert } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let { onAccept, onCancel }: { onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="consent">
|
||||
<div class="heading">
|
||||
<ShieldAlert size={18} aria-hidden="true" />
|
||||
<strong>Before you record</strong>
|
||||
<strong>{t("consent.heading")}</strong>
|
||||
</div>
|
||||
<p>
|
||||
Recording conversations without the consent of participants may be illegal in your region. Check
|
||||
your local recording laws. This is a caution, not legal advice.
|
||||
</p>
|
||||
<p>{t("consent.body")}</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — enable recording</button>
|
||||
<button onclick={onCancel}>Cancel</button>
|
||||
<button class="primary" onclick={onAccept}>{t("consent.accept")}</button>
|
||||
<button onclick={onCancel}>{t("consent.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// (recording consent, ADR-0009): both gate a single Settings toggle AND a
|
||||
// second use-time trigger point on the same one-time flag.
|
||||
import { Globe } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let {
|
||||
providerLabel,
|
||||
onAccept,
|
||||
@@ -16,16 +17,15 @@
|
||||
<div class="banner" role="alertdialog" aria-labelledby="hosted-ai-heading">
|
||||
<div class="heading">
|
||||
<Globe size={18} aria-hidden="true" />
|
||||
<strong id="hosted-ai-heading">Before using {providerLabel}</strong>
|
||||
<strong id="hosted-ai-heading">{t("hosted.heading", { provider: providerLabel })}</strong>
|
||||
</div>
|
||||
<p>
|
||||
Generating with <strong>{providerLabel}</strong> sends this meeting's transcript to their servers
|
||||
— it leaves this device and is subject to their privacy policy. WhispAssist has no control over data
|
||||
handling once it leaves your device. This is off by default; you're choosing it now.
|
||||
{t("hosted.body_1")} <strong>{providerLabel}</strong>
|
||||
{t("hosted.body_2")}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — continue</button>
|
||||
<button class="ghost" onclick={onCancel}>Cancel</button>
|
||||
<button class="primary" onclick={onAccept}>{t("hosted.accept")}</button>
|
||||
<button class="ghost" onclick={onCancel}>{t("hosted.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { api, errorMessage } from "../api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { trapFocus } from "../actions/trapFocus";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import { X, FileUp, Link as LinkIcon } from "@lucide/svelte";
|
||||
|
||||
let { onClose, onImported }: { onClose: () => void; onImported: (id: string) => void } = $props();
|
||||
@@ -21,8 +22,21 @@
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Audio / video",
|
||||
extensions: ["mp3", "m4a", "wav", "aac", "ogg", "opus", "flac", "mp4", "mkv", "mov", "webm", "avi"],
|
||||
name: t("import.filter_av"),
|
||||
extensions: [
|
||||
"mp3",
|
||||
"m4a",
|
||||
"wav",
|
||||
"aac",
|
||||
"ogg",
|
||||
"opus",
|
||||
"flac",
|
||||
"mp4",
|
||||
"mkv",
|
||||
"mov",
|
||||
"webm",
|
||||
"avi",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -48,44 +62,55 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true" aria-label="Add a meeting from a file or URL" tabindex="-1" use:trapFocus>
|
||||
<div
|
||||
class="overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("import.dialog_label")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="panel">
|
||||
<header>
|
||||
<strong>Add a meeting</strong>
|
||||
<button class="close" onclick={onClose} aria-label="Close" title="Close (Esc)">
|
||||
<strong>{t("import.title")}</strong>
|
||||
<button
|
||||
class="close"
|
||||
onclick={onClose}
|
||||
aria-label={t("import.close")}
|
||||
title={t("import.close_title")}
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="muted">
|
||||
Import an existing recording — a local audio/video file, or a link
|
||||
(YouTube, a streaming page, or a direct media URL). It's transcribed and
|
||||
diarized just like a live recording.
|
||||
</p>
|
||||
<p class="muted">{t("import.body")}</p>
|
||||
|
||||
<label class="wide">
|
||||
File or URL
|
||||
{t("import.file_or_url")}
|
||||
<div class="row">
|
||||
<input
|
||||
class="grow"
|
||||
bind:value={source}
|
||||
placeholder="Paste a URL, or browse for a file…"
|
||||
placeholder={t("import.source_placeholder")}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button onclick={browse} disabled={busy} title="Choose a local file">
|
||||
<FileUp size={14} aria-hidden="true" /> Browse…
|
||||
<button onclick={browse} disabled={busy} title={t("import.choose_file")}>
|
||||
<FileUp size={14} aria-hidden="true" />
|
||||
{t("import.browse")}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="wide">
|
||||
Title <em>(optional)</em>
|
||||
<input bind:value={title} placeholder="Defaults to the file name" disabled={busy} />
|
||||
{t("import.title_label")} <em>({t("import.optional")})</em>
|
||||
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
|
||||
</label>
|
||||
|
||||
<p class="muted small">
|
||||
<LinkIcon size={12} aria-hidden="true" /> Requires <code>ffmpeg</code> installed and on your
|
||||
PATH (plus <code>yt-dlp</code> for URLs). WhispAssist doesn't bundle them.
|
||||
<LinkIcon size={12} aria-hidden="true" />
|
||||
{t("import.requires_1")} <code>ffmpeg</code>
|
||||
{t("import.requires_2")} <code>yt-dlp</code>
|
||||
{t("import.requires_3")}
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
@@ -94,9 +119,9 @@
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={doImport} disabled={!source.trim() || busy}>
|
||||
{busy ? "Importing… this can take a while" : "Import"}
|
||||
{busy ? t("import.importing") : t("import.import")}
|
||||
</button>
|
||||
<button class="link" onclick={onClose} disabled={busy}>Cancel</button>
|
||||
<button class="link" onclick={onClose} disabled={busy}>{t("import.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// system/loopback level (green) and — when the mic is enabled — the microphone
|
||||
// level overlaid in the accent colour, so both sides of the call are visible
|
||||
// at a glance. Each stream shows an rms fill + a peak marker.
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let {
|
||||
rms,
|
||||
peak,
|
||||
@@ -29,7 +30,7 @@
|
||||
<div
|
||||
class="meter"
|
||||
role="meter"
|
||||
aria-label={showMic ? "System and microphone input level" : "System input level"}
|
||||
aria-label={showMic ? t("levelmeter.system_mic") : t("levelmeter.system")}
|
||||
aria-valuenow={Math.round(Math.max(rmsPct, showMic ? micRmsPct : 0))}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// list it's rendered in (not a global delete — the caller decides what
|
||||
// "remove" means).
|
||||
import { X } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
interface Props {
|
||||
tag: string;
|
||||
@@ -20,12 +21,17 @@
|
||||
class="label"
|
||||
onclick={onClick}
|
||||
disabled={!onClick}
|
||||
title={onClick ? `Filter meetings tagged "${tag}"` : undefined}
|
||||
title={onClick ? t("tagchip.filter", { tag }) : undefined}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{#if removable}
|
||||
<button type="button" class="remove" onclick={onRemove} aria-label={`Remove tag ${tag}`}>
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={onRemove}
|
||||
aria-label={t("tagchip.remove", { tag })}
|
||||
>
|
||||
<X size={10} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// plain <select> — three icon buttons instead of a text dropdown, defaults
|
||||
// to "system" so the app follows the OS until the user picks an override.
|
||||
import { Monitor, Sun, Moon } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
interface Props {
|
||||
value: "system" | "light" | "dark";
|
||||
@@ -10,21 +11,23 @@
|
||||
}
|
||||
let { value, onchange }: Props = $props();
|
||||
|
||||
// labelKey resolves through t() in the template so a language switch relabels
|
||||
// the buttons (a t() call in this module-level const would evaluate once).
|
||||
const options = [
|
||||
{ id: "system", label: "Match system", Icon: Monitor },
|
||||
{ id: "light", label: "Light", Icon: Sun },
|
||||
{ id: "dark", label: "Dark", Icon: Moon },
|
||||
{ id: "system", labelKey: "theme.system", Icon: Monitor },
|
||||
{ id: "light", labelKey: "theme.light", Icon: Sun },
|
||||
{ id: "dark", labelKey: "theme.dark", Icon: Moon },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="toggle" role="radiogroup" aria-label="Theme">
|
||||
<div class="toggle" role="radiogroup" aria-label={t("theme.group")}>
|
||||
{#each options as opt (opt.id)}
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={value === opt.id}
|
||||
class:active={value === opt.id}
|
||||
title={opt.label}
|
||||
aria-label={opt.label}
|
||||
title={t(opt.labelKey)}
|
||||
aria-label={t(opt.labelKey)}
|
||||
onclick={() => onchange(opt.id)}
|
||||
>
|
||||
<opt.Icon size={15} strokeWidth={2} aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
{
|
||||
"nav.recording": "Recording",
|
||||
"nav.hardware": "Hardware",
|
||||
"nav.storage": "Storage",
|
||||
"nav.calendar": "Calendar",
|
||||
"nav.sync": "Sync",
|
||||
"nav.ai": "AI",
|
||||
"nav.mcp": "MCP server",
|
||||
"nav.privacy": "Privacy",
|
||||
"nav.about": "About",
|
||||
"nav.language": "Language",
|
||||
|
||||
"settings.language.title": "Language",
|
||||
"settings.language.display": "Display language",
|
||||
"settings.language.display_hint": "The language of the app interface. Adding a language is as simple as dropping in one translation file.",
|
||||
|
||||
"settings.transcription.title": "Transcription Language",
|
||||
"settings.transcription.label": "Language",
|
||||
"settings.transcription.auto": "Auto-detect",
|
||||
"settings.transcription.applies_hint": "Applies to the next recording. The language actually used is shown on each meeting afterward.",
|
||||
"settings.transcription.english_only": "English only",
|
||||
"settings.transcription.model_english_only": "\"{model}\" is English-only.",
|
||||
"settings.transcription.no_model": "No model selected.",
|
||||
"settings.transcription.switch_multilingual": "Switch to a multilingual model above to choose a language.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.active_backend": "Active backend",
|
||||
"settings.hardware.model_meta": "· model {size}",
|
||||
"settings.hardware.preferred_backend": "Preferred backend",
|
||||
"settings.hardware.auto_backend": "Auto (best available)",
|
||||
"settings.hardware.not_available": "not available",
|
||||
"settings.hardware.fallback_hint": "Falls back automatically (NPU → NVIDIA → AMD → Intel → CPU) if the chosen backend fails to load.",
|
||||
"settings.hardware.audio_devices": "Audio Devices",
|
||||
"settings.hardware.recording_device": "Recording device",
|
||||
"settings.hardware.default_system_audio": "Default system audio",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist records whatever this device plays (loopback) — the other side of the call. Pick a specific output if you don't want it following Windows' system default.",
|
||||
"settings.hardware.microphone": "Microphone",
|
||||
"settings.hardware.mic_off": "Off — don't capture my microphone",
|
||||
"settings.hardware.default_mic": "Default microphone",
|
||||
"settings.hardware.mic_hint": "Adds your own voice to the live transcript so both sides of the meeting are captured. Stays on your device — nothing is uploaded. Choose “Off” to transcribe only the system audio above.",
|
||||
"settings.hardware.npu_detected": "NPU accelerator detected",
|
||||
"settings.hardware.ready": "Ready",
|
||||
"settings.hardware.downloading": "Downloading…",
|
||||
"settings.hardware.package_needed": "Package needed",
|
||||
"settings.hardware.npu_hint": "Your NPU needs a one-time acceleration package (Whisper ONNX model{extra}). It downloads in the background; you can also start it now.",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO runtime",
|
||||
"settings.hardware.download_npu": "Download NPU package",
|
||||
"settings.hardware.dml_title": "GPU acceleration (DirectML)",
|
||||
"settings.hardware.dml_hint": "Your GPU can be accelerated without Vulkan via DirectML — a one-time package (Whisper ONNX model{extra}).",
|
||||
"settings.hardware.plus_directml": " + DirectML runtime",
|
||||
"settings.hardware.download_dml": "Download DirectML package",
|
||||
"settings.hardware.detection_unavailable": "Hardware detection unavailable.",
|
||||
"settings.hardware.low_overhead": "Low overhead preset",
|
||||
"settings.hardware.low_overhead_note": "CPU + smallest model, for battery/background use",
|
||||
"settings.hardware.models": "Models",
|
||||
"settings.hardware.downloading_model": "Downloading {name}",
|
||||
"settings.hardware.active_badge": "Active",
|
||||
"settings.hardware.installed": "Installed",
|
||||
"settings.hardware.use": "Use",
|
||||
"settings.hardware.remove": "Remove",
|
||||
"settings.hardware.cant_remove_active": "Can't remove the active model",
|
||||
"settings.hardware.download": "Download",
|
||||
"settings.hardware.diarization": "Speaker diarization",
|
||||
"settings.hardware.diarization_hint": "Install both models to have finished recordings separated by speaker (Speaker 1, Speaker 2, …) instead of one running transcript. Runs fully offline, once per meeting after it stops. Without them, every line is attributed to a single speaker.",
|
||||
|
||||
"settings.storage.title": "Storage",
|
||||
"settings.storage.location_label": "Location",
|
||||
"settings.storage.location_hint": "Changing the storage location isn't supported yet — this is where meetings, models, and the database currently live.",
|
||||
"settings.storage.retention_title": "Retention",
|
||||
"settings.storage.retention_hint": "Automatically delete the oldest meetings past a limit. Checked once at startup; empty means no limit. Never touches a meeting that's currently recording.",
|
||||
"settings.storage.max_age": "Max age (days)",
|
||||
"settings.storage.max_size": "Max size (GB)",
|
||||
"settings.storage.no_limit": "no limit",
|
||||
"settings.storage.export_import_title": "Export & import",
|
||||
"settings.storage.export_hint_1": "Move recordings between computers as portable bundle folders (audio, transcript, notes, summary, and a",
|
||||
"settings.storage.export_hint_2": "manifest). Export to any folder — a synced drive, a USB stick, or a sync target's local mount — then import it on the other machine. Imported meetings get a fresh id, so re-importing never overwrites anything.",
|
||||
"settings.storage.export_dialog_title": "Export all meetings into…",
|
||||
"settings.storage.import_dialog_title": "Import a bundle (or a folder of bundles)…",
|
||||
"settings.storage.exporting": "Exporting…",
|
||||
"settings.storage.export_all": "Export all meetings…",
|
||||
"settings.storage.importing": "Importing…",
|
||||
"settings.storage.import_all": "Import meetings…",
|
||||
"settings.storage.exported_one": "Exported {n} meeting.",
|
||||
"settings.storage.exported_many": "Exported {n} meetings.",
|
||||
"settings.storage.imported_one": "Imported {n} meeting.",
|
||||
"settings.storage.imported_many": "Imported {n} meetings.",
|
||||
|
||||
"settings.calendar.title": "Calendar & Outlook .pst",
|
||||
"settings.calendar.intro_1": "Import events and attendees from a local Outlook",
|
||||
"settings.calendar.intro_2": "backup — read-only, nothing is written back to the file. Nothing leaves this device.",
|
||||
"settings.calendar.pst_file": ".pst file",
|
||||
"settings.calendar.no_file": "No file selected",
|
||||
"settings.calendar.browse": "Browse…",
|
||||
"settings.calendar.password": "Password",
|
||||
"settings.calendar.password_note": "rarely needed",
|
||||
"settings.calendar.optional": "optional",
|
||||
"settings.calendar.import_range": "Import range",
|
||||
"settings.calendar.range_30": "Last 30 days",
|
||||
"settings.calendar.range_90": "Last 90 days",
|
||||
"settings.calendar.range_180": "Last 6 months",
|
||||
"settings.calendar.range_365": "Last 1 year",
|
||||
"settings.calendar.range_all": "All time",
|
||||
"settings.calendar.range_hint": "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.",
|
||||
"settings.calendar.importing": "Importing…",
|
||||
"settings.calendar.import": "Import",
|
||||
"settings.calendar.import_progress": "{processed}/{total} events",
|
||||
"settings.calendar.auto_reimport": "Re-import this file automatically on launch",
|
||||
"settings.calendar.auto_reimport_hint": "Runs once at startup, not on a timer — re-import is safe to repeat (existing events are matched and updated, not duplicated).",
|
||||
"settings.calendar.import_failed": "Import failed: {error} — the file itself is untouched; check the path and try again.",
|
||||
"settings.calendar.cleanup_title": "Clean up",
|
||||
"settings.calendar.cleanup_hint": "Events already attached to a recorded meeting are always kept, no matter which option below is picked.",
|
||||
"settings.calendar.cleanup_30": "Older than 30 days",
|
||||
"settings.calendar.cleanup_90": "Older than 90 days",
|
||||
"settings.calendar.cleanup_180": "Older than 6 months",
|
||||
"settings.calendar.cleanup_365": "Older than 1 year",
|
||||
"settings.calendar.cleanup_all": "Delete all",
|
||||
"settings.calendar.cleaning": "Cleaning up…",
|
||||
"settings.calendar.cleanup_btn": "Clean up calendar",
|
||||
"settings.calendar.cleanup_result": "Deleted {deleted}{extra}.",
|
||||
"settings.calendar.cleanup_kept": ", kept {n} (linked to a meeting)",
|
||||
"settings.calendar.cleanup_failed": "Clean up failed: {error}",
|
||||
"settings.calendar.imported_events": "Imported events",
|
||||
"settings.calendar.no_events": "No events imported yet.",
|
||||
"settings.calendar.search_title": "Search title",
|
||||
"settings.calendar.search_placeholder": "Meeting name…",
|
||||
"settings.calendar.date_label": "Date",
|
||||
"settings.calendar.events_count": "{shown} of {total} events",
|
||||
"settings.calendar.untitled": "(untitled)",
|
||||
|
||||
"settings.dialog_title": "Settings",
|
||||
"settings.close": "Close",
|
||||
"settings.close_title": "Close (Esc)",
|
||||
|
||||
"settings.sync.stub": "Preview mode — the sync backend isn't implemented yet (Phase 9). Changes are kept in the UI only.",
|
||||
"settings.sync.title": "Sync & upload",
|
||||
"settings.sync.enable_label": "Enable uploading meeting artifacts to configured targets",
|
||||
"settings.sync.enable_hint": "Off by default. Nothing is uploaded unless this is on and a target is enabled. Self-hosted targets keep data on your own server; third-party clouds are clearly labeled.",
|
||||
"settings.sync.targets_title": "Targets",
|
||||
"settings.sync.no_targets": "No targets yet. Add one below.",
|
||||
"settings.sync.third_party": "third-party",
|
||||
"settings.sync.your_server": "your server",
|
||||
"settings.sync.edit": "Edit",
|
||||
"settings.sync.remove": "Remove",
|
||||
"settings.sync.edit_target": "Edit target",
|
||||
"settings.sync.add_target": "Add a target",
|
||||
"settings.sync.webdav_hint": "WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable SeafDAV server-side.)",
|
||||
"settings.sync.name": "Name",
|
||||
"settings.sync.name_placeholder": "Home Nextcloud",
|
||||
"settings.sync.provider": "Provider",
|
||||
"settings.sync.server_url": "Server URL",
|
||||
"settings.sync.server_url_note_1": "Just your server URL — WhispAssist adds",
|
||||
"settings.sync.server_url_note_2": "automatically.",
|
||||
"settings.sync.remote_folder": "Remote folder",
|
||||
"settings.sync.username": "Username",
|
||||
"settings.sync.app_password": "App password",
|
||||
"settings.sync.pw_keep": "leave blank to keep current password",
|
||||
"settings.sync.pw_store": "stored in OS credential store",
|
||||
"settings.sync.upload_legend": "Upload",
|
||||
"settings.sync.artifact_transcript": "transcript",
|
||||
"settings.sync.artifact_notes": "notes",
|
||||
"settings.sync.artifact_summary": "summary",
|
||||
"settings.sync.artifact_recording": "recording (.wav, if retained)",
|
||||
"settings.sync.allow_plaintext": "Allow plaintext http for a LAN address (not recommended)",
|
||||
"settings.sync.encrypt_before": "Encrypt before upload (destination stores only ciphertext)",
|
||||
"settings.sync.requires_vault": "— requires an unlocked vault",
|
||||
"settings.sync.test_connection": "Test connection",
|
||||
"settings.sync.save_changes": "Save changes",
|
||||
"settings.sync.add_target_btn": "Add target",
|
||||
"settings.sync.cancel": "Cancel",
|
||||
"settings.sync.third_party_banner": "{kind} is a third-party cloud — uploading sends your data off your device to {kind}.",
|
||||
"settings.sync.link_account": "Link {kind} account…",
|
||||
"settings.sync.oauth_hint": "Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential store.",
|
||||
"settings.sync.footnote": "Credentials are never written to settings or the database — only the OS credential store. TLS is required for non-LAN targets.",
|
||||
|
||||
"settings.ai.title": "AI summary provider",
|
||||
"settings.ai.intro_1": "Summaries run on a local LLM by default. Point this at Ollama on this PC or another machine on your LAN (e.g.",
|
||||
"settings.ai.intro_2": "— both count as local, so nothing leaves your network. Hosted providers (Anthropic) are optional, off by default, and send the transcript to a third party once you turn one on.",
|
||||
"settings.ai.provider": "Provider",
|
||||
"settings.ai.off": "Off",
|
||||
"settings.ai.provider_ollama": "Ollama (local / LAN)",
|
||||
"settings.ai.provider_custom": "Custom (OpenAI-compatible)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — hosted, leaves this device",
|
||||
"settings.ai.endpoint": "Endpoint",
|
||||
"settings.ai.model": "Model",
|
||||
"settings.ai.api_key": "API key",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (already set — leave blank to keep it)",
|
||||
"settings.ai.show_api_key": "Show API key",
|
||||
"settings.ai.hide_api_key": "Hide API key",
|
||||
"settings.ai.anthropic_banner": "Anthropic is a hosted, third-party service — this meeting's transcript leaves your device when you generate a summary.",
|
||||
"settings.ai.endpoint_banner": "This endpoint isn't on your machine or LAN — your transcript would leave your network.",
|
||||
"settings.ai.saving": "Saving…",
|
||||
"settings.ai.save": "Save",
|
||||
"settings.ai.test_connection": "Test connection",
|
||||
"settings.ai.this_hosted_provider": "this hosted provider",
|
||||
"settings.ai.reachable": "Reachable",
|
||||
"settings.ai.unreachable": "Unreachable",
|
||||
"settings.ai.model_count_one": "· {n} model",
|
||||
"settings.ai.model_count_many": "· {n} models",
|
||||
"settings.ai.on_lan": "· on your machine / LAN",
|
||||
"settings.ai.leaves_network": "· leaves your network",
|
||||
"settings.ai.advanced_title": "Advanced Ollama configuration",
|
||||
"settings.ai.changed": "{n} changed",
|
||||
"settings.ai.reset_all": "Reset all to defaults",
|
||||
"settings.ai.system_prompt": "System prompt",
|
||||
"settings.ai.system_prompt_placeholder": "e.g. You are a concise meeting summarizer.",
|
||||
"settings.ai.system_prompt_hint": "Added before WhispAssist's required output format, so your instructions can't break summary/action-item parsing.",
|
||||
"settings.ai.think": "Think",
|
||||
"settings.ai.think_help": "Reasoning effort (reasoning models only).",
|
||||
"settings.ai.keep_alive": "Keep alive",
|
||||
"settings.ai.keep_alive_help": "How long the model stays in RAM. e.g. 5m, 1h, 0 (unload), -1 (forever).",
|
||||
"settings.ai.runtime_hardware": "Runtime & hardware",
|
||||
"settings.ai.rarely_needed": "— rarely needed",
|
||||
"settings.ai.save_advanced": "Save advanced",
|
||||
"settings.ai.saved": "Saved",
|
||||
"settings.ai.turn_off": "Turn off",
|
||||
"settings.ai.default_value": "Default {value}.",
|
||||
"settings.ai.reset_default": "Reset to default",
|
||||
"settings.ai.reset_field": "Reset {name}",
|
||||
|
||||
"settings.mcp.title": "MCP server",
|
||||
"settings.mcp.banner_1": "This lets your own coding agent (Claude Code, Codex, Copilot, OpenCode, …) pull meeting context on your local machine. Once connected,",
|
||||
"settings.mcp.banner_agent": "that agent",
|
||||
"settings.mcp.banner_2": "may forward what it reads to its own model provider's cloud — outside WhispAssist's control. WhispAssist itself never sends this data anywhere; the server only listens on this device (",
|
||||
"settings.mcp.banner_3": ") and every read is logged below.",
|
||||
"settings.mcp.enable": "Enable the MCP server",
|
||||
"settings.mcp.enable_hint": "Off by default. Loopback-only, token-gated — nothing is reachable from the network.",
|
||||
"settings.mcp.transport": "Transport",
|
||||
"settings.mcp.transport_http": "Streamable HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (agent spawns a process)",
|
||||
"settings.mcp.port": "Port",
|
||||
"settings.mcp.transport_change_hint": "To change transport/port, turn the server off first, then back on.",
|
||||
"settings.mcp.scope_title": "Scope",
|
||||
"settings.mcp.expose": "Expose",
|
||||
"settings.mcp.scope_none": "None — nothing is shared",
|
||||
"settings.mcp.scope_selected": "Selected — only feature briefs you've marked shared",
|
||||
"settings.mcp.scope_all": "All — meetings, transcripts, action items, and shared briefs",
|
||||
"settings.mcp.expose_recordings": "Also allow meetings with a saved recording (off by default — a recorded meeting's transcript is withheld even in \"All\" scope until this is on)",
|
||||
"settings.mcp.token_title": "New auth token — shown once, copy it now",
|
||||
"settings.mcp.token_hint": "This won't be shown again. It's stored in your OS credential store; if you lose it, turn the server off and back on to mint a new one.",
|
||||
"settings.mcp.copied": "Copied",
|
||||
"settings.mcp.copy": "Copy",
|
||||
"settings.mcp.endpoint": "Endpoint",
|
||||
"settings.mcp.endpoint_hint": "Point your agent's MCP client config at this {kind}, with the token above as a bearer credential.",
|
||||
"settings.mcp.kind_command": "command",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Access log",
|
||||
"settings.mcp.access_log_hint": "Every tool read an agent makes, allowed or denied (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "No agent has read anything yet.",
|
||||
"settings.mcp.log_meeting": "meeting {id}",
|
||||
"settings.mcp.refresh": "Refresh",
|
||||
|
||||
"settings.privacy.title": "Privacy",
|
||||
"settings.privacy.intro": "What WhispAssist is actually allowed to send off this device right now. With everything off (the default), nothing leaves the device at all.",
|
||||
"settings.privacy.llm_endpoint": "LLM endpoint",
|
||||
"settings.privacy.off": "off",
|
||||
"settings.privacy.local_only": "local-only",
|
||||
"settings.privacy.leaves_device": "leaves this device",
|
||||
"settings.privacy.sync_label": "Sync",
|
||||
"settings.privacy.enabled": "enabled",
|
||||
"settings.privacy.mcp_label": "MCP server",
|
||||
"settings.privacy.mcp_on": "on · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Inbound on loopback only — it adds nothing to the egress list above. A connected agent may still forward what it reads to its own model provider; see the MCP server tab.",
|
||||
"settings.privacy.egress_title": "Egress allowlist",
|
||||
"settings.privacy.no_egress": "No hosts are allowlisted — WA makes no content egress.",
|
||||
"settings.privacy.sync_targets_title": "Sync targets",
|
||||
"settings.privacy.third_party": "third-party",
|
||||
"settings.privacy.your_server": "your server",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "no TLS",
|
||||
"settings.privacy.refresh": "Refresh",
|
||||
"settings.privacy.unavailable": "Privacy self-check unavailable.",
|
||||
"settings.privacy.vault_title": "Encryption vault",
|
||||
"settings.privacy.vault_intro": "Encrypt notes, transcripts, and summaries at rest with a password. (Audio files are not encrypted yet.)",
|
||||
"settings.privacy.vault_password": "Vault password",
|
||||
"settings.privacy.enable_vault": "Enable vault",
|
||||
"settings.privacy.vault_pw_hint": "Use at least 8 characters. If you forget it, encrypted content can't be recovered.",
|
||||
"settings.privacy.vault_locked_1": "Vault is ",
|
||||
"settings.privacy.locked_word": "locked",
|
||||
"settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.",
|
||||
"settings.privacy.password": "Password",
|
||||
"settings.privacy.unlock": "Unlock",
|
||||
"settings.privacy.vault_unlocked_1": "Vault is ",
|
||||
"settings.privacy.unlocked_word": "unlocked",
|
||||
"settings.privacy.vault_unlocked_2": ". New notes, transcripts, and summaries are encrypted at rest.",
|
||||
"settings.privacy.lock_now": "Lock now",
|
||||
"settings.privacy.change_password": "Change password",
|
||||
"settings.privacy.current_password": "Current password",
|
||||
"settings.privacy.new_password": "New password",
|
||||
|
||||
"settings.about.title": "About",
|
||||
"settings.about.tagline": "A fully local, open-source, Windows-native meeting assistant.",
|
||||
"settings.about.build_commit": "Build commit",
|
||||
|
||||
"consent.heading": "Before you record",
|
||||
"consent.body": "Recording conversations without the consent of participants may be illegal in your region. Check your local recording laws. This is a caution, not legal advice.",
|
||||
"consent.accept": "I understand — enable recording",
|
||||
"consent.cancel": "Cancel",
|
||||
|
||||
"hosted.heading": "Before using {provider}",
|
||||
"hosted.body_1": "Generating with",
|
||||
"hosted.body_2": "sends this meeting's transcript to their servers — it leaves this device and is subject to their privacy policy. WhispAssist has no control over data handling once it leaves your device. This is off by default; you're choosing it now.",
|
||||
"hosted.accept": "I understand — continue",
|
||||
"hosted.cancel": "Cancel",
|
||||
|
||||
"theme.group": "Theme",
|
||||
"theme.system": "Match system",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
"import.dialog_label": "Add a meeting from a file or URL",
|
||||
"import.title": "Add a meeting",
|
||||
"import.close": "Close",
|
||||
"import.close_title": "Close (Esc)",
|
||||
"import.body": "Import an existing recording — a local audio/video file, or a link (YouTube, a streaming page, or a direct media URL). It's transcribed and diarized just like a live recording.",
|
||||
"import.file_or_url": "File or URL",
|
||||
"import.source_placeholder": "Paste a URL, or browse for a file…",
|
||||
"import.choose_file": "Choose a local file",
|
||||
"import.browse": "Browse…",
|
||||
"import.title_label": "Title",
|
||||
"import.optional": "optional",
|
||||
"import.title_placeholder": "Defaults to the file name",
|
||||
"import.filter_av": "Audio / video",
|
||||
"import.requires_1": "Requires",
|
||||
"import.requires_2": "installed and on your PATH (plus",
|
||||
"import.requires_3": "for URLs). WhispAssist doesn't bundle them.",
|
||||
"import.importing": "Importing… this can take a while",
|
||||
"import.import": "Import",
|
||||
"import.cancel": "Cancel",
|
||||
|
||||
"tagchip.filter": "Filter meetings tagged \"{tag}\"",
|
||||
"tagchip.remove": "Remove tag {tag}",
|
||||
|
||||
"levelmeter.system_mic": "System and microphone input level",
|
||||
"levelmeter.system": "System input level",
|
||||
|
||||
"app.tagline": "local · private",
|
||||
"app.note_template": "Note template",
|
||||
"app.no_template": "No template",
|
||||
"app.add_meeting": "Add meeting",
|
||||
"app.add_meeting_title": "Add a meeting from a file or URL",
|
||||
"app.record": "Record",
|
||||
"app.record_title": "Start recording (Ctrl+Shift+R)",
|
||||
"app.stop": "Stop",
|
||||
"app.stop_title": "Stop recording (Ctrl+Shift+R)",
|
||||
"app.cancel": "Cancel",
|
||||
"app.cancel_title": "Discard this recording and delete it",
|
||||
"app.recording": "Recording…",
|
||||
"app.backend_title": "Active transcription backend",
|
||||
"app.retention_title": "Save audio as .wav for this meeting",
|
||||
"app.saving": "saving",
|
||||
"app.not_saved": "not saved",
|
||||
"app.reconnecting": "reconnecting audio device…",
|
||||
"app.settings": "Settings",
|
||||
"app.settings_title": "Settings (Ctrl+,)",
|
||||
"app.discard_confirm": "Discard this recording? Its audio and transcript will be deleted.",
|
||||
"app.announce_started": "Recording started",
|
||||
"app.announce_paused": "Recording paused",
|
||||
"app.announce_stopped": "Recording stopped",
|
||||
"app.consent_dialog": "Recording consent",
|
||||
"app.vault_dialog": "Unlock encryption vault",
|
||||
"app.vault_title": "Unlock encryption vault",
|
||||
"app.vault_desc": "Your meetings are encrypted at rest. Enter your password to read them.",
|
||||
"app.vault_password": "Vault password",
|
||||
"app.vault_incorrect": "Incorrect password",
|
||||
"app.unlock": "Unlock",
|
||||
"app.continue_locked": "Continue locked",
|
||||
"app.show_meetings": "Show meetings list",
|
||||
"app.hide_meetings": "Hide meetings list",
|
||||
"app.show_summary": "Show summary panel",
|
||||
"app.hide_summary": "Hide summary panel",
|
||||
"app.resize_meetings": "Resize meetings list",
|
||||
"app.resize_summary": "Resize summary panel",
|
||||
|
||||
"meetings.search_placeholder": "Search meetings…",
|
||||
"meetings.search_aria": "Search meetings",
|
||||
"meetings.filter_tag_aria": "Filter by tag",
|
||||
"meetings.all_tags": "All tags",
|
||||
"meetings.from_date_aria": "From date",
|
||||
"meetings.to_date_aria": "To date",
|
||||
"meetings.bulk_format_aria": "Bulk export format",
|
||||
"meetings.bulk_export": "Bulk export",
|
||||
"meetings.bulk_export_title": "Export every meeting matching the tag/date filters above",
|
||||
"meetings.exporting": "Exporting…",
|
||||
"meetings.exported_one": "Exported {n} meeting.",
|
||||
"meetings.exported_many": "Exported {n} meetings.",
|
||||
"meetings.searching": "Searching…",
|
||||
"meetings.loading": "Loading meetings…",
|
||||
"meetings.no_matches": "No matches.",
|
||||
"meetings.empty": "No meetings yet — click Record above to start.",
|
||||
"meetings.status.recording": "recording",
|
||||
"meetings.status.transcribing": "transcribing",
|
||||
"meetings.status.recovering": "recovering",
|
||||
"meetings.status.error": "error",
|
||||
"meetings.resume": "Resume transcription",
|
||||
"meetings.delete_aria": "Delete meeting",
|
||||
"meetings.delete_confirm": "Delete this meeting? This removes its recording, transcript, and notes.",
|
||||
|
||||
"transcript.heading": "Transcript",
|
||||
"transcript.title_aria": "Meeting title",
|
||||
"transcript.lang_title": "Transcription language",
|
||||
"transcript.lang_auto": "auto-detecting…",
|
||||
"transcript.show": "Show transcript",
|
||||
"transcript.hide": "Hide transcript",
|
||||
"transcript.empty": "No transcript for this meeting.",
|
||||
"transcript.will_appear": "Transcript will appear here as you record.",
|
||||
"transcript.play_from_here": "Play from here",
|
||||
"transcript.resize": "Resize transcript and notes",
|
||||
"transcript.reprocess_placeholder": "Re-transcribe with…",
|
||||
"transcript.reprocess_lang_aria": "Reprocess language",
|
||||
"transcript.reprocess_keep_lang": "Keep current language",
|
||||
"transcript.reprocess_go": "Go",
|
||||
"transcript.retranscribing": "Re-transcribing…",
|
||||
"transcript.has_note": "Has a note",
|
||||
"transcript.note_placeholder": "Add a note for this moment…",
|
||||
"transcript.note_aria": "Note for this transcript line",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word document",
|
||||
"transcript.filter_obsidian": "Obsidian note",
|
||||
|
||||
"notes.heading": "Notes",
|
||||
"notes.show": "Show notes",
|
||||
"notes.hide": "Hide notes",
|
||||
"notes.placeholder": "Notes…",
|
||||
"notes.live_placeholder": "Type notes while you talk…",
|
||||
"notes.toolbar_aria": "Notes formatting",
|
||||
"notes.bold": "Bold",
|
||||
"notes.italic": "Italic",
|
||||
"notes.h1": "Heading 1",
|
||||
"notes.h2": "Heading 2",
|
||||
"notes.bullet": "Bullet list",
|
||||
"notes.checkbox_title": "Checkbox",
|
||||
"notes.checkbox_aria": "Checkbox list item",
|
||||
"notes.edit_raw": "Edit the raw markdown",
|
||||
"notes.render": "Render the markdown",
|
||||
"notes.editor": "Editor",
|
||||
"notes.preview": "Preview",
|
||||
"notes.export_md_title": "Export notes as .md",
|
||||
"notes.export_pdf_title": "Export notes as .pdf",
|
||||
"notes.export_docx_title": "Export notes as .docx",
|
||||
"notes.export_bundle_title": "Export audio + transcript + notes to a folder",
|
||||
"notes.export_obsidian_title": "Export one Obsidian note (notes + summary + transcript, no audio)",
|
||||
|
||||
"summary.recording_heading": "Recording",
|
||||
"summary.loading_recording": "Loading recording…",
|
||||
"summary.sync_heading": "Sync",
|
||||
"summary.uploading": "Uploading…",
|
||||
"summary.upload_now": "Upload now",
|
||||
"summary.no_uploads": "No uploads yet for this meeting.",
|
||||
"summary.retry": "retry",
|
||||
"summary.tags_heading": "Tags",
|
||||
"summary.tags_select": "Select a meeting to tag it.",
|
||||
"summary.tag_add_placeholder": "Add tag…",
|
||||
"summary.tag_first_placeholder": "project, client, topic…",
|
||||
"summary.generating": "Generating…",
|
||||
"summary.generate_tags": "Generate tags",
|
||||
"summary.saving": "Saving…",
|
||||
"summary.save_tags": "Save tags",
|
||||
"summary.summary_heading": "Summary",
|
||||
"summary.ai_provider": "AI provider",
|
||||
"summary.provider_off": "Off",
|
||||
"summary.provider_custom": "Custom",
|
||||
"summary.local": "local",
|
||||
"summary.leaves_device": "leaves this device",
|
||||
"summary.this_hosted_provider": "this hosted provider",
|
||||
"summary.select_generate": "Select a meeting to generate a summary.",
|
||||
"summary.decisions_heading": "Decisions",
|
||||
"summary.regenerate": "Regenerate",
|
||||
"summary.generated_hint": "Generated locally after the meeting (requires a local LLM provider).",
|
||||
"summary.no_provider": "No AI provider configured — enable one in Settings.",
|
||||
"summary.generate_summary": "Generate summary",
|
||||
"summary.briefs_heading": "Feature briefs",
|
||||
"summary.briefs_select": "Select a meeting to create or view feature briefs.",
|
||||
"summary.briefs_desc": "Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a coding agent, or serve it over the MCP server once that's on.",
|
||||
"summary.brief_repo_placeholder": "Target repo (optional), e.g. acme/reporting-web",
|
||||
"summary.distilling": "Distilling…",
|
||||
"summary.create_brief": "Create feature brief",
|
||||
"summary.brief_mcp_title": "Available when the MCP server is on",
|
||||
"summary.copied": "Copied",
|
||||
"summary.copy_md": "Copy as Markdown",
|
||||
"summary.copy_json": "Copy as JSON",
|
||||
"summary.brief_problem": "Problem",
|
||||
"summary.brief_outcome": "Desired outcome",
|
||||
"summary.brief_criteria": "Acceptance criteria",
|
||||
"summary.brief_none": "None captured.",
|
||||
"summary.brief_context": "Context",
|
||||
"summary.action_items_heading": "Action items",
|
||||
"summary.ai_select": "Select a meeting to see its action items.",
|
||||
"summary.ai_empty": "None yet — add one below, or generate a summary to extract them automatically.",
|
||||
"summary.confirmed": "Confirmed",
|
||||
"summary.ai_text_placeholder": "Action item…",
|
||||
"summary.ai_text_aria": "Action item text",
|
||||
"summary.owner": "Owner",
|
||||
"summary.due_date": "Due date",
|
||||
"summary.reminder_title": "Schedule a local reminder for this due date",
|
||||
"summary.ai_delete_title": "Delete this action item",
|
||||
"summary.ai_delete_aria": "Delete action item",
|
||||
"summary.add_action_item": "Add action item",
|
||||
"summary.save_action_items": "Save action items",
|
||||
"summary.calendar_heading": "Calendar event",
|
||||
"summary.cal_select": "Select a meeting to link it to a calendar event.",
|
||||
"summary.event_search_placeholder": "Search event title…",
|
||||
"summary.linked_event": "Linked event",
|
||||
"summary.change_event": "Change event…",
|
||||
"summary.link_event": "Link an event…",
|
||||
"summary.untitled_event": "(untitled)",
|
||||
"summary.no_events_1": "No events imported yet — import a",
|
||||
"summary.no_events_2": "from Settings → Calendar.",
|
||||
"summary.no_events_match": "No events match this search/date — try clearing one.",
|
||||
"summary.organizer_label": "Organizer: {name}",
|
||||
"summary.participants_heading": "Participants",
|
||||
"summary.participants_hint": "Populated from the linked calendar event.",
|
||||
"summary.speakers_heading": "Speakers",
|
||||
"summary.speakers_select": "Select a meeting to name its speakers.",
|
||||
"summary.no_speakers": "No speakers detected yet.",
|
||||
"summary.speaker_name_placeholder": "Speaker name",
|
||||
"summary.save": "Save",
|
||||
"summary.cancel": "Cancel",
|
||||
"summary.name_speaker": "Name this speaker…",
|
||||
"summary.add_new_name": "+ Add new name…",
|
||||
|
||||
"settings.recording.title": "Recording",
|
||||
"settings.recording.record_default": "Record meetings by default",
|
||||
"settings.recording.save_audio_as": "save audio as",
|
||||
"settings.recording.default_hint": "Off by default. When off, audio is used only to produce the transcript and is deleted when the meeting is finalized. You can also toggle recording per meeting.",
|
||||
"settings.recording.consent_label": "Consent: {status}.",
|
||||
"settings.recording.consent_ack": "acknowledged",
|
||||
"settings.recording.consent_not": "not yet acknowledged",
|
||||
"settings.recording.auto_label": "Auto-start recording when a calendar event begins",
|
||||
"settings.recording.auto_hint": "Only while WhispAssist is open. When an imported calendar event's start time arrives, a recording begins automatically (using your default retention setting above). Nothing runs in the background — the timer is armed only while the app is running. Import events under Settings → Calendar."
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Minimal hand-rolled i18n — no dependency. A reactive `locale` (persisted to
|
||||
// localStorage, UI ephemera like the layout store) plus a `t(key)` lookup over
|
||||
// per-language JSON dictionaries. English is the fallback for any missing key,
|
||||
// so a partial translation degrades to English rather than showing raw keys.
|
||||
//
|
||||
// Adding a language: create `<code>.json` next to en.json, import it, and add
|
||||
// it to DICTS + LOCALES below. Translating that one file is the whole job.
|
||||
|
||||
import en from "./en.json";
|
||||
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
// Register languages here. en is the source-of-truth key set + fallback.
|
||||
const DICTS: Record<string, Dict> = { en };
|
||||
export const LOCALES: { code: string; label: string }[] = [{ code: "en", label: "English" }];
|
||||
|
||||
const KEY = "wa-locale-v1";
|
||||
|
||||
function load(): string {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved && saved in DICTS) return saved;
|
||||
} catch {
|
||||
/* localStorage unavailable — fall through to default */
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
class I18n {
|
||||
locale = $state(load());
|
||||
|
||||
setLocale(code: string) {
|
||||
if (!(code in DICTS)) return;
|
||||
this.locale = code;
|
||||
try {
|
||||
localStorage.setItem(KEY, code);
|
||||
} catch {
|
||||
/* non-fatal: preference just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up `key` in the active locale, falling back to English then the key
|
||||
* itself. `vars` fills `{name}` placeholders. Reads `locale` so components
|
||||
* that call `t()` in markup re-render when the language changes. */
|
||||
t = (key: string, vars?: Record<string, string | number>): string => {
|
||||
const dict = DICTS[this.locale] ?? en;
|
||||
let s = dict[key] ?? (en as Dict)[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
export const i18n = new I18n();
|
||||
export const t = i18n.t;
|
||||
@@ -0,0 +1,29 @@
|
||||
// Shared recording-playback state so the transcript (center pane,
|
||||
// TranscriptNotes) and the <audio> element (side pane, SummaryPanel) can talk
|
||||
// to each other across the App layout: click a transcript segment to seek the
|
||||
// player, and highlight/auto-scroll the segment that's currently playing.
|
||||
// Pure UI ephemera — no persistence, reset per meeting.
|
||||
|
||||
class PlayerStore {
|
||||
/** Playback position in ms, pushed from the <audio> element's timeupdate. */
|
||||
currentMs = $state(0);
|
||||
/** Target of the latest seek request, in ms. */
|
||||
seekMs = $state<number | null>(null);
|
||||
/** Bumped on every seek() so clicking the *same* segment twice re-seeks
|
||||
* (a plain seekMs assignment wouldn't fire when the value is unchanged). */
|
||||
seekNonce = $state(0);
|
||||
|
||||
/** Ask the player to jump to `ms` and play (SummaryPanel watches seekNonce). */
|
||||
seek(ms: number) {
|
||||
this.seekMs = ms;
|
||||
this.seekNonce++;
|
||||
}
|
||||
|
||||
/** Clear when the selected meeting changes so a new recording starts at 0. */
|
||||
reset() {
|
||||
this.currentMs = 0;
|
||||
this.seekMs = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const player = new PlayerStore();
|
||||
@@ -5,6 +5,12 @@
|
||||
import { api, type MeetingListItem, type SearchHit } from "../api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Search, Trash2, Download, RotateCcw } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
// Meeting status ("recording"/"transcribing"/"recovering"/"error") → its
|
||||
// localized badge label. The status value itself is app vocabulary, not user
|
||||
// content, so it gets keyed.
|
||||
const statusLabel = (s: string) => t(`meetings.status.${s}`);
|
||||
|
||||
let query = $state("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -55,7 +61,10 @@
|
||||
from: toUnix(fromFilter),
|
||||
to: toUnix(toFilter),
|
||||
});
|
||||
bulkResult = `Exported ${count} meeting${count === 1 ? "" : "s"}.`;
|
||||
bulkResult =
|
||||
count === 1
|
||||
? t("meetings.exported_one", { n: count })
|
||||
: t("meetings.exported_many", { n: count });
|
||||
} finally {
|
||||
bulkExporting = false;
|
||||
}
|
||||
@@ -93,7 +102,7 @@
|
||||
|
||||
async function removeMeeting(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Delete this meeting? This removes its recording, transcript, and notes.")) return;
|
||||
if (!confirm(t("meetings.delete_confirm"))) return;
|
||||
await meetings.remove(id);
|
||||
}
|
||||
|
||||
@@ -107,24 +116,38 @@
|
||||
<div class="search-box">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input
|
||||
placeholder="Search meetings…"
|
||||
aria-label="Search meetings"
|
||||
placeholder={t("meetings.search_placeholder")}
|
||||
aria-label={t("meetings.search_aria")}
|
||||
bind:value={query}
|
||||
oninput={onSearchInput}
|
||||
/>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select aria-label="Filter by tag" bind:value={tagFilter} onchange={onFilterChange}>
|
||||
<option value="">All tags</option>
|
||||
{#each meetings.allTags as t (t)}
|
||||
<option value={t}>{t}</option>
|
||||
<select
|
||||
aria-label={t("meetings.filter_tag_aria")}
|
||||
bind:value={tagFilter}
|
||||
onchange={onFilterChange}
|
||||
>
|
||||
<option value="">{t("meetings.all_tags")}</option>
|
||||
{#each meetings.allTags as tag (tag)}
|
||||
<option value={tag}>{tag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input type="date" aria-label="From date" bind:value={fromFilter} onchange={onFilterChange} />
|
||||
<input type="date" aria-label="To date" bind:value={toFilter} onchange={onFilterChange} />
|
||||
<input
|
||||
type="date"
|
||||
aria-label={t("meetings.from_date_aria")}
|
||||
bind:value={fromFilter}
|
||||
onchange={onFilterChange}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
aria-label={t("meetings.to_date_aria")}
|
||||
bind:value={toFilter}
|
||||
onchange={onFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select aria-label="Bulk export format" bind:value={bulkFormat}>
|
||||
<select aria-label={t("meetings.bulk_format_aria")} bind:value={bulkFormat}>
|
||||
<option value="md">.md</option>
|
||||
<option value="pdf">.pdf</option>
|
||||
<option value="docx">.docx</option>
|
||||
@@ -134,23 +157,23 @@
|
||||
class="bulk-btn"
|
||||
onclick={bulkExport}
|
||||
disabled={bulkExporting}
|
||||
title="Export every meeting matching the tag/date filters above"
|
||||
title={t("meetings.bulk_export_title")}
|
||||
>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{bulkExporting ? "Exporting…" : "Bulk export"}
|
||||
{bulkExporting ? t("meetings.exporting") : t("meetings.bulk_export")}
|
||||
</button>
|
||||
</div>
|
||||
{#if bulkResult}
|
||||
<p class="muted small">{bulkResult}</p>
|
||||
{/if}
|
||||
{#if meetings.searching}
|
||||
<p class="muted">Searching…</p>
|
||||
<p class="muted">{t("meetings.searching")}</p>
|
||||
{:else if displayItems.length === 0 && meetings.loading}
|
||||
<p class="muted">Loading meetings…</p>
|
||||
<p class="muted">{t("meetings.loading")}</p>
|
||||
{:else if displayItems.length === 0 && meetings.searchResults !== null}
|
||||
<p class="muted">No matches.</p>
|
||||
<p class="muted">{t("meetings.no_matches")}</p>
|
||||
{:else if displayItems.length === 0}
|
||||
<p class="muted">No meetings yet — click Record above to start.</p>
|
||||
<p class="muted">{t("meetings.empty")}</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each displayItems as m (m.id)}
|
||||
@@ -159,11 +182,11 @@
|
||||
<span class="row">
|
||||
<span class="title">{m.title}</span>
|
||||
{#if m.status === "recording" || m.status === "transcribing"}
|
||||
<span class="badge live">{m.status}</span>
|
||||
<span class="badge live">{statusLabel(m.status)}</span>
|
||||
{:else if m.status === "recovering"}
|
||||
<span class="badge recovering">recovering</span>
|
||||
<span class="badge recovering">{statusLabel("recovering")}</span>
|
||||
{:else if m.status === "error"}
|
||||
<span class="badge error">error</span>
|
||||
<span class="badge error">{statusLabel("error")}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="row muted small">
|
||||
@@ -175,8 +198,8 @@
|
||||
{/if}
|
||||
{#if m.tags.length > 0}
|
||||
<span class="row tags">
|
||||
{#each m.tags as t (t)}
|
||||
<span class="chip">{t}</span>
|
||||
{#each m.tags as tag (tag)}
|
||||
<span class="chip">{tag}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -185,14 +208,14 @@
|
||||
{#if m.status === "recovering"}
|
||||
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
Resume transcription
|
||||
{t("meetings.resume")}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="link danger"
|
||||
onclick={(e) => removeMeeting(e, m.id)}
|
||||
aria-label="Delete meeting"
|
||||
title="Delete meeting"
|
||||
aria-label={t("meetings.delete_aria")}
|
||||
title={t("meetings.delete_aria")}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
+546
-371
File diff suppressed because it is too large
Load Diff
+236
-102
@@ -4,6 +4,7 @@
|
||||
import { meetings } from "../stores/meetings.svelte";
|
||||
import { calendar } from "../stores/calendar.svelte";
|
||||
import { settings } from "../stores/settings.svelte";
|
||||
import { player } from "../stores/player.svelte";
|
||||
import {
|
||||
api,
|
||||
errorMessage,
|
||||
@@ -14,6 +15,7 @@
|
||||
type LlmStatus,
|
||||
} from "../api";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import TagChip from "../components/TagChip.svelte";
|
||||
import HostedAiBanner from "../components/HostedAiBanner.svelte";
|
||||
import {
|
||||
@@ -30,6 +32,8 @@
|
||||
Check,
|
||||
Cpu,
|
||||
Globe,
|
||||
Plus,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
onMount(() => calendar.load());
|
||||
@@ -37,10 +41,12 @@
|
||||
// ---- Recording playback (FR-REC-5) ----
|
||||
let audioSrc = $state<string | null>(null);
|
||||
let audioError = $state<string | null>(null);
|
||||
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
audioSrc = null;
|
||||
audioError = null;
|
||||
player.reset();
|
||||
if (m?.recorded) {
|
||||
const id = m.id;
|
||||
api
|
||||
@@ -54,6 +60,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Seek + play when a transcript segment is clicked (player.seek bumps the
|
||||
// nonce so re-clicking the same segment still jumps). Reading seekNonce is
|
||||
// what makes this effect re-run.
|
||||
$effect(() => {
|
||||
player.seekNonce;
|
||||
const ms = player.seekMs;
|
||||
if (ms == null || !audioEl) return;
|
||||
audioEl.currentTime = ms / 1000;
|
||||
void audioEl.play().catch(() => {});
|
||||
});
|
||||
|
||||
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
|
||||
let llmStatus = $state<LlmStatus | null>(null);
|
||||
async function refreshLlmStatus() {
|
||||
@@ -75,12 +92,24 @@
|
||||
// the exact same set_llm_provider command (and hence the same "anthropic's
|
||||
// endpoint is fixed" / "switching away resets a stale hosted endpoint"
|
||||
// guarantees — see apply_llm_provider_args in commands.rs).
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
off: "Off",
|
||||
ollama: "Ollama",
|
||||
custom: "Custom",
|
||||
anthropic: "Anthropic (Claude)",
|
||||
};
|
||||
// Provider dropdown values. Labels resolve through `providerLabel` (a
|
||||
// function, not a static map, so a language switch re-renders them);
|
||||
// brand names stay literal, only the generic "Off"/"Custom" are translated.
|
||||
const PROVIDER_IDS = ["off", "ollama", "custom", "anthropic"];
|
||||
function providerLabel(id: string): string {
|
||||
switch (id) {
|
||||
case "off":
|
||||
return t("summary.provider_off");
|
||||
case "custom":
|
||||
return t("summary.provider_custom");
|
||||
case "ollama":
|
||||
return "Ollama";
|
||||
case "anthropic":
|
||||
return "Anthropic (Claude)";
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
}
|
||||
function isHostedStatus(status: LlmStatus | null): boolean {
|
||||
return !!status && status.provider !== "off" && !status.isLocal;
|
||||
}
|
||||
@@ -92,7 +121,7 @@
|
||||
// gate that matters even if the quick switch's own check is skipped, e.g.
|
||||
// "custom" resolving to a hosted endpoint that was configured elsewhere).
|
||||
let showHostedBanner = $state(false);
|
||||
let hostedBannerLabel = $state("this hosted provider");
|
||||
let hostedBannerLabel = $state(t("summary.this_hosted_provider"));
|
||||
let pendingHostedAction = $state<(() => void) | null>(null);
|
||||
function requireHostedAck(providerLabel: string, action: () => void) {
|
||||
if (!settings.settings.hosted_ai_acknowledged) {
|
||||
@@ -129,13 +158,13 @@
|
||||
if (next === "anthropic") {
|
||||
// Deterministically hosted — worth confirming before even switching to
|
||||
// it, not just before the next generate.
|
||||
requireHostedAck(PROVIDER_LABELS[next], () => void switchProvider(next));
|
||||
requireHostedAck(providerLabel(next), () => void switchProvider(next));
|
||||
} else {
|
||||
void switchProvider(next);
|
||||
}
|
||||
}
|
||||
function onGenerateClick() {
|
||||
const label = llmStatus ? (PROVIDER_LABELS[llmStatus.provider] ?? llmStatus.provider) : "";
|
||||
const label = llmStatus ? providerLabel(llmStatus.provider) : "";
|
||||
if (isHostedStatus(llmStatus)) {
|
||||
requireHostedAck(label, generateSummary);
|
||||
} else {
|
||||
@@ -143,20 +172,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Editable copy so toggling a checkbox doesn't persist until "Save" is
|
||||
// pressed; recomputes whenever a different meeting (or a freshly generated
|
||||
// summary) comes in. bind:checked mutates each item in place, which a plain
|
||||
// $derived array tolerates fine — only the array identity is recomputed.
|
||||
let editableItems = $derived(
|
||||
(meetings.selected?.summary?.action_items ?? []).map((i) => ({ ...i }) as ActionItem),
|
||||
);
|
||||
// Editable working copy so add/delete/edit don't persist until "Save" is
|
||||
// pressed. Resynced from the meeting's table-backed action_items whenever a
|
||||
// *different* meeting is selected (same guard pattern as pendingTags), so
|
||||
// it isn't clobbered mid-edit.
|
||||
let editableItems = $state<ActionItem[]>([]);
|
||||
let loadedItemsForId: string | null = null;
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
if (m && m.id !== loadedItemsForId) {
|
||||
editableItems = m.action_items.map((i) => ({ ...i }));
|
||||
loadedItemsForId = m.id;
|
||||
} else if (!m) {
|
||||
loadedItemsForId = null;
|
||||
}
|
||||
});
|
||||
|
||||
function addActionItem() {
|
||||
editableItems = [
|
||||
...editableItems,
|
||||
{ id: null, text: "", owner: null, due_at: null, confirmed: false, reminder_set: false },
|
||||
];
|
||||
}
|
||||
function removeActionItem(i: number) {
|
||||
editableItems = editableItems.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
let savingItems = $state(false);
|
||||
async function saveActionItems() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
savingItems = true;
|
||||
try {
|
||||
await meetings.confirmActionItems(m.id, editableItems);
|
||||
// Drop blank rows (an added-but-never-typed item) rather than persisting
|
||||
// empty tasks; keep null owner rather than "".
|
||||
const items = editableItems
|
||||
.filter((i) => i.text.trim())
|
||||
.map((i) => ({ ...i, owner: i.owner?.trim() || null }));
|
||||
await meetings.confirmActionItems(m.id, items);
|
||||
// confirmActionItems reloaded the meeting — resync so freshly inserted
|
||||
// rows carry their new ids (a second save would otherwise re-insert them).
|
||||
editableItems = (meetings.selected?.action_items ?? []).map((i) => ({ ...i }));
|
||||
} finally {
|
||||
savingItems = false;
|
||||
}
|
||||
@@ -278,8 +334,8 @@
|
||||
});
|
||||
|
||||
function addTag(raw: string) {
|
||||
const t = raw.trim().toLowerCase();
|
||||
if (t && !pendingTags.includes(t)) pendingTags.push(t);
|
||||
const tag = raw.trim().toLowerCase();
|
||||
if (tag && !pendingTags.includes(tag)) pendingTags.push(tag);
|
||||
}
|
||||
// GitHub-topics-style input: a comma commits everything before it as its
|
||||
// own chip immediately, leaving whatever's after as the live draft.
|
||||
@@ -299,8 +355,8 @@
|
||||
if (tagDraft.trim()) addTag(tagDraft);
|
||||
tagDraft = "";
|
||||
}
|
||||
function removeTag(t: string) {
|
||||
pendingTags = pendingTags.filter((x) => x !== t);
|
||||
function removeTag(tag: string) {
|
||||
pendingTags = pendingTags.filter((x) => x !== tag);
|
||||
}
|
||||
|
||||
let savingTags = $state(false);
|
||||
@@ -450,7 +506,7 @@
|
||||
|
||||
<div class="wrap">
|
||||
{#if meetings.selected?.recorded}
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.recording_heading")}</h3>
|
||||
{#if audioSrc}
|
||||
<!-- a meeting recording has no caption track — no svelte-ignore needed,
|
||||
the current eslint-plugin-svelte doesn't flag this element. -->
|
||||
@@ -461,17 +517,19 @@
|
||||
controls
|
||||
controlsList="nodownload noplaybackrate"
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
bind:this={audioEl}
|
||||
ontimeupdate={() => audioEl && (player.currentMs = audioEl.currentTime * 1000)}
|
||||
src={audioSrc}
|
||||
></audio>
|
||||
{:else if audioError}
|
||||
<p class="muted">{audioError}</p>
|
||||
{:else}
|
||||
<p class="muted">Loading recording…</p>
|
||||
<p class="muted">{t("summary.loading_recording")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if meetings.selected && settings.settings.sync_enabled}
|
||||
<h3><UploadCloud size={14} aria-hidden="true" /> Sync</h3>
|
||||
<h3><UploadCloud size={14} aria-hidden="true" /> {t("summary.sync_heading")}</h3>
|
||||
<button
|
||||
class="primary upload-now"
|
||||
disabled={meetings.uploading}
|
||||
@@ -481,7 +539,7 @@
|
||||
}}
|
||||
>
|
||||
<UploadCloud size={14} aria-hidden="true" />
|
||||
{meetings.uploading ? "Uploading…" : "Upload now"}
|
||||
{meetings.uploading ? t("summary.uploading") : t("summary.upload_now")}
|
||||
</button>
|
||||
{#if meetings.syncJobs.length}
|
||||
<ul class="jobs">
|
||||
@@ -500,34 +558,36 @@
|
||||
onclick={() => {
|
||||
const m = meetings.selected;
|
||||
if (m) meetings.retryJob(job.jobId, m.id);
|
||||
}}>retry</button
|
||||
}}>{t("summary.retry")}</button
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted">No uploads yet for this meeting.</p>
|
||||
<p class="muted">{t("summary.no_uploads")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Tags size={14} aria-hidden="true" /> Tags</h3>
|
||||
<h3><Tags size={14} aria-hidden="true" /> {t("summary.tags_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to tag it.</p>
|
||||
<p class="muted">{t("summary.tags_select")}</p>
|
||||
{:else}
|
||||
<div class="tag-editor">
|
||||
{#each pendingTags as t (t)}
|
||||
{#each pendingTags as tag (tag)}
|
||||
<TagChip
|
||||
tag={t}
|
||||
{tag}
|
||||
removable
|
||||
onRemove={() => removeTag(t)}
|
||||
onClick={() => meetings.filterByTag(t)}
|
||||
onRemove={() => removeTag(tag)}
|
||||
onClick={() => meetings.filterByTag(tag)}
|
||||
/>
|
||||
{/each}
|
||||
<input
|
||||
class="tag-input"
|
||||
list="known-tags"
|
||||
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
|
||||
placeholder={pendingTags.length
|
||||
? t("summary.tag_add_placeholder")
|
||||
: t("summary.tag_first_placeholder")}
|
||||
bind:value={tagDraft}
|
||||
oninput={onTagInput}
|
||||
onkeydown={onTagInputKeydown}
|
||||
@@ -535,17 +595,17 @@
|
||||
/>
|
||||
</div>
|
||||
<datalist id="known-tags">
|
||||
{#each meetings.allTags as t (t)}
|
||||
<option value={t}></option>
|
||||
{#each meetings.allTags as tag (tag)}
|
||||
<option value={tag}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{generatingTags ? "Generating…" : "Generate tags"}
|
||||
{generatingTags ? t("summary.generating") : t("summary.generate_tags")}
|
||||
</button>
|
||||
<button class="link" onclick={saveTags} disabled={savingTags}>
|
||||
{savingTags ? "Saving…" : "Save tags"}
|
||||
{savingTags ? t("summary.saving") : t("summary.save_tags")}
|
||||
</button>
|
||||
</div>
|
||||
{#if tagGenError}
|
||||
@@ -553,18 +613,18 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> {t("summary.summary_heading")}</h3>
|
||||
|
||||
<div class="provider-row">
|
||||
<label class="provider-select">
|
||||
<span class="sr-only">AI provider</span>
|
||||
<span class="sr-only">{t("summary.ai_provider")}</span>
|
||||
<select
|
||||
value={llmStatus?.provider ?? settings.settings.llm_provider}
|
||||
onchange={onProviderChange}
|
||||
disabled={switchingProvider}
|
||||
>
|
||||
{#each Object.entries(PROVIDER_LABELS) as [id, label] (id)}
|
||||
<option value={id}>{label}</option>
|
||||
{#each PROVIDER_IDS as id (id)}
|
||||
<option value={id}>{providerLabel(id)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
@@ -574,7 +634,7 @@
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
{llmStatus.isLocal ? "local" : "leaves this device"}
|
||||
{llmStatus.isLocal ? t("summary.local") : t("summary.leaves_device")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -587,9 +647,9 @@
|
||||
{/if}
|
||||
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to generate a summary.</p>
|
||||
<p class="muted">{t("summary.select_generate")}</p>
|
||||
{:else if meetings.summarizingId === meetings.selected.id}
|
||||
<p class="muted">Generating…</p>
|
||||
<p class="muted">{t("summary.generating")}</p>
|
||||
{#if meetings.summaryStream}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="summary-md">{@html renderMarkdown(meetings.summaryStream)}</div>
|
||||
@@ -598,22 +658,22 @@
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="summary-md">{@html renderMarkdown(meetings.selected.summary.summary_md)}</div>
|
||||
{#if meetings.selected.summary.decisions.length > 0}
|
||||
<h4>Decisions</h4>
|
||||
<h4>{t("summary.decisions_heading")}</h4>
|
||||
<ul class="decisions">
|
||||
{#each meetings.selected.summary.decisions as d, i (i)}
|
||||
<li>{d}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<button class="link" onclick={onGenerateClick}>Regenerate</button>
|
||||
<button class="link" onclick={onGenerateClick}>{t("summary.regenerate")}</button>
|
||||
{:else}
|
||||
<p class="muted">Generated locally after the meeting (requires a local LLM provider).</p>
|
||||
<p class="muted">{t("summary.generated_hint")}</p>
|
||||
{#if llmStatus && llmStatus.provider === "off"}
|
||||
<p class="muted small">No AI provider configured — enable one in Settings.</p>
|
||||
<p class="muted small">{t("summary.no_provider")}</p>
|
||||
{:else}
|
||||
<button class="primary" onclick={onGenerateClick}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Generate summary
|
||||
{t("summary.generate_summary")}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -621,24 +681,21 @@
|
||||
<p class="error">{meetings.summaryError}</p>
|
||||
{/if}
|
||||
|
||||
<h3><FileText size={14} aria-hidden="true" /> Feature briefs</h3>
|
||||
<h3><FileText size={14} aria-hidden="true" /> {t("summary.briefs_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to create or view feature briefs.</p>
|
||||
<p class="muted">{t("summary.briefs_select")}</p>
|
||||
{:else}
|
||||
<p class="muted small">
|
||||
Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a
|
||||
coding agent, or serve it over the MCP server once that's on.
|
||||
</p>
|
||||
<p class="muted small">{t("summary.briefs_desc")}</p>
|
||||
<div class="brief-create">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Target repo (optional), e.g. acme/reporting-web"
|
||||
placeholder={t("summary.brief_repo_placeholder")}
|
||||
bind:value={briefTargetRepo}
|
||||
disabled={creatingBrief}
|
||||
/>
|
||||
<button class="primary" onclick={createBrief} disabled={creatingBrief}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{creatingBrief ? "Distilling…" : "Create feature brief"}
|
||||
{creatingBrief ? t("summary.distilling") : t("summary.create_brief")}
|
||||
</button>
|
||||
</div>
|
||||
{#if briefError}
|
||||
@@ -653,7 +710,7 @@
|
||||
{b.title}
|
||||
{#if b.target_repo}<span class="muted small">{b.target_repo}</span>{/if}
|
||||
</button>
|
||||
<label class="expose" title="Available when the MCP server is on">
|
||||
<label class="expose" title={t("summary.brief_mcp_title")}>
|
||||
<input type="checkbox" checked={b.exposed} onchange={() => toggleBriefExposed(b)} />
|
||||
<span class="muted small">MCP</span>
|
||||
</label>
|
||||
@@ -667,24 +724,24 @@
|
||||
<div class="brief-actions">
|
||||
<button class="link" onclick={copyBriefMarkdown}>
|
||||
{#if copiedFormat === "md"}
|
||||
<Check size={13} aria-hidden="true" /> Copied
|
||||
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
|
||||
{:else}
|
||||
<Copy size={13} aria-hidden="true" /> Copy as Markdown
|
||||
<Copy size={13} aria-hidden="true" /> {t("summary.copy_md")}
|
||||
{/if}
|
||||
</button>
|
||||
<button class="link" onclick={copyBriefJson}>
|
||||
{#if copiedFormat === "json"}
|
||||
<Check size={13} aria-hidden="true" /> Copied
|
||||
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
|
||||
{:else}
|
||||
<Copy size={13} aria-hidden="true" /> Copy as JSON
|
||||
<Copy size={13} aria-hidden="true" /> {t("summary.copy_json")}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<h4>Problem</h4>
|
||||
<h4>{t("summary.brief_problem")}</h4>
|
||||
<p class="brief-text">{selectedBrief.problem}</p>
|
||||
<h4>Desired outcome</h4>
|
||||
<h4>{t("summary.brief_outcome")}</h4>
|
||||
<p class="brief-text">{selectedBrief.desired_outcome}</p>
|
||||
<h4>Acceptance criteria</h4>
|
||||
<h4>{t("summary.brief_criteria")}</h4>
|
||||
{#if selectedBrief.acceptance_criteria.length}
|
||||
<ul class="decisions">
|
||||
{#each selectedBrief.acceptance_criteria as c, i (i)}
|
||||
@@ -692,10 +749,10 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted small">None captured.</p>
|
||||
<p class="muted small">{t("summary.brief_none")}</p>
|
||||
{/if}
|
||||
{#if selectedBrief.context_excerpts.length}
|
||||
<h4>Context</h4>
|
||||
<h4>{t("summary.brief_context")}</h4>
|
||||
<ul class="excerpts">
|
||||
{#each selectedBrief.context_excerpts as e, i (i)}
|
||||
<li><span class="speaker">{e.speaker}:</span> "{e.text}"</li>
|
||||
@@ -706,72 +763,111 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><ListTodo size={14} aria-hidden="true" /> Action items</h3>
|
||||
<h3><ListTodo size={14} aria-hidden="true" /> {t("summary.action_items_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to see its action items.</p>
|
||||
{:else if editableItems.length === 0}
|
||||
<p class="muted">Parsed from the summary; edit and confirm before they become tasks.</p>
|
||||
<p class="muted">{t("summary.ai_select")}</p>
|
||||
{:else}
|
||||
{#if editableItems.length === 0}
|
||||
<p class="muted">{t("summary.ai_empty")}</p>
|
||||
{:else}
|
||||
<ul class="action-items">
|
||||
{#each editableItems as item, i (i)}
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={item.confirmed} />
|
||||
<span>{item.text}</span>
|
||||
</label>
|
||||
{#if item.owner}<span class="muted small">{item.owner}</span>{/if}
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={item.confirmed}
|
||||
aria-label={t("summary.confirmed")}
|
||||
title={t("summary.confirmed")}
|
||||
/>
|
||||
<input
|
||||
class="ai-text"
|
||||
bind:value={item.text}
|
||||
placeholder={t("summary.ai_text_placeholder")}
|
||||
aria-label={t("summary.ai_text_aria")}
|
||||
/>
|
||||
<input
|
||||
class="ai-owner"
|
||||
value={item.owner ?? ""}
|
||||
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
|
||||
placeholder={t("summary.owner")}
|
||||
aria-label={t("summary.owner")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
class="due-date"
|
||||
aria-label="Due date"
|
||||
aria-label={t("summary.due_date")}
|
||||
value={dueDateInput(item.due_at)}
|
||||
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<label class="remind" title="Schedule a local reminder for this due date">
|
||||
<label class="remind" title={t("summary.reminder_title")}>
|
||||
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</label>
|
||||
<button
|
||||
class="ai-del"
|
||||
onclick={() => removeActionItem(i)}
|
||||
title={t("summary.ai_delete_title")}
|
||||
aria-label={t("summary.ai_delete_aria")}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="ai-actions">
|
||||
<button class="link" onclick={addActionItem}>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
{t("summary.add_action_item")}
|
||||
</button>
|
||||
<button class="primary" onclick={saveActionItems} disabled={savingItems}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{savingItems ? "Saving…" : "Save action items"}
|
||||
{savingItems ? t("summary.saving") : t("summary.save_action_items")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3><CalendarDays size={14} aria-hidden="true" /> Calendar event</h3>
|
||||
<h3><CalendarDays size={14} aria-hidden="true" /> {t("summary.calendar_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to link it to a calendar event.</p>
|
||||
<p class="muted">{t("summary.cal_select")}</p>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<input type="text" bind:value={eventLinkSearch} placeholder="Search event title…" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={eventLinkSearch}
|
||||
placeholder={t("summary.event_search_placeholder")}
|
||||
/>
|
||||
<input type="date" bind:value={eventLinkDateFilter} />
|
||||
</div>
|
||||
<label class="pick"
|
||||
>Linked event
|
||||
>{t("summary.linked_event")}
|
||||
<select value={meetings.selected.calendar_event_id ?? ""} onchange={onPickEvent}>
|
||||
<option value="" disabled>{eventDetail ? "Change event…" : "Link an event…"}</option>
|
||||
<option value="" disabled
|
||||
>{eventDetail ? t("summary.change_event") : t("summary.link_event")}</option
|
||||
>
|
||||
{#each filteredLinkEvents as ev (ev.id)}
|
||||
<option value={ev.id}
|
||||
>{ev.subject ?? "(untitled)"} — {formatEventTime(ev.starts_at)}</option
|
||||
>{ev.subject ?? t("summary.untitled_event")} — {formatEventTime(ev.starts_at)}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if calendar.events.length === 0}
|
||||
<p class="muted small">
|
||||
No events imported yet — import a <code>.pst</code> from Settings → Calendar.
|
||||
{t("summary.no_events_1")} <code>.pst</code>
|
||||
{t("summary.no_events_2")}
|
||||
</p>
|
||||
{:else if filteredLinkEvents.length === 0}
|
||||
<p class="muted small">No events match this search/date — try clearing one.</p>
|
||||
<p class="muted small">{t("summary.no_events_match")}</p>
|
||||
{/if}
|
||||
|
||||
{#if eventDetail}
|
||||
<div class="event-card">
|
||||
<div class="event-title">{eventDetail.event.subject ?? "(untitled)"}</div>
|
||||
<div class="event-title">{eventDetail.event.subject ?? t("summary.untitled_event")}</div>
|
||||
{#if eventDetail.event.organizer}
|
||||
<div class="muted small">Organizer: {eventDetail.event.organizer}</div>
|
||||
<div class="muted small">
|
||||
{t("summary.organizer_label", { name: eventDetail.event.organizer })}
|
||||
</div>
|
||||
{/if}
|
||||
{#if eventDetail.event.starts_at}
|
||||
<div class="muted small">{formatEventTime(eventDetail.event.starts_at)}</div>
|
||||
@@ -780,9 +876,9 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Users size={14} aria-hidden="true" /> Participants</h3>
|
||||
<h3><Users size={14} aria-hidden="true" /> {t("summary.participants_heading")}</h3>
|
||||
{#if !eventDetail || eventDetail.participants.length === 0}
|
||||
<p class="muted">Populated from the linked calendar event.</p>
|
||||
<p class="muted">{t("summary.participants_hint")}</p>
|
||||
{:else}
|
||||
<ul class="participants">
|
||||
{#each eventDetail.participants as p (p.id)}
|
||||
@@ -794,11 +890,11 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> Speakers</h3>
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.speakers_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to name its speakers.</p>
|
||||
<p class="muted">{t("summary.speakers_select")}</p>
|
||||
{:else if meetings.selected.speakers.length === 0}
|
||||
<p class="muted">No speakers detected yet.</p>
|
||||
<p class="muted">{t("summary.no_speakers")}</p>
|
||||
{:else}
|
||||
<ul class="speakers">
|
||||
{#each meetings.selected.speakers as s (s.label)}
|
||||
@@ -807,23 +903,26 @@
|
||||
{#if addingNameFor === s.label}
|
||||
<input
|
||||
class="grow"
|
||||
placeholder="Speaker name"
|
||||
placeholder={t("summary.speaker_name_placeholder")}
|
||||
bind:value={newNameDraft}
|
||||
onkeydown={(e) => e.key === "Enter" && confirmNewName(s.label)}
|
||||
/>
|
||||
<button class="link" onclick={() => confirmNewName(s.label)}>Save</button>
|
||||
<button class="link" onclick={() => (addingNameFor = null)}>Cancel</button>
|
||||
<button class="link" onclick={() => confirmNewName(s.label)}>{t("summary.save")}</button
|
||||
>
|
||||
<button class="link" onclick={() => (addingNameFor = null)}
|
||||
>{t("summary.cancel")}</button
|
||||
>
|
||||
{:else}
|
||||
<select
|
||||
class="grow"
|
||||
value={s.participant_id ?? ""}
|
||||
onchange={(e) => onPickSpeakerOption(e, s.label)}
|
||||
>
|
||||
<option value="" disabled>{s.display_name ?? "Name this speaker…"}</option>
|
||||
<option value="" disabled>{s.display_name ?? t("summary.name_speaker")}</option>
|
||||
{#each eventDetail?.participants ?? [] as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
<option value={NEW_NAME}>+ Add new name…</option>
|
||||
<option value={NEW_NAME}>{t("summary.add_new_name")}</option>
|
||||
</select>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -982,8 +1081,7 @@
|
||||
ul.action-items li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -992,6 +1090,42 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ai-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ai-owner {
|
||||
flex: none;
|
||||
width: 5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ai-del {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.ai-del:hover {
|
||||
color: var(--danger);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.ai-actions .link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import { recording } from "../stores/recording.svelte";
|
||||
import { meetings } from "../stores/meetings.svelte";
|
||||
import { settings } from "../stores/settings.svelte";
|
||||
import { player } from "../stores/player.svelte";
|
||||
import { api, type SpeakerInfo } from "../api";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
import { layout, clamp } from "../stores/layout.svelte";
|
||||
@@ -35,11 +37,22 @@
|
||||
return speakers.find((s) => s.label === label)?.display_name ?? label;
|
||||
}
|
||||
|
||||
// Transcript timestamps: segment start_ms → "m:ss" (or "h:mm:ss" past an
|
||||
// hour). Shown as a quiet monospace prefix so a line reads "0:42 Alice: …".
|
||||
function fmtTs(ms: number): string {
|
||||
const total = Math.floor(ms / 1000);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const mm = h ? String(m).padStart(2, "0") : String(m);
|
||||
return `${h ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// T8.7/FR-TRX-4: the meeting view shows the language actually used, not
|
||||
// just the raw ISO code — falls back to the code itself if it's not in
|
||||
// the (curated) catalog, and to "auto-detecting…" before any is known.
|
||||
function languageLabel(code: string | null): string {
|
||||
if (!code) return "auto-detecting…";
|
||||
if (!code) return t("transcript.lang_auto");
|
||||
return settings.languages.find((l) => l.code === code)?.label ?? code;
|
||||
}
|
||||
|
||||
@@ -150,7 +163,7 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.md`,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
filters: [{ name: t("transcript.filter_md"), extensions: ["md"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "md");
|
||||
}
|
||||
@@ -167,7 +180,7 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.pdf`,
|
||||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||
filters: [{ name: t("transcript.filter_pdf"), extensions: ["pdf"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "pdf");
|
||||
}
|
||||
@@ -177,11 +190,52 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.docx`,
|
||||
filters: [{ name: "Word document", extensions: ["docx"] }],
|
||||
filters: [{ name: t("transcript.filter_docx"), extensions: ["docx"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "docx");
|
||||
}
|
||||
|
||||
// Export a single self-contained Obsidian note (frontmatter + notes + summary
|
||||
// + action items + timestamped transcript, no audio) — save it into a vault
|
||||
// folder from the dialog. FR-STORE-4 sibling of the bundle export.
|
||||
async function exportObsidian() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.md`,
|
||||
filters: [{ name: t("transcript.filter_obsidian"), extensions: ["md"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "obsidian");
|
||||
}
|
||||
|
||||
// FR-REC-5: the finalized segment currently playing (greatest start_ms at or
|
||||
// before the playhead), for highlight + auto-scroll. currentMs ticks ~4x/s
|
||||
// but this only changes value at a segment boundary, so the effect below is
|
||||
// quiet between boundaries.
|
||||
const activeSegId = $derived.by(() => {
|
||||
const ms = player.currentMs;
|
||||
let id: number | null = null;
|
||||
for (const s of meetings.selected?.segments ?? []) {
|
||||
if (s.start_ms <= ms) id = s.id;
|
||||
else break;
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
||||
// Auto-scroll the playing segment into view — unless the user scrolled the
|
||||
// transcript by hand recently, so playback doesn't yank them back.
|
||||
// ponytail: 4s manual-scroll grace; widen if it still feels grabby.
|
||||
let transcriptEl = $state<HTMLElement | null>(null);
|
||||
let lastManualScroll = 0;
|
||||
$effect(() => {
|
||||
const id = activeSegId;
|
||||
if (id == null || !transcriptEl) return;
|
||||
if (Date.now() - lastManualScroll < 4000) return;
|
||||
transcriptEl
|
||||
.querySelector<HTMLElement>(".seg.active")
|
||||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
|
||||
let reprocessModel = $state("");
|
||||
// T8.7/FR-TRX-4: "" reuses the meeting's current language (backend default
|
||||
// when `language` is omitted) rather than resetting it to auto.
|
||||
@@ -206,34 +260,44 @@
|
||||
class="meeting-title"
|
||||
value={m.title}
|
||||
onchange={onTitleChange}
|
||||
aria-label="Meeting title"
|
||||
aria-label={t("transcript.title_aria")}
|
||||
/>
|
||||
<div
|
||||
class="split"
|
||||
style="grid-template-columns: {splitColumns()};"
|
||||
bind:clientWidth={splitWidth}
|
||||
>
|
||||
<div class="pane transcript" class:collapsed={layout.transcriptCollapsed}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -- wheel/touchmove
|
||||
here only note "the user scrolled by hand" to pause playback
|
||||
auto-scroll; the pane isn't an interactive control. -->
|
||||
<div
|
||||
class="pane transcript"
|
||||
class:collapsed={layout.transcriptCollapsed}
|
||||
bind:this={transcriptEl}
|
||||
onwheel={() => (lastManualScroll = Date.now())}
|
||||
ontouchmove={() => (lastManualScroll = Date.now())}
|
||||
>
|
||||
{#if layout.transcriptCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleTranscript}
|
||||
title="Show transcript"
|
||||
aria-label="Show transcript"
|
||||
title={t("transcript.show")}
|
||||
aria-label={t("transcript.show")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<MessageSquareText size={14} aria-hidden="true" /> Transcript
|
||||
<span class="badge lang" title="Transcription language"
|
||||
<MessageSquareText size={14} aria-hidden="true" />
|
||||
{t("transcript.heading")}
|
||||
<span class="badge lang" title={t("transcript.lang_title")}
|
||||
>{languageLabel(m.language)}</span
|
||||
>
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleTranscript}
|
||||
title="Hide transcript"
|
||||
aria-label="Hide transcript"
|
||||
title={t("transcript.hide")}
|
||||
aria-label={t("transcript.hide")}
|
||||
>
|
||||
<PanelLeftClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -242,15 +306,18 @@
|
||||
{@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
|
||||
<div class="reprocess">
|
||||
<select bind:value={reprocessModel}>
|
||||
<option value="">Re-transcribe with…</option>
|
||||
<option value="">{t("transcript.reprocess_placeholder")}</option>
|
||||
{#each settings.models.filter((mo) => mo.installed) as mo (mo.id)}
|
||||
<option value={mo.id}>{mo.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if reprocessModelInfo?.multilingual}
|
||||
<select bind:value={reprocessLanguage} aria-label="Reprocess language">
|
||||
<option value="">Keep current language</option>
|
||||
<option value="auto">Auto-detect</option>
|
||||
<select
|
||||
bind:value={reprocessLanguage}
|
||||
aria-label={t("transcript.reprocess_lang_aria")}
|
||||
>
|
||||
<option value="">{t("transcript.reprocess_keep_lang")}</option>
|
||||
<option value="auto">{t("settings.transcription.auto")}</option>
|
||||
{#each settings.languages as l (l.code)}
|
||||
<option value={l.code}>{l.label}</option>
|
||||
{/each}
|
||||
@@ -258,22 +325,32 @@
|
||||
{/if}
|
||||
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
|
||||
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
|
||||
{reprocessing ? "Re-transcribing…" : "Go"}
|
||||
{reprocessing ? t("transcript.retranscribing") : t("transcript.reprocess_go")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if m.segments.length === 0}
|
||||
<p class="muted">No transcript for this meeting.</p>
|
||||
<p class="muted">{t("transcript.empty")}</p>
|
||||
{:else}
|
||||
{#each m.segments as s (s.id)}
|
||||
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={s.id === activeSegId}
|
||||
onclick={() => player.seek(s.start_ms)}
|
||||
title={t("transcript.play_from_here")}
|
||||
>
|
||||
<span class="ts">{fmtTs(s.start_ms)}</span>
|
||||
<strong>{speakerName(s.speaker, m.speakers)}:</strong>
|
||||
{s.text}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !layout.transcriptCollapsed && !layout.notesCollapsed}
|
||||
<Splitter
|
||||
label="Resize transcript and notes"
|
||||
label={t("transcript.resize")}
|
||||
onResize={onSplitResize}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
@@ -285,85 +362,102 @@
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleNotes}
|
||||
title="Show notes"
|
||||
aria-label="Show notes"
|
||||
title={t("notes.show")}
|
||||
aria-label={t("notes.show")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<NotebookPen size={14} aria-hidden="true" /> Notes
|
||||
<NotebookPen size={14} aria-hidden="true" />
|
||||
{t("notes.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleNotes}
|
||||
title="Hide notes"
|
||||
aria-label="Hide notes"
|
||||
title={t("notes.hide")}
|
||||
aria-label={t("notes.hide")}
|
||||
>
|
||||
<PanelRightClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
|
||||
<button onclick={() => wrapSelection("**")} title="Bold" aria-label="Bold">
|
||||
<div class="toolbar" role="toolbar" aria-label={t("notes.toolbar_aria")}>
|
||||
<button
|
||||
onclick={() => wrapSelection("**")}
|
||||
title={t("notes.bold")}
|
||||
aria-label={t("notes.bold")}
|
||||
>
|
||||
<Bold size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => wrapSelection("_")} title="Italic" aria-label="Italic">
|
||||
<button
|
||||
onclick={() => wrapSelection("_")}
|
||||
title={t("notes.italic")}
|
||||
aria-label={t("notes.italic")}
|
||||
>
|
||||
<Italic size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => insertLinePrefix("# ")} title="Heading 1" aria-label="Heading 1">
|
||||
<button
|
||||
onclick={() => insertLinePrefix("# ")}
|
||||
title={t("notes.h1")}
|
||||
aria-label={t("notes.h1")}
|
||||
>
|
||||
<Heading1 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("## ")}
|
||||
title="Heading 2"
|
||||
aria-label="Heading 2"
|
||||
title={t("notes.h2")}
|
||||
aria-label={t("notes.h2")}
|
||||
>
|
||||
<Heading2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- ")}
|
||||
title="Bullet list"
|
||||
aria-label="Bullet list"
|
||||
title={t("notes.bullet")}
|
||||
aria-label={t("notes.bullet")}
|
||||
>
|
||||
<List size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- [ ] ")}
|
||||
title="Checkbox"
|
||||
aria-label="Checkbox list item"
|
||||
title={t("notes.checkbox_title")}
|
||||
aria-label={t("notes.checkbox_aria")}
|
||||
>
|
||||
<ListChecks size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="toggle"
|
||||
onclick={() => (notesPreview = !notesPreview)}
|
||||
title={notesPreview ? "Edit the raw markdown" : "Render the markdown"}
|
||||
title={notesPreview ? t("notes.edit_raw") : t("notes.render")}
|
||||
aria-pressed={notesPreview}
|
||||
>
|
||||
{#if notesPreview}
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Editor
|
||||
{t("notes.editor")}
|
||||
{:else}
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
Preview
|
||||
{t("notes.preview")}
|
||||
{/if}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={exportMd} title="Export notes as .md">
|
||||
<button onclick={exportMd} title={t("notes.export_md_title")}>
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
.md
|
||||
</button>
|
||||
<button onclick={exportPdf} title="Export notes as .pdf">
|
||||
<button onclick={exportPdf} title={t("notes.export_pdf_title")}>
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
PDF
|
||||
</button>
|
||||
<button onclick={exportDocx} title="Export notes as .docx">
|
||||
<button onclick={exportDocx} title={t("notes.export_docx_title")}>
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
Word
|
||||
</button>
|
||||
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
|
||||
<button onclick={exportBundle} title={t("notes.export_bundle_title")}>
|
||||
<FolderOutput size={13} aria-hidden="true" />
|
||||
Bundle
|
||||
</button>
|
||||
<button onclick={exportObsidian} title={t("notes.export_obsidian_title")}>
|
||||
<NotebookPen size={13} aria-hidden="true" />
|
||||
Obsidian
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-preview">
|
||||
{#if notesPreview}
|
||||
@@ -374,7 +468,7 @@
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
placeholder="Notes…"
|
||||
placeholder={t("notes.placeholder")}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -382,7 +476,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else if recording.state === "idle"}
|
||||
<p class="muted pad">Transcript will appear here as you record.</p>
|
||||
<p class="muted pad">{t("transcript.will_appear")}</p>
|
||||
{:else}
|
||||
<!-- Granola-style redesign: the Notes pane is open and typable while
|
||||
recording, and clicking a transcript line attaches a note to that
|
||||
@@ -397,25 +491,26 @@
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleTranscript}
|
||||
title="Show transcript"
|
||||
aria-label="Show transcript"
|
||||
title={t("transcript.show")}
|
||||
aria-label={t("transcript.show")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<MessageSquareText size={14} aria-hidden="true" /> Transcript
|
||||
<MessageSquareText size={14} aria-hidden="true" />
|
||||
{t("transcript.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleTranscript}
|
||||
title="Hide transcript"
|
||||
aria-label="Hide transcript"
|
||||
title={t("transcript.hide")}
|
||||
aria-label={t("transcript.hide")}
|
||||
>
|
||||
<PanelLeftClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
{#if recording.segments.length === 0}
|
||||
<p class="muted">Transcript will appear here as you record.</p>
|
||||
<p class="muted">{t("transcript.will_appear")}</p>
|
||||
{:else}
|
||||
{#each recording.segments as s (s.id)}
|
||||
{@const hasNote = recording.segmentNotes.has(s.start_ms)}
|
||||
@@ -428,21 +523,22 @@
|
||||
class:active={open}
|
||||
onclick={() => (selectedSegmentMs = open ? null : s.start_ms)}
|
||||
>
|
||||
<span class="ts">{fmtTs(s.start_ms)}</span>
|
||||
<strong>{speakerName(s.speaker)}:</strong>
|
||||
{s.text}
|
||||
{#if hasNote}
|
||||
<span class="note-badge" title="Has a note">📝</span>
|
||||
<span class="note-badge" title={t("transcript.has_note")}>📝</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if open}
|
||||
<input
|
||||
type="text"
|
||||
class="segment-note-input"
|
||||
placeholder="Add a note for this moment…"
|
||||
placeholder={t("transcript.note_placeholder")}
|
||||
value={recording.segmentNotes.get(s.start_ms) ?? ""}
|
||||
onchange={(e) =>
|
||||
recording.setSegmentNote(s.start_ms, (e.target as HTMLInputElement).value)}
|
||||
aria-label="Note for this transcript line"
|
||||
aria-label={t("transcript.note_aria")}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -452,7 +548,7 @@
|
||||
</div>
|
||||
{#if !layout.transcriptCollapsed && !layout.notesCollapsed}
|
||||
<Splitter
|
||||
label="Resize transcript and notes"
|
||||
label={t("transcript.resize")}
|
||||
onResize={onSplitResize}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
@@ -464,19 +560,20 @@
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleNotes}
|
||||
title="Show notes"
|
||||
aria-label="Show notes"
|
||||
title={t("notes.show")}
|
||||
aria-label={t("notes.show")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<NotebookPen size={14} aria-hidden="true" /> Notes
|
||||
<NotebookPen size={14} aria-hidden="true" />
|
||||
{t("notes.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleNotes}
|
||||
title="Hide notes"
|
||||
aria-label="Hide notes"
|
||||
title={t("notes.hide")}
|
||||
aria-label={t("notes.hide")}
|
||||
>
|
||||
<PanelRightClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -485,7 +582,7 @@
|
||||
class="live-notes"
|
||||
value={recording.notesText}
|
||||
oninput={(e) => recording.setNotesText((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder="Type notes while you talk…"
|
||||
placeholder={t("notes.live_placeholder")}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -561,6 +658,38 @@
|
||||
.note-badge {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
/* Finalized-transcript line: a click-to-seek button styled to read as plain
|
||||
transcript text, highlighted while it's the segment currently playing. */
|
||||
.seg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.seg:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.seg.active {
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
/* Transcript timestamp: a quiet monospace prefix, not competing with the
|
||||
speaker name or text for attention. */
|
||||
.ts {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.78em;
|
||||
color: var(--muted);
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
.segment-note-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user