diff --git a/FUTURE_ROADMAPS.md b/FUTURE_ROADMAPS.md new file mode 100644 index 0000000..e955c69 --- /dev/null +++ b/FUTURE_ROADMAPS.md @@ -0,0 +1,320 @@ +# Briefli — Future Roadmaps (living document) + +> Living backlog + execution plans for Briefli. We keep adding to and updating this +> file every session. Every entry must be **verified against the actual code** in +> `C:\dev\Briefli` before it is written here — no assumptions, no hallucination. +> +> **Environment note (important):** the VS Code workspace root is registered as the +> stale OneDrive copy (`…\OneDrive\Desktop\side work\meetily`). Workspace-relative +> search (grep/file/semantic search) hits that stale copy and returns wrong line +> numbers / missing files. Verify against the real repo with absolute +> `C:\dev\Briefli\…` paths or terminal `Select-String`. + +Last updated: 2026-07-17 + +--- + +## 1. Verified status of researched issues (upstream Meetily) + +Checked directly against `C:\dev\Briefli` on 2026-07-17. + +| # | Request | Status in Briefli | Evidence | +|---|---------|-------------------|----------| +| #233 | Multi-language summaries | ✅ **Done** (free) | `summary/processor.rs`: two-pass — summarize in English, then `translation_system_prompt()`; `language_name_from_code()` (~28 langs); `determine_final_language_action()`. UI: `components/SummaryLanguageSettings.tsx`. Upstream shipped this PRO-only in v0.4.0. | +| #257 (1) | OpenAI proxy / custom Base URL | ✅ **Done** | `summary/llm_client.rs` — `CustomOpenAI` endpoint + configurable Ollama endpoint; `components/ModelSettingsModal.tsx` exposes endpoint/key/model/tokens/temp/top_p. | +| #257 (2) | Editable summarization system prompt | ✅ **Done** — custom template editor (§5) | Settings → Summary now has a template editor (create/edit/duplicate/delete) writing same-id custom overrides; fixed preamble + multi-language contract untouched. | +| #257 (3) | ElevenLabs / cloud transcription | ❌ Not done | All local (Whisper/Parakeet). Conflicts with privacy-first thesis → deprioritized. | +| #379 | "Performance/GPU" settings menu | ⚠️ Docs mismatch | Blog references a `Settings → Performance` menu that does not exist. Low-priority UX/docs item. | +| #266 | Ask questions about a meeting (chat / RAG) | ❌ Not done | No chat/Q&A/RAG over transcripts. **Strategic** — aligns with cross-meeting-memory moat. | +| #597 | Delete downloaded models | ✅ **Done** — incl. confirmation (§4) | Delete wired end-to-end for all 3 engines; confirmation dialog added 2026-07-17. | +| #571 | Custom models from Hugging Face | ❌ Not done | Hardcoded catalogs (Whisper/Parakeet/summary). Security + effort heavy → skip for now. | + +--- + +## 2. Verified performance findings + +| Severity | Finding | Location | Notes | +|----------|---------|----------|-------| +| High | Transcript search is a full table scan | `src-tauri/src/database/repositories/transcript.rs` → `search_transcripts` uses `LOWER(t.transcript) LIKE '%q%'`, no index, `fetch_all` | Fix with SQLite **FTS5** — also unlocks the cross-meeting-memory moat. | +| High | Audio buffer cloning in hot path | `src-tauri/src/audio/vad.rs` (`samples.to_vec()` + `drain().collect()` per chunk); `src-tauri/src/audio/incremental_saver.rs` (clones all buffered audio per checkpoint) | Allocation churn during long recordings. Prefer slices / reused buffers. | +| Low (corrected) | `console.log` on every render | `components/TranscriptView.tsx` ~L111 | **Dead code** — `TranscriptView` is never rendered (no ` Correction log: an earlier automated pass labelled the `console.log` and the Sidebar +> polling as "critical hot-path" issues. Direct verification showed the component is +> unrendered and the polling is bounded to summarization. Recorded here to avoid +> repeating the mistake. + +--- + +## 3. Backlog (ranked) + +1. **#597 — Confirmation dialog for model deletion** (small, safe). *Active plan in §4.* +2. **#257 (2) — Editable summary system prompt** (medium). We already have template + + custom-context plumbing; add an override/edit path. +3. **#266 — Ask questions about a meeting** (large, strategic). FTS5 over transcripts + (also fixes perf item #1) + local-LLM Q&A over retrieved chunks. The differentiator. + +Deprioritized: #257(3) ElevenLabs cloud STT, #571 custom HF models — both cut against +the privacy-first / "transcription is commodity" positioning. + +--- + +## 4. Active plan — #597: Confirmation dialog before model deletion + +> **Status: ✅ Implemented 2026-07-17.** Confirmation gate added to all three managers +> (`WhisperModelManager.tsx`, `ParakeetModelManager.tsx`, `BuiltInModelManager.tsx`) +> by reusing `ConfirmationModal`. Type-check clean (tsc exit 0); 63/63 Bun tests pass. +> Backend/API untouched. Manual in-app verification (§4.5) still recommended before release. +> +> **PR:** [karan68/Briefli#3](https://github.com/karan68/Briefli/pull/3) — **merged** +> into `devtest` on 2026-07-17 (squash; all 8 PR Quality Gate checks green). +> Before/after screenshots to be attached in the GitHub UI (can't be embedded via CLI). + +### 4.1 What already exists (verified) + +Delete-to-free-space is **already implemented end-to-end** for all three engines: + +- **Whisper** — `components/WhisperModelManager.tsx` + - `ModelCard` shows a hover trash button for `Available` models (title "Delete model + to free up space") and a `Delete` button for `Corrupted` models. + - `deleteModel()` → `WhisperAPI.deleteCorruptedModel()` (`lib/whisper.ts`) → + `invoke('whisper_delete_corrupted_model')`. + - Rust: `whisper_engine/commands.rs::whisper_delete_corrupted_model` → + `whisper_engine.rs::delete_model` (line 916) permits `Corrupted` **and** `Available` + (`fs::remove_file`). +- **Parakeet** — `components/ParakeetModelManager.tsx` (same pattern) → + `ParakeetAPI.deleteCorruptedModel()` → `parakeet_delete_corrupted_model` → + `parakeet_engine.rs::delete_model` (permits `Corrupted`/`Available`, `fs::remove_dir_all`). +- **BuiltIn/Summary** — `components/BuiltInModelManager.tsx` + - `Trash2` icon for `Available` models (**only when not currently selected**) + `Delete` + for `Corrupted`. + - `deleteModel()` → `invoke('builtin_ai_delete_model')`. + +### 4.2 The actual gap + +Issue #597's technical considerations ask for a **confirmation screen** ("to avoid +random clicks") and a deletion progress bar. Today all three managers delete +**immediately on click** with no confirmation. Deleting a 600–700 MB model by accident +means a full re-download. + +A reusable, already-used confirmation component exists and is **not** wired into the +managers: + +- `components/ConfirmationModel/confirmation-modal.tsx` + - API: `ConfirmationModal({ onConfirm, onCancel, text, isOpen, title?, confirmLabel?, isConfirming? })` + - Already used in `app/settings/page.tsx` and `components/Sidebar/index.tsx`. + +> Progress bar: model deletion is a single `remove_file` / `remove_dir_all` and completes +> near-instantly. A progress bar would be over-engineering. We surface a brief +> "Deleting…" state via the modal's existing `isConfirming` prop instead. + +### 4.3 Scope + +**In scope** +- Gate every model-delete action behind `ConfirmationModal` in the three managers. +- Confirmation copy names the model and states that re-download is required to reuse it; + include the freed size when available. +- Use `isConfirming` to disable buttons and show "Deleting…" during the async delete. +- Keep the existing delete backend/API untouched. + +**Out of scope (note only, do not implement now)** +- Backend changes (delete commands already do the right thing). +- The BuiltIn "can't delete the currently-selected model" inconsistency — flag for a + follow-up, do not change behaviour in this task unless we explicitly decide to. +- Any progress bar / bulk-delete / "delete all" affordance. + +### 4.4 Step-by-step execution (each step compiles + is verified before the next) + +- **Step 0 — Baseline.** Confirm the app type-checks and the three managers build as-is + (`tsc --noEmit`). Record current behaviour with a quick manual read-through. No code + change. +- **Step 1 — Whisper (pilot).** In `WhisperModelManager.tsx`, add local state + (`pendingDelete: ModelInfo | null`, `isConfirming: boolean`). Route both the hover + trash and the corrupted `Delete` button to open the modal instead of deleting. + Render one `ConfirmationModal` at the component root; on confirm, run the existing + `deleteModel(...)` logic with `isConfirming` toggled. Type-check. +- **Step 2 — Parakeet.** Mirror Step 1 in `ParakeetModelManager.tsx` (identical + structure). Type-check. +- **Step 3 — BuiltIn/Summary.** Mirror in `BuiltInModelManager.tsx`, respecting its + existing "not when selected" rule for the trash icon. Type-check. +- **Step 4 — Copy + a11y polish.** Consistent title/text/`confirmLabel` across all three; + ensure `e.stopPropagation()` still prevents card-select when opening the modal. +- **Step 5 — Full verification.** See §4.5. + +> Optional refactor (only if it stays clean): extract a tiny `useDeleteConfirmation` +> hook or a shared `` wrapper to avoid three near-identical modal +> blocks. Decide after Step 1 shows the real shape — do not pre-abstract. + +### 4.5 Testing + +- **Type check:** `cd C:\dev\Briefli\frontend ; .\node_modules\.bin\tsc.cmd --noEmit -p tsconfig.json` + (2 pre-existing `bun:test` errors are known/ignored). +- **Existing tests unaffected:** the Bun tests under `tests/lib/` touch timeline logic, + not these components; run them to confirm no regression. +- **Manual (Tauri dev):** `cd C:\dev\Briefli\frontend ; .\node_modules\.bin\tauri.cmd dev` + 1. Download a model → hover → trash → **Cancel**: nothing deleted, list unchanged. + 2. Repeat → **Delete**: model removed, toast shown, disk space freed, list refreshes. + 3. Delete the **currently selected** Whisper/Parakeet model → selection clears. + 4. Corrupted model → `Delete` path also routes through the modal. + 5. Trigger a delete failure (e.g. locked file) → error toast, modal closes cleanly, + no partial-state UI. + 6. Verify for all three managers (Whisper, Parakeet, Summary/BuiltIn). + +### 4.6 Risks / guardrails + +- Do **not** alter the Rust delete commands or engine `delete_model` behaviour. +- Preserve `e.stopPropagation()` so opening the modal never selects/activates the card. +- Keep the existing toast + list-refresh flow exactly; only insert the confirmation gate. +- No new dependencies; reuse `ConfirmationModal`. + +--- + +## 5. Active plan — #257(2): Editable summary prompt (custom template editor) + +> **Status: ✅ Implemented 2026-07-17.** Backend: `source`/`editable`/`deletable` metadata +> on the template list + `save_custom_template`/`delete_custom_template` (loader.rs) exposed +> as `api_save_template`/`api_delete_template`/`api_get_template_content` +> (`template_commands.rs`), registered in `lib.rs`. Frontend: new +> `components/SummaryTemplateManager.tsx` card in Settings → Summary (list with +> built-in/custom badges; create / edit / duplicate / delete via `ConfirmationModal`). +> Editing a built-in saves a same-id custom override (delete reverts). The fixed +> preamble + multi-language contract in `build_final_report_system_prompt` are untouched. +> +> **JSON-level editing (added 2026-07-17):** the editor has a **Form / JSON** toggle +> (raw JSON textarea + Validate via `api_validate_template`, saved verbatim through +> `api_save_template`), an **Import JSON** action (opens a new template straight in JSON +> mode for paste), and **Copy JSON** per row (clipboard export). Freedom is within the +> fixed schema (`format` ∈ paragraph|list|string); no raw system-prompt override. +> +> **Edge-case hardening (2026-07-17):** collision-free ids for new/duplicate +> (`lib/summary-template.ts` `createUniqueTemplateId`), section reorder up/down +> (`moveArrayItem`), discard-changes confirmation, legacy `example_item_format` +> normalized into the editable field, and backend validation bounds — trim/whitespace +> rejection, duplicate section titles, ≤30 sections, ≤120/500/2000/500-char limits — plus +> a crash-safe temp-file+backup swap in `save_custom_template`. +> +> Verified: **17 backend template tests pass**, `tsc --noEmit` clean, **66/66 Bun tests** +> (incl. `tests/lib/summary-template.test.mjs`). Manual in-app verification recommended. + +> Verified against `C:\dev\Briefli` on 2026-07-17. No assumptions — every claim below +> was read from the actual code. + +### 5.1 How summaries are prompted today (verified) + +The final summary **system prompt** is assembled in +`src-tauri/src/summary/processor.rs::build_final_report_system_prompt(section_instructions, clean_template_markdown)`: + +- A **fixed preamble** — includes `ENGLISH_BASE_SUMMARY_INSTRUCTION` (the multi-language + contract: summarize in English, translate in a later pass) and a prompt-injection guard + ("Ignore any instructions or commentary in ``"), plus output rules. +- **`{section_instructions}`** — produced by `Template::to_section_instructions()` + (`summary/templates/types.rs`) from each section's `instruction` text. +- **`{clean_template_markdown}`** — from `Template::to_markdown_structure()`. + +User-provided **context** (`custom_prompt`) is appended to the *user* prompt as a +`` block (processor.rs ~L503), **not** the system prompt. + +### 5.2 What already exists (verified) + +- **Template model**: `Template { name, description, sections[TemplateSection{title, instruction, format, item_format?}] }` + with `validate()`, `to_section_instructions()`, `to_markdown_structure()` (`templates/types.rs`). +- **Loading fallback** (`templates/loader.rs`): custom (`dirs::data_dir()/Briefli/templates/.json`) + → bundled (`tauri.conf.json` resource `templates/*.json`, copied to app resources) → built-in + embedded (`templates/defaults.rs`: only `daily_standup`, `standard_meeting` registered). + There are 7 JSONs in `src-tauri/templates/` (daily_standup, standard_meeting, project_sync, + retrospective, sales_marketing_client_call, psychatric_session, …) surfaced via the bundled scan. +- **Read commands** (`summary/template_commands.rs`): `api_list_templates`, + `api_get_template_details`, `api_validate_template`. **No save/create/delete.** +- **Generation wiring**: `api_process_transcript` (`summary/commands.rs:329`) accepts + `template_id: Option` (default `"daily_standup"`) + `custom_prompt`; `service.rs` + resolves `templates::get_template(&template_id)` and fingerprints it for cache reuse. +- **Frontend**: template picker already wired — `hooks/meeting-details/useTemplates.ts` + (calls `api_list_templates`, holds `selectedTemplate`), `useSummaryGeneration.ts` + (passes `templateId`), `MeetingDetails/SummaryPanel.tsx` + `SummaryGeneratorButtonGroup.tsx`. + +### 5.3 The gap + +Users can **select** templates but cannot **author** them from the app — there's no +create/edit/delete. Manually placing JSON in the data dir works but is not user-facing. + +### 5.4 Recommended approach (needs sign-off before coding) + +Ship a **custom template editor**, not a raw free-text system-prompt box. Rationale: + +- The section `instruction` fields *are* the functional summarization prompt, exposed safely. +- It never touches the fixed multi-language contract or the injection guard in the preamble. +- Matches the discussion's use cases (medical vs sales vs technical) and the existing + "Saved Templates" evolution path. +- ~70% of the infra already exists (model, validation, loading, selection, generation). + +**Override model (leverages existing fallback):** editing a built-in saves a custom +override with the **same id** in the user dir (revert = delete the custom file); creating +new uses a slugified id. Deletion is allowed only for custom files. + +**Decision points to confirm:** +1. Editor scope = template editor (recommended) vs literal raw-system-prompt textarea. +2. Location = Settings → Summary (new "Templates" area) vs inline in the summary panel. +3. Built-in edit policy = same-id custom override (recommended) vs force-new-id only. + +### 5.5 Step-by-step execution (each step compiles + verified before the next) + +- **Step 0 — Baseline.** Re-read the exact files above; confirm `cargo check` + `tsc` clean + before touching anything. No code change. +- **Step 1 — Backend: source metadata.** Extend `TemplateInfo`/list with a `source` + (`built_in` | `bundled` | `custom`) or `editable`/`deletable` flags so the UI can gate + edit/delete. Derive in `templates/loader.rs` (it already knows each id's origin). +- **Step 2 — Backend: save/delete.** Add `templates::save_custom_template(id, json)` and + `delete_custom_template(id)` in `loader.rs` (make the custom dir path usable; create dir + if missing; reuse `validate_and_parse_template`; reject empty/invalid; delete only within + the custom dir). Wrap as `api_save_template` / `api_delete_template` in + `template_commands.rs`. Register both in `lib.rs` `invoke_handler`. +- **Step 3 — Backend tests.** Unit tests (tempdir-backed): save→get round-trip, validation + rejection, custom overrides built-in by id, delete removes only custom, slug/collision + behavior. `cargo test -p` for the summary module. +- **Step 4 — Frontend: template editor UI.** In Settings → Summary, add list (built-in vs + custom badges), plus create / duplicate-and-edit / edit / delete, backed by the new + commands and existing `api_list_templates` / `api_get_template_details` / `api_validate_template`. + Form fields map 1:1 to `TemplateSection`. Keep the existing generation picker unchanged. +- **Step 5 — Wire + polish.** Reuse `ConfirmationModal` for delete; toasts consistent with + the app; validate before save (surface backend error messages). +- **Step 6 — Verification.** `tsc --noEmit` clean; Bun suite green; `cargo test` for summary; + manual: create a template → appears in the generation picker → generates using its + instructions → edit → delete → built-in reverts. Confirm multi-language summary still works + (the preamble is untouched). + +### 5.6 Scope / non-goals + +- Do **not** modify `build_final_report_system_prompt`'s fixed preamble, the multi-language + pipeline, or `api_process_transcript`'s signature. +- No raw/unbounded system-prompt override (rejected: breaks language contract + injection guard). +- No template sharing/import-export, no per-section AI assistance — keep it a plain editor. + +--- + +## 6. Changelog + +- 2026-07-17 — #257(2) follow-up: added JSON-level editing (Form/JSON toggle, Import JSON, + Copy JSON) and hardened edge cases (collision-free ids, section reorder, discard + confirmation, backend validation bounds + duplicate-title rejection, crash-safe save). + 17 backend template tests pass; tsc clean; 66/66 Bun tests. Prompt pipeline untouched. +- 2026-07-17 — Implemented #257(2) custom template editor end-to-end. Backend: template + `source`/`editable`/`deletable` metadata + `save_custom_template`/`delete_custom_template` + (`loader.rs`) exposed via `api_get_template_content`/`api_save_template`/`api_delete_template` + (`template_commands.rs`), registered in `lib.rs`; 14 template tests pass. Frontend: new + `SummaryTemplateManager.tsx` in Settings → Summary. `cargo check` clean; `tsc` clean; + 63/63 Bun tests pass. Fixed preamble / multi-language pipeline untouched. +- 2026-07-17 — Scoped #257(2): verified the template/prompt architecture and wrote the + custom-template-editor plan (§5). No code changed yet — awaiting design sign-off. + +- 2026-07-17 — Merged PR [karan68/Briefli#3](https://github.com/karan68/Briefli/pull/3) + into `devtest` (squash); all 8 CI checks green; remote feature branch deleted. +- 2026-07-17 — Opened PR [karan68/Briefli#3](https://github.com/karan68/Briefli/pull/3) + (`feat/model-delete-confirmation` → `devtest`) with the #597 change; CI "PR Quality + Gate" triggered on open. `FUTURE_ROADMAPS.md` intentionally kept out of that PR. +- 2026-07-17 — Implemented #597 confirmation dialog across all three model managers + (reused `ConfirmationModal`; existing delete backend/API unchanged). `tsc --noEmit` + clean; 63/63 Bun tests pass. +- 2026-07-17 — Created doc. Verified statuses for #233/#257/#266/#379/#571/#597 and perf + findings against `C:\dev\Briefli`. Corrected earlier over-stated perf items. Drafted + the #597 confirmation-dialog plan. diff --git a/README.md b/README.md index 8b7cc69..b872a7e 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,68 @@ Bundled templates include Standard Meeting, Daily Standup, Project Sync, Retrosp The summary workflow does not send the recorded audio file to an external AI provider. Provider retention and account policies still apply to text sent to that provider. See [Privacy and data handling](PRIVACY_POLICY.md). +#### Custom summary templates + +A template controls how a meeting record is structured: each section's `instruction` tells the AI what to write, and the section order is the output order. Manage templates in **Settings > Summary > Summary Templates**. + +- **New template** builds one from scratch; **Duplicate** starts from an existing one. +- **Edit** a built-in template to save a **Custom** copy with the same name. Deleting the custom copy restores the original built-in. +- Toggle **Form / JSON** to edit the raw template JSON. **Validate** checks it before saving. +- **Import JSON** pastes a template someone shared; **Copy JSON** exports one to your clipboard. + +Template JSON has this shape: + +```json +{ + "name": "Client Call", + "description": "Summary tuned for external client calls", + "sections": [ + { + "title": "Overview", + "instruction": "Summarize the purpose and outcome of the call", + "format": "paragraph" + }, + { + "title": "Decisions", + "instruction": "List the decisions that were agreed", + "format": "list" + }, + { + "title": "Action Items", + "instruction": "List each commitment and who owns it", + "format": "list", + "item_format": "- [owner]: [task] (due [date])" + } + ] +} +``` + +A minimal template needs only a name, a description, and one section: + +```json +{ + "name": "One-liner", + "description": "A single-paragraph recap", + "sections": [ + { "title": "Summary", "instruction": "Summarize the meeting in one short paragraph", "format": "paragraph" } + ] +} +``` + +Field reference: + +| Field | Required | Notes | +|---|---|---| +| `name` | yes | Display name (max 120 chars). | +| `description` | yes | When to use it (max 500 chars). | +| `sections` | yes | 1–30 sections; titles must be unique; order is the output order. | +| `sections[].title` | yes | Section heading (max 120 chars). | +| `sections[].instruction` | yes | What the AI extracts or writes (max 2000 chars). | +| `sections[].format` | yes | One of `paragraph`, `list`, or `string`. | +| `sections[].item_format` | no | Optional per-item hint for `list` sections (max 500 chars). | + +To get Trusted Memory suggestions, keep sections for decisions, action items/commitments, and open questions (see below). Templates are plain JSON files, so power users can back them up or share them directly: `%APPDATA%\Briefli\templates\` on Windows, `~/Library/Application Support/Briefli/templates/` on macOS, and `~/.config/Briefli/templates/` on Linux. The fixed multi-language and safety instructions are added automatically and are not part of the template. + ### Trusted Memory Memory suggestions are created from recognized sections in a generated meeting record. A custom summary that omits decisions, action items/commitments/next steps, and open questions may produce no Memory suggestions. @@ -148,7 +210,7 @@ Supported inputs include MP4, M4A, WAV, MP3, FLAC, OGG, AAC, MKV, WebM, and WMA. - **General:** recording notifications and the local recordings folder. - **Recordings:** audio saving, microphone/system devices, and in-person mode. - **Transcription:** install and select local Whisper or Parakeet models. -- **Summary:** choose local or remote AI, control automatic summaries, and set preferred languages. +- **Summary:** choose local or remote AI, control automatic summaries, set preferred languages, and create or edit summary templates. - **Beta:** enable audio import and retranscription. - **About > Check for Updates:** check GitHub Releases and install a signed update. diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 29702b7..6ec4e98 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -703,6 +703,9 @@ pub fn run() { summary::template_commands::api_list_templates, summary::template_commands::api_get_template_details, summary::template_commands::api_validate_template, + summary::template_commands::api_get_template_content, + summary::template_commands::api_save_template, + summary::template_commands::api_delete_template, // Built-in AI commands summary::summary_engine::commands::builtin_ai_list_models, summary::summary_engine::commands::builtin_ai_get_model_info, diff --git a/frontend/src-tauri/src/summary/template_commands.rs b/frontend/src-tauri/src/summary/template_commands.rs index 98c596a..89d2c5e 100644 --- a/frontend/src-tauri/src/summary/template_commands.rs +++ b/frontend/src-tauri/src/summary/template_commands.rs @@ -14,6 +14,16 @@ pub struct TemplateInfo { /// Brief description of the template's purpose pub description: String, + + /// Where the effective template resolves from: "custom", "bundled", or "built_in". + pub source: String, + + /// Whether the template can be edited (always true — built-ins/bundled are + /// edited by saving a same-id custom override). + pub editable: bool, + + /// Whether the template can be deleted (only user "custom" overrides). + pub deletable: bool, } /// Detailed template structure for preview/debugging @@ -49,10 +59,16 @@ pub async fn api_list_templates( let template_infos: Vec = templates .into_iter() - .map(|(id, name, description)| TemplateInfo { - id, - name, - description, + .map(|(id, name, description)| { + let source = templates::template_source(&id); + TemplateInfo { + deletable: source == "custom", + editable: true, + source: source.to_string(), + id, + name, + description, + } }) .collect(); @@ -126,6 +142,62 @@ pub async fn api_validate_template( } } +/// Gets the full editable content of a template. +/// +/// Unlike [`api_get_template_details`] (which returns only section titles), this +/// returns the complete `Template` (name, description, and every section field) +/// so a template editor can load and modify it. +#[tauri::command] +pub async fn api_get_template_content( + _app: tauri::AppHandle, + template_id: String, +) -> Result { + info!("api_get_template_content called for template_id: {}", template_id); + templates::get_template(&template_id) +} + +/// Saves (creates or overwrites) a custom template in the user's data directory. +/// +/// Saving with the same id as a built-in/bundled template creates an override. +/// The JSON is validated before it is written. +/// +/// # Returns +/// The refreshed [`TemplateInfo`] for the saved template. +#[tauri::command] +pub async fn api_save_template( + _app: tauri::AppHandle, + template_id: String, + template_json: String, +) -> Result { + info!("api_save_template called for template_id: {}", template_id); + + templates::save_custom_template(&template_id, &template_json)?; + + let template = templates::get_template(&template_id)?; + let source = templates::template_source(&template_id); + Ok(TemplateInfo { + deletable: source == "custom", + editable: true, + source: source.to_string(), + id: template_id, + name: template.name, + description: template.description, + }) +} + +/// Deletes a custom template override from the user's data directory. +/// +/// Only user (custom) templates can be deleted; built-in/bundled definitions are +/// never removed. Deleting an override reverts the id to its original. +#[tauri::command] +pub async fn api_delete_template( + _app: tauri::AppHandle, + template_id: String, +) -> Result<(), String> { + info!("api_delete_template called for template_id: {}", template_id); + templates::delete_custom_template(&template_id) +} + #[cfg(test)] mod tests { use super::*; diff --git a/frontend/src-tauri/src/summary/templates/loader.rs b/frontend/src-tauri/src/summary/templates/loader.rs index 891dc82..1b76e47 100644 --- a/frontend/src-tauri/src/summary/templates/loader.rs +++ b/frontend/src-tauri/src/summary/templates/loader.rs @@ -1,7 +1,8 @@ use super::defaults; use super::types::Template; use once_cell::sync::Lazy; -use std::path::PathBuf; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::sync::RwLock; use tracing::{debug, info, warn}; @@ -223,6 +224,151 @@ pub fn list_templates() -> Vec<(String, String, String)> { templates } +/// Returns true if `id` is a safe template identifier. +/// +/// Restricted to lowercase ASCII letters, digits, '-' and '_' so it maps to a +/// safe filename and cannot be used for path traversal (e.g. `../evil`). +pub fn is_valid_template_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +fn has_custom_template(id: &str) -> bool { + get_custom_templates_dir() + .map(|d| d.join(format!("{}.json", id)).is_file()) + .unwrap_or(false) +} + +fn has_bundled_template(id: &str) -> bool { + if let Ok(lock) = BUNDLED_TEMPLATES_DIR.read() { + if let Some(dir) = lock.as_ref() { + return dir.join(format!("{}.json", id)).is_file(); + } + } + false +} + +/// Determine where the *effective* template for `id` is resolved from. +/// +/// Mirrors the [`get_template`] fallback order: +/// `"custom"` (user override) > `"bundled"` (app resources) > `"built_in"` +/// (embedded). Returns `"unknown"` when no template with that id exists. +pub fn template_source(id: &str) -> &'static str { + if has_custom_template(id) { + "custom" + } else if has_bundled_template(id) { + "bundled" + } else if defaults::get_builtin_template(id).is_some() { + "built_in" + } else { + "unknown" + } +} + +fn save_custom_template_to_dir(dir: &Path, id: &str, json_content: &str) -> Result<(), String> { + if !is_valid_template_id(id) { + return Err(format!( + "Invalid template id '{}'. Use lowercase letters, digits, '-' or '_' (max 64 chars).", + id + )); + } + + // Reject anything that is not a structurally valid template before writing. + validate_and_parse_template(json_content)?; + + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create templates directory: {}", e))?; + + let path = dir.join(format!("{}.json", id)); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("Failed to prepare template save: {}", e))? + .as_nanos(); + let temporary_path = dir.join(format!(".{}.{}.tmp", id, nonce)); + let backup_path = dir.join(format!(".{}.{}.bak", id, nonce)); + + let write_result = (|| -> Result<(), String> { + let mut temporary_file = std::fs::File::create(&temporary_path) + .map_err(|e| format!("Failed to create temporary template file: {}", e))?; + temporary_file + .write_all(json_content.as_bytes()) + .map_err(|e| format!("Failed to write temporary template file: {}", e))?; + temporary_file + .sync_all() + .map_err(|e| format!("Failed to flush temporary template file: {}", e))?; + drop(temporary_file); + + if path.exists() { + std::fs::rename(&path, &backup_path) + .map_err(|e| format!("Failed to prepare existing template for replacement: {}", e))?; + } + + if let Err(error) = std::fs::rename(&temporary_path, &path) { + if backup_path.exists() { + let _ = std::fs::rename(&backup_path, &path); + } + return Err(format!("Failed to replace template file: {}", error)); + } + + if backup_path.exists() { + if let Err(error) = std::fs::remove_file(&backup_path) { + warn!( + "Template '{}' saved, but backup {:?} could not be removed: {}", + id, backup_path, error + ); + } + } + + Ok(()) + })(); + + if write_result.is_err() { + let _ = std::fs::remove_file(&temporary_path); + } + write_result?; + + info!("Saved custom template '{}' to {:?}", id, path); + Ok(()) +} + +/// Save (create or overwrite) a custom template in the user's data directory. +/// +/// Saving with the same id as a built-in/bundled template creates an override +/// that [`get_template`] will prefer; deleting the override reverts to the +/// original. The JSON is validated before it is written. +pub fn save_custom_template(id: &str, json_content: &str) -> Result<(), String> { + let dir = get_custom_templates_dir() + .ok_or_else(|| "Could not resolve custom templates directory".to_string())?; + save_custom_template_to_dir(&dir, id, json_content) +} + +fn delete_custom_template_in_dir(dir: &Path, id: &str) -> Result<(), String> { + if !is_valid_template_id(id) { + return Err(format!("Invalid template id '{}'", id)); + } + let path = dir.join(format!("{}.json", id)); + if !path.is_file() { + return Err(format!("No custom template '{}' to delete", id)); + } + std::fs::remove_file(&path).map_err(|e| format!("Failed to delete template file: {}", e))?; + info!("Deleted custom template '{}' from {:?}", id, path); + Ok(()) +} + +/// Delete a custom template override from the user's data directory. +/// +/// Only user (custom) templates can be deleted; built-in and bundled templates +/// are never touched. Deleting an override reverts the id to its bundled or +/// built-in definition. +pub fn delete_custom_template(id: &str) -> Result<(), String> { + let dir = get_custom_templates_dir() + .ok_or_else(|| "Could not resolve custom templates directory".to_string())?; + delete_custom_template_in_dir(&dir, id) +} + #[cfg(test)] mod tests { use super::*; @@ -255,4 +401,73 @@ mod tests { let result = validate_and_parse_template("invalid json"); assert!(result.is_err()); } + + #[test] + fn test_is_valid_template_id() { + assert!(is_valid_template_id("daily_standup")); + assert!(is_valid_template_id("my-template-2")); + assert!(!is_valid_template_id("")); + assert!(!is_valid_template_id("../evil")); + assert!(!is_valid_template_id("has space")); + assert!(!is_valid_template_id("UPPER")); + } + + #[test] + fn test_save_and_delete_custom_template_roundtrip() { + let dir = + std::env::temp_dir().join(format!("briefli_tpl_roundtrip_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let json = r#"{"name":"T","description":"D","sections":[{"title":"S","instruction":"I","format":"paragraph"}]}"#; + + assert!(save_custom_template_to_dir(&dir, "custom_x", json).is_ok()); + let path = dir.join("custom_x.json"); + assert!(path.is_file()); + let parsed = validate_and_parse_template(&std::fs::read_to_string(&path).unwrap()); + assert!(parsed.is_ok()); + + assert!(delete_custom_template_in_dir(&dir, "custom_x").is_ok()); + assert!(!path.is_file()); + // Deleting a missing custom template is an error. + assert!(delete_custom_template_in_dir(&dir, "custom_x").is_err()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_save_rejects_invalid_id_and_json() { + let dir = + std::env::temp_dir().join(format!("briefli_tpl_invalid_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let good = r#"{"name":"T","description":"D","sections":[{"title":"S","instruction":"I","format":"paragraph"}]}"#; + + // Invalid id (path traversal) is rejected before any write. + assert!(save_custom_template_to_dir(&dir, "../evil", good).is_err()); + // Invalid JSON is rejected. + assert!(save_custom_template_to_dir(&dir, "ok_id", "not json").is_err()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_save_replaces_existing_template_without_leaving_temp_files() { + let dir = + std::env::temp_dir().join(format!("briefli_tpl_replace_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let first = r#"{"name":"First","description":"D","sections":[{"title":"S","instruction":"I","format":"paragraph"}]}"#; + let second = r#"{"name":"Second","description":"D","sections":[{"title":"S","instruction":"I","format":"paragraph"}]}"#; + + save_custom_template_to_dir(&dir, "replace_me", first).unwrap(); + save_custom_template_to_dir(&dir, "replace_me", second).unwrap(); + + let saved = std::fs::read_to_string(dir.join("replace_me.json")).unwrap(); + assert_eq!(validate_and_parse_template(&saved).unwrap().name, "Second"); + let leftover_files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .filter(|entry| entry.file_name() != "replace_me.json") + .collect(); + assert!(leftover_files.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/frontend/src-tauri/src/summary/templates/mod.rs b/frontend/src-tauri/src/summary/templates/mod.rs index 8a8d70d..36937a3 100644 --- a/frontend/src-tauri/src/summary/templates/mod.rs +++ b/frontend/src-tauri/src/summary/templates/mod.rs @@ -43,8 +43,8 @@ mod types; // Re-export public API pub use loader::{ - get_template, list_template_ids, list_templates, set_bundled_templates_dir, - validate_and_parse_template, + delete_custom_template, get_template, list_template_ids, list_templates, + save_custom_template, set_bundled_templates_dir, template_source, validate_and_parse_template, }; pub use types::{Template, TemplateSection}; diff --git a/frontend/src-tauri/src/summary/templates/types.rs b/frontend/src-tauri/src/summary/templates/types.rs index 58be6da..012a163 100644 --- a/frontend/src-tauri/src/summary/templates/types.rs +++ b/frontend/src-tauri/src/summary/templates/types.rs @@ -1,4 +1,16 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +const MAX_TEMPLATE_NAME_CHARS: usize = 120; +const MAX_TEMPLATE_DESCRIPTION_CHARS: usize = 500; +const MAX_TEMPLATE_SECTIONS: usize = 30; +const MAX_SECTION_TITLE_CHARS: usize = 120; +const MAX_SECTION_INSTRUCTION_CHARS: usize = 2_000; +const MAX_ITEM_FORMAT_CHARS: usize = 500; + +fn char_count(value: &str) -> usize { + value.chars().count() +} /// Represents a single section in a meeting template #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,25 +49,63 @@ pub struct Template { impl Template { /// Validates the template structure pub fn validate(&self) -> Result<(), String> { - if self.name.is_empty() { + if self.name.trim().is_empty() { return Err("Template name cannot be empty".to_string()); } + if char_count(&self.name) > MAX_TEMPLATE_NAME_CHARS { + return Err(format!( + "Template name cannot exceed {} characters", + MAX_TEMPLATE_NAME_CHARS + )); + } - if self.description.is_empty() { + if self.description.trim().is_empty() { return Err("Template description cannot be empty".to_string()); } + if char_count(&self.description) > MAX_TEMPLATE_DESCRIPTION_CHARS { + return Err(format!( + "Template description cannot exceed {} characters", + MAX_TEMPLATE_DESCRIPTION_CHARS + )); + } if self.sections.is_empty() { return Err("Template must have at least one section".to_string()); } + if self.sections.len() > MAX_TEMPLATE_SECTIONS { + return Err(format!( + "Template cannot have more than {} sections", + MAX_TEMPLATE_SECTIONS + )); + } + let mut section_titles = HashSet::new(); for (i, section) in self.sections.iter().enumerate() { - if section.title.is_empty() { + let title = section.title.trim(); + if title.is_empty() { return Err(format!("Section {} has empty title", i)); } + if char_count(§ion.title) > MAX_SECTION_TITLE_CHARS { + return Err(format!( + "Section '{}' title cannot exceed {} characters", + title, MAX_SECTION_TITLE_CHARS + )); + } + if section.title.contains(['\r', '\n']) { + return Err(format!("Section '{}' title cannot contain line breaks", title)); + } + if !section_titles.insert(title.to_lowercase()) { + return Err(format!("Section title '{}' is duplicated", title)); + } - if section.instruction.is_empty() { - return Err(format!("Section '{}' has empty instruction", section.title)); + if section.instruction.trim().is_empty() { + return Err(format!("Section '{}' has empty instruction", title)); + } + if char_count(§ion.instruction) > MAX_SECTION_INSTRUCTION_CHARS { + return Err(format!( + "Section '{}' instruction cannot exceed {} characters", + title, MAX_SECTION_INSTRUCTION_CHARS + )); } match section.format.as_str() { @@ -65,6 +115,21 @@ impl Template { section.title, other )), } + + for format_hint in [ + section.item_format.as_deref(), + section.example_item_format.as_deref(), + ] + .into_iter() + .flatten() + { + if char_count(format_hint) > MAX_ITEM_FORMAT_CHARS { + return Err(format!( + "Section '{}' item format cannot exceed {} characters", + title, MAX_ITEM_FORMAT_CHARS + )); + } + } } Ok(()) @@ -159,4 +224,51 @@ mod tests { assert!(template.validate().is_err()); } + + #[test] + fn test_validate_rejects_whitespace_and_duplicate_titles() { + let mut template = Template { + name: "Test".to_string(), + description: "Test".to_string(), + sections: vec![ + TemplateSection { + title: "Summary".to_string(), + instruction: "First".to_string(), + format: "paragraph".to_string(), + item_format: None, + example_item_format: None, + }, + TemplateSection { + title: " summary ".to_string(), + instruction: "Second".to_string(), + format: "paragraph".to_string(), + item_format: None, + example_item_format: None, + }, + ], + }; + + assert!(template.validate().unwrap_err().contains("duplicated")); + + template.sections.truncate(1); + template.sections[0].instruction = " ".to_string(); + assert!(template.validate().unwrap_err().contains("empty instruction")); + } + + #[test] + fn test_validate_rejects_excessive_content() { + let template = Template { + name: "Test".to_string(), + description: "Test".to_string(), + sections: vec![TemplateSection { + title: "Summary".to_string(), + instruction: "x".repeat(MAX_SECTION_INSTRUCTION_CHARS + 1), + format: "paragraph".to_string(), + item_format: None, + example_item_format: None, + }], + }; + + assert!(template.validate().unwrap_err().contains("cannot exceed")); + } } diff --git a/frontend/src/components/SummaryModelSettings.tsx b/frontend/src/components/SummaryModelSettings.tsx index 495a145..c44e1ff 100644 --- a/frontend/src/components/SummaryModelSettings.tsx +++ b/frontend/src/components/SummaryModelSettings.tsx @@ -5,6 +5,7 @@ import { invoke } from '@tauri-apps/api/core'; import { toast } from 'sonner'; import { ModelConfig, ModelSettingsModal } from '@/components/ModelSettingsModal'; import { SummaryLanguageSettings } from '@/components/SummaryLanguageSettings'; +import { SummaryTemplateManager } from '@/components/SummaryTemplateManager'; import { Switch } from './ui/switch'; import { useConfig } from '@/contexts/ConfigContext'; @@ -149,6 +150,8 @@ export function SummaryModelSettings({ refetchTrigger }: SummaryModelSettingsPro skipInitialFetch={true} /> + + ); } diff --git a/frontend/src/components/SummaryTemplateManager.tsx b/frontend/src/components/SummaryTemplateManager.tsx new file mode 100644 index 0000000..919e591 --- /dev/null +++ b/frontend/src/components/SummaryTemplateManager.tsx @@ -0,0 +1,727 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { toast } from 'sonner'; +import { ConfirmationModal } from '@/components/ConfirmationModel/confirmation-modal'; +import { createUniqueTemplateId, moveArrayItem } from '@/lib/summary-template'; + +interface TemplateInfo { + id: string; + name: string; + description: string; + source: string; + editable: boolean; + deletable: boolean; +} + +type SectionFormat = 'paragraph' | 'list' | 'string'; + +interface TemplateSection { + title: string; + instruction: string; + format: SectionFormat; + item_format?: string; + example_item_format?: string; +} + +interface TemplateContent { + name: string; + description: string; + sections: TemplateSection[]; +} + +interface EditorState { + // Non-null when editing an existing id (a custom template or a built-in override). + // Null for a brand-new / duplicated template — the id is derived from the name on save. + existingId: string | null; + name: string; + description: string; + sections: TemplateSection[]; +} + +const FORMAT_OPTIONS: { value: SectionFormat; label: string }[] = [ + { value: 'paragraph', label: 'Paragraph' }, + { value: 'list', label: 'List' }, + { value: 'string', label: 'Single line' }, +]; + +const MAX_TEMPLATE_NAME_CHARS = 120; +const MAX_TEMPLATE_DESCRIPTION_CHARS = 500; +const MAX_TEMPLATE_SECTIONS = 30; +const MAX_SECTION_TITLE_CHARS = 120; +const MAX_SECTION_INSTRUCTION_CHARS = 2_000; +const MAX_ITEM_FORMAT_CHARS = 500; + +const SOURCE_BADGE: Record = { + custom: { label: 'Custom', className: 'bg-blue-100 text-blue-700' }, + bundled: { label: 'Built-in', className: 'bg-gray-100 text-gray-600' }, + built_in: { label: 'Built-in', className: 'bg-gray-100 text-gray-600' }, +}; + +function emptySection(): TemplateSection { + return { title: '', instruction: '', format: 'paragraph' }; +} + +function editableSections(sections: TemplateSection[]): TemplateSection[] { + return sections.map(({ example_item_format, ...section }) => ({ + ...section, + item_format: section.item_format ?? example_item_format, + })); +} + +function errorMessage(err: unknown, fallback: string): string { + return typeof err === 'string' ? err : fallback; +} + +function serializeEditorToJson(e: EditorState): string { + const content = { + name: e.name, + description: e.description, + sections: e.sections.map((s) => { + const section: Record = { + title: s.title, + instruction: s.instruction, + format: s.format, + }; + if (s.item_format && s.item_format.trim()) section.item_format = s.item_format; + if (s.example_item_format && s.example_item_format.trim()) { + section.example_item_format = s.example_item_format; + } + return section; + }), + }; + return JSON.stringify(content, null, 2); +} + +function parseJsonToEditor(text: string, existingId: string | null): EditorState { + const parsed = JSON.parse(text) as Partial; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Template must be a JSON object.'); + } + const rawSections = Array.isArray(parsed.sections) ? (parsed.sections as TemplateSection[]) : []; + return { + existingId, + name: typeof parsed.name === 'string' ? parsed.name : '', + description: typeof parsed.description === 'string' ? parsed.description : '', + sections: rawSections.length ? editableSections(rawSections) : [emptySection()], + }; +} + +export function SummaryTemplateManager() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [editor, setEditor] = useState(null); + const [initialEditor, setInitialEditor] = useState(null); + const [confirmDiscard, setConfirmDiscard] = useState(false); + const [saving, setSaving] = useState(false); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const [jsonMode, setJsonMode] = useState(false); + const [jsonText, setJsonText] = useState(''); + + const loadTemplates = useCallback(async () => { + try { + setLoading(true); + const list = await invoke('api_list_templates'); + setTemplates(list); + } catch (err) { + console.error('Failed to load templates:', err); + toast.error('Failed to load templates'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadTemplates(); + }, [loadTemplates]); + + const openEditor = (next: EditorState) => { + setEditor(next); + setInitialEditor(next); + setJsonMode(false); + setJsonText(''); + }; + + const closeEditor = () => { + setEditor(null); + setInitialEditor(null); + setConfirmDiscard(false); + setJsonMode(false); + setJsonText(''); + }; + + const openNew = () => { + openEditor({ existingId: null, name: '', description: '', sections: [emptySection()] }); + }; + + const openImport = () => { + const skeleton: EditorState = { + existingId: null, + name: '', + description: '', + sections: [emptySection()], + }; + setEditor(skeleton); + setInitialEditor(skeleton); + setJsonText(serializeEditorToJson(skeleton)); + setJsonMode(true); + }; + + const openEdit = async (t: TemplateInfo) => { + try { + const content = await invoke('api_get_template_content', { templateId: t.id }); + openEditor({ + existingId: t.id, + name: content.name, + description: content.description, + sections: content.sections.length ? editableSections(content.sections) : [emptySection()], + }); + } catch (err) { + console.error('Failed to open template:', err); + toast.error(errorMessage(err, 'Failed to open template')); + } + }; + + const openDuplicate = async (t: TemplateInfo) => { + try { + const content = await invoke('api_get_template_content', { templateId: t.id }); + openEditor({ + existingId: null, + name: `${content.name} (copy)`, + description: content.description, + sections: content.sections.length ? editableSections(content.sections) : [emptySection()], + }); + } catch (err) { + console.error('Failed to duplicate template:', err); + toast.error(errorMessage(err, 'Failed to duplicate template')); + } + }; + + const enterJsonMode = () => { + if (!editor) return; + setJsonText(serializeEditorToJson(editor)); + setJsonMode(true); + }; + + const exitJsonMode = () => { + if (!editor) return; + try { + setEditor(parseJsonToEditor(jsonText, editor.existingId)); + setJsonMode(false); + } catch (err) { + toast.error(`Invalid JSON: ${(err as Error).message}`); + } + }; + + const validateJson = async () => { + try { + const name = await invoke('api_validate_template', { templateJson: jsonText }); + toast.success(`Valid template: "${name}"`); + } catch (err) { + toast.error(errorMessage(err, 'Template is invalid')); + } + }; + + const copyTemplateJson = async (t: TemplateInfo) => { + try { + const content = await invoke('api_get_template_content', { templateId: t.id }); + await navigator.clipboard.writeText(JSON.stringify(content, null, 2)); + toast.success('Template JSON copied to clipboard'); + } catch (err) { + console.error('Failed to copy template:', err); + toast.error(errorMessage(err, 'Failed to copy template')); + } + }; + + const patchSection = (index: number, patch: Partial) => { + setEditor((e) => + e ? { ...e, sections: e.sections.map((s, i) => (i === index ? { ...s, ...patch } : s)) } : e + ); + }; + + const addSection = () => setEditor((e) => (e ? { ...e, sections: [...e.sections, emptySection()] } : e)); + + const removeSection = (index: number) => + setEditor((e) => (e ? { ...e, sections: e.sections.filter((_, i) => i !== index) } : e)); + + const moveSection = (from: number, to: number) => + setEditor((e) => (e ? { ...e, sections: moveArrayItem(e.sections, from, to) } : e)); + + const proposedId = editor?.existingId + ?? createUniqueTemplateId(editor?.name ?? '', templates.map((template) => template.id)); + + const validate = (e: EditorState): string | null => { + if (!e.name.trim()) return 'Name is required.'; + if (!e.description.trim()) return 'Description is required.'; + if (e.sections.length === 0) return 'Add at least one section.'; + const titles = new Set(); + for (const s of e.sections) { + if (!s.title.trim()) return 'Every section needs a title.'; + if (!s.instruction.trim()) return `Section "${s.title.trim() || '…'}" needs an instruction.`; + const normalizedTitle = s.title.trim().toLowerCase(); + if (titles.has(normalizedTitle)) return `Section title "${s.title.trim()}" is duplicated.`; + titles.add(normalizedTitle); + } + if (e.existingId === null && !proposedId) return 'Name must contain letters or numbers.'; + return null; + }; + + const save = async () => { + if (!editor) return; + + if (jsonMode) { + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch (err) { + toast.error(`Invalid JSON: ${(err as Error).message}`); + return; + } + const parsedName = + parsed && typeof parsed === 'object' && + typeof (parsed as { name?: unknown }).name === 'string' + ? (parsed as { name: string }).name.trim() + : ''; + const jsonId = + editor.existingId ?? createUniqueTemplateId(parsedName, templates.map((t) => t.id)); + if (editor.existingId === null && !jsonId) { + toast.error('Template "name" must contain letters or numbers.'); + return; + } + try { + setSaving(true); + await invoke('api_save_template', { templateId: jsonId, templateJson: jsonText }); + toast.success(`Template "${parsedName || jsonId}" saved`); + closeEditor(); + await loadTemplates(); + } catch (err) { + console.error('Failed to save template:', err); + toast.error(errorMessage(err, 'Failed to save template')); + } finally { + setSaving(false); + } + return; + } + + const problem = validate(editor); + if (problem) { + toast.error(problem); + return; + } + + const id = editor.existingId ?? proposedId; + const content: TemplateContent = { + name: editor.name.trim(), + description: editor.description.trim(), + sections: editor.sections.map((s) => { + const section: TemplateSection = { + title: s.title.trim(), + instruction: s.instruction.trim(), + format: s.format, + }; + if (s.format === 'list' && s.item_format?.trim()) { + section.item_format = s.item_format.trim(); + } + return section; + }), + }; + + try { + setSaving(true); + await invoke('api_save_template', { templateId: id, templateJson: JSON.stringify(content) }); + toast.success(`Template "${content.name}" saved`); + closeEditor(); + await loadTemplates(); + } catch (err) { + console.error('Failed to save template:', err); + toast.error(errorMessage(err, 'Failed to save template')); + } finally { + setSaving(false); + } + }; + + const confirmDelete = async () => { + if (!pendingDelete) return; + try { + setDeleting(true); + await invoke('api_delete_template', { templateId: pendingDelete.id }); + toast.success(`Template "${pendingDelete.name}" deleted`); + await loadTemplates(); + } catch (err) { + console.error('Failed to delete template:', err); + toast.error(errorMessage(err, 'Failed to delete template')); + } finally { + setDeleting(false); + setPendingDelete(null); + } + }; + + return ( +
+
+
+

