Skip to content

feat: add VTT export functionality to listener2 plugin#1934

Merged
yujonglee merged 5 commits intomainfrom
devin/1764208545-vtt-export
Nov 27, 2025
Merged

feat: add VTT export functionality to listener2 plugin#1934
yujonglee merged 5 commits intomainfrom
devin/1764208545-vtt-export

Conversation

@yujonglee
Copy link
Contributor

@yujonglee yujonglee commented Nov 27, 2025

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:

  • Added export_to_vtt Tauri command in listener2 plugin that accepts session_id and words array
  • Added VttWord struct for serializing word data between frontend and backend
  • Added "Export VTT" button in the transcript component that gathers words from TinyBase and calls the export command
  • VTT files are written to <DATA>/hyprnote/sessions/<session_id>/transcript.vtt
  • Uses safe i64::try_from() conversion for timestamps with proper error propagation

Updates since last revision

  • Fixed potential u64 to i64 overflow: replaced direct as i64 cast with i64::try_from() and proper error handling. If start_ms or end_ms exceeds i64::MAX, the command now returns an error instead of silently overflowing.

Review & Testing Checklist for Human

  • Verify VTT timestamp format: Confirm aspasia's Moment::from(i64) expects milliseconds (not seconds). Incorrect units would produce malformed timestamps.
  • Test word data gathering: Verify that main.INDEXES.transcriptBySession and main.INDEXES.wordsByTranscript correctly retrieve words with text, start_ms, and end_ms fields populated.
  • End-to-end test: Record a session with transcript, click "Export VTT" button, verify the file is created at the expected path and contains valid VTT content with correct timestamps.
  • Check button placement/styling: The button is intentionally minimal ("very raw") per request - confirm this matches expectations.

Recommended test plan:

  1. Start the desktop app with ONBOARDING=0 pnpm -F desktop tauri dev
  2. Create or open a session with existing transcript words
  3. Click the "Export VTT" button in the transcript panel
  4. Check console for success message with file path
  5. Open the generated transcript.vtt file and verify format/timestamps

Notes

  • No user-facing feedback (toast/notification) on export success/failure - only console logging
  • Also fixed pre-existing TypeScript error: removed unused cn import in src/components/main/sidebar/search/index.tsx

Link to Devin run: https://app.devin.ai/sessions/a9cb970b7f774a2bb8dd7578f9355e16
Requested by: yujonglee (@yujonglee)

- 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-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@netlify
Copy link

netlify bot commented Nov 27, 2025

Deploy Preview for hyprnote-storybook ready!

Name Link
🔨 Latest commit cc0098a
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/692822b6af908700088772cb
😎 Deploy Preview https://deploy-preview-1934--hyprnote-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Nov 27, 2025

Deploy Preview for hyprnote ready!

Name Link
🔨 Latest commit cc0098a
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/692822b67a999f0008a3abb2
😎 Deploy Preview https://deploy-preview-1934--hyprnote.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 27, 2025

📝 Walkthrough

Walkthrough

This pull request adds a new Tauri command export_to_vtt that exports WebVTT subtitle files for sessions. A new VttWord struct represents timed words. The command resolves per-session data directories, converts word entries to WebVTT format, and exports to transcript.vtt files. Command registration occurs in both the plugin builder and lib.rs configuration. Minor cleanup includes removing an unused import and adding module imports for related functionality.

Changes

Cohort / File(s) Summary
VTT Export Feature Implementation
plugins/listener2/src/subtitle.rs, plugins/listener2/src/commands.rs, plugins/listener2/src/lib.rs, plugins/listener2/build.rs
Introduces VttWord struct (text, start_ms, end_ms) with serialization support. Implements export_to_vtt command that resolves per-session directories under hyprnote/sessions, converts VttWord entries to WebVttCue items, and exports to transcript.vtt. Registers command in Tauri plugin builder via lib.rs and build.rs COMMANDS array.
UI Import Updates
apps/desktop/src/components/main/sidebar/search/index.tsx, apps/desktop/src/components/main/body/sessions/note-input/header.tsx
Removes unused cn import from search component. Adds imports of buildSegments, ChannelProfile, and WordLike type to header component (no logic changes).

