feat: add VTT export functionality to listener2 plugin#1934
Conversation
- Add export_to_vtt command using aspasia library - Add VttWord struct for word data serialization - Add Export VTT button in transcript component - Export creates transcript.vtt in session folder Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
✅ Deploy Preview for hyprnote-storybook ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for hyprnote ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughWalkthroughThis pull request adds a new Tauri command Changes
Sequence DiagramsequenceDiagram
participant Frontend as Frontend App
participant Command as export_to_vtt Command
participant FileSystem as File System
Frontend->>Command: export_to_vtt(app_handle, session_id, words)
Command->>FileSystem: Resolve session directory<br/>(hyprnote/sessions/{session_id})
FileSystem-->>Command: Directory path
Command->>FileSystem: Create per-session directory
FileSystem-->>Command: Directory created/exists
Note over Command: Convert VttWord[] to<br/>WebVttCue[] with timing
Command->>FileSystem: Write transcript.vtt file
FileSystem-->>Command: File written
Command-->>Frontend: Return file path (Result<String, String>)
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes
Possibly related PRs
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
apps/desktop/src/components/main/body/sessions/note-input/transcript/index.tsx (1)
123-130: Consider adding loading state to the button.The button could show loading/disabled state during export to prevent multiple clicks and provide visual feedback.
Example improvement:
const [isExporting, setIsExporting] = useState(false); // In handleExportVtt: setIsExporting(true); try { const result = await commands.exportToVtt(sessionId, words); // ... handle result } finally { setIsExporting(false); } // In JSX: <button onClick={handleExportVtt} disabled={isExporting} className="text-xs text-neutral-500 hover:text-neutral-700 disabled:opacity-50" > {isExporting ? "Exporting..." : "Export VTT"} </button>plugins/listener2/src/commands.rs (1)
40-40: Use async I/O instead of blocking operations.The function uses blocking filesystem operations (
std::fs::create_dir_allat line 40 andvtt.exportat line 56) within an async context, which can block the async runtime thread and impact performance.Refactor to use async I/O:
- std::fs::create_dir_all(&session_dir).map_err(|e| e.to_string())?; + tokio::fs::create_dir_all(&session_dir) + .await + .map_err(|e| e.to_string())?;For line 56, since
vtt.export()is synchronous, usespawn_blocking:let vtt = WebVttSubtitle::builder().cues(cues).build(); - vtt.export(&vtt_path).map_err(|e| e.to_string())?; + let vtt_path_clone = vtt_path.clone(); + tokio::task::spawn_blocking(move || { + vtt.export(&vtt_path_clone) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?;
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
plugins/listener2/js/bindings.gen.tsis excluded by!**/*.gen.ts
📒 Files selected for processing (4)
apps/desktop/src/components/main/body/sessions/note-input/transcript/index.tsx(3 hunks)plugins/listener2/src/commands.rs(2 hunks)plugins/listener2/src/lib.rs(1 hunks)plugins/listener2/src/subtitle.rs(1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: Avoid creating a bunch of types/interfaces if they are not shared. Especially for function props, just inline them instead.
Never do manual state management for form/mutation. Use useForm (from tanstack-form) and useQuery/useMutation (from tanstack-query) instead for 99% of cases. Avoid patterns like setError.
If there are many classNames with conditional logic, usecn(import from@hypr/utils). It is similar toclsx. Always pass an array and split by logical grouping.
Usemotion/reactinstead offramer-motion.
Files:
apps/desktop/src/components/main/body/sessions/note-input/transcript/index.tsx
🧬 Code graph analysis (3)
plugins/listener2/src/lib.rs (1)
plugins/listener2/src/commands.rs (1)
export_to_vtt(27-59)
plugins/listener2/src/commands.rs (3)
owhisper/owhisper-config/src/lib.rs (1)
data_dir(52-54)plugins/local-stt/src/server/supervisor.rs (1)
e(115-115)plugins/listener2/src/subtitle.rs (1)
from(23-37)
apps/desktop/src/components/main/body/sessions/note-input/transcript/index.tsx (4)
plugins/local-llm/src/ext/plugin.rs (1)
store(320-320)plugins/listener/build.rs (1)
main(13-15)plugins/misc/build.rs (1)
main(14-23)owhisper/owhisper-server/src/commands/run/state.rs (1)
words(82-85)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
- GitHub Check: Redirect rules - hyprnote
- GitHub Check: Header rules - hyprnote
- GitHub Check: Pages changed - hyprnote
- GitHub Check: fmt
🔇 Additional comments (4)
plugins/listener2/src/subtitle.rs (1)
10-15: LGTM! Clean data structure for VTT export.The
VttWordstruct is well-designed with appropriate visibility, field types, and derives for serialization and type generation.plugins/listener2/src/lib.rs (1)
31-31: LGTM! Command registration follows the established pattern.The
export_to_vttcommand is correctly registered with the specta builder, consistent with the existing commands.apps/desktop/src/components/main/body/sessions/note-input/transcript/index.tsx (1)
3-3: LGTM! Import is necessary for the VTT export functionality.plugins/listener2/src/commands.rs (1)
1-3: LGTM! Imports are appropriate for the VTT export functionality.
apps/desktop/src/components/main/body/sessions/note-input/transcript/index.tsx
Outdated
Show resolved
Hide resolved
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Use try_from to safely convert start_ms and end_ms from u64 to i64, returning an error if the value exceeds i64::MAX instead of silently overflowing. Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
plugins/listener2/src/commands.rs (1)
34-42: Sanitizesession_idto prevent path traversal when constructingsession_dir
session_idis joined directly into the filesystem path. A craftedsession_id(e.g., with..or separators) could escape the intended data directory and writetranscript.vttto arbitrary locations. This is a security-sensitive path and should be constrained.Consider validating
session_idagainst a safe character set and adding a post-join check thatsession_dirstill resides underdata_dir:pub async fn export_to_vtt<R: tauri::Runtime>( app: tauri::AppHandle<R>, session_id: String, words: Vec<VttWord>, ) -> Result<String, String> { use aspasia::{webvtt::WebVttCue, Moment, WebVttSubtitle}; - let data_dir = app + // Restrict session_id to a conservative, path-safe character set. + if session_id.is_empty() + || !session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + { + return Err("Invalid session_id".to_string()); + } + + let data_dir = app .path() .resolve("hyprnote/sessions", BaseDirectory::Data) .map_err(|e| e.to_string())?; - let session_dir = data_dir.join(&session_id); + let session_dir = data_dir.join(&session_id); + + // Extra guard: ensure the resulting path stays within data_dir. + if !session_dir.starts_with(&data_dir) { + return Err("Invalid session_id".to_string()); + } std::fs::create_dir_all(&session_dir).map_err(|e| e.to_string())?;(Adjust allowed characters if your existing session IDs use a different format.)
🧹 Nitpick comments (1)
plugins/listener2/src/commands.rs (1)
32-60: Timestamp conversion is now safe; confirm error-on-overflow semantics are desiredThe new
i64::try_fromconversion forstart_ms/end_mscleanly prevents overflow and propagates a descriptiveErr(String)if any timestamp exceedsi64::MAX, which addresses the earlier overflow risk.Two follow-ups to consider:
- Behavior: currently, a single out-of-range word fails the entire export. If bad data is expected to be rare and you prefer a “best-effort” export, you might instead log and skip offending words rather than aborting.
- Minor clarity: inside this function you import
aspasia::Subtitlewhile the module already usescrate::Subtitleelsewhere. Since onlyWebVttCue,Moment, andWebVttSubtitleare used here, droppingSubtitlefrom the inneruselist would avoid type-name confusion.No changes are strictly required for correctness; this is about desired UX and readability.
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
plugins/listener2/src/commands.rs(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
plugins/listener2/src/commands.rs (1)
plugins/listener2/src/subtitle.rs (1)
from(23-37)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
- GitHub Check: Redirect rules - hyprnote-storybook
- GitHub Check: Header rules - hyprnote-storybook
- GitHub Check: Pages changed - hyprnote-storybook
- GitHub Check: Redirect rules - hyprnote
- GitHub Check: Header rules - hyprnote
- GitHub Check: Pages changed - hyprnote
- GitHub Check: ci (linux, depot-ubuntu-22.04-8)
- GitHub Check: ci (linux, depot-ubuntu-24.04-8)
- GitHub Check: ci (macos, depot-macos-14)
- GitHub Check: fmt
🔇 Additional comments (2)
plugins/listener2/src/commands.rs (2)
1-3: Imports look consistent with new VTT export functionality
BaseDirectoryandVttWordare correctly wired for use inexport_to_vtt, and existing command signatures remain unchanged.
62-66: VTT build and export flow looks correct and consistent withsubtitle.rsusageBuilding
WebVttSubtitlefrom the collected cues and exporting tovtt_path, then returningvtt_path.to_string_lossy().to_string(), is straightforward and matches howMomentis round-tripped inplugins/listener2/src/subtitle.rs(Moment ↔ i64 ↔ u64), so units should remain consistent end-to-end.
There was a problem hiding this comment.
Actionable comments posted: 1
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (5)
plugins/listener2/permissions/autogenerated/commands/export_to_vtt.tomlis excluded by!plugins/**/permissions/**plugins/listener2/permissions/autogenerated/commands/ping.tomlis excluded by!plugins/**/permissions/**plugins/listener2/permissions/autogenerated/reference.mdis excluded by!plugins/**/permissions/**plugins/listener2/permissions/default.tomlis excluded by!plugins/**/permissions/**plugins/listener2/permissions/schemas/schema.jsonis excluded by!plugins/**/permissions/**
📒 Files selected for processing (2)
apps/desktop/src/components/main/body/sessions/note-input/header.tsx(1 hunks)plugins/listener2/build.rs(1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: Avoid creating a bunch of types/interfaces if they are not shared. Especially for function props, just inline them instead.
Never do manual state management for form/mutation. Use useForm (from tanstack-form) and useQuery/useMutation (from tanstack-query) instead for 99% of cases. Avoid patterns like setError.
If there are many classNames with conditional logic, usecn(import from@hypr/utils). It is similar toclsx. Always pass an array and split by logical grouping.
Usemotion/reactinstead offramer-motion.
Files:
apps/desktop/src/components/main/body/sessions/note-input/header.tsx
🧠 Learnings (1)
📚 Learning: 2025-11-24T16:32:13.593Z
Learnt from: CR
Repo: fastrepl/hyprnote PR: 0
File: packages/nango/.cursor/rules/nango.mdc:0-0
Timestamp: 2025-11-24T16:32:13.593Z
Learning: Applies to packages/nango/**/models.ts : Do NOT edit the models.ts file - it is automatically generated at compilation time
Applied to files:
apps/desktop/src/components/main/body/sessions/note-input/header.tsx
🪛 GitHub Actions: .github/workflows/desktop_ci.yaml
apps/desktop/src/components/main/body/sessions/note-input/header.tsx
[error] 31-31: TS6192 All imports in import declaration are unused.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
- GitHub Check: Redirect rules - hyprnote
- GitHub Check: Redirect rules - hyprnote-storybook
- GitHub Check: Header rules - hyprnote
- GitHub Check: Header rules - hyprnote-storybook
- GitHub Check: Pages changed - hyprnote
- GitHub Check: Pages changed - hyprnote-storybook
- GitHub Check: fmt
🔇 Additional comments (1)
plugins/listener2/build.rs (1)
1-1: Command registration looks correct; ensure cross‑file consistency forexport_to_vtt.Adding
"export_to_vtt"toCOMMANDScleanly registers the new Tauri command via the existingtauri_plugin::Builder::new(COMMANDS)flow and aligns with the PR intent.Please double‑check that:
export_to_vttis actually implemented and exported incommands.rs(or equivalent),- specta bindings / lib.rs registration reference the same string name (no typos or casing differences).
If those are in sync, this piece is good to go.
Summary
Adds VTT (WebVTT) export functionality to the listener2 plugin, allowing users to export transcript words to a standard subtitle format. The implementation uses the aspasia library (already a dependency) to generate valid VTT files.
Changes:
export_to_vttTauri command in listener2 plugin that accepts session_id and words arrayVttWordstruct for serializing word data between frontend and backend<DATA>/hyprnote/sessions/<session_id>/transcript.vtti64::try_from()conversion for timestamps with proper error propagationUpdates since last revision
as i64cast withi64::try_from()and proper error handling. Ifstart_msorend_msexceedsi64::MAX, the command now returns an error instead of silently overflowing.Review & Testing Checklist for Human
Moment::from(i64)expects milliseconds (not seconds). Incorrect units would produce malformed timestamps.main.INDEXES.transcriptBySessionandmain.INDEXES.wordsByTranscriptcorrectly retrieve words withtext,start_ms, andend_msfields populated.Recommended test plan:
ONBOARDING=0 pnpm -F desktop tauri devtranscript.vttfile and verify format/timestampsNotes
cnimport insrc/components/main/sidebar/search/index.tsxLink to Devin run: https://app.devin.ai/sessions/a9cb970b7f774a2bb8dd7578f9355e16
Requested by: yujonglee (@yujonglee)