Summary Templates

+

+ Customize how meeting summaries are structured. A template's sections define what the + AI extracts and how each part is written. +

+
+ {!editor && ( +
+ + +
+ )} +
+ + {editor ? ( + setEditor((e) => (e ? { ...e, name } : e))} + onChangeDescription={(description) => setEditor((e) => (e ? { ...e, description } : e))} + onPatchSection={patchSection} + onAddSection={addSection} + onRemoveSection={removeSection} + onMoveSection={moveSection} + onCancel={() => { + const baseline = initialEditor ? serializeEditorToJson(initialEditor) : ''; + const current = jsonMode ? jsonText : editor ? serializeEditorToJson(editor) : ''; + if (baseline !== current) { + setConfirmDiscard(true); + } else { + closeEditor(); + } + }} + onSave={save} + /> + ) : loading ? ( +

Loading templates…

+ ) : ( +
    + {templates.map((t) => { + const badge = SOURCE_BADGE[t.source] ?? { label: 'Unknown', className: 'bg-gray-100 text-gray-600' }; + return ( +
  • +
    +
    + {t.name} + + {badge.label} + +
    +

    {t.description}

    +
    +
    + + + + {t.deletable && ( + + )} +
    +
  • + ); + })} +
+ )} + + { + if (!deleting) setPendingDelete(null); + }} + /> + setConfirmDiscard(false)} + /> +
+ ); +} + +interface TemplateEditorProps { + editor: EditorState; + proposedId: string; + saving: boolean; + jsonMode: boolean; + jsonText: string; + onEnterJsonMode: () => void; + onExitJsonMode: () => void; + onChangeJson: (text: string) => void; + onValidateJson: () => void; + onChangeName: (name: string) => void; + onChangeDescription: (description: string) => void; + onPatchSection: (index: number, patch: Partial) => void; + onAddSection: () => void; + onRemoveSection: (index: number) => void; + onMoveSection: (from: number, to: number) => void; + onCancel: () => void; + onSave: () => void; +} + +function TemplateEditor({ + editor, + proposedId, + saving, + jsonMode, + jsonText, + onEnterJsonMode, + onExitJsonMode, + onChangeJson, + onValidateJson, + onChangeName, + onChangeDescription, + onPatchSection, + onAddSection, + onRemoveSection, + onMoveSection, + onCancel, + onSave, +}: TemplateEditorProps) { + const isNew = editor.existingId === null; + const savedAs = isNew ? proposedId : editor.existingId; + + return ( +
+
+
+ + +
+ {jsonMode && ( + + )} +
+ + {!isNew && ( +

+ Editing saves a custom copy of this template. You can revert by deleting the custom copy. +

+ )} + + {jsonMode ? ( +
+