Sequence Diagram

sequenceDiagram
    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>)
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

  • Primary focus: Verify WebVTT timing conversion logic (milliseconds to VTT timestamp format)
  • Confirm per-session directory resolution uses BaseDirectory correctly and handles creation edge cases
  • Validate serialization of VttWord struct matches frontend data contracts

Possibly related PRs

  • fixes before beta release #1692: Both PRs modify listener-related code to use Tauri's BaseDirectory/Manager for resolving hyprnote session data paths with similar path-resolution patterns.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding VTT export functionality to the listener2 plugin, which aligns with the primary objective of the changeset.
Description check ✅ Passed The description is directly related to the changeset, providing detailed context about the VTT export feature, implementation details, testing checklist, and notes about related fixes.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch devin/1764208545-vtt-export

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_all at line 40 and vtt.export at 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, use spawn_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

📥 Commits

Reviewing files that changed from the base of the PR and between 21777ff and 2c64c9a.

⛔ Files ignored due to path filters (1)
  • plugins/listener2/js/bindings.gen.ts is 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, use cn (import from @hypr/utils). It is similar to clsx. Always pass an array and split by logical grouping.
Use motion/react instead of framer-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 VttWord struct 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_vtt command 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.

devin-ai-integration bot and others added 2 commits November 27, 2025 02:09
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>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
plugins/listener2/src/commands.rs (1)

34-42: Sanitize session_id to prevent path traversal when constructing session_dir

session_id is joined directly into the filesystem path. A crafted session_id (e.g., with .. or separators) could escape the intended data directory and write transcript.vtt to arbitrary locations. This is a security-sensitive path and should be constrained.

Consider validating session_id against a safe character set and adding a post-join check that session_dir still resides under data_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 desired

The new i64::try_from conversion for start_ms / end_ms cleanly prevents overflow and propagates a descriptive Err(String) if any timestamp exceeds i64::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::Subtitle while the module already uses crate::Subtitle elsewhere. Since only WebVttCue, Moment, and WebVttSubtitle are used here, dropping Subtitle from the inner use list 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

📥 Commits

Reviewing files that changed from the base of the PR and between e681e0b and ff689cd.

📒 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

BaseDirectory and VttWord are correctly wired for use in export_to_vtt, and existing command signatures remain unchanged.


62-66: VTT build and export flow looks correct and consistent with subtitle.rs usage

Building WebVttSubtitle from the collected cues and exporting to vtt_path, then returning vtt_path.to_string_lossy().to_string(), is straightforward and matches how Moment is round-tripped in plugins/listener2/src/subtitle.rs (Moment ↔ i64 ↔ u64), so units should remain consistent end-to-end.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ff689cd and 71dc3c8.

⛔ Files ignored due to path filters (5)
  • plugins/listener2/permissions/autogenerated/commands/export_to_vtt.toml is excluded by !plugins/**/permissions/**
  • plugins/listener2/permissions/autogenerated/commands/ping.toml is excluded by !plugins/**/permissions/**
  • plugins/listener2/permissions/autogenerated/reference.md is excluded by !plugins/**/permissions/**
  • plugins/listener2/permissions/default.toml is excluded by !plugins/**/permissions/**
  • plugins/listener2/permissions/schemas/schema.json is 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, use cn (import from @hypr/utils). It is similar to clsx. Always pass an array and split by logical grouping.
Use motion/react instead of framer-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 for export_to_vtt.

Adding "export_to_vtt" to COMMANDS cleanly registers the new Tauri command via the existing tauri_plugin::Builder::new(COMMANDS) flow and aligns with the PR intent.

Please double‑check that:

  • export_to_vtt is actually implemented and exported in commands.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.

@yujonglee yujonglee merged commit e1a82ac into main Nov 27, 2025
13 checks passed
@yujonglee yujonglee deleted the devin/1764208545-vtt-export branch November 27, 2025 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant