Add API specification and remove deprecated client files

- Introduced a new API specification file `noteflow-api-spec.json` detailing the real-time audio-to-text functionality.
- Removed obsolete client configuration files, including `.prettierrc.json`, `biome.json`, `index.html`, and various TypeScript configuration files.
- Deleted unused components and tests from the client directory to streamline the project structure.
This commit is contained in:
2025-12-23 10:20:45 +00:00
parent 4567d5a03c
commit 3ea953c677
135 changed files with 537 additions and 31284 deletions

View File

@@ -1,4 +0,0 @@
{
"printWidth": 100,
"singleQuote": true
}

View File

@@ -1,11 +0,0 @@
{
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"formatter": {
"enabled": false
}
}

View File

@@ -1,184 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Annotation E2E Tests
*
* Tests the annotation toolbar and annotation display
* functionality within transcript segments.
*/
test.describe('Annotation Toolbar', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
});
test('should show annotation buttons on segment hover', async ({ page }) => {
// Find transcript segments if they exist
const segments = page.locator('[class*="group rounded-lg"]');
if ((await segments.count()) > 0) {
const firstSegment = segments.first();
await firstSegment.hover();
// Toolbar should appear on hover
await page.waitForTimeout(100);
const toolbar = firstSegment.locator('[class*="group-hover:block"]');
// Toolbar may or may not be visible depending on meeting state
}
});
test('should have action item button', async ({ page }) => {
const segments = page.locator('[class*="group rounded-lg"]');
if ((await segments.count()) > 0) {
await segments.first().hover();
const actionItemButton = page.getByTitle('Action Item');
// May or may not be visible depending on meeting state
}
});
test('should have decision button', async ({ page }) => {
const segments = page.locator('[class*="group rounded-lg"]');
if ((await segments.count()) > 0) {
await segments.first().hover();
const decisionButton = page.getByTitle('Decision');
// May or may not be visible depending on meeting state
}
});
test('should have note button', async ({ page }) => {
const segments = page.locator('[class*="group rounded-lg"]');
if ((await segments.count()) > 0) {
await segments.first().hover();
const noteButton = page.getByTitle('Note');
// May or may not be visible depending on meeting state
}
});
test('should have risk button', async ({ page }) => {
const segments = page.locator('[class*="group rounded-lg"]');
if ((await segments.count()) > 0) {
await segments.first().hover();
const riskButton = page.getByTitle('Risk');
// May or may not be visible depending on meeting state
}
});
});
test.describe('Annotation Display', () => {
test('should show annotation badges on segments', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Annotation badges would appear on segments with annotations
const annotationBadges = page.locator('[class*="inline-flex items-center gap-1"]');
// Will be visible only if segments have annotations
});
test('should show annotation type icons', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Look for annotation type indicators
const annotationIcons = page.locator('svg');
// SVG icons used throughout the interface
});
});
test.describe('Annotation Note Input', () => {
test('should show text input for note annotations', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const segments = page.locator('[class*="group rounded-lg"]');
if ((await segments.count()) > 0) {
await segments.first().hover();
const noteButton = page.getByTitle('Note');
if (await noteButton.isVisible()) {
await noteButton.click();
// Input field should appear
const noteInput = page.getByPlaceholder('Add note...');
if (await noteInput.isVisible()) {
await expect(noteInput).toBeVisible();
}
}
}
});
test('should close note input on Escape', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const segments = page.locator('[class*="group rounded-lg"]');
if ((await segments.count()) > 0) {
await segments.first().hover();
const noteButton = page.getByTitle('Note');
if (await noteButton.isVisible()) {
await noteButton.click();
const noteInput = page.getByPlaceholder('Add note...');
if (await noteInput.isVisible()) {
await page.keyboard.press('Escape');
// Input should close
await page.waitForTimeout(100);
}
}
}
});
});
test.describe('Annotation Removal', () => {
test('should have remove button on annotation badges', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Annotation badges have X buttons for removal
const removeButtons = page.locator('[class*="h-3 w-3"]');
// Will be visible only if annotations exist
});
});
test.describe('Annotation Accessibility', () => {
test('should have accessible toolbar buttons', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Toolbar buttons should have titles
const buttonsWithTitles = page.locator('button[title]');
const count = await buttonsWithTitles.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should support keyboard interaction for note input', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const segments = page.locator('[class*="group rounded-lg"]');
if ((await segments.count()) > 0) {
await segments.first().hover();
const noteButton = page.getByTitle('Note');
if (await noteButton.isVisible()) {
await noteButton.click();
const noteInput = page.getByPlaceholder('Add note...');
if (await noteInput.isVisible()) {
// Should be focusable and typable
await noteInput.focus();
await page.keyboard.type('Test note');
await expect(noteInput).toHaveValue('Test note');
}
}
}
});
});

View File

@@ -1,109 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Core application E2E tests
*
* These tests verify basic application structure and navigation.
* Note: Tauri-specific functionality (IPC, system tray) cannot be tested
* in web mode and would require Tauri's WebDriver integration.
*/
test.describe('Application', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should display NoteFlow header', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'NoteFlow' })).toBeVisible();
});
test('should show connection panel', async ({ page }) => {
// Should show either Connected or Disconnected status
const connectionStatus = page.getByText(/Connected|Disconnected/);
await expect(connectionStatus).toBeVisible();
});
test('should have navigation buttons', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Recording', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Library' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
});
});
test.describe('Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should switch to Recording view', async ({ page }) => {
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Recording view should be active
await expect(page.getByRole('button', { name: 'Recording', exact: true })).toHaveClass(/active|selected|bg-/);
});
test('should switch to Settings view', async ({ page }) => {
await page.getByRole('button', { name: 'Settings' }).click();
await expect(page.getByRole('button', { name: 'Settings' })).toHaveClass(/active|selected|bg-/);
});
test('should switch to Library view', async ({ page }) => {
await page.getByRole('button', { name: 'Library' }).click();
await expect(page.getByRole('button', { name: 'Library' })).toHaveClass(/active|selected|bg-/);
});
});
test.describe('Recording View', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
});
test('should show VU meter when available', async ({ page }) => {
// VU meter displays dB level
const vuMeter = page.getByText(/dB/);
// May or may not be visible depending on connection state
if (await vuMeter.isVisible()) {
await expect(vuMeter).toBeVisible();
}
});
});
test.describe('Library View', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
});
test('should show meeting library interface', async ({ page }) => {
// Library view should show some indication of meeting list
// This could be an empty state or meeting cards
const libraryContent = page.locator('main');
await expect(libraryContent).toBeVisible();
});
});
test.describe('Accessibility', () => {
test('should have proper heading structure', async ({ page }) => {
await page.goto('/');
// Should have exactly one h1
const h1Elements = page.locator('h1');
await expect(h1Elements).toHaveCount(1);
});
test('should have accessible navigation buttons', async ({ page }) => {
await page.goto('/');
const buttons = page.getByRole('button');
// All buttons should have accessible names
const buttonCount = await buttons.count();
expect(buttonCount).toBeGreaterThan(0);
});
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/');
// Tab through the interface
await page.keyboard.press('Tab');
// Something should be focused
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
});
});

View File

@@ -1,65 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Connection state E2E tests
*
* These tests verify the connection panel behavior in web mode.
* Note: Actual gRPC connections require the Tauri runtime.
*/
test.describe('Connection Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should show initial disconnected state', async ({ page }) => {
// In web mode without Tauri, should show disconnected
const status = page.getByText('Disconnected');
await expect(status).toBeVisible();
});
test('should show reconnect button when disconnected', async ({ page }) => {
const reconnectButton = page.getByTitle('Connect to server');
// May not be visible if already connected
if (await page.getByText('Disconnected').isVisible()) {
await expect(reconnectButton).toBeVisible();
}
});
test('should attempt reconnection on button click', async ({ page }) => {
// Find the connect button if visible
const connectButton = page.getByTitle('Connect to server');
if (await connectButton.isVisible()) {
// Click should trigger connection attempt
await connectButton.click();
// Button should show loading state (spinning animation)
// Note: In web mode, this will likely fail but UI should respond
await page.waitForTimeout(500);
}
});
});
test.describe('Connection Status Indicator', () => {
test('should display appropriate icon for connection state', async ({ page }) => {
await page.goto('/');
// Should have either connected or disconnected indicator
const connectionArea = page.locator('.flex.items-center.gap-2').first();
await expect(connectionArea).toBeVisible();
// Should contain status text
const hasStatus = await page.getByText(/Connected|Disconnected/).isVisible();
expect(hasStatus).toBe(true);
});
test('should use correct color for disconnected state', async ({ page }) => {
await page.goto('/');
if (await page.getByText('Disconnected').isVisible()) {
// Disconnected should have red styling
const statusBadge = page.getByText('Disconnected').locator('..');
await expect(statusBadge).toHaveClass(/red/);
}
});
});

View File

@@ -1,80 +0,0 @@
import { test as base, expect } from '@playwright/test';
/**
* Custom fixtures for NoteFlow E2E tests
*
* These fixtures provide common utilities and mock data
* for testing the application in web mode.
*/
// Mock data for tests
export const mockMeeting = {
id: 'test-meeting-1',
title: 'Test Meeting',
state: 'completed',
created_at: '2024-01-01T10:00:00Z',
updated_at: '2024-01-01T11:00:00Z',
duration_secs: 3600,
segment_count: 10,
has_summary: true,
};
export const mockSegment = {
id: 'test-segment-1',
meeting_id: 'test-meeting-1',
speaker: 'Speaker 1',
text: 'This is a test transcript segment.',
start_time: 0.0,
end_time: 5.0,
confidence: 0.95,
is_partial: false,
};
export const mockServerInfo = {
version: '1.0.0',
capabilities: ['streaming', 'diarization', 'summarization'],
asr_model: 'whisper-large',
};
// Extended test with custom fixtures
export const test = base.extend<{
connectedState: void;
disconnectedState: void;
}>({
// Fixture to ensure disconnected state
disconnectedState: async ({ page }, use) => {
await page.goto('/');
// In web mode, we're always disconnected since no Tauri runtime
await use();
},
// Fixture for connected state (would need mock server)
connectedState: async ({ page }, use) => {
// Note: This would require a mock WebSocket/gRPC server
// For now, just navigate to page
await page.goto('/');
await use();
},
});
export { expect };
// Helper functions for tests
export async function waitForAppReady(page: import('@playwright/test').Page) {
// Wait for the main app to be rendered
await page.waitForSelector('h1:has-text("NoteFlow")');
}
export async function navigateToView(
page: import('@playwright/test').Page,
view: 'Recording' | 'Review' | 'Library'
) {
await page.getByRole('button', { name: view }).click();
// Wait for navigation to complete
await page.waitForTimeout(100);
}
export async function getConnectionStatus(page: import('@playwright/test').Page) {
const connected = await page.getByText('Connected').isVisible();
return connected ? 'connected' : 'disconnected';
}

View File

@@ -1,303 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Keyboard Navigation E2E Tests
*
* Comprehensive tests for keyboard accessibility across
* all views and components.
*/
test.describe('Global Keyboard Navigation', () => {
test('should start with focus on interactive element', async ({ page }) => {
await page.goto('/');
// Tab should move focus to first interactive element
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
});
test('should navigate through header buttons', async ({ page }) => {
await page.goto('/');
// Tab through header navigation
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
}
// Should be able to reach navigation buttons
const navButtons = page.getByRole('button');
const count = await navButtons.count();
expect(count).toBeGreaterThan(0);
});
test('should activate buttons with Enter key', async ({ page }) => {
await page.goto('/');
// Focus on Library button and activate
const libraryButton = page.getByRole('button', { name: 'Library' });
await libraryButton.focus();
await page.keyboard.press('Enter');
// Should switch to library view
await expect(libraryButton).toHaveClass(/bg-primary/);
});
test('should activate buttons with Space key', async ({ page }) => {
await page.goto('/');
const settingsButton = page.getByRole('button', { name: 'Settings' });
await settingsButton.focus();
await page.keyboard.press('Space');
await expect(settingsButton).toHaveClass(/bg-primary/);
});
});
test.describe('Recording View Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
});
test('should navigate to recording controls', async ({ page }) => {
// Tab through to find recording controls
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
}
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
});
test('should navigate to playback slider if present', async ({ page }) => {
const slider = page.getByRole('slider');
if (await slider.isVisible()) {
await slider.focus();
await expect(slider).toBeFocused();
// Arrow keys should change value
await page.keyboard.press('ArrowRight');
// Value may change depending on initial state
}
});
});
test.describe('Library View Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
});
test('should focus search input on Tab', async ({ page }) => {
const searchInput = page.getByPlaceholder('Search meetings...');
// Tab until search is focused
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
if (await searchInput.evaluate((el) => el === document.activeElement)) {
break;
}
}
// Should be able to type in search
await searchInput.focus();
await page.keyboard.type('test');
await expect(searchInput).toHaveValue('test');
});
test('should navigate to refresh button', async ({ page }) => {
const refreshButton = page.getByRole('button', { name: /Refresh/i });
if (await refreshButton.isVisible()) {
await refreshButton.focus();
await expect(refreshButton).toBeFocused();
}
});
test('should navigate through meeting cards', async ({ page }) => {
const meetingCards = page.locator('[class*="cursor-pointer"]');
if ((await meetingCards.count()) > 0) {
// Tab through meeting cards
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
}
const focused = page.locator(':focus');
const isVisible = await focused.isVisible();
expect(isVisible).toBe(true);
}
});
});
test.describe('Settings View Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Settings' }).click();
});
test('should navigate through settings fields', async ({ page }) => {
// Tab through settings form
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
}
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
});
test('should allow text input in fields', async ({ page }) => {
const textInputs = page.locator('input[type="text"]');
if ((await textInputs.count()) > 0) {
const firstInput = textInputs.first();
await firstInput.focus();
await page.keyboard.type('test value');
const value = await firstInput.inputValue();
expect(value).toContain('test value');
}
});
test('should navigate checkboxes', async ({ page }) => {
const checkboxes = page.locator('input[type="checkbox"]');
if ((await checkboxes.count()) > 0) {
const firstCheckbox = checkboxes.first();
await firstCheckbox.focus();
// Space should toggle checkbox
const initialState = await firstCheckbox.isChecked();
await page.keyboard.press('Space');
const newState = await firstCheckbox.isChecked();
expect(newState).not.toBe(initialState);
}
});
});
test.describe('Dialog Keyboard Navigation', () => {
test('should close settings dialog with Escape', async ({ page }) => {
await page.goto('/');
// Settings view functions as a page, not a dialog
await page.getByRole('button', { name: 'Settings' }).click();
// Escape should return to previous view or be handled
await page.keyboard.press('Escape');
// May or may not change view depending on implementation
});
test('should trap focus in modal dialogs', async ({ page }) => {
await page.goto('/');
// If there's a trigger dialog, it should trap focus
const dialog = page.getByRole('dialog');
if (await dialog.isVisible()) {
// Tab should cycle within dialog
for (let i = 0; i < 20; i++) {
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
// Focus should remain inside dialog
}
}
});
});
test.describe('Focus Visibility', () => {
test('should show visible focus indicators', async ({ page }) => {
await page.goto('/');
// Tab to first button
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
// Focus ring should be visible (CSS outline or ring)
// This is a visual check - Playwright can verify element is focused
});
test('should maintain focus after view switch', async ({ page }) => {
await page.goto('/');
// Switch views and verify focus management
await page.getByRole('button', { name: 'Library' }).click();
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
});
});
test.describe('Arrow Key Navigation', () => {
test('should navigate slider with arrow keys', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const slider = page.getByRole('slider');
if (await slider.isVisible()) {
await slider.focus();
const initialValue = await slider.inputValue();
await page.keyboard.press('ArrowRight');
const newValue = await slider.inputValue();
// Value should change (increase)
expect(parseInt(newValue)).toBeGreaterThanOrEqual(parseInt(initialValue));
}
});
});
test.describe('Skip Links', () => {
test('should skip to main content if skip link exists', async ({ page }) => {
await page.goto('/');
// Many apps have skip links as first focusable element
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
const text = await focused.textContent();
// Skip link would have text like "Skip to content" or "Skip to main"
// Not all apps implement this
});
});
test.describe('Form Navigation', () => {
test('should navigate form fields in order', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Settings' }).click();
const inputs = page.locator('input');
const inputCount = await inputs.count();
if (inputCount > 1) {
// Tab through inputs in order
const firstInput = inputs.first();
await firstInput.focus();
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
}
});
test('should submit form with Enter', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
const searchInput = page.getByPlaceholder('Search meetings...');
await searchInput.focus();
await searchInput.fill('test search');
await page.keyboard.press('Enter');
// Search should be applied (enter may or may not submit)
await expect(searchInput).toHaveValue('test search');
});
});

View File

@@ -1,140 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Meeting Library E2E Tests
*
* Tests the meeting library view including search, selection,
* export, and delete functionality.
*/
test.describe('Meeting Library', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
});
test('should display library view', async ({ page }) => {
// Library view should be visible
const mainContent = page.locator('main');
await expect(mainContent).toBeVisible();
});
test('should show search input', async ({ page }) => {
const searchInput = page.getByPlaceholder('Search meetings...');
await expect(searchInput).toBeVisible();
});
test('should show refresh button', async ({ page }) => {
const refreshButton = page.getByRole('button', { name: /refresh/i });
await expect(refreshButton).toBeVisible();
});
test('should filter meetings on search input', async ({ page }) => {
const searchInput = page.getByPlaceholder('Search meetings...');
await searchInput.fill('test');
// Should filter meetings (or show "No matching meetings found" if none match)
await page.waitForTimeout(300);
const content = page.locator('main');
await expect(content).toBeVisible();
});
test('should clear search on empty input', async ({ page }) => {
const searchInput = page.getByPlaceholder('Search meetings...');
await searchInput.fill('test');
await searchInput.fill('');
// Should show all meetings or empty state
await page.waitForTimeout(300);
const content = page.locator('main');
await expect(content).toBeVisible();
});
});
test.describe('Library Empty State', () => {
test('should show empty state message when no meetings', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
// May show "No meetings yet" or meeting list
const emptyMessage = page.getByText(/No meetings yet|No matching meetings/);
const meetingCards = page.locator('[class*="cursor-pointer"]');
const hasEmpty = await emptyMessage.isVisible().catch(() => false);
const hasMeetings = (await meetingCards.count()) > 0;
expect(hasEmpty || hasMeetings).toBe(true);
});
});
test.describe('Library Meeting Actions', () => {
test('should show export button on meeting cards', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
// Find meeting cards if they exist
const meetingCards = page.locator('[class*="cursor-pointer"]').first();
if (await meetingCards.isVisible()) {
// Export button should be visible on hover
await meetingCards.hover();
const exportButton = page.getByTitle('Export as Markdown');
// May or may not be visible depending on CSS hover state
await page.waitForTimeout(100);
}
});
test('should show delete button on meeting cards', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
const meetingCards = page.locator('[class*="cursor-pointer"]').first();
if (await meetingCards.isVisible()) {
await meetingCards.hover();
const deleteButton = page.getByTitle('Delete meeting');
await page.waitForTimeout(100);
}
});
});
test.describe('Library Keyboard Navigation', () => {
test('should focus search input first on Tab', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
// Focus the search input directly
const searchInput = page.getByPlaceholder('Search meetings...');
await searchInput.focus();
await expect(searchInput).toBeFocused();
});
test('should allow typing in search when focused', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
const searchInput = page.getByPlaceholder('Search meetings...');
await searchInput.focus();
await page.keyboard.type('meeting');
await expect(searchInput).toHaveValue('meeting');
});
});
test.describe('Library Accessibility', () => {
test('should have accessible search input', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
const searchInput = page.getByPlaceholder('Search meetings...');
await expect(searchInput).toHaveAttribute('type', 'text');
});
test('should have accessible buttons', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Library' }).click();
const refreshButton = page.getByRole('button', { name: /refresh/i });
await expect(refreshButton).toBeVisible();
});
});

View File

@@ -1,93 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Playback E2E Tests
*
* Tests the playback controls UX including play/pause/stop
* and seek functionality.
*/
test.describe('Playback Controls', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Recording view contains playback controls
await page.getByRole('button', { name: 'Recording', exact: true }).click();
});
test('should show playback controls in Review view', async ({ page }) => {
// Playback controls area should exist
const playbackArea = page.locator('input[type="range"]');
// May or may not be visible depending on whether a meeting is loaded
if (await playbackArea.isVisible()) {
await expect(playbackArea).toBeVisible();
}
});
test('should display time in correct format', async ({ page }) => {
// Look for time display (00:00 format)
const timeDisplay = page.getByText(/\d{1,2}:\d{2}/);
// At least one time display should be present if playback is visible
const count = await timeDisplay.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Playback Keyboard Shortcuts', () => {
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/');
// Tab through interface
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Something should be focused
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
});
test('should support space bar for play/pause toggle', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Focus on a playback button if present
const playButton = page.locator('button').filter({ has: page.locator('svg') }).first();
if (await playButton.isVisible()) {
await playButton.focus();
// Space should toggle (in a real app with Tauri)
await page.keyboard.press('Space');
}
});
});
test.describe('Seek Bar Interaction', () => {
test('should have accessible slider', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const slider = page.getByRole('slider');
if (await slider.isVisible()) {
await expect(slider).toBeVisible();
// Should have min/max attributes
await expect(slider).toHaveAttribute('min', '0');
}
});
test('should update position display on drag', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const slider = page.getByRole('slider');
if (await slider.isVisible()) {
// Get initial value
const initialValue = await slider.inputValue();
// Simulate drag
await slider.fill('30');
// Value should change
const newValue = await slider.inputValue();
expect(newValue).toBe('30');
}
});
});

View File

@@ -1,112 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Recording E2E Tests
*
* Tests the recording flow including start/stop and
* connection state handling.
*/
test.describe('Recording View', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
});
test('should show recording controls', async ({ page }) => {
// Should have either Start Recording or Stop button
const startButton = page.getByRole('button', { name: /start recording/i });
const stopButton = page.getByRole('button', { name: /stop/i });
const hasStart = await startButton.isVisible();
const hasStop = await stopButton.isVisible();
// One of them should be visible
expect(hasStart || hasStop).toBe(true);
});
test('should show VU meter', async ({ page }) => {
// VU meter should display dB level
const vuMeter = page.getByText(/dB/);
await expect(vuMeter).toBeVisible();
});
test('should disable recording when disconnected', async ({ page }) => {
// If disconnected, start button should be disabled
const disconnected = await page.getByText('Disconnected').isVisible();
if (disconnected) {
const startButton = page.getByRole('button', { name: /start recording/i });
if (await startButton.isVisible()) {
await expect(startButton).toBeDisabled();
}
}
});
});
test.describe('Recording Timer', () => {
test('should show timer when recording', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// If recording is active, timer should show
const stopButton = page.getByRole('button', { name: /stop/i });
if (await stopButton.isVisible()) {
// Look for time display (could be 00:00 or similar format)
const timeDisplay = page.getByText(/\d{1,2}:\d{2}(:\d{2})?/);
await expect(timeDisplay.first()).toBeVisible();
}
});
});
test.describe('Recording Start Flow', () => {
test('should show loading state when starting', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const startButton = page.getByRole('button', { name: /start recording/i });
if (await startButton.isVisible() && !(await startButton.isDisabled())) {
// Click should trigger loading state
await startButton.click();
// Button should become disabled during loading
// In web mode without Tauri, this will likely fail quickly
// but we test the UI response
await page.waitForTimeout(100);
}
});
});
test.describe('Recording Stop Flow', () => {
test('should show stop button when recording', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// If we're recording, stop button should be visible
const stopButton = page.getByRole('button', { name: /stop/i });
if (await stopButton.isVisible()) {
await expect(stopButton).toBeEnabled();
}
});
});
test.describe('VU Meter Visual Feedback', () => {
test('should display level indicator', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// VU meter should have segments
const vuContainer = page.locator('.h-4'); // Segment height class
const count = await vuContainer.count();
expect(count).toBeGreaterThan(0);
});
test('should show dB value', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const dbDisplay = page.getByText(/-?\d+ dB/);
await expect(dbDisplay).toBeVisible();
});
});

View File

@@ -1,209 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Settings E2E Tests
*
* Tests the settings panel UX including preferences,
* audio device selection, and connection settings.
*/
test.describe('Settings Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should open settings dialog', async ({ page }) => {
// Look for settings button (gear icon)
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
// Settings dialog should open
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
}
});
test('should close settings with Escape key', async ({ page }) => {
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Press Escape to close
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
}
});
test('should close settings with close button', async ({ page }) => {
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Find and click close button
const closeButton = dialog.locator('button').filter({ hasText: /close|cancel|×/i }).first();
if (await closeButton.isVisible()) {
await closeButton.click();
await expect(dialog).not.toBeVisible();
}
}
});
});
test.describe('Connection Settings', () => {
test('should show server address field', async ({ page }) => {
await page.goto('/');
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
// Look for server URL input
const serverInput = page.getByLabel(/server/i);
if (await serverInput.isVisible()) {
await expect(serverInput).toBeVisible();
}
}
});
test('should validate server address format', async ({ page }) => {
await page.goto('/');
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
const serverInput = page.getByLabel(/server/i);
if (await serverInput.isVisible()) {
// Enter invalid address
await serverInput.fill('not-a-valid-url');
// Look for validation error
const error = page.getByText(/invalid|error/i);
// May or may not show error depending on validation timing
}
}
});
});
test.describe('Audio Device Settings', () => {
test('should show device selection dropdown', async ({ page }) => {
await page.goto('/');
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
// Look for audio device section
const audioSection = page.getByText(/audio|microphone|device/i);
if (await audioSection.first().isVisible()) {
await expect(audioSection.first()).toBeVisible();
}
}
});
});
test.describe('Data Directory Settings', () => {
test('should show data directory field', async ({ page }) => {
await page.goto('/');
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
// Look for data directory input
const dataInput = page.getByLabel(/data|directory|folder/i);
if (await dataInput.first().isVisible()) {
await expect(dataInput.first()).toBeVisible();
}
}
});
});
test.describe('Settings Persistence', () => {
test('should preserve settings after close and reopen', async ({ page }) => {
await page.goto('/');
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
// Open settings
await settingsButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Find a text input
const input = dialog.locator('input[type="text"]').first();
if (await input.isVisible()) {
const originalValue = await input.inputValue();
// Close and reopen
await page.keyboard.press('Escape');
await settingsButton.click();
// Value should be preserved
const newValue = await input.inputValue();
expect(newValue).toBe(originalValue);
}
}
});
});
test.describe('Settings Accessibility', () => {
test('should have proper labels for inputs', async ({ page }) => {
await page.goto('/');
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
const dialog = page.getByRole('dialog');
if (await dialog.isVisible()) {
// All inputs should have labels
const inputs = dialog.locator('input');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
// Should have either aria-label or associated label
const hasLabel =
(await input.getAttribute('aria-label')) ||
(await input.getAttribute('id'));
// Not all inputs require labels, so we just check there's some accessibility
}
}
}
});
test('should be navigable with Tab key', async ({ page }) => {
await page.goto('/');
const settingsButton = page.getByTitle(/settings/i);
if (await settingsButton.isVisible()) {
await settingsButton.click();
const dialog = page.getByRole('dialog');
if (await dialog.isVisible()) {
// Tab through elements
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Something should be focused inside dialog
const focused = dialog.locator(':focus');
const count = await focused.count();
expect(count).toBe(1);
}
}
});
});

View File

@@ -1,178 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Summary Panel E2E Tests
*
* Tests the summary generation, display, and
* citation interaction features.
*/
test.describe('Summary Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
});
test('should display summary heading', async ({ page }) => {
const summaryHeading = page.getByRole('heading', { name: 'Summary' });
await expect(summaryHeading).toBeVisible();
});
test('should show generate button', async ({ page }) => {
const generateButton = page.getByRole('button', { name: /Generate|Regenerate/i });
// Button may or may not be visible depending on meeting state
const mainContent = page.locator('main');
await expect(mainContent).toBeVisible();
});
test('should show empty state when no summary', async ({ page }) => {
// Either show prompt to generate or record first
const emptyMessage = page.getByText(/Record a meeting|Click Generate|No transcript/);
const summaryContent = page.getByText(/Executive Summary/);
const hasEmpty = await emptyMessage.first().isVisible().catch(() => false);
const hasSummary = await summaryContent.isVisible().catch(() => false);
expect(hasEmpty || hasSummary).toBe(true);
});
});
test.describe('Summary Generation', () => {
test('should disable generate button when no segments', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const generateButton = page.getByRole('button', { name: /Generate/i });
if (await generateButton.isVisible()) {
// Button should be disabled when no segments
const isDisabled = await generateButton.isDisabled();
// May or may not be disabled depending on state
}
});
test('should show loading state during generation', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Loading indicator would appear during generation
const loadingIndicator = page.locator('[class*="animate-spin"]');
// May or may not be visible
});
});
test.describe('Summary Display', () => {
test('should display executive summary section', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Executive summary heading should exist in structure
const execSummaryLabel = page.getByText('Executive Summary');
// Will be visible only if summary exists
});
test('should display key points section when available', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const keyPointsLabel = page.getByText('Key Points');
// Will be visible only if summary has key points
});
test('should display action items section when available', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const actionItemsLabel = page.getByText('Action Items');
// Will be visible only if summary has action items
});
});
test.describe('Summary Citations', () => {
test('should show citation links on key points', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Citation links have format [#N]
const citationLinks = page.getByText(/\[#\d+\]/);
// Will be visible only if summary has citations
});
test('should make citation links clickable', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const citationLinks = page.locator('button').filter({ hasText: /\[#\d+\]/ });
if ((await citationLinks.count()) > 0) {
// Citation links should be interactive
await expect(citationLinks.first()).toBeVisible();
}
});
});
test.describe('Summary Action Items', () => {
test('should show assignee badges on action items', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Action items may have assignee badges
const assigneeBadges = page.locator('[class*="bg-primary/20"]');
// Will be visible only if summary has action items with assignees
});
test('should show priority badges on action items', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Priority badges: High (red), Medium (yellow), Low (blue)
const priorityBadges = page.getByText(/High|Medium|Low/);
// Will be visible only if summary has prioritized action items
});
});
test.describe('Summary Error Handling', () => {
test('should display error message on generation failure', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Error message would appear in red
const errorMessage = page.locator('[class*="bg-red"]');
// May or may not be visible depending on state
});
});
test.describe('Summary Scrolling', () => {
test('should have scrollable summary content', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const scrollableContent = page.locator('[class*="overflow-auto"]');
await expect(scrollableContent.first()).toBeVisible();
});
});
test.describe('Summary Accessibility', () => {
test('should have proper heading hierarchy', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Summary heading should be h2
const h2Elements = page.locator('h2');
const count = await h2Elements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Summary heading should be visible
const summaryHeading = page.getByRole('heading', { name: 'Summary' });
await expect(summaryHeading).toBeVisible();
// Main content area should be navigable
const mainContent = page.locator('main');
await expect(mainContent).toBeVisible();
});
});

View File

@@ -1,150 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Transcript View E2E Tests
*
* Tests the transcript display, segment interaction,
* and annotation features.
*/
test.describe('Transcript View', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Transcript is part of Recording view
await page.getByRole('button', { name: 'Recording', exact: true }).click();
});
test('should display transcript area', async ({ page }) => {
// Main content area should be visible
const mainContent = page.locator('main');
await expect(mainContent).toBeVisible();
});
test('should show empty state when no transcript', async ({ page }) => {
// Either show "Listening..." or "No transcript yet"
const emptyMessage = page.getByText(/Listening|No transcript yet/);
// May or may not be visible depending on state
const hasEmptyState = await emptyMessage.isVisible().catch(() => false);
const hasSegments = (await page.locator('[class*="rounded-lg p-3"]').count()) > 0;
expect(hasEmptyState || hasSegments).toBe(true);
});
test('should show partial text indicator when recording', async ({ page }) => {
// Partial text appears when ASR is processing
// This would show a dashed border element
const partialIndicator = page.locator('[class*="border-dashed"]');
// May or may not be visible depending on recording state
await page.waitForTimeout(100);
});
});
test.describe('Transcript Segment Display', () => {
test('should display speaker labels on segments', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// If segments exist, they should have speaker labels
const segments = page.locator('[class*="rounded-lg p-3"]');
if ((await segments.count()) > 0) {
// Speaker badge should have color styling
const speakerBadge = segments.first().locator('[class*="rounded text-xs"]');
await expect(speakerBadge).toBeVisible();
}
});
test('should display timestamps on segments', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const segments = page.locator('[class*="rounded-lg p-3"]');
if ((await segments.count()) > 0) {
// Timestamps should be visible (format: MM:SS - MM:SS)
const timestamp = segments.first().getByText(/\d{1,2}:\d{2}/);
await expect(timestamp.first()).toBeVisible();
}
});
});
test.describe('Transcript Click to Seek', () => {
test('should have clickable segments', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
const segments = page.locator('[class*="cursor-pointer"]');
if ((await segments.count()) > 0) {
// Segments should be clickable (have cursor-pointer)
const firstSegment = segments.first();
await expect(firstSegment).toHaveClass(/cursor-pointer/);
}
});
});
test.describe('Transcript Annotations', () => {
test('should show annotation toolbar on segment hover', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// If there's a current meeting and segments
const segments = page.locator('[class*="rounded-lg p-3"]');
if ((await segments.count()) > 0) {
await segments.first().hover();
// Annotation toolbar should appear on hover
await page.waitForTimeout(100);
const toolbar = page.locator('[class*="group-hover:block"]');
// May be visible if meeting is loaded
}
});
});
test.describe('Transcript Auto-scroll', () => {
test('should have scrollable transcript container', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Transcript container should be scrollable
const transcriptContainer = page.locator('[class*="overflow-auto"]');
await expect(transcriptContainer.first()).toBeVisible();
});
});
test.describe('Transcript Accessibility', () => {
test('should have proper text contrast', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Text should be visible and readable
const mainContent = page.locator('main');
await expect(mainContent).toBeVisible();
});
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// Tab through transcript area
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toBeVisible();
});
});
test.describe('Transcript and Playback Integration', () => {
test('should highlight segment during playback', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Recording', exact: true }).click();
// If segments exist and playback is active
const highlightedSegment = page.locator('[class*="bg-primary/20"]');
// May or may not be visible depending on playback state
await page.waitForTimeout(100);
});
});

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NoteFlow</title>
</head>
<body class="bg-gray-900 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7223
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
{
"name": "noteflow-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "biome check src",
"lint:fix": "biome check src --write",
"lint:rs": "cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"format:rs": "cargo fmt --manifest-path src-tauri/Cargo.toml",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.3.0",
"immer": "^10.0.0",
"lucide-react": "^0.400.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-virtuoso": "^4.7.0",
"tailwind-merge": "^2.2.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@playwright/test": "^1.41.0",
"@tauri-apps/cli": "^2.0.0",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0",
"@testing-library/user-event": "^14.5.0",
"@types/node": "^25.0.3",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"@vitest/coverage-v8": "^1.2.0",
"@vitest/ui": "^1.2.0",
"autoprefixer": "^10.4.0",
"jsdom": "^24.0.0",
"postcss": "^8.4.0",
"prettier": "^3.7.4",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.3.0",
"vite": "^5.1.0",
"vitest": "^1.2.0"
}
}

View File

@@ -1,47 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for NoteFlow Tauri E2E tests
*
* These tests run against the development server and verify
* end-to-end user flows.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
timeout: 30000,
expect: {
timeout: 5000,
},
use: {
baseURL: 'http://localhost:1420',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:1420',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,95 +0,0 @@
[package]
name = "noteflow-client"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "NoteFlow Meeting Notetaker - Tauri Client"
authors = ["NoteFlow Team"]
[lib]
name = "noteflow_tauri_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
tonic-build = "0.12"
[dependencies]
# Tauri
tauri = { version = "2.0", features = [] }
tauri-plugin-shell = "2.0"
tauri-plugin-dialog = "2.0"
tauri-plugin-fs = "2.0"
tauri-plugin-notification = "2.0"
tauri-plugin-global-shortcut = "2.0"
tauri-plugin-window-state = "2.0"
# Async runtime
tokio = { version = "1.35", features = ["full"] }
tokio-stream = "0.1"
futures = "0.3"
async-stream = "0.3"
# gRPC
tonic = "0.12"
prost = "0.13"
prost-types = "0.13"
# Audio
cpal = "0.15"
rodio = { version = "0.19", default-features = false, features = ["wav"] }
rubato = "0.15"
ringbuf = "0.4"
dasp = { version = "0.11", features = ["signal", "interpolate"] }
# Encryption
aes-gcm = "0.10"
rand = "0.8"
# Keychain
keyring = "2.3"
# System integration
sysinfo = "0.31"
active-win-pos-rs = "0.8"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Time
chrono = { version = "0.4", features = ["serde"] }
# Error handling
thiserror = "1.0"
anyhow = "1.0"
# Logging
log = "0.4"
tracing = "0.1"
tracing-subscriber = "0.3"
# Utilities
uuid = { version = "1.6", features = ["v4", "serde"] }
parking_lot = "0.12"
once_cell = "1.19"
directories = "5.0"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25"
objc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging"] }
[target.'cfg(target_os = "linux")'.dependencies]
x11rb = "0.13"
[dev-dependencies]
tokio-test = "0.4"
tempfile = "3.9"
mockall = "0.12"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

View File

@@ -1,22 +0,0 @@
// src-tauri/build.rs
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Compile protobuf definitions
let proto_file = PathBuf::from("../../src/noteflow/grpc/proto/noteflow.proto");
let proto_include = PathBuf::from("../../src/noteflow/grpc/proto");
// Only compile protos if the file exists
if proto_file.exists() {
tonic_build::configure()
.build_server(false) // Client only
.build_client(true)
.out_dir("src/grpc")
.compile_protos(&[proto_file], &[proto_include])?;
}
// Standard Tauri build
tauri_build::build();
Ok(())
}

View File

@@ -1,18 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0/capability",
"identifier": "default",
"description": "Default capabilities for NoteFlow",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"dialog:allow-save",
"dialog:allow-open",
"fs:allow-read",
"fs:allow-write",
"notification:default",
"global-shortcut:allow-register",
"window-state:allow-restore-state",
"window-state:allow-save-window-state"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 B

View File

@@ -1,238 +0,0 @@
//! Audio capture using cpal.
//!
//! Provides real-time audio input capture with callback-based sample delivery.
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, Stream, StreamConfig};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::constants::audio::DEFAULT_SAMPLE_RATE;
use crate::helpers::now_timestamp;
/// Audio capture handle.
///
/// Wraps a cpal input stream and provides start/stop controls.
/// Audio samples are delivered via callback as f32 with timestamp.
pub struct AudioCapture {
stream: Option<Stream>,
running: Arc<AtomicBool>,
}
impl AudioCapture {
/// Create a new audio capture instance.
pub fn new() -> Result<Self, String> {
Ok(Self {
stream: None,
running: Arc::new(AtomicBool::new(false)),
})
}
/// Start capturing audio from the default input device.
///
/// The callback receives audio samples as f32 slice and a timestamp (Unix seconds).
/// Samples are delivered in chunks as they become available from the audio device.
pub fn start<F>(&mut self, callback: F) -> Result<(), String>
where
F: FnMut(&[f32], f64) + Send + 'static,
{
if self.running.load(Ordering::SeqCst) {
return Err("Capture already running".to_string());
}
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or("No input device available")?;
let supported_config = device
.default_input_config()
.map_err(|e| format!("Failed to get input config: {e}"))?;
let sample_format = supported_config.sample_format();
let config: StreamConfig = supported_config.into();
// Log the device info
let device_name = device.name().unwrap_or_else(|_| "Unknown".to_string());
log::info!(
"Starting audio capture: device={}, rate={}, channels={}, format={:?}",
device_name,
config.sample_rate.0,
config.channels,
sample_format
);
let running = Arc::clone(&self.running);
let error_running = Arc::clone(&self.running);
// Build the stream based on sample format
let stream = match sample_format {
SampleFormat::F32 => self.build_stream(&device, &config, callback, running)?,
SampleFormat::I16 => {
self.build_stream_converting::<i16, _>(&device, &config, callback, running)?
}
SampleFormat::U16 => {
self.build_stream_converting::<u16, _>(&device, &config, callback, running)?
}
SampleFormat::I8 => {
self.build_stream_converting::<i8, _>(&device, &config, callback, running)?
}
SampleFormat::I32 => {
self.build_stream_converting::<i32, _>(&device, &config, callback, running)?
}
SampleFormat::I64 => {
self.build_stream_converting::<i64, _>(&device, &config, callback, running)?
}
SampleFormat::U8 => {
self.build_stream_converting::<u8, _>(&device, &config, callback, running)?
}
SampleFormat::U32 => {
self.build_stream_converting::<u32, _>(&device, &config, callback, running)?
}
SampleFormat::U64 => {
self.build_stream_converting::<u64, _>(&device, &config, callback, running)?
}
SampleFormat::F64 => {
self.build_stream_converting::<f64, _>(&device, &config, callback, running)?
}
_ => return Err(format!("Unsupported sample format: {:?}", sample_format)),
};
// Start the stream
stream.play().map_err(|e| {
error_running.store(false, Ordering::SeqCst);
format!("Failed to start stream: {e}")
})?;
self.running.store(true, Ordering::SeqCst);
self.stream = Some(stream);
Ok(())
}
/// Build a stream for f32 samples (native, no conversion needed).
fn build_stream<F>(
&self,
device: &cpal::Device,
config: &StreamConfig,
mut callback: F,
running: Arc<AtomicBool>,
) -> Result<Stream, String>
where
F: FnMut(&[f32], f64) + Send + 'static,
{
let err_running = Arc::clone(&running);
device
.build_input_stream(
config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
if running.load(Ordering::SeqCst) {
let timestamp = now_timestamp();
callback(data, timestamp);
}
},
move |err| {
log::error!("Audio stream error: {}", err);
err_running.store(false, Ordering::SeqCst);
},
None,
)
.map_err(|e| format!("Failed to build input stream: {e}"))
}
/// Build a stream with sample format conversion to f32.
fn build_stream_converting<T, F>(
&self,
device: &cpal::Device,
config: &StreamConfig,
mut callback: F,
running: Arc<AtomicBool>,
) -> Result<Stream, String>
where
T: cpal::SizedSample + cpal::FromSample<f32> + Send + 'static,
f32: cpal::FromSample<T>,
F: FnMut(&[f32], f64) + Send + 'static,
{
let err_running = Arc::clone(&running);
device
.build_input_stream(
config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
if running.load(Ordering::SeqCst) {
let timestamp = now_timestamp();
// Convert samples to f32
let converted: Vec<f32> =
data.iter().map(|&s| cpal::Sample::from_sample(s)).collect();
callback(&converted, timestamp);
}
},
move |err| {
log::error!("Audio stream error: {}", err);
err_running.store(false, Ordering::SeqCst);
},
None,
)
.map_err(|e| format!("Failed to build input stream: {e}"))
}
/// Stop capturing audio.
pub fn stop(&mut self) {
self.running.store(false, Ordering::SeqCst);
// Dropping the stream will stop it
self.stream = None;
log::info!("Audio capture stopped");
}
/// Check if capture is running.
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
/// Get the default sample rate.
///
/// Returns the configured default or queries the default device.
pub fn default_sample_rate() -> u32 {
cpal::default_host()
.default_input_device()
.and_then(|d| d.default_input_config().ok())
.map(|c| c.sample_rate().0)
.unwrap_or(DEFAULT_SAMPLE_RATE)
}
}
impl Default for AudioCapture {
fn default() -> Self {
Self::new().expect("Failed to create AudioCapture")
}
}
// Stream is not Send, but our wrapper is safe because we only access
// the stream from the thread that created it
unsafe impl Send for AudioCapture {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn can_create_capture() {
let capture = AudioCapture::new();
assert!(capture.is_ok());
}
#[test]
fn default_sample_rate_is_reasonable() {
let rate = AudioCapture::default_sample_rate();
// Sample rate should be between 8kHz and 192kHz
assert!(rate >= 8000);
assert!(rate <= 192000);
}
#[test]
fn capture_starts_not_running() {
let capture = AudioCapture::new().unwrap();
assert!(!capture.is_running());
}
}

View File

@@ -1,76 +0,0 @@
//! Audio device enumeration using cpal.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use crate::grpc::types::AudioDeviceInfo;
use cpal::traits::{DeviceTrait, HostTrait};
/// Generate a stable device ID from device name.
///
/// Uses hash of name to provide consistent ID across restarts,
/// unlike enumeration index which changes with device ordering.
fn stable_device_id(name: &str) -> u32 {
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
(hasher.finish() % u32::MAX as u64) as u32
}
/// List available audio input devices.
pub fn list_input_devices() -> Result<Vec<AudioDeviceInfo>, String> {
let host = cpal::default_host();
let default_name = host
.default_input_device()
.and_then(|d| d.name().ok())
.unwrap_or_default();
let mut devices = Vec::new();
for device in host
.input_devices()
.map_err(|e| format!("Failed to enumerate devices: {e}"))?
{
let name = match device.name() {
Ok(n) => n,
Err(_) => continue,
};
let config = device
.default_input_config()
.map_err(|e| format!("Failed to get config: {e}"))?;
devices.push(AudioDeviceInfo {
id: stable_device_id(&name),
name: name.clone(),
channels: config.channels() as u32,
sample_rate: config.sample_rate().0,
is_default: name == default_name,
});
}
Ok(devices)
}
/// Get the default input device.
pub fn get_default_input_device() -> Result<Option<AudioDeviceInfo>, String> {
let host = cpal::default_host();
let device = match host.default_input_device() {
Some(d) => d,
None => return Ok(None),
};
let name = device
.name()
.map_err(|e| format!("Failed to get name: {e}"))?;
let config = device
.default_input_config()
.map_err(|e| format!("Failed to get config: {e}"))?;
Ok(Some(AudioDeviceInfo {
id: stable_device_id(&name),
name,
channels: config.channels() as u32,
sample_rate: config.sample_rate().0,
is_default: true,
}))
}

View File

@@ -1,126 +0,0 @@
//! Audio file loading and decryption.
//!
//! Decrypts and parses .nfaudio files into playable audio buffers.
use std::path::Path;
use crate::crypto::CryptoBox;
use crate::grpc::types::TimestampedAudio;
/// Audio file format constants
const SAMPLE_RATE_BYTES: usize = 4;
const NUM_SAMPLES_BYTES: usize = 4;
const HEADER_SIZE: usize = SAMPLE_RATE_BYTES + NUM_SAMPLES_BYTES;
const BYTES_PER_SAMPLE: usize = 4;
/// Default chunk duration in seconds for TimestampedAudio segments
const CHUNK_DURATION: f64 = 0.1;
/// Load and decrypt a .nfaudio file into playable audio buffer.
///
/// File format (after decryption):
/// - [4 bytes: sample_rate u32 LE]
/// - [4 bytes: num_samples u32 LE]
/// - [num_samples * 4 bytes: f32 samples LE]
pub fn load_audio_file(
crypto: &CryptoBox,
path: &Path,
) -> Result<(Vec<TimestampedAudio>, u32), String> {
// Read encrypted file
let encrypted = std::fs::read(path).map_err(|e| format!("Failed to read audio file: {}", e))?;
// Decrypt
let decrypted = crypto.decrypt(&encrypted)?;
// Parse header
if decrypted.len() < HEADER_SIZE {
return Err("Audio file too short".to_string());
}
let sample_rate = u32::from_le_bytes(
decrypted[..SAMPLE_RATE_BYTES]
.try_into()
.map_err(|_| "Invalid sample rate bytes")?,
);
if sample_rate == 0 {
return Err("Invalid sample rate: 0".to_string());
}
let num_samples = u32::from_le_bytes(
decrypted[SAMPLE_RATE_BYTES..HEADER_SIZE]
.try_into()
.map_err(|_| "Invalid sample count bytes")?,
) as usize;
// Validate payload size
let expected_size = HEADER_SIZE + num_samples * BYTES_PER_SAMPLE;
if decrypted.len() < expected_size {
return Err(format!(
"Audio file truncated: expected {} bytes, got {}",
expected_size,
decrypted.len()
));
}
// Parse samples
let samples = parse_samples(&decrypted[HEADER_SIZE..], num_samples)?;
// Convert to TimestampedAudio chunks
let buffer = samples_to_chunks(&samples, sample_rate);
Ok((buffer, sample_rate))
}
/// Parse f32 samples from raw bytes.
fn parse_samples(data: &[u8], num_samples: usize) -> Result<Vec<f32>, String> {
let mut samples = Vec::with_capacity(num_samples);
for i in 0..num_samples {
let offset = i * BYTES_PER_SAMPLE;
let bytes: [u8; 4] = data[offset..offset + BYTES_PER_SAMPLE]
.try_into()
.map_err(|_| "Invalid sample bytes")?;
samples.push(f32::from_le_bytes(bytes));
}
Ok(samples)
}
/// Convert flat samples into TimestampedAudio chunks.
fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<TimestampedAudio> {
let chunk_samples = ((sample_rate as f64 * CHUNK_DURATION) as usize).max(1);
let mut chunks = Vec::new();
let mut offset = 0;
while offset < samples.len() {
let end = (offset + chunk_samples).min(samples.len());
let frame_count = end - offset;
let duration = frame_count as f64 / sample_rate as f64;
let timestamp = offset as f64 / sample_rate as f64;
chunks.push(TimestampedAudio {
frames: samples[offset..end].to_vec(),
timestamp,
duration,
});
offset = end;
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_samples_to_chunks() {
let samples: Vec<f32> = (0..4800).map(|i| i as f32 / 4800.0).collect();
let chunks = samples_to_chunks(&samples, 48000);
assert!(!chunks.is_empty());
assert!((chunks[0].duration - 0.1).abs() < 0.01);
assert_eq!(chunks[0].timestamp, 0.0);
}
}

View File

@@ -1,17 +0,0 @@
//! Audio capture and playback functionality.
//!
//! Organized into submodules:
//! - `capture`: Audio input using cpal
//! - `playback`: Audio output using rodio
//! - `devices`: Device enumeration
//! - `loader`: Encrypted audio file loading
mod capture;
mod devices;
mod loader;
mod playback;
pub use capture::AudioCapture;
pub use devices::{get_default_input_device, list_input_devices};
pub use loader::load_audio_file;
pub use playback::{PlaybackHandle, PlaybackStarted};

View File

@@ -1,209 +0,0 @@
//! Audio playback using rodio with thread-safe channel-based control.
//!
//! The audio stream is owned by a dedicated thread since `cpal::Stream` is not `Send`.
//! Commands are sent via channels, making `PlaybackHandle` safe to store in `AppState`.
use crate::grpc::types::TimestampedAudio;
use parking_lot::Mutex;
use rodio::{buffer::SamplesBuffer, OutputStream, Sink};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
/// Commands sent to the audio thread.
#[derive(Debug)]
pub enum PlaybackCommand {
/// Start playback with audio buffer and sample rate.
Play(Vec<TimestampedAudio>, u32),
/// Pause playback.
Pause,
/// Resume playback.
Resume,
/// Stop playback and reset.
Stop,
/// Shutdown the audio thread.
Shutdown,
}
/// Response from audio thread after Play command.
#[derive(Debug)]
pub struct PlaybackStarted {
pub duration: f64,
pub playing_flag: Arc<AtomicBool>,
pub position_atomic: Arc<AtomicU64>,
}
/// Thread-safe handle for controlling audio playback.
/// This can be stored in `AppState` as it only contains `Send + Sync` types.
pub struct PlaybackHandle {
command_tx: Sender<PlaybackCommand>,
response_rx: Mutex<Receiver<Result<PlaybackStarted, String>>>,
_thread: JoinHandle<()>,
}
impl PlaybackHandle {
/// Create a new playback handle, spawning the audio thread.
pub fn new() -> Result<Self, String> {
let (command_tx, command_rx) = mpsc::channel::<PlaybackCommand>();
let (response_tx, response_rx) = mpsc::channel::<Result<PlaybackStarted, String>>();
let thread = thread::spawn(move || {
audio_thread_main(command_rx, response_tx);
});
Ok(Self {
command_tx,
response_rx: Mutex::new(response_rx),
_thread: thread,
})
}
/// Start playback with the given audio buffer.
pub fn play(
&self,
audio_buffer: Vec<TimestampedAudio>,
sample_rate: u32,
) -> Result<PlaybackStarted, String> {
self.command_tx
.send(PlaybackCommand::Play(audio_buffer, sample_rate))
.map_err(|_| "Audio thread disconnected".to_string())?;
self.response_rx
.lock()
.recv()
.map_err(|_| "Audio thread disconnected".to_string())?
}
/// Pause playback.
pub fn pause(&self) -> Result<(), String> {
self.command_tx
.send(PlaybackCommand::Pause)
.map_err(|_| "Audio thread disconnected".to_string())
}
/// Resume playback.
pub fn resume(&self) -> Result<(), String> {
self.command_tx
.send(PlaybackCommand::Resume)
.map_err(|_| "Audio thread disconnected".to_string())
}
/// Stop playback.
pub fn stop(&self) -> Result<(), String> {
self.command_tx
.send(PlaybackCommand::Stop)
.map_err(|_| "Audio thread disconnected".to_string())
}
}
impl Drop for PlaybackHandle {
fn drop(&mut self) {
let _ = self.command_tx.send(PlaybackCommand::Shutdown);
}
}
// ============================================================================
// Audio Thread
// ============================================================================
/// Main loop for the audio thread.
fn audio_thread_main(
command_rx: Receiver<PlaybackCommand>,
response_tx: Sender<Result<PlaybackStarted, String>>,
) {
// Audio state owned by this thread (not Send, stays here)
let mut audio_state: Option<AudioState> = None;
loop {
let command = match command_rx.recv() {
Ok(cmd) => cmd,
Err(_) => break, // Channel closed
};
match command {
PlaybackCommand::Play(audio_buffer, sample_rate) => {
let result = start_playback(&mut audio_state, audio_buffer, sample_rate);
let _ = response_tx.send(result);
}
PlaybackCommand::Pause => {
if let Some(ref state) = audio_state {
state.sink.pause();
state.playing.store(false, Ordering::SeqCst);
}
}
PlaybackCommand::Resume => {
if let Some(ref state) = audio_state {
state.sink.play();
state.playing.store(true, Ordering::SeqCst);
}
}
PlaybackCommand::Stop => {
if let Some(ref state) = audio_state {
state.sink.stop();
state.playing.store(false, Ordering::SeqCst);
state.position.store(0, Ordering::SeqCst);
}
audio_state = None;
}
PlaybackCommand::Shutdown => break,
}
}
}
/// Internal audio state owned by the audio thread.
struct AudioState {
_stream: OutputStream,
sink: Sink,
position: Arc<AtomicU64>,
playing: Arc<AtomicBool>,
}
fn start_playback(
audio_state: &mut Option<AudioState>,
audio_buffer: Vec<TimestampedAudio>,
sample_rate: u32,
) -> Result<PlaybackStarted, String> {
if audio_buffer.is_empty() {
return Err("No audio to play".to_string());
}
let sample_rate = sample_rate.max(1);
// Create output stream
let (stream, stream_handle) =
OutputStream::try_default().map_err(|e| format!("Failed to get output stream: {e}"))?;
let sink =
Sink::try_new(&stream_handle).map_err(|e| format!("Failed to create sink: {e}"))?;
// Concatenate all frames into single buffer
let samples: Vec<f32> = audio_buffer
.iter()
.flat_map(|ta| ta.frames.iter().copied())
.collect();
let duration = samples.len() as f64 / sample_rate as f64;
let source = SamplesBuffer::new(1, sample_rate, samples);
sink.append(source);
sink.play();
let position = Arc::new(AtomicU64::new(0));
let playing = Arc::new(AtomicBool::new(true));
let started = PlaybackStarted {
duration,
playing_flag: Arc::clone(&playing),
position_atomic: Arc::clone(&position),
};
*audio_state = Some(AudioState {
_stream: stream,
sink,
position,
playing,
});
Ok(started)
}

View File

@@ -1,381 +0,0 @@
//! In-memory cache implementation
//!
//! A thread-safe, LRU-based in-memory cache with TTL support.
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use parking_lot::RwLock;
use serde::{de::DeserializeOwned, Serialize};
use super::{Cache, CacheError, CacheResult, CacheStats};
/// Cached entry with metadata
#[derive(Debug, Clone)]
struct CacheEntry {
/// Serialized value as JSON
data: String,
/// When this entry was created
created_at: Instant,
/// Time-to-live duration
ttl: Duration,
/// Last access time for LRU eviction
last_access: Instant,
/// Approximate size in bytes
size: usize,
}
impl CacheEntry {
fn new(data: String, ttl: Duration) -> Self {
let size = data.len();
let now = Instant::now();
Self {
data,
created_at: now,
ttl,
last_access: now,
size,
}
}
fn is_expired(&self) -> bool {
self.created_at.elapsed() > self.ttl
}
fn touch(&mut self) {
self.last_access = Instant::now();
}
}
/// Thread-safe in-memory cache with LRU eviction
#[derive(Debug)]
pub struct MemoryCache {
/// The cache storage
entries: RwLock<HashMap<String, CacheEntry>>,
/// Maximum number of entries
max_items: usize,
/// Default TTL for entries
default_ttl: Duration,
/// Hit counter
hits: AtomicU64,
/// Miss counter
misses: AtomicU64,
}
impl MemoryCache {
/// Create a new memory cache
pub fn new(max_items: usize, default_ttl: Duration) -> Self {
Self {
entries: RwLock::new(HashMap::new()),
max_items,
default_ttl,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
/// Evict expired entries (operates on already-locked entries)
fn evict_expired_locked(entries: &mut HashMap<String, CacheEntry>) {
entries.retain(|_, entry| !entry.is_expired());
}
/// Evict least recently used entries if over capacity (operates on already-locked entries)
fn evict_lru_locked(entries: &mut HashMap<String, CacheEntry>, max_items: usize) {
while entries.len() > max_items {
// Find the least recently accessed entry
if let Some(lru_key) = entries
.iter()
.min_by_key(|(_, entry)| entry.last_access)
.map(|(key, _)| key.clone())
{
entries.remove(&lru_key);
} else {
break;
}
}
}
}
impl Cache for MemoryCache {
fn get<T: DeserializeOwned + Send + 'static>(
&self,
key: &str,
) -> Pin<Box<dyn Future<Output = CacheResult<Option<T>>> + Send + '_>> {
let key = key.to_string();
Box::pin(async move {
// Use write lock directly to avoid TOCTOU race condition
// between checking expiry and updating access time
let mut entries = self.entries.write();
if let Some(entry) = entries.get_mut(&key) {
if entry.is_expired() {
entries.remove(&key);
self.misses.fetch_add(1, Ordering::Relaxed);
return Ok(None);
}
// Clone data before releasing lock to avoid holding lock during deserialization
let data = entry.data.clone();
entry.touch();
drop(entries); // Release lock before CPU-intensive deserialization
self.hits.fetch_add(1, Ordering::Relaxed);
let value: T = serde_json::from_str(&data)
.map_err(|e| CacheError::Serialization(e.to_string()))?;
return Ok(Some(value));
}
drop(entries);
self.misses.fetch_add(1, Ordering::Relaxed);
Ok(None)
})
}
fn set<T: Serialize + Send + Sync + 'static>(
&self,
key: &str,
value: &T,
ttl: Option<Duration>,
) -> Pin<Box<dyn Future<Output = CacheResult<()>> + Send + '_>> {
let key = key.to_string();
let ttl = ttl.unwrap_or(self.default_ttl);
let max_items = self.max_items;
// Serialize synchronously since we have a reference
let data = match serde_json::to_string(value) {
Ok(d) => d,
Err(e) => {
return Box::pin(async move { Err(CacheError::Serialization(e.to_string())) })
}
};
Box::pin(async move {
let entry = CacheEntry::new(data, ttl);
// Single lock acquisition for all operations to avoid race conditions
let mut entries = self.entries.write();
// Evict expired entries
Self::evict_expired_locked(&mut entries);
// Insert new entry
entries.insert(key, entry);
// Evict LRU if over capacity
Self::evict_lru_locked(&mut entries, max_items);
Ok(())
})
}
fn delete(&self, key: &str) -> Pin<Box<dyn Future<Output = CacheResult<bool>> + Send + '_>> {
let key = key.to_string();
Box::pin(async move {
let removed = self.entries.write().remove(&key).is_some();
Ok(removed)
})
}
fn delete_by_prefix(
&self,
prefix: &str,
) -> Pin<Box<dyn Future<Output = CacheResult<usize>> + Send + '_>> {
let prefix = prefix.to_string();
Box::pin(async move {
let mut entries = self.entries.write();
let keys_to_remove: Vec<String> = entries
.keys()
.filter(|k| k.starts_with(&prefix))
.cloned()
.collect();
let count = keys_to_remove.len();
for key in keys_to_remove {
entries.remove(&key);
}
Ok(count)
})
}
fn exists(&self, key: &str) -> Pin<Box<dyn Future<Output = CacheResult<bool>> + Send + '_>> {
let key = key.to_string();
Box::pin(async move {
let mut entries = self.entries.write();
match entries.get_mut(&key) {
Some(entry) if entry.is_expired() => {
// Clean up expired entry to prevent memory leak
entries.remove(&key);
self.misses.fetch_add(1, Ordering::Relaxed);
Ok(false)
}
Some(entry) => {
entry.touch();
self.hits.fetch_add(1, Ordering::Relaxed);
Ok(true)
}
None => {
self.misses.fetch_add(1, Ordering::Relaxed);
Ok(false)
}
}
})
}
fn clear(&self) -> Pin<Box<dyn Future<Output = CacheResult<()>> + Send + '_>> {
Box::pin(async move {
self.entries.write().clear();
self.hits.store(0, Ordering::Relaxed);
self.misses.store(0, Ordering::Relaxed);
Ok(())
})
}
fn stats(&self) -> CacheStats {
let entries = self.entries.read();
let bytes = entries.values().map(|e| e.size).sum();
CacheStats {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
items: entries.len(),
bytes,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn set_and_get() {
let cache = MemoryCache::new(100, Duration::from_secs(60));
cache
.set("key1", &"value1".to_string(), None)
.await
.unwrap();
let result: Option<String> = cache.get("key1").await.unwrap();
assert_eq!(result, Some("value1".to_string()));
}
#[tokio::test]
async fn get_nonexistent_returns_none() {
let cache = MemoryCache::new(100, Duration::from_secs(60));
let result: Option<String> = cache.get("nonexistent").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn expired_entries_are_removed() {
let cache = MemoryCache::new(100, Duration::from_millis(10));
cache
.set("key1", &"value1".to_string(), None)
.await
.unwrap();
// Wait for expiration
tokio::time::sleep(Duration::from_millis(20)).await;
let result: Option<String> = cache.get("key1").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn delete_removes_entry() {
let cache = MemoryCache::new(100, Duration::from_secs(60));
cache
.set("key1", &"value1".to_string(), None)
.await
.unwrap();
let deleted = cache.delete("key1").await.unwrap();
assert!(deleted);
let result: Option<String> = cache.get("key1").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn exists_checks_presence() {
let cache = MemoryCache::new(100, Duration::from_secs(60));
assert!(!cache.exists("key1").await.unwrap());
cache
.set("key1", &"value1".to_string(), None)
.await
.unwrap();
assert!(cache.exists("key1").await.unwrap());
}
#[tokio::test]
async fn lru_eviction() {
let cache = MemoryCache::new(2, Duration::from_secs(60));
cache
.set("key1", &"value1".to_string(), None)
.await
.unwrap();
cache
.set("key2", &"value2".to_string(), None)
.await
.unwrap();
// Access key1 to make it more recent
let _: Option<String> = cache.get("key1").await.unwrap();
// Add key3, should evict key2 (least recently used)
cache
.set("key3", &"value3".to_string(), None)
.await
.unwrap();
assert!(cache.exists("key1").await.unwrap());
assert!(!cache.exists("key2").await.unwrap());
assert!(cache.exists("key3").await.unwrap());
}
#[tokio::test]
async fn stats_tracking() {
let cache = MemoryCache::new(100, Duration::from_secs(60));
cache
.set("key1", &"value1".to_string(), None)
.await
.unwrap();
let _: Option<String> = cache.get("key1").await.unwrap(); // hit
let _: Option<String> = cache.get("key2").await.unwrap(); // miss
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
assert_eq!(stats.items, 1);
}
#[tokio::test]
async fn complex_types() {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct User {
id: u32,
name: String,
}
let cache = MemoryCache::new(100, Duration::from_secs(60));
let user = User {
id: 1,
name: "Alice".to_string(),
};
cache.set("user:1", &user, None).await.unwrap();
let result: Option<User> = cache.get("user:1").await.unwrap();
assert_eq!(result, Some(user));
}
}

View File

@@ -1,214 +0,0 @@
//! Cache abstraction layer
//!
//! Provides a unified caching interface with support for multiple backends:
//! - In-memory cache (default)
//! - Redis cache (requires feature flag)
//! - No-op cache (disabled)
mod memory;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use serde::{de::DeserializeOwned, Serialize};
use crate::config::config;
pub use memory::MemoryCache;
/// Type alias for async cache results
pub type CacheResult<T> = Result<T, CacheError>;
/// Cache error types
#[derive(Debug, Clone, thiserror::Error)]
pub enum CacheError {
#[error("Key not found: {0}")]
NotFound(String),
#[error("Serialization error: {0}")]
Serialization(String),
#[error("Connection error: {0}")]
Connection(String),
#[error("Cache disabled")]
Disabled,
}
/// Cache trait for all backends
pub trait Cache: Send + Sync {
/// Get a value by key
fn get<T: DeserializeOwned + Send + 'static>(
&self,
key: &str,
) -> Pin<Box<dyn Future<Output = CacheResult<Option<T>>> + Send + '_>>;
/// Set a value with optional TTL
fn set<T: Serialize + Send + Sync + 'static>(
&self,
key: &str,
value: &T,
ttl: Option<Duration>,
) -> Pin<Box<dyn Future<Output = CacheResult<()>> + Send + '_>>;
/// Delete a value by key
fn delete(&self, key: &str) -> Pin<Box<dyn Future<Output = CacheResult<bool>> + Send + '_>>;
/// Delete all values with keys matching the given prefix
fn delete_by_prefix(
&self,
prefix: &str,
) -> Pin<Box<dyn Future<Output = CacheResult<usize>> + Send + '_>>;
/// Check if a key exists
fn exists(&self, key: &str) -> Pin<Box<dyn Future<Output = CacheResult<bool>> + Send + '_>>;
/// Clear all cached values
fn clear(&self) -> Pin<Box<dyn Future<Output = CacheResult<()>> + Send + '_>>;
/// Get cache statistics
fn stats(&self) -> CacheStats;
}
/// Cache statistics
#[derive(Debug, Clone, Default)]
pub struct CacheStats {
/// Number of cache hits
pub hits: u64,
/// Number of cache misses
pub misses: u64,
/// Current number of items
pub items: usize,
/// Total bytes used (approximate)
pub bytes: usize,
}
impl CacheStats {
/// Hit rate as a percentage
pub fn hit_rate(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
(self.hits as f64 / total as f64) * 100.0
}
}
}
/// No-op cache implementation (when caching is disabled)
#[derive(Debug, Default)]
pub struct NoOpCache;
impl Cache for NoOpCache {
fn get<T: DeserializeOwned + Send + 'static>(
&self,
_key: &str,
) -> Pin<Box<dyn Future<Output = CacheResult<Option<T>>> + Send + '_>> {
Box::pin(async { Ok(None) })
}
fn set<T: Serialize + Send + Sync + 'static>(
&self,
_key: &str,
_value: &T,
_ttl: Option<Duration>,
) -> Pin<Box<dyn Future<Output = CacheResult<()>> + Send + '_>> {
Box::pin(async { Ok(()) })
}
fn delete(&self, _key: &str) -> Pin<Box<dyn Future<Output = CacheResult<bool>> + Send + '_>> {
Box::pin(async { Ok(false) })
}
fn delete_by_prefix(
&self,
_prefix: &str,
) -> Pin<Box<dyn Future<Output = CacheResult<usize>> + Send + '_>> {
Box::pin(async { Ok(0) })
}
fn exists(&self, _key: &str) -> Pin<Box<dyn Future<Output = CacheResult<bool>> + Send + '_>> {
Box::pin(async { Ok(false) })
}
fn clear(&self) -> Pin<Box<dyn Future<Output = CacheResult<()>> + Send + '_>> {
Box::pin(async { Ok(()) })
}
fn stats(&self) -> CacheStats {
CacheStats::default()
}
}
/// Create a memory cache instance based on configuration
pub fn create_cache() -> Arc<MemoryCache> {
let cfg = config();
Arc::new(MemoryCache::new(
cfg.cache.max_memory_items,
Duration::from_secs(cfg.cache.default_ttl_secs),
))
}
/// Cache key builder for consistent key formatting
pub struct CacheKey;
impl CacheKey {
/// Build a key for meeting data
pub fn meeting(meeting_id: &str) -> String {
format!("meeting:{}", meeting_id)
}
/// Build a key for meeting list
pub fn meeting_list() -> String {
"meetings:list".to_string()
}
/// Build a key for segments
pub fn segments(meeting_id: &str) -> String {
format!("segments:{}", meeting_id)
}
/// Build a key for summary
pub fn summary(meeting_id: &str) -> String {
format!("summary:{}", meeting_id)
}
/// Build a key for server info
pub fn server_info() -> String {
"server:info".to_string()
}
/// Build a custom key with prefix
pub fn custom(prefix: &str, id: &str) -> String {
format!("{}:{}", prefix, id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn noop_cache_returns_none() {
let cache = NoOpCache;
let result: Option<String> = cache.get("test").await.unwrap();
assert!(result.is_none());
}
#[test]
fn cache_key_formatting() {
assert_eq!(CacheKey::meeting("abc"), "meeting:abc");
assert_eq!(CacheKey::segments("abc"), "segments:abc");
assert_eq!(CacheKey::custom("user", "123"), "user:123");
}
#[test]
fn cache_stats_hit_rate() {
let stats = CacheStats {
hits: 80,
misses: 20,
items: 100,
bytes: 0,
};
assert!((stats.hit_rate() - 80.0).abs() < 0.01);
}
}

View File

@@ -1,134 +0,0 @@
//! Annotation-related Tauri commands
use std::sync::Arc;
use tauri::State;
use crate::grpc::types::{AnnotationInfo, AnnotationType};
use crate::state::AppState;
/// Add annotation to a meeting
#[tauri::command]
pub async fn add_annotation(
state: State<'_, Arc<AppState>>,
meeting_id: String,
annotation_type: String,
text: String,
start_time: f64,
end_time: f64,
segment_ids: Option<Vec<i32>>,
) -> Result<AnnotationInfo, String> {
let anno_type = AnnotationType::from(annotation_type.as_str());
let annotation = state
.grpc_client
.add_annotation(
&meeting_id,
anno_type,
&text,
start_time,
end_time,
segment_ids,
)
.await
.map_err(|e| e.to_string())?;
// Add to cache
state.annotations.write().push(annotation.clone());
Ok(annotation)
}
/// Get annotation by ID
#[tauri::command]
pub async fn get_annotation(
state: State<'_, Arc<AppState>>,
annotation_id: String,
) -> Result<AnnotationInfo, String> {
state
.grpc_client
.get_annotation(&annotation_id)
.await
.map_err(|e| e.to_string())
}
/// List annotations for a meeting
#[tauri::command]
pub async fn list_annotations(
state: State<'_, Arc<AppState>>,
meeting_id: String,
start_time: Option<f64>,
end_time: Option<f64>,
) -> Result<Vec<AnnotationInfo>, String> {
let annotations = state
.grpc_client
.list_annotations(
&meeting_id,
start_time.unwrap_or(0.0),
end_time.unwrap_or(f64::MAX),
)
.await
.map_err(|e| e.to_string())?;
// Update cache
*state.annotations.write() = annotations.clone();
Ok(annotations)
}
/// Update an existing annotation
#[tauri::command]
pub async fn update_annotation(
state: State<'_, Arc<AppState>>,
annotation_id: String,
annotation_type: Option<String>,
text: Option<String>,
start_time: Option<f64>,
end_time: Option<f64>,
segment_ids: Option<Vec<i32>>,
) -> Result<AnnotationInfo, String> {
let anno_type = annotation_type.map(|t| AnnotationType::from(t.as_str()));
let updated = state
.grpc_client
.update_annotation(
&annotation_id,
anno_type,
text.as_deref(),
start_time,
end_time,
segment_ids,
)
.await
.map_err(|e| e.to_string())?;
// Update cache
if let Some(pos) = state
.annotations
.read()
.iter()
.position(|a| a.id == annotation_id)
{
state.annotations.write()[pos] = updated.clone();
}
Ok(updated)
}
/// Delete an annotation
#[tauri::command]
pub async fn delete_annotation(
state: State<'_, Arc<AppState>>,
annotation_id: String,
) -> Result<bool, String> {
let success = state
.grpc_client
.delete_annotation(&annotation_id)
.await
.map_err(|e| e.to_string())?;
if success {
state.annotations.write().retain(|a| a.id != annotation_id);
}
Ok(success)
}

View File

@@ -1,72 +0,0 @@
//! Audio device-related Tauri commands
use std::sync::Arc;
use tauri::State;
use crate::audio;
use crate::grpc::types::AudioDeviceInfo;
use crate::state::AppState;
/// List available audio input devices
#[tauri::command]
pub async fn list_audio_devices() -> Result<Vec<AudioDeviceInfo>, String> {
audio::list_input_devices()
}
/// Select an audio input device
#[tauri::command]
pub async fn select_audio_device(
state: State<'_, Arc<AppState>>,
device_id: Option<u32>,
) -> Result<(), String> {
let mut prefs = state.preferences.write();
if let Some(id) = device_id {
let devices = audio::list_input_devices()?;
let device_name = devices.iter().find(|d| d.id == id).map(|d| d.name.clone());
prefs.insert("audio_device_id".to_string(), serde_json::json!(id));
prefs.insert(
"audio_device_name".to_string(),
serde_json::json!(device_name),
);
} else {
prefs.remove("audio_device_id");
prefs.remove("audio_device_name");
}
Ok(())
}
/// Get current audio device
#[tauri::command]
pub async fn get_current_device(
state: State<'_, Arc<AppState>>,
) -> Result<Option<AudioDeviceInfo>, String> {
let prefs = state.preferences.read();
let device_id = prefs
.get("audio_device_id")
.and_then(|v| v.as_u64())
.map(|id| id as u32);
let device_name = prefs
.get("audio_device_name")
.and_then(|v| v.as_str())
.map(|name| name.to_string());
if device_id.is_none() && device_name.is_none() {
return audio::get_default_input_device();
}
let devices = audio::list_input_devices()?;
if let Some(name) = device_name {
if let Some(device) = devices.iter().find(|d| d.name == name) {
return Ok(Some(device.clone()));
}
}
if let Some(id) = device_id {
return Ok(devices.into_iter().find(|d| d.id == id));
}
audio::get_default_input_device()
}

View File

@@ -1,54 +0,0 @@
//! Connection-related Tauri commands
use std::sync::Arc;
use tauri::State;
use crate::grpc::types::ServerInfo;
use crate::state::{AppState, AppStatus};
/// Connect to gRPC server
#[tauri::command]
pub async fn connect(
state: State<'_, Arc<AppState>>,
address: String,
) -> Result<ServerInfo, String> {
// Connect via gRPC client (it manages its own state atomically)
let info = state.grpc_client.connect(&address).await?;
// Cache server info for quick access (gRPC client is source of truth for connection)
*state.server_info.write() = Some(info.clone());
Ok(info)
}
/// Disconnect from server
#[tauri::command]
pub async fn disconnect(state: State<'_, Arc<AppState>>) -> Result<(), String> {
// Stop recording if active (check from local state for speed)
if *state.recording.read() {
state.grpc_client.stop_streaming().await;
state.reset_recording_state();
}
// Disconnect gRPC (it manages its own state atomically)
state.grpc_client.disconnect().await;
// Clear cached server info
*state.server_info.write() = None;
Ok(())
}
/// Get current server info
#[tauri::command]
pub async fn get_server_info(
state: State<'_, Arc<AppState>>,
) -> Result<Option<ServerInfo>, String> {
Ok(state.server_info.read().clone())
}
/// Get full application status
#[tauri::command]
pub async fn get_status(state: State<'_, Arc<AppState>>) -> Result<AppStatus, String> {
Ok(state.get_status())
}

View File

@@ -1,60 +0,0 @@
//! Diarization-related Tauri commands
use std::sync::Arc;
use tauri::State;
use crate::grpc::types::{DiarizationResult, RenameSpeakerResult};
use crate::state::AppState;
/// Start background speaker diarization refinement
#[tauri::command]
pub async fn refine_speaker_diarization(
state: State<'_, Arc<AppState>>,
meeting_id: String,
num_speakers: Option<u32>,
) -> Result<DiarizationResult, String> {
state
.grpc_client
.refine_speaker_diarization(&meeting_id, num_speakers)
.await
.map_err(|e| e.to_string())
}
/// Get status of a diarization job
#[tauri::command]
pub async fn get_diarization_job_status(
state: State<'_, Arc<AppState>>,
job_id: String,
) -> Result<DiarizationResult, String> {
state
.grpc_client
.get_diarization_job_status(&job_id)
.await
.map_err(|e| e.to_string())
}
/// Rename a speaker across all segments
#[tauri::command]
pub async fn rename_speaker(
state: State<'_, Arc<AppState>>,
meeting_id: String,
old_speaker_id: String,
new_speaker_name: String,
) -> Result<RenameSpeakerResult, String> {
let result = state
.grpc_client
.rename_speaker(&meeting_id, &old_speaker_id, &new_speaker_name)
.await
.map_err(|e| e.to_string())?;
// Update cached segments
if result.success {
for segment in state.transcript_segments.write().iter_mut() {
if segment.speaker_id == old_speaker_id {
segment.speaker_id = new_speaker_name.clone();
}
}
}
Ok(result)
}

View File

@@ -1,55 +0,0 @@
//! Export-related Tauri commands
use std::sync::Arc;
use tauri::{AppHandle, State};
use tauri_plugin_dialog::DialogExt;
use crate::grpc::types::{ExportFormat, ExportResult};
use crate::state::AppState;
/// Export meeting transcript
#[tauri::command]
pub async fn export_transcript(
state: State<'_, Arc<AppState>>,
meeting_id: String,
format: String,
) -> Result<ExportResult, String> {
let fmt = ExportFormat::from(format.as_str());
state
.grpc_client
.export_transcript(&meeting_id, fmt)
.await
.map_err(|e| e.to_string())
}
/// Save export file to disk
#[tauri::command]
pub async fn save_export_file(
app: AppHandle,
content: String,
default_name: String,
extension: String,
) -> Result<bool, String> {
tauri::async_runtime::spawn_blocking(move || {
// Sanitize inputs to prevent path injection
let default_name = default_name.replace(['/', '\\'], "_");
let extension = extension.trim().trim_start_matches('.').to_string();
let file_path = app
.dialog()
.file()
.set_file_name(&default_name)
.add_filter("Export", &[extension.as_str()])
.blocking_save_file();
if let Some(path) = file_path {
std::fs::write(path.as_path().unwrap(), content)
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(true)
} else {
Ok(false)
}
})
.await
.map_err(|e| format!("Failed to save file: {}", e))?
}

View File

@@ -1,171 +0,0 @@
//! Meeting-related Tauri commands
use std::sync::Arc;
use tauri::{AppHandle, Emitter, State};
use crate::audio::load_audio_file;
use crate::constants::audio::DEFAULT_SAMPLE_RATE;
use crate::constants::Events;
use crate::grpc::types::{MeetingDetails, MeetingInfo, MeetingState};
use crate::state::AppState;
/// List meetings with optional filtering
#[tauri::command]
pub async fn list_meetings(
state: State<'_, Arc<AppState>>,
states: Option<Vec<String>>,
limit: Option<u32>,
offset: Option<u32>,
sort_desc: Option<bool>,
) -> Result<(Vec<MeetingInfo>, u32), String> {
let states: Option<Vec<MeetingState>> = states.map(|s| {
s.iter()
.filter_map(|st| match st.as_str() {
"created" => Some(MeetingState::Created),
"recording" => Some(MeetingState::Recording),
"stopping" => Some(MeetingState::Stopping),
"stopped" => Some(MeetingState::Stopped),
"completed" => Some(MeetingState::Completed),
"error" => Some(MeetingState::Error),
_ => None,
})
.collect()
});
let (meetings, total) = state
.grpc_client
.list_meetings(
states,
limit.unwrap_or(100),
offset.unwrap_or(0),
sort_desc.unwrap_or(true),
)
.await?;
// Cache meetings
*state.meetings.write() = meetings.clone();
Ok((meetings, total))
}
/// Get meeting details with segments and summary
#[tauri::command]
pub async fn get_meeting(
state: State<'_, Arc<AppState>>,
meeting_id: String,
include_segments: bool,
include_summary: bool,
) -> Result<MeetingDetails, String> {
state
.grpc_client
.get_meeting(&meeting_id, include_segments, include_summary)
.await
.map_err(|e| e.to_string())
}
/// Delete a meeting
#[tauri::command]
pub async fn delete_meeting(
state: State<'_, Arc<AppState>>,
meeting_id: String,
) -> Result<bool, String> {
let success = state.grpc_client.delete_meeting(&meeting_id).await?;
if success {
// Remove from cache
state.meetings.write().retain(|m| m.id != meeting_id);
// Clear if selected
if state.selected_meeting.read().as_ref().map(|m| &m.id) == Some(&meeting_id) {
*state.selected_meeting.write() = None;
}
}
Ok(success)
}
/// Select a meeting for review (loads segments, annotations, audio)
#[tauri::command]
pub async fn select_meeting(
app: AppHandle,
state: State<'_, Arc<AppState>>,
meeting_id: String,
) -> Result<MeetingDetails, String> {
// Stop any current playback
{
let playback_guard = state.audio_playback.read();
if let Some(ref handle) = *playback_guard {
let _ = handle.stop();
}
}
*state.audio_playback.write() = None;
*state.playback_state.write() = crate::state::PlaybackState::Stopped;
*state.playback_position.write() = 0.0;
*state.playback_samples_played.write() = 0;
*state.highlighted_segment_index.write() = None;
let _ = app.emit(Events::PLAYBACK_STATE, "stopped");
let _ = app.emit(Events::PLAYBACK_POSITION, 0.0);
let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null);
// Fetch meeting with segments
let details = state
.grpc_client
.get_meeting(&meeting_id, true, true)
.await
.map_err(|e| e.to_string())?;
// Fetch annotations
let annotations = state
.grpc_client
.list_annotations(&meeting_id, 0.0, 0.0)
.await
.map_err(|e| e.to_string())?;
// Load audio file if exists
let audio_result = load_meeting_audio(&state, &meeting_id);
// Update state
*state.selected_meeting.write() = Some(details.meeting.clone());
*state.current_meeting.write() = Some(details.meeting.clone());
state
.transcript_segments
.write()
.clone_from(&details.segments);
*state.annotations.write() = annotations;
*state.current_summary.write() = details.summary.clone();
*state.highlighted_segment_index.write() = None;
// Apply audio state
if let Ok((buffer, duration, sample_rate)) = audio_result {
*state.session_audio_buffer.write() = buffer;
*state.playback_duration.write() = duration;
*state.playback_position.write() = 0.0;
*state.playback_sample_rate.write() = sample_rate;
} else {
// Clear audio buffer if no audio file
state.session_audio_buffer.write().clear();
*state.playback_duration.write() = 0.0;
*state.playback_position.write() = 0.0;
*state.playback_sample_rate.write() = DEFAULT_SAMPLE_RATE;
}
Ok(details)
}
/// Load audio file for a meeting from the meetings directory.
fn load_meeting_audio(
state: &Arc<AppState>,
meeting_id: &str,
) -> Result<(Vec<crate::grpc::types::TimestampedAudio>, f64, u32), String> {
let meetings_dir = state.meetings_dir.read().clone();
let audio_path = meetings_dir.join(format!("{}.nfaudio", meeting_id));
if !audio_path.exists() {
return Err("Audio file not found".to_string());
}
let (buffer, sample_rate) = load_audio_file(&state.crypto, &audio_path)?;
let duration: f64 = buffer.iter().map(|ta| ta.duration).sum();
Ok((buffer, duration, sample_rate))
}

View File

@@ -1,21 +0,0 @@
//! Tauri command handlers
//!
//! This module contains all the command handlers that are exposed
//! to the React frontend via Tauri's invoke mechanism.
pub mod annotation;
pub mod audio;
pub mod connection;
pub mod diarization;
pub mod export;
pub mod meeting;
pub mod playback;
pub mod preferences;
pub mod recording;
pub mod summary;
pub mod triggers;
#[cfg(test)]
mod playback_tests;
#[cfg(test)]
mod recording_tests;

View File

@@ -1,257 +0,0 @@
//! Playback-related Tauri commands
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, State};
use crate::audio::PlaybackHandle;
use crate::constants::Events;
use crate::state::{AppState, PlaybackInfo, PlaybackState};
/// Position update interval in milliseconds.
const POSITION_UPDATE_INTERVAL_MS: u64 = 50;
/// Start or resume playback.
#[tauri::command]
pub async fn play(app: AppHandle, state: State<'_, Arc<AppState>>) -> Result<(), String> {
let current_state = *state.playback_state.read();
match current_state {
PlaybackState::Stopped => {
start_playback(&app, &state)?;
}
PlaybackState::Paused => {
resume_playback(&app, &state)?;
}
PlaybackState::Playing => {
// Already playing
}
}
let _ = app.emit(Events::PLAYBACK_STATE, "playing");
Ok(())
}
/// Pause playback.
#[tauri::command]
pub async fn pause(app: AppHandle, state: State<'_, Arc<AppState>>) -> Result<(), String> {
if let Some(ref handle) = *state.audio_playback.read() {
handle.pause()?;
}
*state.playback_state.write() = PlaybackState::Paused;
let _ = app.emit(Events::PLAYBACK_STATE, "paused");
Ok(())
}
/// Stop playback.
#[tauri::command]
pub async fn stop(app: AppHandle, state: State<'_, Arc<AppState>>) -> Result<(), String> {
{
let playback_guard = state.audio_playback.read();
if let Some(ref handle) = *playback_guard {
let _ = handle.stop();
}
}
*state.audio_playback.write() = None;
*state.playback_state.write() = PlaybackState::Stopped;
*state.playback_position.write() = 0.0;
*state.playback_samples_played.write() = 0;
*state.highlighted_segment_index.write() = None;
let _ = app.emit(Events::PLAYBACK_STATE, "stopped");
let _ = app.emit(Events::PLAYBACK_POSITION, 0.0);
let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null);
Ok(())
}
/// Seek to position.
#[tauri::command]
pub async fn seek(
app: AppHandle,
state: State<'_, Arc<AppState>>,
position: f64,
) -> Result<PlaybackInfo, String> {
if !position.is_finite() {
return Err("Invalid seek position".to_string());
}
let duration = *state.playback_duration.read();
let clamped = position.clamp(0.0, duration.max(0.0));
*state.playback_position.write() = clamped;
// Update highlight
let highlighted_segment = state.find_segment_at_position(clamped);
*state.highlighted_segment_index.write() = highlighted_segment;
if let Some(index) = highlighted_segment {
let _ = app.emit(Events::HIGHLIGHT_CHANGE, index);
} else {
let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null);
}
let _ = app.emit(Events::PLAYBACK_POSITION, clamped);
let playback_state = *state.playback_state.read();
Ok(PlaybackInfo {
state: playback_state,
position: clamped,
duration,
highlighted_segment,
})
}
/// Get current playback state.
#[tauri::command]
pub async fn get_playback_state(state: State<'_, Arc<AppState>>) -> Result<PlaybackInfo, String> {
Ok(PlaybackInfo {
state: *state.playback_state.read(),
position: *state.playback_position.read(),
duration: *state.playback_duration.read(),
highlighted_segment: *state.highlighted_segment_index.read(),
})
}
// ============================================================================
// Internal helpers
// ============================================================================
fn start_playback(app: &AppHandle, state: &Arc<AppState>) -> Result<(), String> {
let audio_buffer = state.session_audio_buffer.read().clone();
if audio_buffer.is_empty() {
return Err("No audio to play".to_string());
}
let sample_rate = *state.playback_sample_rate.read();
if sample_rate == 0 {
return Err("Invalid sample rate for playback".to_string());
}
let handle = PlaybackHandle::new()?;
let started = handle.play(audio_buffer, sample_rate)?;
*state.playback_duration.write() = started.duration;
*state.playback_position.write() = 0.0;
*state.playback_samples_played.write() = 0;
*state.playback_state.write() = PlaybackState::Playing;
// Store handle
*state.audio_playback.write() = Some(handle);
// Spawn position tracking thread
spawn_position_tracker(
app.clone(),
Arc::clone(state),
started.playing_flag,
started.position_atomic,
sample_rate,
0, // Start from beginning
);
Ok(())
}
fn resume_playback(app: &AppHandle, state: &Arc<AppState>) -> Result<(), String> {
let playback_guard = state.audio_playback.read();
let handle = playback_guard
.as_ref()
.ok_or("No playback to resume")?;
handle.resume()?;
drop(playback_guard);
*state.playback_state.write() = PlaybackState::Playing;
// Respawn position tracker with accumulated samples
let samples_played = *state.playback_samples_played.read();
let sample_rate = *state.playback_sample_rate.read();
// We need to get the atomics again - create new ones for tracking
let playing_flag = Arc::new(std::sync::atomic::AtomicBool::new(true));
let position_atomic = Arc::new(std::sync::atomic::AtomicU64::new(
(*state.playback_position.read()).to_bits(),
));
spawn_position_tracker(
app.clone(),
Arc::clone(state),
playing_flag,
position_atomic,
sample_rate,
samples_played,
);
Ok(())
}
fn spawn_position_tracker(
app: AppHandle,
state: Arc<AppState>,
playing_flag: Arc<std::sync::atomic::AtomicBool>,
position_atomic: Arc<std::sync::atomic::AtomicU64>,
sample_rate: u32,
initial_samples: u64,
) {
let sample_rate = (sample_rate.max(1)) as f64;
std::thread::spawn(move || {
let mut samples_played: u64 = initial_samples;
let mut last_highlight: Option<usize> = None;
loop {
// Check playback state from AppState
let current_state = *state.playback_state.read();
if current_state == PlaybackState::Stopped {
break;
}
if current_state == PlaybackState::Paused {
// Save accumulated samples for resume
*state.playback_samples_played.write() = samples_played;
playing_flag.store(false, Ordering::SeqCst);
// Exit thread when paused - will be respawned on resume
break;
}
// Calculate position from samples played
samples_played += (sample_rate * POSITION_UPDATE_INTERVAL_MS as f64 / 1000.0) as u64;
let position = samples_played as f64 / sample_rate;
// Update position atomic and state
position_atomic.store(position.to_bits(), Ordering::SeqCst);
*state.playback_position.write() = position;
// Check if finished
let duration = *state.playback_duration.read();
if position >= duration {
*state.playback_state.write() = PlaybackState::Stopped;
*state.playback_position.write() = duration;
*state.highlighted_segment_index.write() = None;
playing_flag.store(false, Ordering::SeqCst);
let _ = app.emit(Events::PLAYBACK_STATE, "stopped");
let _ = app.emit(Events::PLAYBACK_POSITION, duration);
let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null);
break;
}
// Update highlighted segment
let highlight = state.find_segment_at_position(position);
if highlight != last_highlight {
last_highlight = highlight;
*state.highlighted_segment_index.write() = highlight;
if let Some(index) = highlight {
let _ = app.emit(Events::HIGHLIGHT_CHANGE, index);
} else {
let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null);
}
}
// Emit position update
let _ = app.emit(Events::PLAYBACK_POSITION, position);
std::thread::sleep(Duration::from_millis(POSITION_UPDATE_INTERVAL_MS));
}
});
}

View File

@@ -1,94 +0,0 @@
//! Unit tests for playback commands
//!
//! These tests verify the playback state machine and seek behavior.
#[cfg(test)]
mod tests {
use crate::state::{PlaybackInfo, PlaybackState};
#[test]
fn playback_state_default() {
let state = PlaybackState::default();
assert_eq!(state, PlaybackState::Stopped);
}
#[test]
fn playback_state_serialization() {
assert_eq!(
serde_json::to_string(&PlaybackState::Stopped).unwrap(),
"\"stopped\""
);
assert_eq!(
serde_json::to_string(&PlaybackState::Playing).unwrap(),
"\"playing\""
);
assert_eq!(
serde_json::to_string(&PlaybackState::Paused).unwrap(),
"\"paused\""
);
}
#[test]
fn playback_info_construction() {
let info = PlaybackInfo {
state: PlaybackState::Playing,
position: 30.5,
duration: 120.0,
highlighted_segment: Some(5),
};
assert_eq!(info.state, PlaybackState::Playing);
assert!((info.position - 30.5).abs() < f64::EPSILON);
assert!((info.duration - 120.0).abs() < f64::EPSILON);
assert_eq!(info.highlighted_segment, Some(5));
}
#[test]
fn playback_info_without_highlight() {
let info = PlaybackInfo {
state: PlaybackState::Stopped,
position: 0.0,
duration: 0.0,
highlighted_segment: None,
};
assert_eq!(info.highlighted_segment, None);
}
#[test]
fn seek_position_clamping() {
// Test the clamping logic that would be used in the seek command
let duration = 100.0_f64;
// Normal position
let pos = 50.0_f64;
let clamped = pos.clamp(0.0, duration.max(0.0));
assert!((clamped - 50.0).abs() < f64::EPSILON);
// Position beyond duration
let pos = 150.0_f64;
let clamped = pos.clamp(0.0, duration.max(0.0));
assert!((clamped - 100.0).abs() < f64::EPSILON);
// Negative position
let pos = -10.0_f64;
let clamped = pos.clamp(0.0, duration.max(0.0));
assert!((clamped - 0.0).abs() < f64::EPSILON);
}
#[test]
fn seek_position_with_zero_duration() {
let duration = 0.0_f64;
let pos = 50.0_f64;
let clamped = pos.clamp(0.0, duration.max(0.0));
assert!((clamped - 0.0).abs() < f64::EPSILON);
}
#[test]
fn validate_finite_position() {
assert!(10.0_f64.is_finite());
assert!(!f64::INFINITY.is_finite());
assert!(!f64::NEG_INFINITY.is_finite());
assert!(!f64::NAN.is_finite());
}
}

View File

@@ -1,249 +0,0 @@
//! User preferences commands
//!
//! Handles getting and saving user preferences to persistent storage.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use keyring::Entry;
use tauri::State;
use crate::constants::{preferences as prefs_config, secrets};
use crate::state::AppState;
use serde::{Deserialize, Serialize};
/// Summarization provider options
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SummarizationProvider {
#[default]
None,
Cloud,
Ollama,
}
/// User preferences structure
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserPreferences {
pub server_url: String,
pub data_directory: String,
pub encryption_enabled: bool,
pub auto_start_enabled: bool,
pub trigger_confidence_threshold: f32,
pub summarization_provider: SummarizationProvider,
pub cloud_api_key: String,
pub ollama_url: String,
}
impl Default for UserPreferences {
fn default() -> Self {
Self {
server_url: "localhost:50051".to_string(),
data_directory: get_default_data_dir(),
encryption_enabled: true,
auto_start_enabled: true,
trigger_confidence_threshold: 0.7,
summarization_provider: SummarizationProvider::None,
cloud_api_key: String::new(),
ollama_url: "http://localhost:11434".to_string(),
}
}
}
/// Get default data directory path
fn get_default_data_dir() -> String {
directories::ProjectDirs::from("com", "noteflow", "NoteFlow")
.map(|d| d.data_dir().to_string_lossy().to_string())
.unwrap_or_else(|| {
directories::BaseDirs::new()
.map(|d| d.home_dir().join(".noteflow").to_string_lossy().to_string())
.unwrap_or_else(|| "/tmp/noteflow".to_string())
})
}
// ============================================================================
// Preferences Persistence
// ============================================================================
/// Get the preferences file path
fn get_preferences_path() -> PathBuf {
directories::ProjectDirs::from("com", "noteflow", "NoteFlow")
.map(|d| d.config_dir().join(prefs_config::FILENAME))
.unwrap_or_else(|| PathBuf::from("/tmp/noteflow").join(prefs_config::FILENAME))
}
/// Load preferences from disk
pub fn load_preferences_from_disk() -> HashMap<String, serde_json::Value> {
let path = get_preferences_path();
if path.exists() {
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
HashMap::new()
}
}
/// Save preferences to disk
fn persist_preferences(prefs: &HashMap<String, serde_json::Value>) -> Result<(), String> {
let path = get_preferences_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {e}"))?;
}
let json = serde_json::to_string_pretty(prefs)
.map_err(|e| format!("Failed to serialize preferences: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Failed to save preferences: {e}"))
}
// ============================================================================
// Secure API Key Storage
// ============================================================================
/// Get API key from system keychain
fn get_api_key() -> Option<String> {
Entry::new(secrets::KEYCHAIN_SERVICE, secrets::API_KEY_USERNAME)
.ok()
.and_then(|entry| entry.get_password().ok())
.filter(|s| !s.is_empty())
}
/// Store API key in system keychain
fn set_api_key(key: &str) -> Result<(), String> {
let entry = Entry::new(secrets::KEYCHAIN_SERVICE, secrets::API_KEY_USERNAME)
.map_err(|e| format!("Keychain access failed: {e}"))?;
if key.is_empty() {
// Delete credential if key is empty
entry.delete_password().ok();
} else {
entry
.set_password(key)
.map_err(|e| format!("Failed to store API key: {e}"))?;
}
Ok(())
}
/// Mask API key for display (show first/last 4 chars)
fn mask_api_key(key: &str) -> String {
if key.is_empty() {
String::new()
} else if key.len() <= 8 {
"*".repeat(key.len())
} else {
format!("{}...{}", &key[..4], &key[key.len() - 4..])
}
}
/// Get user preferences
#[tauri::command]
pub async fn get_preferences(state: State<'_, Arc<AppState>>) -> Result<UserPreferences, String> {
let prefs = state.preferences.read();
// Get API key from keychain and mask it for display
let masked_api_key = get_api_key().map(|k| mask_api_key(&k)).unwrap_or_default();
// Reconstruct preferences from stored values
Ok(UserPreferences {
server_url: prefs
.get("server_url")
.and_then(|v| v.as_str())
.unwrap_or("localhost:50051")
.to_string(),
data_directory: prefs
.get("data_directory")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(get_default_data_dir),
encryption_enabled: prefs
.get("encryption_enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true),
auto_start_enabled: prefs
.get("auto_start_enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true),
trigger_confidence_threshold: prefs
.get("trigger_confidence_threshold")
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(0.7),
summarization_provider: prefs
.get("summarization_provider")
.and_then(|v| v.as_str())
.map(|s| match s {
"cloud" => SummarizationProvider::Cloud,
"ollama" => SummarizationProvider::Ollama,
_ => SummarizationProvider::None,
})
.unwrap_or_default(),
cloud_api_key: masked_api_key,
ollama_url: prefs
.get("ollama_url")
.and_then(|v| v.as_str())
.unwrap_or("http://localhost:11434")
.to_string(),
})
}
/// Save user preferences
#[tauri::command]
pub async fn save_preferences(
state: State<'_, Arc<AppState>>,
preferences: UserPreferences,
) -> Result<(), String> {
// Store API key in keychain if user entered a new one (not masked)
let api_key = &preferences.cloud_api_key;
if !api_key.contains("...") && !api_key.contains("*") {
set_api_key(api_key)?;
}
let mut prefs = state.preferences.write();
prefs.insert(
"server_url".to_string(),
serde_json::json!(preferences.server_url),
);
prefs.insert(
"data_directory".to_string(),
serde_json::json!(preferences.data_directory),
);
prefs.insert(
"encryption_enabled".to_string(),
serde_json::json!(preferences.encryption_enabled),
);
prefs.insert(
"auto_start_enabled".to_string(),
serde_json::json!(preferences.auto_start_enabled),
);
prefs.insert(
"trigger_confidence_threshold".to_string(),
serde_json::json!(preferences.trigger_confidence_threshold),
);
prefs.insert(
"summarization_provider".to_string(),
serde_json::json!(match preferences.summarization_provider {
SummarizationProvider::Cloud => "cloud",
SummarizationProvider::Ollama => "ollama",
SummarizationProvider::None => "none",
}),
);
prefs.insert(
"ollama_url".to_string(),
serde_json::json!(preferences.ollama_url),
);
// Note: cloud_api_key is stored in keychain, not in preferences file
// Update meetings directory if changed
let meetings_dir = std::path::PathBuf::from(&preferences.data_directory).join("meetings");
*state.meetings_dir.write() = meetings_dir;
// Persist to disk
persist_preferences(&prefs)?;
Ok(())
}

View File

@@ -1,95 +0,0 @@
//! Recording-related Tauri commands
use std::sync::Arc;
use tauri::State;
use crate::grpc::types::MeetingInfo;
use crate::helpers::now_timestamp;
use crate::state::AppState;
/// Start recording a new meeting
#[tauri::command]
pub async fn start_recording(
state: State<'_, Arc<AppState>>,
title: String,
) -> Result<MeetingInfo, String> {
// Check connection (from gRPC client - single source of truth)
if !state.grpc_client.is_connected() {
return Err("Not connected to server".to_string());
}
// Check not already recording (local state)
if *state.recording.read() {
return Err("Already recording".to_string());
}
// Create meeting via gRPC
let meeting = state
.grpc_client
.create_meeting(&title, None)
.await
.map_err(|e| e.to_string())?;
// Start gRPC streaming - if this fails, clean up the meeting reference
if let Err(e) = state.grpc_client.start_streaming(&meeting.id).await {
// Cleanup: don't leave orphaned meeting in state
*state.current_meeting.write() = None;
// Best-effort cleanup to avoid orphaned meetings on the server.
let _ = state.grpc_client.stop_meeting(&meeting.id).await;
let _ = state.grpc_client.delete_meeting(&meeting.id).await;
return Err(e.to_string());
}
// All operations succeeded - update state atomically
*state.current_meeting.write() = Some(meeting.clone());
*state.recording.write() = true;
*state.recording_start_time.write() = Some(now_timestamp());
// Clear previous session
state.clear_transcript();
state.clear_session_audio();
Ok(meeting)
}
/// Stop recording
#[tauri::command]
pub async fn stop_recording(state: State<'_, Arc<AppState>>) -> Result<MeetingInfo, String> {
// Guard against multiple stops
if !*state.recording.read() {
return Err("Not recording".to_string());
}
// Stop gRPC streaming first (best-effort)
state.grpc_client.stop_streaming().await;
// Get current meeting - reset recording state if missing to prevent stuck UI
let meeting = state.current_meeting.read().clone().ok_or_else(|| {
// Avoid leaving the app stuck "recording" without a meeting reference
*state.recording.write() = false;
*state.recording_start_time.write() = None;
*state.elapsed_seconds.write() = 0;
"No current meeting".to_string()
})?;
// Stop on server; if it fails, still reset local recording state to prevent stuck UI
let updated_meeting = match state.grpc_client.stop_meeting(&meeting.id).await {
Ok(m) => m,
Err(e) => {
*state.recording.write() = false;
*state.recording_start_time.write() = None;
*state.elapsed_seconds.write() = 0;
return Err(e.to_string());
}
};
// Reset recording state and update meeting reference for playback
*state.recording.write() = false;
*state.recording_start_time.write() = None;
*state.elapsed_seconds.write() = 0;
*state.current_meeting.write() = Some(updated_meeting.clone());
Ok(updated_meeting)
}

View File

@@ -1,62 +0,0 @@
//! Unit tests for recording commands
//!
//! These tests verify recording state management and error handling.
#[cfg(test)]
mod tests {
use crate::helpers::now_timestamp;
#[test]
fn now_timestamp_is_positive() {
let ts = now_timestamp();
assert!(ts > 0.0, "Timestamp should be positive");
}
#[test]
fn now_timestamp_is_increasing() {
let ts1 = now_timestamp();
std::thread::sleep(std::time::Duration::from_millis(10));
let ts2 = now_timestamp();
assert!(ts2 >= ts1, "Second timestamp should be >= first");
}
#[test]
fn recording_state_transitions() {
// Test valid state transitions
// false -> true (start recording)
// true -> false (stop recording)
let mut recording = false;
// Start recording
recording = true;
assert!(recording);
// Stop recording
recording = false;
assert!(!recording);
}
#[test]
fn elapsed_seconds_calculation() {
let start = now_timestamp();
std::thread::sleep(std::time::Duration::from_millis(100));
let end = now_timestamp();
let elapsed = (end - start) as u32;
// Should be 0 since we slept < 1 second
assert_eq!(elapsed, 0);
}
#[test]
fn elapsed_seconds_formatting() {
// Test formatting of elapsed time
let seconds = 3661_u32; // 1 hour, 1 minute, 1 second
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
assert_eq!(hours, 1);
assert_eq!(minutes, 1);
assert_eq!(secs, 1);
}
}

View File

@@ -1,39 +0,0 @@
//! Summary-related Tauri commands
use std::sync::Arc;
use tauri::State;
use crate::grpc::types::SummaryInfo;
use crate::state::AppState;
/// Generate AI summary for a meeting
#[tauri::command]
pub async fn generate_summary(
state: State<'_, Arc<AppState>>,
meeting_id: String,
force_regenerate: bool,
) -> Result<SummaryInfo, String> {
// Set loading state
*state.summary_loading.write() = true;
*state.summary_error.write() = None;
let result = state
.grpc_client
.generate_summary(&meeting_id, force_regenerate)
.await;
// Update state
*state.summary_loading.write() = false;
match result {
Ok(summary) => {
*state.current_summary.write() = Some(summary.clone());
Ok(summary)
}
Err(e) => {
let error_msg = e.to_string();
*state.summary_error.write() = Some(error_msg.clone());
Err(error_msg)
}
}
}

View File

@@ -1,121 +0,0 @@
//! Trigger-related Tauri commands
use std::sync::Arc;
use tauri::State;
use crate::grpc::types::MeetingInfo;
use crate::helpers::now_timestamp;
use crate::state::{AppState, TriggerStatus};
/// Enable or disable trigger detection
#[tauri::command]
pub async fn set_trigger_enabled(
state: State<'_, Arc<AppState>>,
enabled: bool,
) -> Result<(), String> {
*state.trigger_enabled.write() = enabled;
Ok(())
}
/// Snooze triggers for a duration
#[tauri::command]
pub async fn snooze_triggers(
state: State<'_, Arc<AppState>>,
minutes: Option<u32>,
) -> Result<(), String> {
if let Some(ref mut service) = *state.trigger_service.lock().await {
service.snooze(minutes.map(|m| m as f64 * 60.0));
}
*state.trigger_pending.write() = false;
*state.trigger_decision.write() = None;
Ok(())
}
/// Reset snooze
#[tauri::command]
pub async fn reset_snooze(state: State<'_, Arc<AppState>>) -> Result<(), String> {
if let Some(ref mut service) = *state.trigger_service.lock().await {
service.reset_snooze();
}
Ok(())
}
/// Get trigger status
#[tauri::command]
pub async fn get_trigger_status(state: State<'_, Arc<AppState>>) -> Result<TriggerStatus, String> {
let service = state.trigger_service.lock().await;
Ok(TriggerStatus {
enabled: *state.trigger_enabled.read(),
pending: *state.trigger_pending.read(),
decision: state.trigger_decision.read().clone(),
snoozed: service.as_ref().map(|s| s.is_snoozed()).unwrap_or(false),
snooze_remaining: service
.as_ref()
.map(|s| s.snooze_remaining_seconds())
.unwrap_or(0.0),
})
}
/// Dismiss pending trigger
#[tauri::command]
pub async fn dismiss_trigger(state: State<'_, Arc<AppState>>) -> Result<(), String> {
*state.trigger_pending.write() = false;
*state.trigger_decision.write() = None;
Ok(())
}
/// Accept trigger and start recording
#[tauri::command]
pub async fn accept_trigger(
state: State<'_, Arc<AppState>>,
title: Option<String>,
) -> Result<MeetingInfo, String> {
// Same safety checks as manual recording start
if !state.grpc_client.is_connected() {
return Err("Not connected to server".to_string());
}
if *state.recording.read() {
return Err("Already recording".to_string());
}
// Start recording with trigger-detected title
let title = title.unwrap_or_else(|| {
state
.trigger_decision
.read()
.as_ref()
.and_then(|d| d.detected_app.clone())
.map(|app| format!("{} Meeting", app))
.unwrap_or_else(|| "Auto-detected Meeting".to_string())
});
// Create meeting via gRPC
let meeting = state.grpc_client.create_meeting(&title, None).await?;
// Start streaming; don't update local state unless this succeeds
if let Err(e) = state.grpc_client.start_streaming(&meeting.id).await {
*state.current_meeting.write() = None;
// Best-effort cleanup to avoid orphaned meetings on the server.
let _ = state.grpc_client.stop_meeting(&meeting.id).await;
let _ = state.grpc_client.delete_meeting(&meeting.id).await;
// Keep decision/pending so the user can retry or dismiss.
*state.trigger_pending.write() = true;
return Err(e.to_string());
}
// Clear pending UI state only after successful start to avoid losing the prompt on transient errors
*state.trigger_pending.write() = false;
// All operations succeeded - update state
*state.current_meeting.write() = Some(meeting.clone());
*state.recording.write() = true;
*state.recording_start_time.write() = Some(now_timestamp());
*state.trigger_decision.write() = None;
state.clear_transcript();
state.clear_session_audio();
Ok(meeting)
}

View File

@@ -1,286 +0,0 @@
//! Application configuration
//!
//! Centralized configuration loaded from environment variables with sensible defaults.
//! All configurable values should be defined here rather than hardcoded.
use std::env;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
/// Global configuration instance
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
/// Get the global configuration (initializes on first access)
pub fn config() -> &'static AppConfig {
CONFIG.get_or_init(AppConfig::from_env)
}
/// Application configuration
#[derive(Debug, Clone)]
pub struct AppConfig {
/// Server configuration
pub server: ServerConfig,
/// Audio configuration
pub audio: AudioConfig,
/// Storage configuration
pub storage: StorageConfig,
/// Trigger configuration
pub triggers: TriggerConfig,
/// Cache configuration
pub cache: CacheConfig,
}
impl AppConfig {
/// Load configuration from environment variables
pub fn from_env() -> Self {
Self {
server: ServerConfig::from_env(),
audio: AudioConfig::from_env(),
storage: StorageConfig::from_env(),
triggers: TriggerConfig::from_env(),
cache: CacheConfig::from_env(),
}
}
}
/// Server connection configuration
#[derive(Debug, Clone)]
pub struct ServerConfig {
/// Default server address
pub default_address: String,
/// Connection timeout
pub connect_timeout: Duration,
/// Request timeout
pub request_timeout: Duration,
/// Maximum retry attempts
pub max_retries: u32,
/// Retry backoff base (milliseconds)
pub retry_backoff_ms: u64,
}
impl ServerConfig {
fn from_env() -> Self {
Self {
default_address: env::var("NOTEFLOW_SERVER_ADDRESS")
.unwrap_or_else(|_| "localhost:50051".to_string()),
connect_timeout: Duration::from_secs(
env::var("NOTEFLOW_CONNECT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
),
request_timeout: Duration::from_secs(
env::var("NOTEFLOW_REQUEST_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30),
),
max_retries: env::var("NOTEFLOW_MAX_RETRIES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3),
retry_backoff_ms: env::var("NOTEFLOW_RETRY_BACKOFF_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000),
}
}
}
/// Audio capture/playback configuration
#[derive(Debug, Clone)]
pub struct AudioConfig {
/// Default sample rate (Hz)
pub sample_rate: u32,
/// Default number of channels
pub channels: u32,
/// Buffer size in frames
pub buffer_size: u32,
/// Minimum dB level (silence)
pub min_db_level: f32,
/// Maximum dB level
pub max_db_level: f32,
/// VU meter update rate (Hz)
pub vu_update_rate: u32,
}
impl AudioConfig {
fn from_env() -> Self {
Self {
sample_rate: env::var("NOTEFLOW_SAMPLE_RATE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(16000),
channels: env::var("NOTEFLOW_AUDIO_CHANNELS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1),
buffer_size: env::var("NOTEFLOW_AUDIO_BUFFER_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1024),
min_db_level: env::var("NOTEFLOW_MIN_DB_LEVEL")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(-60.0),
max_db_level: env::var("NOTEFLOW_MAX_DB_LEVEL")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.0),
vu_update_rate: env::var("NOTEFLOW_VU_UPDATE_RATE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20),
}
}
}
/// Storage configuration
#[derive(Debug, Clone)]
pub struct StorageConfig {
/// Base directory for meetings
pub meetings_dir: PathBuf,
/// Enable audio encryption
pub encrypt_audio: bool,
/// Maximum audio file size (bytes)
pub max_audio_size: u64,
}
impl StorageConfig {
fn from_env() -> Self {
let default_meetings_dir = directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".noteflow").join("meetings"))
.unwrap_or_else(|| PathBuf::from("/tmp/noteflow/meetings"));
Self {
meetings_dir: env::var("NOTEFLOW_MEETINGS_DIR")
.map(PathBuf::from)
.unwrap_or(default_meetings_dir),
encrypt_audio: env::var("NOTEFLOW_ENCRYPT_AUDIO")
.map(|v| v.to_lowercase() != "false" && v != "0")
.unwrap_or(true),
max_audio_size: env::var("NOTEFLOW_MAX_AUDIO_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 1024 * 1024), // 500 MB default
}
}
}
/// Trigger detection configuration
#[derive(Debug, Clone)]
pub struct TriggerConfig {
/// Enable trigger detection
pub enabled: bool,
/// Polling interval
pub poll_interval: Duration,
/// Default snooze duration
pub snooze_duration: Duration,
/// Auto-start threshold (confidence 0.0-1.0)
pub auto_start_threshold: f32,
}
impl TriggerConfig {
fn from_env() -> Self {
let auto_start_threshold: f32 = env::var("NOTEFLOW_AUTO_START_THRESHOLD")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.8_f32)
.clamp(0.0, 1.0); // Ensure threshold is within valid range
Self {
enabled: env::var("NOTEFLOW_TRIGGERS_ENABLED")
.map(|v| v.to_lowercase() == "true" || v == "1")
.unwrap_or(false),
poll_interval: Duration::from_secs(
env::var("NOTEFLOW_TRIGGER_POLL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5)
.max(1), // Minimum 1 second poll interval
),
snooze_duration: Duration::from_secs(
env::var("NOTEFLOW_SNOOZE_DURATION_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300)
.max(1), // Minimum 1 second snooze
),
auto_start_threshold,
}
}
}
/// Cache configuration
#[derive(Debug, Clone)]
pub struct CacheConfig {
/// Cache backend type
pub backend: CacheBackend,
/// Redis URL (if using Redis)
pub redis_url: Option<String>,
/// Default TTL for cached items (seconds)
pub default_ttl_secs: u64,
/// Maximum memory cache size (items)
pub max_memory_items: usize,
}
/// Supported cache backends
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CacheBackend {
/// In-memory cache (default)
Memory,
/// Redis cache
Redis,
/// No caching
None,
}
impl CacheConfig {
fn from_env() -> Self {
let backend = match env::var("NOTEFLOW_CACHE_BACKEND")
.unwrap_or_default()
.to_lowercase()
.as_str()
{
"redis" => CacheBackend::Redis,
"none" | "disabled" => CacheBackend::None,
_ => CacheBackend::Memory,
};
Self {
backend,
redis_url: env::var("NOTEFLOW_REDIS_URL").ok(),
default_ttl_secs: env::var("NOTEFLOW_CACHE_TTL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300),
max_memory_items: env::var("NOTEFLOW_CACHE_MAX_ITEMS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_valid() {
let config = AppConfig::from_env();
assert!(!config.server.default_address.is_empty());
assert!(config.server.connect_timeout.as_secs() > 0);
assert!(config.audio.sample_rate > 0);
assert!(config.triggers.auto_start_threshold >= 0.0);
assert!(config.triggers.auto_start_threshold <= 1.0);
}
#[test]
fn cache_backend_parsing() {
// Default is Memory
assert_eq!(CacheConfig::from_env().backend, CacheBackend::Memory);
}
}

View File

@@ -1,127 +0,0 @@
//! Application-wide constants
//!
//! Centralized location for all hardcoded values to ensure consistency
//! between Rust backend and TypeScript frontend.
/// Event names emitted from backend to frontend
pub mod events {
/// Transcript update (partial or final segment)
pub const TRANSCRIPT_UPDATE: &str = "TRANSCRIPT_UPDATE";
/// Audio level change (normalized 0.0-1.0)
pub const AUDIO_LEVEL: &str = "AUDIO_LEVEL";
/// Playback position change (seconds)
pub const PLAYBACK_POSITION: &str = "PLAYBACK_POSITION";
/// Playback state change (playing/paused/stopped)
pub const PLAYBACK_STATE: &str = "PLAYBACK_STATE";
/// Highlighted segment index change
pub const HIGHLIGHT_CHANGE: &str = "HIGHLIGHT_CHANGE";
/// Connection state change
pub const CONNECTION_CHANGE: &str = "CONNECTION_CHANGE";
/// Meeting detected by trigger system
pub const MEETING_DETECTED: &str = "MEETING_DETECTED";
/// Recording timer tick (elapsed seconds)
pub const RECORDING_TIMER: &str = "RECORDING_TIMER";
/// Error event
pub const ERROR: &str = "ERROR";
/// Summary generation progress
pub const SUMMARY_PROGRESS: &str = "SUMMARY_PROGRESS";
/// Diarization job progress
pub const DIARIZATION_PROGRESS: &str = "DIARIZATION_PROGRESS";
}
// Backwards-compatible alias for existing call sites.
pub use events as Events;
/// gRPC connection settings
pub mod grpc {
use std::time::Duration;
/// Connection timeout
pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
/// Request timeout
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Default server port
pub const DEFAULT_PORT: u16 = 50051;
}
/// Audio settings
pub mod audio {
/// Default sample rate (Hz)
pub const DEFAULT_SAMPLE_RATE: u32 = 16000;
/// Default number of channels
pub const DEFAULT_CHANNELS: u32 = 1;
/// Minimum dB level (silence)
pub const MIN_DB_LEVEL: f32 = -60.0;
/// Maximum dB level
pub const MAX_DB_LEVEL: f32 = 0.0;
}
/// Crypto settings
pub mod crypto {
/// AES-GCM nonce size in bytes
pub const NONCE_SIZE: usize = 12;
/// AES-256 key size in bytes
pub const KEY_SIZE: usize = 32;
/// Keychain service name
pub const KEYCHAIN_SERVICE: &str = "noteflow";
/// Keychain username for audio key
pub const KEYCHAIN_USERNAME: &str = "audio-key";
}
/// Trigger settings
pub mod triggers {
use std::time::Duration;
/// Default snooze duration
pub const DEFAULT_SNOOZE_DURATION: Duration = Duration::from_secs(300);
/// Trigger polling interval
pub const POLL_INTERVAL: Duration = Duration::from_secs(5);
}
/// Preferences settings
pub mod preferences {
/// Preferences filename
pub const FILENAME: &str = "preferences.json";
}
/// Secrets keychain settings
pub mod secrets {
/// Keychain service name for secrets
pub const KEYCHAIN_SERVICE: &str = "com.noteflow.secrets";
/// Keychain username for cloud API key
pub const API_KEY_USERNAME: &str = "cloud_api_key";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_names_are_uppercase() {
// Event names should be SCREAMING_SNAKE_CASE for convention
assert!(events::TRANSCRIPT_UPDATE
.chars()
.all(|c| c.is_uppercase() || c == '_'));
assert!(events::AUDIO_LEVEL
.chars()
.all(|c| c.is_uppercase() || c == '_'));
assert!(events::CONNECTION_CHANGE
.chars()
.all(|c| c.is_uppercase() || c == '_'));
}
#[test]
fn grpc_timeouts_are_reasonable() {
assert!(grpc::CONNECTION_TIMEOUT.as_secs() >= 1);
assert!(grpc::CONNECTION_TIMEOUT.as_secs() <= 30);
assert!(grpc::REQUEST_TIMEOUT.as_secs() >= 5);
}
#[test]
fn crypto_sizes_are_correct() {
// AES-256 requires 32-byte key
assert_eq!(crypto::KEY_SIZE, 32);
// GCM standard nonce is 12 bytes
assert_eq!(crypto::NONCE_SIZE, 12);
}
}

View File

@@ -1,161 +0,0 @@
//! Cryptographic functionality for audio encryption
//!
//! This module provides AES-256-GCM encryption for audio files
//! and keychain integration for secure key storage.
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use rand::Rng;
use crate::constants::crypto as crypto_config;
/// Crypto box for encryption/decryption operations
pub struct CryptoBox {
cipher: Aes256Gcm,
}
impl std::fmt::Debug for CryptoBox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CryptoBox")
.field("cipher", &"[Aes256Gcm]")
.finish()
}
}
impl CryptoBox {
/// Create a new CryptoBox with a key from keychain or generate new
pub fn new() -> Result<Self, String> {
let key = Self::get_or_create_key()?;
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| format!("Failed to create cipher: {}", e))?;
Ok(Self { cipher })
}
/// Get existing key from keychain or generate a new one
fn get_or_create_key() -> Result<Vec<u8>, String> {
// Try to get key from keychain
let keyring = keyring::Entry::new(
crypto_config::KEYCHAIN_SERVICE,
crypto_config::KEYCHAIN_USERNAME,
)
.map_err(|e| format!("Failed to access keychain: {}", e))?;
match keyring.get_password() {
Ok(key_hex) => {
// Decode existing key; if corrupted, regenerate to avoid bricking startup.
match hex_decode(&key_hex) {
Ok(key) if key.len() == crypto_config::KEY_SIZE => Ok(key),
_ => {
// Key is corrupted - regenerate
let mut key = vec![0u8; crypto_config::KEY_SIZE];
rand::thread_rng().fill(&mut key[..]);
let key_hex = hex_encode(&key);
keyring
.set_password(&key_hex)
.map_err(|e| format!("Failed to store key: {}", e))?;
Ok(key)
}
}
}
Err(keyring::Error::NoEntry) => {
// No key exists yet - generate new key
let mut key = vec![0u8; crypto_config::KEY_SIZE];
rand::thread_rng().fill(&mut key[..]);
// Store in keychain
let key_hex = hex_encode(&key);
keyring
.set_password(&key_hex)
.map_err(|e| format!("Failed to store key: {}", e))?;
Ok(key)
}
Err(e) => {
// Propagate other keychain errors to prevent accidental data loss
Err(format!("Failed to get key from keychain: {}", e))
}
}
}
/// Encrypt data with AES-256-GCM
pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
// Generate random nonce
let mut nonce_bytes = [0u8; crypto_config::NONCE_SIZE];
rand::thread_rng().fill(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
// Encrypt
let ciphertext = self
.cipher
.encrypt(nonce, plaintext)
.map_err(|e| format!("Encryption failed: {}", e))?;
// Prepend nonce to ciphertext
let mut result = Vec::with_capacity(crypto_config::NONCE_SIZE + ciphertext.len());
result.extend_from_slice(&nonce_bytes);
result.extend_from_slice(&ciphertext);
Ok(result)
}
/// Decrypt data with AES-256-GCM
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, String> {
if ciphertext.len() < crypto_config::NONCE_SIZE {
return Err("Ciphertext too short".to_string());
}
// Extract nonce and ciphertext
let nonce = Nonce::from_slice(&ciphertext[..crypto_config::NONCE_SIZE]);
let encrypted = &ciphertext[crypto_config::NONCE_SIZE..];
// Decrypt
self.cipher
.decrypt(nonce, encrypted)
.map_err(|e| format!("Decryption failed: {}", e))
}
}
/// Hex encode bytes (for keychain storage)
fn hex_encode(data: &[u8]) -> String {
let mut result = String::with_capacity(data.len() * 2);
for &byte in data {
// `format!` can't fail; avoids `unwrap()` panic paths.
result.push_str(&format!("{:02x}", byte));
}
result
}
/// Hex decode string to bytes
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
if s.len() % 2 != 0 {
return Err("Hex string must have even length".to_string());
}
let mut bytes = Vec::with_capacity(s.len() / 2);
for chunk in s.as_bytes().chunks(2) {
let hex = std::str::from_utf8(chunk).map_err(|_| "Invalid UTF-8 in hex string")?;
let byte = u8::from_str_radix(hex, 16).map_err(|_| "Invalid hex character")?;
bytes.push(byte);
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt() {
let crypto = CryptoBox::new().unwrap();
let plaintext = b"Hello, World!";
let ciphertext = crypto.encrypt(plaintext).unwrap();
let decrypted = crypto.decrypt(&ciphertext).unwrap();
assert_eq!(plaintext.as_slice(), decrypted.as_slice());
}
}

View File

@@ -1,98 +0,0 @@
//! Tauri event emitters
//!
//! This module provides helper functions for emitting events
//! from the Rust backend to the React frontend.
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use crate::constants::events as event_names;
use crate::grpc::types::TranscriptUpdate;
use crate::state::TriggerDecision;
/// Connection event payload
#[derive(Debug, Clone, Serialize)]
pub struct ConnectionEvent {
pub connected: bool,
pub server_address: String,
pub error: Option<String>,
}
/// Summary progress payload
#[derive(Debug, Clone, Serialize)]
pub struct SummaryProgress {
pub meeting_id: String,
pub status: String,
pub progress: f32,
pub error: Option<String>,
}
/// Diarization progress payload
#[derive(Debug, Clone, Serialize)]
pub struct DiarizationProgress {
pub job_id: String,
pub meeting_id: String,
pub status: String,
pub segments_updated: u32,
pub error: Option<String>,
}
/// Error event payload
#[derive(Debug, Clone, Serialize)]
pub struct ErrorEvent {
pub code: String,
pub message: String,
pub recoverable: bool,
}
/// Emit transcript update
pub fn emit_transcript_update(app: &AppHandle, update: &TranscriptUpdate) {
let _ = app.emit(event_names::TRANSCRIPT_UPDATE, update);
}
/// Emit audio level (normalized 0.0-1.0)
pub fn emit_audio_level(app: &AppHandle, level: f32) {
let _ = app.emit(event_names::AUDIO_LEVEL, level);
}
/// Emit playback position
pub fn emit_playback_position(app: &AppHandle, position: f64) {
let _ = app.emit(event_names::PLAYBACK_POSITION, position);
}
/// Emit playback state change
pub fn emit_playback_state(app: &AppHandle, state: &str) {
let _ = app.emit(event_names::PLAYBACK_STATE, state);
}
/// Emit highlight change
pub fn emit_highlight_change(app: &AppHandle, index: Option<usize>) {
let _ = app.emit(event_names::HIGHLIGHT_CHANGE, index);
}
/// Emit connection change
pub fn emit_connection_change(app: &AppHandle, event: &ConnectionEvent) {
let _ = app.emit(event_names::CONNECTION_CHANGE, event);
}
/// Emit meeting detected (trigger)
pub fn emit_meeting_detected(app: &AppHandle, decision: &TriggerDecision) {
let _ = app.emit(event_names::MEETING_DETECTED, decision);
}
/// Emit recording timer tick
pub fn emit_recording_timer(app: &AppHandle, elapsed: u32) {
let _ = app.emit(event_names::RECORDING_TIMER, elapsed);
}
/// Emit error
pub fn emit_error(app: &AppHandle, code: &str, message: &str, recoverable: bool) {
let _ = app.emit(
event_names::ERROR,
ErrorEvent {
code: code.to_string(),
message: message.to_string(),
recoverable,
},
);
}

View File

@@ -1,507 +0,0 @@
//! gRPC client implementation for NoteFlow server communication
//!
//! This module provides the `GrpcClient` struct which handles all communication
//! with the Python gRPC server using tonic.
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::RwLock;
use tokio::sync::Mutex;
use tonic::transport::Channel;
use super::noteflow::{self as proto, note_flow_service_client::NoteFlowServiceClient};
use super::types::{
AnnotationInfo, AnnotationType, AudioChunk, DiarizationResult, ExportFormat, ExportResult,
JobStatus, MeetingDetails, MeetingInfo, MeetingState, RenameSpeakerResult, ServerInfo,
SummaryInfo,
};
use crate::constants::grpc as grpc_config;
use crate::helpers::new_id;
/// Convert proto Annotation to local AnnotationInfo
fn annotation_from_proto(a: proto::Annotation) -> AnnotationInfo {
AnnotationInfo {
id: a.id,
meeting_id: a.meeting_id,
annotation_type: AnnotationType::from(a.annotation_type),
text: a.text,
start_time: a.start_time,
end_time: a.end_time,
segment_ids: a.segment_ids,
created_at: a.created_at,
}
}
/// gRPC client error type
#[derive(Debug, Clone, thiserror::Error)]
pub enum GrpcError {
#[error("Not connected to server")]
NotConnected,
#[error("No active stream")]
NoActiveStream,
#[error("Invalid address: {0}")]
InvalidAddress(String),
#[error("Connection failed: {0}")]
ConnectionFailed(String),
#[error("Operation not implemented")]
NotImplemented,
}
impl From<GrpcError> for String {
fn from(err: GrpcError) -> Self {
err.to_string()
}
}
/// Connection state - grouped for atomic updates
#[derive(Debug, Clone)]
struct ConnectionState {
channel: Option<Channel>,
server_address: String,
connected: bool,
}
impl Default for ConnectionState {
fn default() -> Self {
Self {
channel: None,
server_address: String::new(),
connected: false,
}
}
}
/// Streaming state - grouped for atomic updates
#[derive(Debug, Clone, Default)]
struct StreamingState {
meeting_id: Option<String>,
active: bool,
}
/// gRPC client for NoteFlow server
#[derive(Debug, Clone)]
pub struct GrpcClient {
inner: Arc<ClientState>,
}
#[derive(Debug)]
struct ClientState {
/// Connection state (channel, address, connected) - single lock for atomicity
connection: RwLock<ConnectionState>,
/// Streaming state (meeting_id, active) - single lock for atomicity
streaming: RwLock<StreamingState>,
/// Audio sender for streaming - async mutex for channel operations
audio_tx: Mutex<Option<tokio::sync::mpsc::Sender<AudioChunk>>>,
}
impl Default for GrpcClient {
fn default() -> Self {
Self::new()
}
}
impl GrpcClient {
/// Create a new gRPC client
pub fn new() -> Self {
Self {
inner: Arc::new(ClientState {
connection: RwLock::new(ConnectionState::default()),
streaming: RwLock::new(StreamingState::default()),
audio_tx: Mutex::new(None),
}),
}
}
/// Check if connected to server
pub fn is_connected(&self) -> bool {
self.inner.connection.read().connected
}
/// Get current server address
pub fn server_address(&self) -> String {
self.inner.connection.read().server_address.clone()
}
/// Require connection or return error
fn require_connection(&self) -> Result<(), GrpcError> {
if self.inner.connection.read().connected {
Ok(())
} else {
Err(GrpcError::NotConnected)
}
}
/// Get tonic client from existing channel (no new state needed)
fn tonic_client(&self) -> Result<NoteFlowServiceClient<Channel>, GrpcError> {
self.inner
.connection
.read()
.channel
.clone()
.map(NoteFlowServiceClient::new)
.ok_or(GrpcError::NotConnected)
}
/// Connect to gRPC server with timeout
pub async fn connect(&self, address: &str) -> Result<ServerInfo, GrpcError> {
let endpoint = Channel::from_shared(format!("http://{}", address))
.map_err(|e| GrpcError::InvalidAddress(e.to_string()))?
.connect_timeout(grpc_config::CONNECTION_TIMEOUT)
.timeout(grpc_config::REQUEST_TIMEOUT);
let channel = endpoint
.connect()
.await
.map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?;
// Probe the connection before committing "connected" state.
let info = {
// Save previous state for rollback
let prev_connected;
let prev_address;
let prev_channel;
{
let conn = self.inner.connection.read();
prev_connected = conn.connected;
prev_address = conn.server_address.clone();
prev_channel = conn.channel.clone();
}
// Atomic state update - set connected state
{
let mut conn = self.inner.connection.write();
conn.channel = Some(channel);
conn.server_address = address.to_string();
conn.connected = true;
}
match self.get_server_info().await {
Ok(info) => info,
Err(e) => {
// Roll back to previous state on failure.
let mut conn = self.inner.connection.write();
conn.connected = prev_connected;
conn.server_address = prev_address;
conn.channel = prev_channel;
return Err(e);
}
}
};
Ok(info)
}
/// Disconnect from server
pub async fn disconnect(&self) {
self.stop_streaming().await;
// Atomic state update - clear all connection state
let mut conn = self.inner.connection.write();
conn.channel = None;
conn.server_address.clear();
conn.connected = false;
}
/// Get server information
pub async fn get_server_info(&self) -> Result<ServerInfo, GrpcError> {
self.require_connection()?;
let mut client = self.tonic_client()?;
let response = client
.get_server_info(proto::ServerInfoRequest {})
.await
.map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?;
let info = response.into_inner();
Ok(ServerInfo {
version: info.version,
asr_model: info.asr_model,
asr_ready: info.asr_ready,
uptime_seconds: info.uptime_seconds,
active_meetings: info.active_meetings as u32,
diarization_enabled: info.diarization_enabled,
diarization_ready: info.diarization_ready,
})
}
/// Create a new meeting
pub async fn create_meeting(
&self,
title: &str,
_metadata: Option<HashMap<String, String>>,
) -> Result<MeetingInfo, GrpcError> {
self.require_connection()?;
// TODO: Implement actual gRPC call
Ok(MeetingInfo::new(title))
}
/// Stop a recording meeting
pub async fn stop_meeting(&self, meeting_id: &str) -> Result<MeetingInfo, GrpcError> {
self.require_connection()?;
// TODO: Implement actual gRPC call
Ok(MeetingInfo::stopped(meeting_id))
}
/// Get meeting details
pub async fn get_meeting(
&self,
meeting_id: &str,
_include_segments: bool,
_include_summary: bool,
) -> Result<MeetingDetails, GrpcError> {
self.require_connection()?;
// TODO: Implement actual gRPC call
Ok(MeetingDetails::empty(meeting_id))
}
/// List meetings with filtering and pagination
pub async fn list_meetings(
&self,
_states: Option<Vec<MeetingState>>,
_limit: u32,
_offset: u32,
_sort_desc: bool,
) -> Result<(Vec<MeetingInfo>, u32), GrpcError> {
self.require_connection()?;
// TODO: Implement actual gRPC call
Ok((vec![], 0))
}
/// Delete a meeting
pub async fn delete_meeting(&self, _meeting_id: &str) -> Result<bool, GrpcError> {
self.require_connection()?;
// TODO: Implement actual gRPC call
Ok(true)
}
/// Start audio streaming for a meeting
pub async fn start_streaming(&self, meeting_id: &str) -> Result<(), GrpcError> {
self.require_connection()?;
// Atomic state update
{
let mut stream = self.inner.streaming.write();
stream.meeting_id = Some(meeting_id.to_string());
stream.active = true;
}
// TODO: Implement actual gRPC streaming
Ok(())
}
/// Stop audio streaming
pub async fn stop_streaming(&self) {
// Clear audio sender first (async)
*self.inner.audio_tx.lock().await = None;
// Atomic state update
let mut stream = self.inner.streaming.write();
stream.meeting_id = None;
stream.active = false;
}
/// Send audio chunk to stream
pub async fn send_audio(&self, _audio: Vec<f32>, _timestamp: f64) -> Result<(), GrpcError> {
if !self.inner.streaming.read().active {
return Err(GrpcError::NoActiveStream);
}
// TODO: Implement actual audio sending
Ok(())
}
/// Add annotation to a meeting
pub async fn add_annotation(
&self,
meeting_id: &str,
annotation_type: AnnotationType,
text: &str,
start_time: f64,
end_time: f64,
segment_ids: Option<Vec<i32>>,
) -> Result<AnnotationInfo, GrpcError> {
self.require_connection()?;
let mut client = self.tonic_client()?;
let request = proto::AddAnnotationRequest {
meeting_id: meeting_id.to_string(),
annotation_type: annotation_type as i32,
text: text.to_string(),
start_time,
end_time,
segment_ids: segment_ids.unwrap_or_default(),
};
let response = client
.add_annotation(request)
.await
.map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?;
Ok(annotation_from_proto(response.into_inner()))
}
/// Get annotation by ID
pub async fn get_annotation(&self, annotation_id: &str) -> Result<AnnotationInfo, GrpcError> {
self.require_connection()?;
let mut client = self.tonic_client()?;
let request = proto::GetAnnotationRequest {
annotation_id: annotation_id.to_string(),
};
let response = client
.get_annotation(request)
.await
.map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?;
Ok(annotation_from_proto(response.into_inner()))
}
/// List annotations for a meeting
pub async fn list_annotations(
&self,
meeting_id: &str,
start_time: f64,
end_time: f64,
) -> Result<Vec<AnnotationInfo>, GrpcError> {
self.require_connection()?;
let mut client = self.tonic_client()?;
let request = proto::ListAnnotationsRequest {
meeting_id: meeting_id.to_string(),
start_time,
end_time,
};
let response = client
.list_annotations(request)
.await
.map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?;
Ok(response
.into_inner()
.annotations
.into_iter()
.map(annotation_from_proto)
.collect())
}
/// Update an existing annotation
pub async fn update_annotation(
&self,
annotation_id: &str,
annotation_type: Option<AnnotationType>,
text: Option<&str>,
start_time: Option<f64>,
end_time: Option<f64>,
segment_ids: Option<Vec<i32>>,
) -> Result<AnnotationInfo, GrpcError> {
self.require_connection()?;
let mut client = self.tonic_client()?;
let request = proto::UpdateAnnotationRequest {
annotation_id: annotation_id.to_string(),
annotation_type: annotation_type.map(|t| t as i32).unwrap_or(0),
text: text.unwrap_or("").to_string(),
start_time: start_time.unwrap_or(0.0),
end_time: end_time.unwrap_or(0.0),
segment_ids: segment_ids.unwrap_or_default(),
};
let response = client
.update_annotation(request)
.await
.map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?;
Ok(annotation_from_proto(response.into_inner()))
}
/// Delete an annotation
pub async fn delete_annotation(&self, annotation_id: &str) -> Result<bool, GrpcError> {
self.require_connection()?;
let mut client = self.tonic_client()?;
let request = proto::DeleteAnnotationRequest {
annotation_id: annotation_id.to_string(),
};
let response = client
.delete_annotation(request)
.await
.map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?;
Ok(response.into_inner().success)
}
/// Generate AI summary for a meeting
pub async fn generate_summary(
&self,
_meeting_id: &str,
_force_regenerate: bool,
) -> Result<SummaryInfo, GrpcError> {
self.require_connection()?;
Err(GrpcError::NotImplemented)
}
/// Export meeting transcript
pub async fn export_transcript(
&self,
_meeting_id: &str,
format: ExportFormat,
) -> Result<ExportResult, GrpcError> {
self.require_connection()?;
// TODO: Implement actual gRPC call
Ok(ExportResult::empty(format))
}
/// Start background speaker diarization refinement
pub async fn refine_speaker_diarization(
&self,
_meeting_id: &str,
_num_speakers: Option<u32>,
) -> Result<DiarizationResult, GrpcError> {
self.require_connection()?;
// TODO: Implement actual gRPC call
Ok(DiarizationResult {
job_id: new_id(),
status: JobStatus::Queued,
segments_updated: 0,
speaker_ids: vec![],
error_message: String::new(),
})
}
/// Get status of a diarization job
pub async fn get_diarization_job_status(
&self,
_job_id: &str,
) -> Result<DiarizationResult, GrpcError> {
self.require_connection()?;
Err(GrpcError::NotImplemented)
}
/// Rename a speaker across all segments
pub async fn rename_speaker(
&self,
_meeting_id: &str,
_old_speaker_id: &str,
_new_speaker_name: &str,
) -> Result<RenameSpeakerResult, GrpcError> {
self.require_connection()?;
// TODO: Implement actual gRPC call
Ok(RenameSpeakerResult {
segments_updated: 0,
success: true,
})
}
}

View File

@@ -1,206 +0,0 @@
//! Unit tests for gRPC types
//!
//! These tests verify type construction and serialization.
//! Actual gRPC calls are tested in tests/grpc_integration.rs
#[cfg(test)]
mod tests {
use crate::grpc::types::*;
#[test]
fn meeting_info_new() {
let meeting = MeetingInfo::new("Test Meeting");
assert!(!meeting.id.is_empty());
assert_eq!(meeting.title, "Test Meeting");
assert_eq!(meeting.state, MeetingState::Recording);
assert!(meeting.created_at > 0.0);
}
#[test]
fn meeting_info_stopped() {
let meeting = MeetingInfo::stopped("meeting-123");
assert_eq!(meeting.id, "meeting-123");
assert_eq!(meeting.state, MeetingState::Stopped);
}
#[test]
fn meeting_state_serialization() {
assert_eq!(
serde_json::to_string(&MeetingState::Created).unwrap(),
"\"created\""
);
assert_eq!(
serde_json::to_string(&MeetingState::Recording).unwrap(),
"\"recording\""
);
assert_eq!(
serde_json::to_string(&MeetingState::Completed).unwrap(),
"\"completed\""
);
assert_eq!(
serde_json::to_string(&MeetingState::Error).unwrap(),
"\"error\""
);
}
#[test]
fn meeting_state_from_i32() {
assert_eq!(MeetingState::from(1), MeetingState::Created);
assert_eq!(MeetingState::from(2), MeetingState::Recording);
assert_eq!(MeetingState::from(3), MeetingState::Stopped);
assert_eq!(MeetingState::from(4), MeetingState::Completed);
assert_eq!(MeetingState::from(5), MeetingState::Error);
assert_eq!(MeetingState::from(99), MeetingState::Unspecified);
}
#[test]
fn server_info_default() {
let info = ServerInfo::default();
assert!(info.version.is_empty());
assert!(!info.asr_ready);
}
#[test]
fn segment_construction() {
let segment = Segment {
segment_id: 1,
text: "Hello world".to_string(),
start_time: 0.0,
end_time: 5.0,
language: "en".to_string(),
speaker_id: "SPEAKER_00".to_string(),
speaker_confidence: 0.95,
words: vec![],
};
assert_eq!(segment.text, "Hello world");
assert!((segment.speaker_confidence - 0.95).abs() < f64::EPSILON);
}
#[test]
fn word_timing_construction() {
let timing = WordTiming {
word: "hello".to_string(),
start_time: 0.0,
end_time: 0.5,
probability: 0.98,
};
assert_eq!(timing.word, "hello");
assert!((timing.end_time - 0.5).abs() < f64::EPSILON);
}
#[test]
fn annotation_type_from_str() {
assert_eq!(AnnotationType::from("action_item"), AnnotationType::ActionItem);
assert_eq!(AnnotationType::from("decision"), AnnotationType::Decision);
assert_eq!(AnnotationType::from("note"), AnnotationType::Note);
assert_eq!(AnnotationType::from("risk"), AnnotationType::Risk);
assert_eq!(AnnotationType::from("unknown"), AnnotationType::Unspecified);
}
#[test]
fn annotation_type_from_i32() {
assert_eq!(AnnotationType::from(1), AnnotationType::ActionItem);
assert_eq!(AnnotationType::from(2), AnnotationType::Decision);
assert_eq!(AnnotationType::from(3), AnnotationType::Note);
assert_eq!(AnnotationType::from(4), AnnotationType::Risk);
assert_eq!(AnnotationType::from(99), AnnotationType::Unspecified);
}
#[test]
fn annotation_info_construction() {
let annotation = AnnotationInfo {
id: "ann-1".to_string(),
meeting_id: "meeting-123".to_string(),
annotation_type: AnnotationType::ActionItem,
text: "Follow up with team".to_string(),
start_time: 10.0,
end_time: 15.0,
segment_ids: vec![1, 2],
created_at: 1234567890.0,
};
assert_eq!(annotation.annotation_type, AnnotationType::ActionItem);
assert_eq!(annotation.text, "Follow up with team");
assert_eq!(annotation.segment_ids.len(), 2);
}
#[test]
fn timestamped_audio_construction() {
let audio = TimestampedAudio {
frames: vec![0.1, 0.2, -0.1, -0.2],
timestamp: 1234567890.0,
duration: 0.1,
};
assert_eq!(audio.frames.len(), 4);
assert!((audio.duration - 0.1).abs() < f64::EPSILON);
}
#[test]
fn export_format_from_str() {
assert_eq!(ExportFormat::from("html"), ExportFormat::Html);
assert_eq!(ExportFormat::from("markdown"), ExportFormat::Markdown);
assert_eq!(ExportFormat::from("unknown"), ExportFormat::Markdown); // default
}
#[test]
fn export_result_empty() {
let result = ExportResult::empty(ExportFormat::Markdown);
assert!(result.content.is_empty());
assert_eq!(result.format_name, "markdown");
assert_eq!(result.file_extension, "md");
let result = ExportResult::empty(ExportFormat::Html);
assert_eq!(result.format_name, "html");
assert_eq!(result.file_extension, "html");
}
#[test]
fn job_status_from_i32() {
assert_eq!(JobStatus::from(1), JobStatus::Queued);
assert_eq!(JobStatus::from(2), JobStatus::Running);
assert_eq!(JobStatus::from(3), JobStatus::Completed);
assert_eq!(JobStatus::from(4), JobStatus::Failed);
assert_eq!(JobStatus::from(99), JobStatus::Unspecified);
}
#[test]
fn update_type_from_i32() {
assert_eq!(UpdateType::from(1), UpdateType::Partial);
assert_eq!(UpdateType::from(2), UpdateType::Final);
assert_eq!(UpdateType::from(3), UpdateType::VadStart);
assert_eq!(UpdateType::from(4), UpdateType::VadEnd);
assert_eq!(UpdateType::from(99), UpdateType::Unspecified);
}
#[test]
fn meeting_details_empty() {
let details = MeetingDetails::empty("meeting-123");
assert_eq!(details.meeting.id, "meeting-123");
assert_eq!(details.meeting.state, MeetingState::Stopped);
assert!(details.segments.is_empty());
assert!(details.summary.is_none());
}
#[test]
fn audio_device_info_construction() {
let device = AudioDeviceInfo {
id: 12345,
name: "USB Microphone".to_string(),
channels: 1,
sample_rate: 48000,
is_default: true,
};
assert_eq!(device.id, 12345);
assert_eq!(device.name, "USB Microphone");
assert!(device.is_default);
}
}

View File

@@ -1,14 +0,0 @@
//! gRPC client module for NoteFlow server communication
pub mod client;
#[allow(clippy::all)]
#[allow(dead_code)]
pub mod noteflow;
pub mod types;
// Re-export main types
pub use client::GrpcClient;
pub use types::*;
#[cfg(test)]
mod client_tests;

File diff suppressed because it is too large Load Diff

View File

@@ -1,357 +0,0 @@
//! Type definitions for gRPC messages
//!
//! These types mirror the protobuf definitions and are used for
//! communication between Rust and the React frontend via Tauri.
use serde::{Deserialize, Serialize};
use crate::helpers::{new_id, now_timestamp};
/// Meeting states (matches proto enum)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MeetingState {
#[default]
Unspecified = 0,
Created = 1,
Recording = 2,
Stopped = 3,
Completed = 4,
Error = 5,
Stopping = 6,
}
impl From<i32> for MeetingState {
fn from(value: i32) -> Self {
match value {
1 => Self::Created,
2 => Self::Recording,
3 => Self::Stopped,
4 => Self::Completed,
5 => Self::Error,
6 => Self::Stopping,
_ => Self::Unspecified,
}
}
}
/// Annotation types (matches proto enum)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AnnotationType {
#[default]
Unspecified = 0,
ActionItem = 1,
Decision = 2,
Note = 3,
Risk = 4,
}
impl From<&str> for AnnotationType {
fn from(value: &str) -> Self {
match value {
"action_item" => Self::ActionItem,
"decision" => Self::Decision,
"note" => Self::Note,
"risk" => Self::Risk,
_ => Self::Unspecified,
}
}
}
impl From<i32> for AnnotationType {
fn from(value: i32) -> Self {
match value {
1 => Self::ActionItem,
2 => Self::Decision,
3 => Self::Note,
4 => Self::Risk,
_ => Self::Unspecified,
}
}
}
/// Export formats (matches proto enum)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ExportFormat {
#[default]
Markdown = 0,
Html = 1,
}
impl From<&str> for ExportFormat {
fn from(value: &str) -> Self {
match value {
"html" => Self::Html,
_ => Self::Markdown,
}
}
}
/// Job status for background tasks (matches proto enum)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum JobStatus {
#[default]
Unspecified = 0,
Queued = 1,
Running = 2,
Completed = 3,
Failed = 4,
}
impl From<i32> for JobStatus {
fn from(value: i32) -> Self {
match value {
1 => Self::Queued,
2 => Self::Running,
3 => Self::Completed,
4 => Self::Failed,
_ => Self::Unspecified,
}
}
}
/// Transcript update type (matches proto enum)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UpdateType {
#[default]
Unspecified = 0,
Partial = 1,
Final = 2,
VadStart = 3,
VadEnd = 4,
}
impl From<i32> for UpdateType {
fn from(value: i32) -> Self {
match value {
1 => Self::Partial,
2 => Self::Final,
3 => Self::VadStart,
4 => Self::VadEnd,
_ => Self::Unspecified,
}
}
}
/// Server info (returned from GetServerInfo)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServerInfo {
pub version: String,
pub asr_model: String,
pub asr_ready: bool,
pub uptime_seconds: f64,
pub active_meetings: u32,
pub diarization_enabled: bool,
pub diarization_ready: bool,
}
/// Meeting info (basic meeting data)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingInfo {
pub id: String,
pub title: String,
pub state: MeetingState,
pub created_at: f64,
pub started_at: f64,
pub ended_at: f64,
pub duration_seconds: f64,
pub segment_count: u32,
}
impl MeetingInfo {
/// Create a new meeting with the given title
pub fn new(title: &str) -> Self {
let now = now_timestamp();
Self {
id: new_id(),
title: title.to_string(),
state: MeetingState::Recording,
created_at: now,
started_at: now,
ended_at: 0.0,
duration_seconds: 0.0,
segment_count: 0,
}
}
/// Create a stopped meeting placeholder
pub fn stopped(meeting_id: &str) -> Self {
Self {
id: meeting_id.to_string(),
title: String::new(),
state: MeetingState::Stopped,
created_at: 0.0,
started_at: 0.0,
ended_at: now_timestamp(),
duration_seconds: 0.0,
segment_count: 0,
}
}
}
/// Meeting with segments and summary
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingDetails {
pub meeting: MeetingInfo,
pub segments: Vec<Segment>,
pub summary: Option<SummaryInfo>,
}
impl MeetingDetails {
/// Create an empty meeting details placeholder
pub fn empty(meeting_id: &str) -> Self {
Self {
meeting: MeetingInfo::stopped(meeting_id),
segments: vec![],
summary: None,
}
}
}
/// Transcript segment
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Segment {
pub segment_id: i32,
pub text: String,
pub start_time: f64,
pub end_time: f64,
pub language: String,
pub speaker_id: String,
pub speaker_confidence: f64,
pub words: Vec<WordTiming>,
}
/// Word-level timing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WordTiming {
pub word: String,
pub start_time: f64,
pub end_time: f64,
pub probability: f64,
}
/// Transcript update from streaming
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptUpdate {
pub update_type: UpdateType,
pub partial_text: String,
pub segment: Option<Segment>,
pub server_timestamp: f64,
}
/// Annotation info
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnotationInfo {
pub id: String,
pub meeting_id: String,
pub annotation_type: AnnotationType,
pub text: String,
pub start_time: f64,
pub end_time: f64,
pub segment_ids: Vec<i32>,
pub created_at: f64,
}
/// Summary info
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryInfo {
pub meeting_id: String,
pub executive_summary: String,
pub key_points: Vec<KeyPoint>,
pub action_items: Vec<ActionItem>,
pub generated_at: f64,
pub model_version: String,
}
/// Key point from summary
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyPoint {
pub text: String,
pub segment_ids: Vec<i32>,
pub start_time: f64,
pub end_time: f64,
}
/// Action item from summary
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionItem {
pub text: String,
pub assignee: String,
pub due_date: Option<f64>,
pub priority: u32,
pub segment_ids: Vec<i32>,
}
/// Export result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportResult {
pub content: String,
pub format_name: String,
pub file_extension: String,
}
impl ExportResult {
/// Create an empty export result placeholder
pub fn empty(format: ExportFormat) -> Self {
let (format_name, file_extension) = match format {
ExportFormat::Markdown => ("markdown", "md"),
ExportFormat::Html => ("html", "html"),
};
Self {
content: String::new(),
format_name: format_name.to_string(),
file_extension: file_extension.to_string(),
}
}
}
/// Diarization job result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiarizationResult {
pub job_id: String,
pub status: JobStatus,
pub segments_updated: u32,
pub speaker_ids: Vec<String>,
pub error_message: String,
}
/// Speaker rename result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameSpeakerResult {
pub segments_updated: u32,
pub success: bool,
}
/// Audio device info
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioDeviceInfo {
pub id: u32,
pub name: String,
pub channels: u32,
pub sample_rate: u32,
pub is_default: bool,
}
/// Audio chunk for streaming
#[derive(Debug, Clone)]
pub struct AudioChunk {
pub meeting_id: String,
pub audio_data: Vec<u8>,
pub timestamp: f64,
pub sample_rate: u32,
pub channels: u32,
}
/// Timestamped audio for playback buffer
#[derive(Debug, Clone)]
pub struct TimestampedAudio {
pub frames: Vec<f32>,
pub timestamp: f64,
pub duration: f64,
}

View File

@@ -1,158 +0,0 @@
//! Common utility functions
//!
//! Centralized helpers used across the application to avoid code duplication.
use std::time::SystemTime;
/// Get current Unix timestamp as f64 seconds
///
/// Returns seconds since Unix epoch with sub-second precision.
/// Falls back to 0.0 if system time is before epoch (shouldn't happen).
#[inline]
pub fn now_timestamp() -> f64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
/// Generate a new UUID v4 string
///
/// Returns a lowercase hyphenated UUID string.
#[inline]
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// Format duration in seconds to human-readable string
///
/// Returns format like "1:23" for short durations or "1:23:45" for longer ones.
/// Handles non-finite or negative values by returning "0:00".
pub fn format_duration(seconds: f64) -> String {
// Guard against non-finite or negative values
if !seconds.is_finite() || seconds < 0.0 {
return "0:00".to_string();
}
let total_secs = seconds.floor() as u64;
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let secs = total_secs % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, secs)
} else {
format!("{}:{:02}", minutes, secs)
}
}
/// Sanitize a filename by replacing invalid characters
///
/// Replaces characters not allowed in filenames with underscores.
pub fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
_ => c,
})
.collect()
}
/// Clamp a value between min and max
#[inline]
pub fn clamp<T: PartialOrd>(value: T, min: T, max: T) -> T {
if value < min {
min
} else if value > max {
max
} else {
value
}
}
/// Normalize audio level from dB to 0.0-1.0 range
///
/// Returns 0.0 if min_db == max_db to avoid division by zero.
#[inline]
pub fn normalize_db_level(db: f32, min_db: f32, max_db: f32) -> f32 {
let range = max_db - min_db;
if range.abs() < f32::EPSILON {
return 0.0;
}
clamp((db - min_db) / range, 0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn now_timestamp_is_positive() {
let ts = now_timestamp();
assert!(ts > 0.0);
// Should be after year 2020 (timestamp > 1577836800)
assert!(ts > 1577836800.0);
}
#[test]
fn new_id_is_valid_uuid() {
let id = new_id();
// UUID v4 format: 8-4-4-4-12 characters
assert_eq!(id.len(), 36);
assert!(uuid::Uuid::parse_str(&id).is_ok());
}
#[test]
fn new_id_is_unique() {
let id1 = new_id();
let id2 = new_id();
assert_ne!(id1, id2);
}
#[test]
fn format_duration_short() {
assert_eq!(format_duration(0.0), "0:00");
assert_eq!(format_duration(5.0), "0:05");
assert_eq!(format_duration(65.0), "1:05");
assert_eq!(format_duration(599.0), "9:59");
}
#[test]
fn format_duration_long() {
assert_eq!(format_duration(3600.0), "1:00:00");
assert_eq!(format_duration(3661.0), "1:01:01");
assert_eq!(format_duration(7325.0), "2:02:05");
}
#[test]
fn sanitize_filename_removes_invalid_chars() {
assert_eq!(sanitize_filename("test/file"), "test_file");
assert_eq!(sanitize_filename("a:b*c?d"), "a_b_c_d");
assert_eq!(sanitize_filename("normal name"), "normal name");
assert_eq!(sanitize_filename("file<>name"), "file__name");
}
#[test]
fn clamp_works_correctly() {
assert_eq!(clamp(5, 0, 10), 5);
assert_eq!(clamp(-5, 0, 10), 0);
assert_eq!(clamp(15, 0, 10), 10);
}
#[test]
fn normalize_db_level_works() {
assert_eq!(normalize_db_level(-60.0, -60.0, 0.0), 0.0);
assert_eq!(normalize_db_level(0.0, -60.0, 0.0), 1.0);
assert_eq!(normalize_db_level(-30.0, -60.0, 0.0), 0.5);
// Clamped values
assert_eq!(normalize_db_level(-100.0, -60.0, 0.0), 0.0);
assert_eq!(normalize_db_level(10.0, -60.0, 0.0), 1.0);
}
#[test]
fn normalize_db_level_handles_zero_range() {
// Should return 0.0 when min == max to avoid division by zero
assert_eq!(normalize_db_level(0.0, 0.0, 0.0), 0.0);
assert_eq!(normalize_db_level(-60.0, -60.0, -60.0), 0.0);
}
}

View File

@@ -1,109 +0,0 @@
//! NoteFlow Tauri Client Library
//!
//! This library implements the Tauri backend for the NoteFlow meeting notetaker.
//! It provides gRPC connectivity, audio capture/playback, and system integration.
use std::sync::Arc;
use tauri::Manager;
pub mod audio;
pub mod cache;
pub mod commands;
pub mod config;
pub mod constants;
pub mod crypto;
pub mod events;
pub mod grpc;
pub mod helpers;
pub mod state;
pub mod triggers;
use commands::preferences::load_preferences_from_disk;
use crypto::CryptoBox;
use state::AppState;
/// Initialize and run the Tauri application
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tracing_subscriber::fmt::init();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_window_state::Builder::default().build())
.setup(|app| {
// Initialize crypto
let crypto = Arc::new(CryptoBox::new().map_err(|e| {
tauri::Error::Setup(
Box::<dyn std::error::Error>::from(format!(
"Failed to initialize crypto: {}",
e
))
.into(),
)
})?);
// Load preferences from disk
let prefs = load_preferences_from_disk();
// Create state with loaded preferences
let state = Arc::new(AppState::new_with_preferences(crypto, prefs));
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
// Connection
commands::connection::connect,
commands::connection::disconnect,
commands::connection::get_server_info,
commands::connection::get_status,
// Recording
commands::recording::start_recording,
commands::recording::stop_recording,
// Meetings
commands::meeting::list_meetings,
commands::meeting::get_meeting,
commands::meeting::delete_meeting,
commands::meeting::select_meeting,
// Annotations
commands::annotation::add_annotation,
commands::annotation::get_annotation,
commands::annotation::list_annotations,
commands::annotation::update_annotation,
commands::annotation::delete_annotation,
// Summary
commands::summary::generate_summary,
// Export
commands::export::export_transcript,
commands::export::save_export_file,
// Diarization
commands::diarization::refine_speaker_diarization,
commands::diarization::get_diarization_job_status,
commands::diarization::rename_speaker,
// Playback
commands::playback::play,
commands::playback::pause,
commands::playback::stop,
commands::playback::seek,
commands::playback::get_playback_state,
// Triggers
commands::triggers::set_trigger_enabled,
commands::triggers::snooze_triggers,
commands::triggers::reset_snooze,
commands::triggers::get_trigger_status,
commands::triggers::dismiss_trigger,
commands::triggers::accept_trigger,
// Audio devices
commands::audio::list_audio_devices,
commands::audio::select_audio_device,
commands::audio::get_current_device,
// Preferences
commands::preferences::get_preferences,
commands::preferences::save_preferences,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
noteflow_tauri_lib::run();
}

View File

@@ -1,406 +0,0 @@
//! Central application state
//!
//! This module defines `AppState` which mirrors the Python `AppState` dataclass
//! and provides thread-safe access to all application state.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use parking_lot::RwLock;
use serde::Serialize;
use tokio::sync::Mutex;
use crate::audio::PlaybackHandle;
use crate::constants::audio::DEFAULT_SAMPLE_RATE;
use crate::crypto::CryptoBox;
use crate::grpc::client::GrpcClient;
use crate::grpc::types::*;
use crate::triggers::TriggerService;
/// Playback state machine
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PlaybackState {
#[default]
Stopped,
Playing,
Paused,
}
/// Trigger source
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TriggerSource {
AudioActivity,
ForegroundApp,
Calendar,
}
/// Trigger action
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TriggerAction {
Ignore,
Notify,
AutoStart,
}
/// Individual trigger signal
#[derive(Debug, Clone, Serialize)]
pub struct TriggerSignal {
pub source: TriggerSource,
pub weight: f32,
pub app_name: Option<String>,
pub timestamp: f64,
}
/// Trigger decision result
#[derive(Debug, Clone, Serialize)]
pub struct TriggerDecision {
pub action: TriggerAction,
pub confidence: f32,
pub signals: Vec<TriggerSignal>,
pub timestamp: f64,
pub detected_app: Option<String>,
}
/// Central application state
///
/// All state is managed here for component access.
/// Wrapped in `tauri::State<Arc<AppState>>` for command handlers.
///
/// Note: Connection state (connected, server_address) is managed by GrpcClient
/// as the single source of truth. AppState only caches server_info for convenience.
pub struct AppState {
// =========================================================================
// Connection State
// =========================================================================
/// gRPC client (shared across commands) - source of truth for connection state
pub grpc_client: GrpcClient,
/// Server information cache (after connect)
pub server_info: RwLock<Option<ServerInfo>>,
// =========================================================================
// Recording State
// =========================================================================
/// Recording active flag
pub recording: RwLock<bool>,
/// Current meeting being recorded
pub current_meeting: RwLock<Option<MeetingInfo>>,
/// Recording start timestamp (Unix epoch)
pub recording_start_time: RwLock<Option<f64>>,
/// Elapsed recording seconds (for timer display)
pub elapsed_seconds: RwLock<u32>,
// =========================================================================
// Audio Capture State
// =========================================================================
/// Current audio level in dB (-60 to 0)
pub current_db_level: RwLock<f32>,
/// Audio level normalized (0.0 to 1.0)
pub current_level_normalized: RwLock<f32>,
// =========================================================================
// Transcript State
// =========================================================================
/// Final transcript segments (ordered by segment_id)
pub transcript_segments: RwLock<Vec<Segment>>,
/// Current partial text (live, not yet final)
pub current_partial_text: RwLock<String>,
// =========================================================================
// Playback State
// =========================================================================
/// Audio playback handle (channel-based, thread-safe)
pub audio_playback: RwLock<Option<PlaybackHandle>>,
/// Playback state machine
pub playback_state: RwLock<PlaybackState>,
/// Current playback position in seconds
pub playback_position: RwLock<f64>,
/// Total playback duration in seconds
pub playback_duration: RwLock<f64>,
/// Sample rate for current playback buffer
pub playback_sample_rate: RwLock<u32>,
/// Accumulated samples played (for resume tracking)
pub playback_samples_played: RwLock<u64>,
/// Session audio buffer (for playback after recording)
pub session_audio_buffer: RwLock<Vec<TimestampedAudio>>,
// =========================================================================
// Transcript Sync State
// =========================================================================
/// Currently highlighted segment index (for playback sync)
pub highlighted_segment_index: RwLock<Option<usize>>,
// =========================================================================
// Annotations State
// =========================================================================
/// Annotations for current meeting
pub annotations: RwLock<Vec<AnnotationInfo>>,
// =========================================================================
// Meeting Library State
// =========================================================================
/// Cached list of meetings
pub meetings: RwLock<Vec<MeetingInfo>>,
/// Currently selected meeting (for review mode)
pub selected_meeting: RwLock<Option<MeetingInfo>>,
// =========================================================================
// Trigger State
// =========================================================================
/// Trigger service instance
pub trigger_service: Mutex<Option<TriggerService>>,
/// Trigger detection enabled
pub trigger_enabled: RwLock<bool>,
/// Trigger prompt pending (dialog shown)
pub trigger_pending: RwLock<bool>,
/// Last trigger decision
pub trigger_decision: RwLock<Option<TriggerDecision>>,
// =========================================================================
// Summary State
// =========================================================================
/// Current meeting summary
pub current_summary: RwLock<Option<SummaryInfo>>,
/// Summary generation in progress
pub summary_loading: RwLock<bool>,
/// Summary generation error
pub summary_error: RwLock<Option<String>>,
// =========================================================================
// Encryption State
// =========================================================================
/// Crypto box for audio encryption
pub crypto: Arc<CryptoBox>,
/// Meetings directory path
pub meetings_dir: RwLock<PathBuf>,
// =========================================================================
// Configuration State
// =========================================================================
/// User preferences (persisted)
pub preferences: RwLock<HashMap<String, serde_json::Value>>,
}
impl AppState {
/// Create new application state with default preferences
pub fn new(crypto: Arc<CryptoBox>) -> Self {
Self::new_with_preferences(crypto, HashMap::new())
}
/// Create new application state with loaded preferences
pub fn new_with_preferences(
crypto: Arc<CryptoBox>,
prefs: HashMap<String, serde_json::Value>,
) -> Self {
// Get meetings directory from preferences or use default
let meetings_dir = prefs
.get("data_directory")
.and_then(|v| v.as_str())
.map(|s| PathBuf::from(s).join("meetings"))
.unwrap_or_else(|| {
directories::ProjectDirs::from("com", "noteflow", "NoteFlow")
.map(|d| d.data_dir().join("meetings"))
.unwrap_or_else(|| {
directories::BaseDirs::new()
.map(|d| d.home_dir().join(".noteflow").join("meetings"))
.unwrap_or_else(|| PathBuf::from("/tmp/noteflow/meetings"))
})
});
Self {
// Connection (GrpcClient is source of truth for connected/address)
grpc_client: GrpcClient::new(),
server_info: RwLock::new(None),
// Recording
recording: RwLock::new(false),
current_meeting: RwLock::new(None),
recording_start_time: RwLock::new(None),
elapsed_seconds: RwLock::new(0),
// Audio capture
current_db_level: RwLock::new(-60.0),
current_level_normalized: RwLock::new(0.0),
// Transcript
transcript_segments: RwLock::new(Vec::new()),
current_partial_text: RwLock::new(String::new()),
// Playback
audio_playback: RwLock::new(None),
playback_state: RwLock::new(PlaybackState::Stopped),
playback_position: RwLock::new(0.0),
playback_duration: RwLock::new(0.0),
playback_sample_rate: RwLock::new(DEFAULT_SAMPLE_RATE),
playback_samples_played: RwLock::new(0),
session_audio_buffer: RwLock::new(Vec::new()),
// Sync
highlighted_segment_index: RwLock::new(None),
// Annotations
annotations: RwLock::new(Vec::new()),
// Meeting library
meetings: RwLock::new(Vec::new()),
selected_meeting: RwLock::new(None),
// Triggers
trigger_service: Mutex::new(None),
trigger_enabled: RwLock::new(true),
trigger_pending: RwLock::new(false),
trigger_decision: RwLock::new(None),
// Summary
current_summary: RwLock::new(None),
summary_loading: RwLock::new(false),
summary_error: RwLock::new(None),
// Encryption
crypto,
meetings_dir: RwLock::new(meetings_dir),
// Config - use loaded preferences
preferences: RwLock::new(prefs),
}
}
/// Clear all transcript data
pub fn clear_transcript(&self) {
self.transcript_segments.write().clear();
*self.current_partial_text.write() = String::new();
}
/// Reset all recording-related state
pub fn reset_recording_state(&self) {
*self.recording.write() = false;
*self.current_meeting.write() = None;
*self.recording_start_time.write() = None;
*self.elapsed_seconds.write() = 0;
}
/// Clear session audio buffer and reset playback
pub fn clear_session_audio(&self) {
self.session_audio_buffer.write().clear();
*self.playback_position.write() = 0.0;
*self.playback_duration.write() = 0.0;
*self.playback_sample_rate.write() = DEFAULT_SAMPLE_RATE;
*self.playback_samples_played.write() = 0;
*self.playback_state.write() = PlaybackState::Stopped;
}
/// Find segment index containing the given playback position
///
/// Uses binary search for O(log n) performance.
pub fn find_segment_at_position(&self, position: f64) -> Option<usize> {
let segments = self.transcript_segments.read();
if segments.is_empty() {
return None;
}
// Binary search for segment containing position
let mut left = 0;
let mut right = segments.len() - 1;
while left <= right {
// Use overflow-safe midpoint calculation
let mid = left + (right - left) / 2;
let segment = &segments[mid];
if segment.start_time <= position && position <= segment.end_time {
return Some(mid);
} else if position < segment.start_time {
if mid == 0 {
break;
}
right = mid - 1;
} else {
left = mid + 1;
}
}
None
}
/// Get snapshot of current state for frontend
pub fn get_status(&self) -> AppStatus {
AppStatus {
// Connection state from gRPC client (single source of truth)
connected: self.grpc_client.is_connected(),
server_address: self.grpc_client.server_address(),
// Local state
recording: *self.recording.read(),
current_meeting_id: self.current_meeting.read().as_ref().map(|m| m.id.clone()),
elapsed_seconds: *self.elapsed_seconds.read(),
playback_state: *self.playback_state.read(),
playback_position: *self.playback_position.read(),
playback_duration: *self.playback_duration.read(),
segment_count: self.transcript_segments.read().len(),
annotation_count: self.annotations.read().len(),
trigger_enabled: *self.trigger_enabled.read(),
trigger_pending: *self.trigger_pending.read(),
summary_loading: *self.summary_loading.read(),
has_summary: self.current_summary.read().is_some(),
}
}
}
/// Status snapshot for frontend
#[derive(Debug, Clone, Serialize)]
pub struct AppStatus {
pub connected: bool,
pub recording: bool,
pub server_address: String,
pub current_meeting_id: Option<String>,
pub elapsed_seconds: u32,
pub playback_state: PlaybackState,
pub playback_position: f64,
pub playback_duration: f64,
pub segment_count: usize,
pub annotation_count: usize,
pub trigger_enabled: bool,
pub trigger_pending: bool,
pub summary_loading: bool,
pub has_summary: bool,
}
/// Trigger status for frontend
#[derive(Debug, Clone, Serialize)]
pub struct TriggerStatus {
pub enabled: bool,
pub pending: bool,
pub decision: Option<TriggerDecision>,
pub snoozed: bool,
pub snooze_remaining: f64,
}
/// Playback info for frontend
#[derive(Debug, Clone, Serialize)]
pub struct PlaybackInfo {
pub state: PlaybackState,
pub position: f64,
pub duration: f64,
pub highlighted_segment: Option<usize>,
}

View File

@@ -1,11 +0,0 @@
//! Application state management
//!
//! This module defines the central `AppState` struct that holds all
//! application state and is shared across Tauri commands.
mod app_state;
pub use app_state::*;
#[cfg(test)]
mod state_tests;

View File

@@ -1,133 +0,0 @@
//! Unit tests for application state management
//!
//! These tests verify state initialization, updates, and consistency.
#[cfg(test)]
mod tests {
use crate::state::{
PlaybackState, TriggerAction, TriggerDecision, TriggerSignal, TriggerSource,
};
#[test]
fn playback_state_equality() {
assert_eq!(PlaybackState::Stopped, PlaybackState::Stopped);
assert_eq!(PlaybackState::Playing, PlaybackState::Playing);
assert_eq!(PlaybackState::Paused, PlaybackState::Paused);
assert_ne!(PlaybackState::Stopped, PlaybackState::Playing);
}
#[test]
fn playback_state_copy() {
let state = PlaybackState::Playing;
let copied = state;
assert_eq!(state, copied);
}
#[test]
fn trigger_source_serialization() {
assert_eq!(
serde_json::to_string(&TriggerSource::AudioActivity).unwrap(),
"\"audio_activity\""
);
assert_eq!(
serde_json::to_string(&TriggerSource::ForegroundApp).unwrap(),
"\"foreground_app\""
);
assert_eq!(
serde_json::to_string(&TriggerSource::Calendar).unwrap(),
"\"calendar\""
);
}
#[test]
fn trigger_action_serialization() {
assert_eq!(
serde_json::to_string(&TriggerAction::Ignore).unwrap(),
"\"ignore\""
);
assert_eq!(
serde_json::to_string(&TriggerAction::Notify).unwrap(),
"\"notify\""
);
assert_eq!(
serde_json::to_string(&TriggerAction::AutoStart).unwrap(),
"\"auto_start\""
);
}
#[test]
fn trigger_signal_construction() {
let signal = TriggerSignal {
source: TriggerSource::ForegroundApp,
weight: 0.8,
app_name: Some("Zoom".to_string()),
timestamp: 1234567890.0,
};
assert_eq!(signal.source, TriggerSource::ForegroundApp);
assert!((signal.weight - 0.8).abs() < f32::EPSILON);
assert_eq!(signal.app_name, Some("Zoom".to_string()));
}
#[test]
fn trigger_signal_without_app_name() {
let signal = TriggerSignal {
source: TriggerSource::AudioActivity,
weight: 0.6,
app_name: None,
timestamp: 1234567890.0,
};
assert_eq!(signal.app_name, None);
}
#[test]
fn trigger_decision_construction() {
let decision = TriggerDecision {
action: TriggerAction::Notify,
confidence: 0.85,
signals: vec![TriggerSignal {
source: TriggerSource::ForegroundApp,
weight: 0.8,
app_name: Some("Zoom".to_string()),
timestamp: 1234567890.0,
}],
timestamp: 1234567890.0,
detected_app: Some("Zoom".to_string()),
};
assert_eq!(decision.action, TriggerAction::Notify);
assert!((decision.confidence - 0.85).abs() < f32::EPSILON);
assert_eq!(decision.signals.len(), 1);
assert_eq!(decision.detected_app, Some("Zoom".to_string()));
}
#[test]
fn trigger_decision_auto_start() {
let decision = TriggerDecision {
action: TriggerAction::AutoStart,
confidence: 0.95,
signals: vec![],
timestamp: 1234567890.0,
detected_app: None,
};
assert_eq!(decision.action, TriggerAction::AutoStart);
assert!(decision.confidence > 0.9);
}
#[test]
fn trigger_decision_serialization() {
let decision = TriggerDecision {
action: TriggerAction::Notify,
confidence: 0.75,
signals: vec![],
timestamp: 0.0,
detected_app: None,
};
let json = serde_json::to_string(&decision).unwrap();
assert!(json.contains("\"action\":\"notify\""));
assert!(json.contains("\"confidence\":0.75"));
}
}

View File

@@ -1,114 +0,0 @@
//! Trigger detection for auto-starting recordings
//!
//! This module provides detection of meeting-related events:
//! - Foreground app detection (Zoom, Teams, etc.)
//! - Audio activity detection
//! - Calendar event proximity
use std::time::Instant;
/// Trigger service for detecting meeting starts
pub struct TriggerService {
snooze_until: Option<Instant>,
last_check: Option<Instant>,
}
impl Default for TriggerService {
fn default() -> Self {
Self::new()
}
}
impl TriggerService {
/// Create a new trigger service
pub fn new() -> Self {
Self {
snooze_until: None,
last_check: None,
}
}
/// Snooze triggers for a duration
pub fn snooze(&mut self, seconds: Option<f64>) {
const DEFAULT_SNOOZE: f64 = 300.0; // 5 minutes
const MAX_SNOOZE: f64 = 24.0 * 60.0 * 60.0; // 24 hours
let mut duration = seconds.unwrap_or(DEFAULT_SNOOZE);
// Validate duration to prevent panics from invalid f64 values
if !duration.is_finite() || duration <= 0.0 {
duration = DEFAULT_SNOOZE;
} else if duration > MAX_SNOOZE {
duration = MAX_SNOOZE;
}
self.snooze_until = Some(Instant::now() + std::time::Duration::from_secs_f64(duration));
}
/// Reset snooze
pub fn reset_snooze(&mut self) {
self.snooze_until = None;
}
/// Check if currently snoozed
pub fn is_snoozed(&self) -> bool {
self.snooze_until
.map(|t| Instant::now() < t)
.unwrap_or(false)
}
/// Get remaining snooze seconds
pub fn snooze_remaining_seconds(&self) -> f64 {
self.snooze_until
.map(|t| {
let now = Instant::now();
if now < t {
(t - now).as_secs_f64()
} else {
0.0
}
})
.unwrap_or(0.0)
}
/// Check for triggers
pub fn check(&mut self) -> Option<crate::state::TriggerDecision> {
if self.is_snoozed() {
return None;
}
self.last_check = Some(Instant::now());
// TODO: Implement actual trigger detection
// - Check foreground app (active-win-pos-rs)
// - Check audio activity
// - Check calendar events
None
}
}
/// Get the currently focused application name
#[allow(dead_code)]
pub fn get_foreground_app() -> Option<String> {
// TODO: Use active-win-pos-rs to get foreground app
None
}
/// Check if the given app name is a known meeting app
#[allow(dead_code)]
pub fn is_meeting_app(app_name: &str) -> bool {
let meeting_apps = [
"zoom",
"teams",
"slack",
"meet",
"webex",
"skype",
"discord",
"gotomeeting",
];
let app_lower = app_name.to_lowercase();
meeting_apps.iter().any(|&app| app_lower.contains(app))
}

View File

@@ -1,68 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "NoteFlow",
"version": "0.1.0",
"identifier": "com.noteflow.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "NoteFlow",
"width": 1024,
"height": 768,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* https://localhost:*"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"linux": {
"appimage": {
"bundleMediaFramework": true
},
"deb": {
"depends": ["libportaudio2", "libasound2"]
}
},
"macOS": {
"minimumSystemVersion": "10.15"
},
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
}
}
},
"plugins": {
"dialog": {},
"fs": {
"scope": ["$APPDATA/**", "$HOME/.noteflow/**"]
},
"notification": {},
"shell": {
"open": true
},
"global-shortcut": {},
"window-state": {}
}
}

View File

@@ -1,383 +0,0 @@
//! Unit tests for audio subsystem edge cases.
//!
//! Run with: cargo test --test audio_unit_tests
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
// =============================================================================
// Device ID Stability Tests
// =============================================================================
/// Replicate the stable_device_id function for testing
fn stable_device_id(name: &str) -> u32 {
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
(hasher.finish() % u32::MAX as u64) as u32
}
#[test]
fn test_stable_device_id_same_for_same_name() {
let id1 = stable_device_id("USB Audio Device");
let id2 = stable_device_id("USB Audio Device");
assert_eq!(id1, id2, "Same name should produce same ID");
}
#[test]
fn test_stable_device_id_different_for_different_names() {
let id1 = stable_device_id("USB Audio Device");
let id2 = stable_device_id("Built-in Microphone");
assert_ne!(id1, id2, "Different names should produce different IDs");
}
#[test]
fn test_stable_device_id_handles_special_characters() {
// Device names can contain special chars
let id1 = stable_device_id("USB (2.0) Device: Main");
let id2 = stable_device_id("USB (2.0) Device: Main");
assert_eq!(id1, id2);
}
#[test]
fn test_stable_device_id_handles_unicode() {
let id1 = stable_device_id("マイク デバイス");
let id2 = stable_device_id("マイク デバイス");
assert_eq!(id1, id2);
}
#[test]
fn test_stable_device_id_handles_empty_string() {
let id = stable_device_id("");
// Should not panic, should return some consistent value
assert_eq!(id, stable_device_id(""));
}
#[test]
fn test_stable_device_id_consistent_across_calls() {
let name = "Microphone Array (Realtek)";
let ids: Vec<u32> = (0..100).map(|_| stable_device_id(name)).collect();
// All IDs should be identical
assert!(ids.iter().all(|&id| id == ids[0]));
}
// =============================================================================
// Audio Loader Edge Cases
// =============================================================================
#[test]
fn test_samples_to_chunks_basic() {
// Replicate the function logic
fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> {
let chunk_duration = 0.1;
let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1);
let mut chunks = Vec::new();
let mut offset = 0;
while offset < samples.len() {
let end = (offset + chunk_samples).min(samples.len());
let frame_count = end - offset;
let duration = frame_count as f64 / sample_rate as f64;
let timestamp = offset as f64 / sample_rate as f64;
chunks.push((timestamp, duration, frame_count));
offset = end;
}
chunks
}
let samples: Vec<f32> = vec![0.0; 48000]; // 1 second at 48kHz
let chunks = samples_to_chunks(&samples, 48000);
// Should have ~10 chunks of 0.1s each
assert_eq!(chunks.len(), 10);
assert!((chunks[0].1 - 0.1).abs() < 0.001);
}
#[test]
fn test_samples_to_chunks_odd_sample_count() {
fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> {
let chunk_duration = 0.1;
let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1);
let mut chunks = Vec::new();
let mut offset = 0;
while offset < samples.len() {
let end = (offset + chunk_samples).min(samples.len());
let frame_count = end - offset;
let duration = frame_count as f64 / sample_rate as f64;
let timestamp = offset as f64 / sample_rate as f64;
chunks.push((timestamp, duration, frame_count));
offset = end;
}
chunks
}
// Odd number of samples that doesn't divide evenly
let samples: Vec<f32> = vec![0.0; 4801];
let chunks = samples_to_chunks(&samples, 48000);
// Last chunk should be smaller
let last = chunks.last().unwrap();
assert!(last.2 < 4800); // Less than a full chunk
}
#[test]
fn test_samples_to_chunks_low_sample_rate() {
fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> {
let chunk_duration = 0.1;
let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1);
let mut chunks = Vec::new();
let mut offset = 0;
while offset < samples.len() {
let end = (offset + chunk_samples).min(samples.len());
let frame_count = end - offset;
let duration = frame_count as f64 / sample_rate as f64;
let timestamp = offset as f64 / sample_rate as f64;
chunks.push((timestamp, duration, frame_count));
offset = end;
}
chunks
}
// Very low sample rate
let samples: Vec<f32> = vec![0.0; 100];
let chunks = samples_to_chunks(&samples, 100); // 100 Hz
// At 100Hz, 0.1s = 10 samples per chunk, so 100 samples = 10 chunks
assert_eq!(chunks.len(), 10);
}
#[test]
fn test_samples_to_chunks_minimum_chunk_size() {
fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> {
let chunk_duration = 0.1;
let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1);
let mut chunks = Vec::new();
let mut offset = 0;
while offset < samples.len() {
let end = (offset + chunk_samples).min(samples.len());
let frame_count = end - offset;
let duration = frame_count as f64 / sample_rate as f64;
let timestamp = offset as f64 / sample_rate as f64;
chunks.push((timestamp, duration, frame_count));
offset = end;
}
chunks
}
// Sample rate so low that 0.1s < 1 sample - should clamp to 1
let samples: Vec<f32> = vec![0.0; 5];
let chunks = samples_to_chunks(&samples, 5); // 5 Hz → 0.5 samples/chunk → clamped to 1
// With .max(1), each chunk has at least 1 sample
assert_eq!(chunks.len(), 5);
for chunk in &chunks {
assert!(chunk.2 >= 1, "Chunk should have at least 1 sample");
}
}
#[test]
fn test_samples_to_chunks_empty_input() {
fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> {
let chunk_duration = 0.1;
let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1);
let mut chunks = Vec::new();
let mut offset = 0;
while offset < samples.len() {
let end = (offset + chunk_samples).min(samples.len());
let frame_count = end - offset;
let duration = frame_count as f64 / sample_rate as f64;
let timestamp = offset as f64 / sample_rate as f64;
chunks.push((timestamp, duration, frame_count));
offset = end;
}
chunks
}
let samples: Vec<f32> = vec![];
let chunks = samples_to_chunks(&samples, 48000);
assert!(chunks.is_empty());
}
// =============================================================================
// Sample Rate Validation Tests
// =============================================================================
#[test]
fn test_sample_rate_zero_rejected() {
// This tests the validation logic: sample_rate == 0 should be rejected
fn validate_sample_rate(sample_rate: u32) -> Result<(), &'static str> {
if sample_rate == 0 {
return Err("Invalid sample rate: 0");
}
Ok(())
}
assert!(validate_sample_rate(0).is_err());
assert!(validate_sample_rate(16000).is_ok());
assert!(validate_sample_rate(48000).is_ok());
}
#[test]
fn test_duration_calculation_various_rates() {
fn calculate_duration(num_samples: usize, sample_rate: u32) -> f64 {
if sample_rate == 0 {
return 0.0;
}
num_samples as f64 / sample_rate as f64
}
// 16kHz: 16000 samples = 1 second
assert!((calculate_duration(16000, 16000) - 1.0).abs() < 0.001);
// 48kHz: 48000 samples = 1 second
assert!((calculate_duration(48000, 48000) - 1.0).abs() < 0.001);
// 44.1kHz: 44100 samples = 1 second
assert!((calculate_duration(44100, 44100) - 1.0).abs() < 0.001);
// Edge case: 0 sample rate
assert_eq!(calculate_duration(1000, 0), 0.0);
}
// =============================================================================
// Playback Position Edge Cases
// =============================================================================
#[test]
fn test_seek_position_clamping() {
fn clamp_position(pos: f64, duration: f64) -> f64 {
pos.clamp(0.0, duration.max(0.0))
}
// Normal case
assert!((clamp_position(50.0, 100.0) - 50.0).abs() < 0.001);
// Beyond duration
assert!((clamp_position(150.0, 100.0) - 100.0).abs() < 0.001);
// Negative
assert!((clamp_position(-10.0, 100.0) - 0.0).abs() < 0.001);
// Zero duration
assert!((clamp_position(50.0, 0.0) - 0.0).abs() < 0.001);
// Negative duration (edge case)
assert!((clamp_position(50.0, -10.0) - 0.0).abs() < 0.001);
}
#[test]
fn test_position_tracking_accumulation() {
// Simulate position tracker accumulating samples
let sample_rate: u32 = 48000;
let samples_per_update: u64 = 4800; // 0.1 second chunks
let mut samples_played: u64 = 0;
// Simulate 10 updates
for _ in 0..10 {
samples_played += samples_per_update;
}
let position = samples_played as f64 / sample_rate as f64;
assert!((position - 1.0).abs() < 0.001, "Should be ~1 second");
}
#[test]
fn test_position_tracking_pause_resume() {
// Simulate pause/resume preserving position
let sample_rate: u32 = 48000;
let mut samples_played: u64 = 0;
// Play for 0.5 seconds
samples_played += 24000;
// Pause - samples_played is preserved
let paused_samples = samples_played;
// Resume - should continue from paused position
samples_played = paused_samples;
samples_played += 24000;
let position = samples_played as f64 / sample_rate as f64;
assert!((position - 1.0).abs() < 0.001, "Should be 1 second total");
}
// =============================================================================
// Segment Finding Edge Cases
// =============================================================================
#[derive(Debug, Clone)]
struct MockSegment {
id: i32,
start_time: f64,
end_time: f64,
}
fn find_segment_at_position(segments: &[MockSegment], position: f64) -> Option<i32> {
for seg in segments {
if position >= seg.start_time && position < seg.end_time {
return Some(seg.id);
}
}
None
}
#[test]
fn test_find_segment_in_gap() {
let segments = vec![
MockSegment { id: 1, start_time: 0.0, end_time: 5.0 },
MockSegment { id: 2, start_time: 10.0, end_time: 15.0 },
];
// Position in gap between segments
let result = find_segment_at_position(&segments, 7.5);
assert!(result.is_none(), "Should return None for gap");
}
#[test]
fn test_find_segment_at_boundary() {
let segments = vec![
MockSegment { id: 1, start_time: 0.0, end_time: 5.0 },
MockSegment { id: 2, start_time: 5.0, end_time: 10.0 },
];
// Exactly at segment boundary
let result = find_segment_at_position(&segments, 5.0);
assert_eq!(result, Some(2), "Should be in second segment");
}
#[test]
fn test_find_segment_before_all() {
let segments = vec![
MockSegment { id: 1, start_time: 5.0, end_time: 10.0 },
];
let result = find_segment_at_position(&segments, 2.0);
assert!(result.is_none(), "Should return None before first segment");
}
#[test]
fn test_find_segment_after_all() {
let segments = vec![
MockSegment { id: 1, start_time: 0.0, end_time: 5.0 },
];
let result = find_segment_at_position(&segments, 10.0);
assert!(result.is_none(), "Should return None after last segment");
}
#[test]
fn test_find_segment_empty_list() {
let segments: Vec<MockSegment> = vec![];
let result = find_segment_at_position(&segments, 5.0);
assert!(result.is_none());
}

View File

@@ -1,326 +0,0 @@
//! Integration tests for gRPC client against live server.
//!
//! These tests require the NoteFlow server running at localhost:50051.
//! Run with: cargo test --test grpc_integration -- --test-threads=1
use std::time::Duration;
use tokio::time::timeout;
// Import the library
use noteflow_tauri_lib::grpc::{GrpcClient, AnnotationType};
const SERVER_ADDR: &str = "localhost:50051";
const TEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Helper to create a connected client or skip test if server unavailable
async fn connected_client() -> Option<GrpcClient> {
let client = GrpcClient::new();
match timeout(Duration::from_secs(2), client.connect(SERVER_ADDR)).await {
Ok(Ok(_)) => Some(client),
Ok(Err(e)) => {
eprintln!("Server unavailable: {e} - skipping integration test");
None
}
Err(_) => {
eprintln!("Connection timeout - skipping integration test");
None
}
}
}
// =============================================================================
// Connection Tests
// =============================================================================
#[tokio::test]
async fn test_connect_to_server() {
let client = GrpcClient::new();
let result = timeout(TEST_TIMEOUT, client.connect(SERVER_ADDR)).await;
match result {
Ok(Ok(info)) => {
assert!(client.is_connected());
assert!(!info.version.is_empty(), "Server should return version");
}
Ok(Err(e)) => {
eprintln!("Server not available: {e}");
}
Err(_) => {
panic!("Connection timed out");
}
}
}
#[tokio::test]
async fn test_disconnect_clears_state() {
let Some(client) = connected_client().await else { return };
assert!(client.is_connected());
client.disconnect().await;
assert!(!client.is_connected());
assert!(client.server_address().is_empty());
}
#[tokio::test]
async fn test_operations_fail_when_disconnected() {
let client = GrpcClient::new();
// Should fail without connection
let result = client.list_annotations("meeting-123", 0.0, 100.0).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Not connected"));
}
// =============================================================================
// Annotation CRUD Tests
// =============================================================================
#[tokio::test]
async fn test_annotation_add_and_get() {
let Some(client) = connected_client().await else { return };
// First create a meeting to attach annotation to
let meeting = client.create_meeting("Test Meeting for Annotations", None).await;
let Ok(meeting) = meeting else {
eprintln!("Could not create meeting - skipping");
return;
};
// Add annotation
let result = client.add_annotation(
&meeting.id,
AnnotationType::ActionItem,
"Follow up with team",
10.0,
15.0,
Some(vec![1, 2]),
).await;
match result {
Ok(annotation) => {
assert!(!annotation.id.is_empty());
assert_eq!(annotation.meeting_id, meeting.id);
assert_eq!(annotation.text, "Follow up with team");
assert!((annotation.start_time - 10.0).abs() < 0.001);
assert!((annotation.end_time - 15.0).abs() < 0.001);
// Get the annotation back
let fetched = client.get_annotation(&annotation.id).await;
match fetched {
Ok(a) => {
assert_eq!(a.id, annotation.id);
assert_eq!(a.text, annotation.text);
}
Err(e) => eprintln!("Get annotation failed: {e}"),
}
}
Err(e) => {
eprintln!("Add annotation failed (server may not support): {e}");
}
}
// Cleanup
let _ = client.delete_meeting(&meeting.id).await;
}
#[tokio::test]
async fn test_annotation_list_filters_by_time() {
let Some(client) = connected_client().await else { return };
// Create meeting
let meeting = client.create_meeting("List Filter Test", None).await;
let Ok(meeting) = meeting else { return };
// Add annotations at different times
let _ = client.add_annotation(&meeting.id, AnnotationType::Note, "Early note", 5.0, 10.0, None).await;
let _ = client.add_annotation(&meeting.id, AnnotationType::Note, "Middle note", 50.0, 55.0, None).await;
let _ = client.add_annotation(&meeting.id, AnnotationType::Note, "Late note", 100.0, 105.0, None).await;
// List only middle range
let result = client.list_annotations(&meeting.id, 40.0, 60.0).await;
match result {
Ok(annotations) => {
// Should only include annotations overlapping 40-60s range
for ann in &annotations {
assert!(
ann.end_time >= 40.0 && ann.start_time <= 60.0,
"Annotation outside time range: {}-{}",
ann.start_time,
ann.end_time
);
}
}
Err(e) => eprintln!("List annotations failed: {e}"),
}
// Cleanup
let _ = client.delete_meeting(&meeting.id).await;
}
#[tokio::test]
async fn test_annotation_update() {
let Some(client) = connected_client().await else { return };
let meeting = client.create_meeting("Update Test", None).await;
let Ok(meeting) = meeting else { return };
// Add annotation
let added = client.add_annotation(
&meeting.id,
AnnotationType::Note,
"Original text",
0.0,
10.0,
None,
).await;
let Ok(annotation) = added else { return };
// Update it
let updated = client.update_annotation(
&annotation.id,
Some(AnnotationType::Decision),
Some("Updated text"),
None,
None,
None,
).await;
match updated {
Ok(a) => {
assert_eq!(a.text, "Updated text");
// Type should be updated
assert_eq!(a.annotation_type, AnnotationType::Decision);
}
Err(e) => eprintln!("Update annotation failed: {e}"),
}
// Cleanup
let _ = client.delete_meeting(&meeting.id).await;
}
#[tokio::test]
async fn test_annotation_delete() {
let Some(client) = connected_client().await else { return };
let meeting = client.create_meeting("Delete Test", None).await;
let Ok(meeting) = meeting else { return };
// Add annotation
let added = client.add_annotation(
&meeting.id,
AnnotationType::Risk,
"To be deleted",
0.0,
5.0,
None,
).await;
let Ok(annotation) = added else { return };
// Delete it
let result = client.delete_annotation(&annotation.id).await;
match result {
Ok(success) => {
assert!(success);
// Verify it's gone
let fetch = client.get_annotation(&annotation.id).await;
assert!(fetch.is_err(), "Deleted annotation should not be fetchable");
}
Err(e) => eprintln!("Delete annotation failed: {e}"),
}
// Cleanup
let _ = client.delete_meeting(&meeting.id).await;
}
// =============================================================================
// Edge Case Tests
// =============================================================================
#[tokio::test]
async fn test_annotation_with_empty_text() {
let Some(client) = connected_client().await else { return };
let meeting = client.create_meeting("Empty Text Test", None).await;
let Ok(meeting) = meeting else { return };
// Empty text should be allowed or rejected gracefully
let result = client.add_annotation(
&meeting.id,
AnnotationType::Note,
"", // Empty text
0.0,
5.0,
None,
).await;
// Either succeeds with empty or returns proper error - shouldn't panic
match result {
Ok(ann) => assert!(ann.text.is_empty()),
Err(e) => assert!(!e.to_string().is_empty()),
}
let _ = client.delete_meeting(&meeting.id).await;
}
#[tokio::test]
async fn test_annotation_with_negative_times() {
let Some(client) = connected_client().await else { return };
let meeting = client.create_meeting("Negative Time Test", None).await;
let Ok(meeting) = meeting else { return };
// Negative times - should be rejected or handled gracefully
let result = client.add_annotation(
&meeting.id,
AnnotationType::Note,
"Negative time test",
-5.0, // Negative start
-2.0, // Negative end
None,
).await;
// Should either reject or clamp - shouldn't panic
match result {
Ok(_) => { /* Server accepted it - valid behavior */ }
Err(e) => assert!(!e.to_string().is_empty()),
}
let _ = client.delete_meeting(&meeting.id).await;
}
#[tokio::test]
async fn test_annotation_on_nonexistent_meeting() {
let Some(client) = connected_client().await else { return };
let result = client.add_annotation(
"nonexistent-meeting-id-12345",
AnnotationType::Note,
"Should fail",
0.0,
5.0,
None,
).await;
// Should return error, not panic
assert!(result.is_err(), "Should fail for nonexistent meeting");
}
#[tokio::test]
async fn test_get_nonexistent_annotation() {
let Some(client) = connected_client().await else { return };
let result = client.get_annotation("nonexistent-annotation-id-12345").await;
// Should return error, not panic
assert!(result.is_err(), "Should fail for nonexistent annotation");
}

View File

@@ -1,280 +0,0 @@
//! Tests for preferences persistence and security.
//!
//! Run with: cargo test --test preferences_tests
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::TempDir;
// =============================================================================
// Preferences File Persistence Tests
// =============================================================================
#[test]
fn test_preferences_round_trip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("preferences.json");
let mut prefs: HashMap<String, serde_json::Value> = HashMap::new();
prefs.insert("server_url".to_string(), serde_json::json!("localhost:50051"));
prefs.insert("data_directory".to_string(), serde_json::json!("/data/noteflow"));
prefs.insert("auto_connect".to_string(), serde_json::json!(true));
// Write
let json = serde_json::to_string_pretty(&prefs).unwrap();
std::fs::write(&path, &json).unwrap();
// Read back
let read_json = std::fs::read_to_string(&path).unwrap();
let loaded: HashMap<String, serde_json::Value> = serde_json::from_str(&read_json).unwrap();
assert_eq!(loaded.get("server_url"), prefs.get("server_url"));
assert_eq!(loaded.get("data_directory"), prefs.get("data_directory"));
assert_eq!(loaded.get("auto_connect"), prefs.get("auto_connect"));
}
#[test]
fn test_preferences_empty_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("preferences.json");
std::fs::write(&path, "{}").unwrap();
let json = std::fs::read_to_string(&path).unwrap();
let loaded: HashMap<String, serde_json::Value> = serde_json::from_str(&json).unwrap();
assert!(loaded.is_empty());
}
#[test]
fn test_preferences_missing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nonexistent.json");
// Should handle gracefully
let result = std::fs::read_to_string(&path);
assert!(result.is_err());
// Default behavior: return empty HashMap
let prefs: HashMap<String, serde_json::Value> = if path.exists() {
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap_or_default()
} else {
HashMap::new()
};
assert!(prefs.is_empty());
}
#[test]
fn test_preferences_corrupt_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("corrupt.json");
// Write invalid JSON
std::fs::write(&path, "{ invalid json ").unwrap();
let json = std::fs::read_to_string(&path).unwrap();
let result: Result<HashMap<String, serde_json::Value>, _> = serde_json::from_str(&json);
assert!(result.is_err(), "Should fail to parse corrupt JSON");
// Fallback should be empty
let prefs = result.unwrap_or_default();
assert!(prefs.is_empty());
}
#[test]
fn test_preferences_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let nested_path = dir.path().join("nested").join("deep").join("preferences.json");
// Parent doesn't exist yet
assert!(!nested_path.parent().unwrap().exists());
// Create parent and write
if let Some(parent) = nested_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&nested_path, "{}").unwrap();
assert!(nested_path.exists());
}
// =============================================================================
// API Key Masking Tests
// =============================================================================
fn mask_api_key(key: &str) -> String {
if key.len() <= 8 {
"*".repeat(key.len())
} else {
format!("{}...{}", &key[..4], &key[key.len()-4..])
}
}
#[test]
fn test_api_key_masking_long_key() {
let key = "sk-1234567890abcdef";
let masked = mask_api_key(key);
assert_eq!(masked, "sk-1...cdef");
assert!(!masked.contains("567890"));
}
#[test]
fn test_api_key_masking_short_key() {
let key = "abc123";
let masked = mask_api_key(key);
assert_eq!(masked, "******");
}
#[test]
fn test_api_key_masking_empty() {
let key = "";
let masked = mask_api_key(key);
assert_eq!(masked, "");
}
#[test]
fn test_api_key_masking_boundary() {
// Exactly 8 chars
let key = "12345678";
let masked = mask_api_key(key);
assert_eq!(masked, "********");
}
#[test]
fn test_api_key_masking_9_chars() {
// 9 chars - should show first 4 and last 4
let key = "123456789";
let masked = mask_api_key(key);
assert_eq!(masked, "1234...6789");
}
fn is_masked_key(key: &str) -> bool {
key.contains("...") || key.chars().all(|c| c == '*')
}
#[test]
fn test_detect_masked_key() {
assert!(is_masked_key("sk-1...cdef"));
assert!(is_masked_key("******"));
assert!(is_masked_key("****"));
assert!(!is_masked_key("sk-1234567890"));
assert!(!is_masked_key("real-api-key"));
}
// =============================================================================
// Preferences Update Logic Tests
// =============================================================================
#[test]
fn test_preferences_merge_update() {
let mut existing: HashMap<String, serde_json::Value> = HashMap::new();
existing.insert("server_url".to_string(), serde_json::json!("old.server:50051"));
existing.insert("keep_this".to_string(), serde_json::json!("preserved"));
// New preferences to merge
let updates: HashMap<String, serde_json::Value> = [
("server_url".to_string(), serde_json::json!("new.server:50051")),
("new_key".to_string(), serde_json::json!("added")),
].into_iter().collect();
// Merge
for (key, value) in updates {
existing.insert(key, value);
}
assert_eq!(existing.get("server_url"), Some(&serde_json::json!("new.server:50051")));
assert_eq!(existing.get("keep_this"), Some(&serde_json::json!("preserved")));
assert_eq!(existing.get("new_key"), Some(&serde_json::json!("added")));
}
#[test]
fn test_preferences_exclude_api_key_from_file() {
let mut prefs: HashMap<String, serde_json::Value> = HashMap::new();
prefs.insert("server_url".to_string(), serde_json::json!("localhost:50051"));
prefs.insert("cloud_api_key".to_string(), serde_json::json!("secret-key"));
// Before persisting, remove API key
prefs.remove("cloud_api_key");
let json = serde_json::to_string(&prefs).unwrap();
assert!(!json.contains("secret-key"));
assert!(!json.contains("cloud_api_key"));
assert!(json.contains("server_url"));
}
// =============================================================================
// Config Directory Tests
// =============================================================================
#[test]
fn test_config_directory_resolution() {
// Test the fallback logic for config directory
fn get_config_dir() -> PathBuf {
directories::ProjectDirs::from("com", "noteflow", "NoteFlow")
.map(|d| d.config_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("/tmp/noteflow"))
}
let dir = get_config_dir();
// Should be a valid path (not empty)
assert!(!dir.as_os_str().is_empty());
// Should contain "noteflow" somewhere in the path
let path_str = dir.to_string_lossy().to_lowercase();
assert!(path_str.contains("noteflow"), "Config dir should contain 'noteflow': {}", path_str);
}
// =============================================================================
// Selected Device Persistence Tests
// =============================================================================
#[test]
fn test_device_selection_persistence_format() {
// Device selection should store both ID and name for robustness
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct DeviceSelection {
id: u32,
name: String,
}
let selection = DeviceSelection {
id: 123456789,
name: "USB Microphone".to_string(),
};
let json = serde_json::to_string(&selection).unwrap();
let loaded: DeviceSelection = serde_json::from_str(&json).unwrap();
assert_eq!(loaded, selection);
}
#[test]
fn test_device_selection_lookup_by_name() {
// When device IDs change, lookup by name should work
#[derive(Debug)]
struct Device {
id: u32,
name: String,
}
let devices = vec![
Device { id: 111, name: "Built-in Mic".to_string() },
Device { id: 222, name: "USB Microphone".to_string() },
];
// Stored preference has old ID but valid name
let stored_name = "USB Microphone";
let found = devices.iter().find(|d| d.name == stored_name);
assert!(found.is_some());
assert_eq!(found.unwrap().id, 222);
}

View File

@@ -1,125 +0,0 @@
import { useEffect } from 'react';
import { ConnectionPanel } from '@/components/connection/ConnectionPanel';
import { MeetingLibrary } from '@/components/meetings/MeetingLibrary';
import { RecordingPanel } from '@/components/recording/RecordingPanel';
import { SettingsPanel } from '@/components/settings';
import { SummaryPanel } from '@/components/summary/SummaryPanel';
import { TranscriptView } from '@/components/transcript/TranscriptView';
import { TriggerDialog } from '@/components/triggers/TriggerDialog';
import { useTauriEvents } from '@/hooks/useTauriEvents';
import { api } from '@/lib/tauri';
import { useStore } from '@/store';
export function App() {
const { viewMode, setViewMode, serverAddress, setConnectionState } = useStore();
// Set up Tauri event listeners
useTauriEvents();
// Connect to server on mount
useEffect(() => {
let isMounted = true;
let attemptId = 0;
const connectToServer = async () => {
const myAttempt = ++attemptId;
try {
const info = await api.connection.connect(serverAddress);
if (isMounted && myAttempt === attemptId) {
setConnectionState(true, serverAddress, info);
}
} catch (error) {
console.error('Failed to connect:', error);
if (isMounted && myAttempt === attemptId) {
setConnectionState(false, serverAddress);
}
}
};
connectToServer();
return () => {
isMounted = false;
attemptId++;
};
}, [serverAddress, setConnectionState]);
return (
<div className="flex h-screen flex-col bg-background text-foreground">
{/* Header */}
<header className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">NoteFlow</h1>
<ConnectionPanel />
</div>
<nav className="flex gap-2">
<NavButton view="recording" currentView={viewMode} onClick={() => setViewMode('recording')}>
Recording
</NavButton>
<NavButton view="library" currentView={viewMode} onClick={() => setViewMode('library')}>
Library
</NavButton>
<NavButton view="settings" currentView={viewMode} onClick={() => setViewMode('settings')}>
Settings
</NavButton>
</nav>
</header>
{/* Main Content */}
<main className="flex-1 overflow-hidden">
{viewMode === 'recording' && <RecordingView />}
{viewMode === 'library' && <MeetingLibrary />}
{viewMode === 'settings' && <SettingsPanel />}
</main>
{/* Trigger Dialog (modal) */}
<TriggerDialog />
</div>
);
}
function NavButton({
view,
currentView,
onClick,
children,
}: {
view: string;
currentView: string;
onClick: () => void;
children: React.ReactNode;
}) {
const isActive = view === currentView;
return (
<button
onClick={onClick}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{children}
</button>
);
}
function RecordingView() {
return (
<div className="flex h-full">
{/* Left panel: Controls and transcript */}
<div className="flex w-2/3 flex-col border-r border-border">
<RecordingPanel />
<TranscriptView />
</div>
{/* Right panel: Summary */}
<div className="w-1/3">
<SummaryPanel />
</div>
</div>
);
}

View File

@@ -1,61 +0,0 @@
import { AlertCircle, AlertTriangle, CheckSquare, StickyNote, X } from 'lucide-react';
import type { AnnotationType } from '@/types/annotation';
interface AnnotationBadgeProps {
type: AnnotationType;
text: string;
onRemove?: () => void;
}
const ANNOTATION_CONFIG: Record<
AnnotationType,
{ icon: typeof CheckSquare; bgColor: string; textColor: string; label: string }
> = {
action_item: {
icon: CheckSquare,
bgColor: 'bg-green-900/30',
textColor: 'text-green-400',
label: 'Action',
},
decision: {
icon: AlertCircle,
bgColor: 'bg-purple-900/30',
textColor: 'text-purple-400',
label: 'Decision',
},
note: {
icon: StickyNote,
bgColor: 'bg-blue-900/30',
textColor: 'text-blue-400',
label: 'Note',
},
risk: {
icon: AlertTriangle,
bgColor: 'bg-red-900/30',
textColor: 'text-red-400',
label: 'Risk',
},
};
export function AnnotationBadge({ type, text, onRemove }: AnnotationBadgeProps) {
const config = ANNOTATION_CONFIG[type];
const Icon = config.icon;
return (
<div
className={`group inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${config.bgColor} ${config.textColor}`}
>
<Icon className="h-3 w-3" />
<span>{text || config.label}</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ml-0.5 hidden rounded-full p-0.5 hover:bg-black/20 group-hover:block"
>
<X className="h-2.5 w-2.5" />
</button>
)}
</div>
);
}

View File

@@ -1,26 +0,0 @@
import type { AnnotationInfo } from '@/types/annotation';
import { AnnotationBadge } from './AnnotationBadge';
interface AnnotationListProps {
annotations: AnnotationInfo[];
onRemove?: (annotationId: string) => void;
}
export function AnnotationList({ annotations, onRemove }: AnnotationListProps) {
if (annotations.length === 0) {
return null;
}
return (
<div className="mt-1 flex flex-wrap gap-1">
{annotations.map((annotation) => (
<AnnotationBadge
key={annotation.id}
type={annotation.annotation_type}
text={annotation.text}
onRemove={onRemove ? () => onRemove(annotation.id) : undefined}
/>
))}
</div>
);
}

View File

@@ -1,374 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { api } from '@/lib/tauri';
import { AnnotationToolbar } from './AnnotationToolbar';
// Mock the store
vi.mock('@/store', () => ({
useStore: vi.fn(() => ({
addAnnotation: vi.fn(),
})),
}));
// Mock the tauri API
vi.mock('@/lib/tauri', () => ({
api: {
annotations: {
add: vi.fn(),
},
},
}));
import { useStore } from '@/store';
const mockUseStore = vi.mocked(useStore);
const mockAddAnnotation = vi.fn();
const defaultProps = {
meetingId: 'meeting-123',
segmentId: 1,
startTime: 10.0,
endTime: 15.0,
};
describe('AnnotationToolbar', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseStore.mockReturnValue({
addAnnotation: mockAddAnnotation,
});
});
describe('rendering', () => {
it('should render all annotation type buttons', () => {
render(<AnnotationToolbar {...defaultProps} />);
expect(screen.getByTitle('Action')).toBeInTheDocument();
expect(screen.getByTitle('Decision')).toBeInTheDocument();
expect(screen.getByTitle('Note')).toBeInTheDocument();
expect(screen.getByTitle('Risk')).toBeInTheDocument();
});
it('should not show note input initially', () => {
render(<AnnotationToolbar {...defaultProps} />);
expect(screen.queryByPlaceholderText('Add note...')).not.toBeInTheDocument();
});
});
describe('quick annotations (action, decision, risk)', () => {
it('should add action item annotation', async () => {
const mockAnnotation = {
id: 'ann-1',
meeting_id: 'meeting-123',
annotation_type: 'action_item',
text: '',
start_time: 10.0,
end_time: 15.0,
segment_ids: [1],
created_at: Date.now() / 1000,
};
vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation);
render(<AnnotationToolbar {...defaultProps} />);
const actionButton = screen.getByTitle('Action');
fireEvent.click(actionButton);
await waitFor(() => {
expect(api.annotations.add).toHaveBeenCalledWith(
'meeting-123',
'action_item',
'',
10.0,
15.0,
[1]
);
});
await waitFor(() => {
expect(mockAddAnnotation).toHaveBeenCalledWith(mockAnnotation);
});
});
it('should add decision annotation', async () => {
const mockAnnotation = {
id: 'ann-2',
meeting_id: 'meeting-123',
annotation_type: 'decision',
text: '',
start_time: 10.0,
end_time: 15.0,
segment_ids: [1],
created_at: Date.now() / 1000,
};
vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation);
render(<AnnotationToolbar {...defaultProps} />);
const decisionButton = screen.getByTitle('Decision');
fireEvent.click(decisionButton);
await waitFor(() => {
expect(api.annotations.add).toHaveBeenCalledWith(
'meeting-123',
'decision',
'',
10.0,
15.0,
[1]
);
});
});
it('should add risk annotation', async () => {
const mockAnnotation = {
id: 'ann-3',
meeting_id: 'meeting-123',
annotation_type: 'risk',
text: '',
start_time: 10.0,
end_time: 15.0,
segment_ids: [1],
created_at: Date.now() / 1000,
};
vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation);
render(<AnnotationToolbar {...defaultProps} />);
const riskButton = screen.getByTitle('Risk');
fireEvent.click(riskButton);
await waitFor(() => {
expect(api.annotations.add).toHaveBeenCalledWith(
'meeting-123',
'risk',
'',
10.0,
15.0,
[1]
);
});
});
});
describe('note annotation with text input', () => {
it('should show text input when clicking Note button', async () => {
render(<AnnotationToolbar {...defaultProps} />);
const noteButton = screen.getByTitle('Note');
fireEvent.click(noteButton);
expect(screen.getByPlaceholderText('Add note...')).toBeInTheDocument();
});
it('should submit note on Enter key', async () => {
const user = userEvent.setup();
const mockAnnotation = {
id: 'ann-4',
meeting_id: 'meeting-123',
annotation_type: 'note',
text: 'Important note',
start_time: 10.0,
end_time: 15.0,
segment_ids: [1],
created_at: Date.now() / 1000,
};
vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation);
render(<AnnotationToolbar {...defaultProps} />);
// Click Note to show input
const noteButton = screen.getByTitle('Note');
fireEvent.click(noteButton);
// Type and submit
const input = screen.getByPlaceholderText('Add note...');
await user.type(input, 'Important note{enter}');
await waitFor(() => {
expect(api.annotations.add).toHaveBeenCalledWith(
'meeting-123',
'note',
'Important note',
10.0,
15.0,
[1]
);
});
});
it('should close input on Escape key', async () => {
const user = userEvent.setup();
render(<AnnotationToolbar {...defaultProps} />);
// Click Note to show input
const noteButton = screen.getByTitle('Note');
fireEvent.click(noteButton);
const input = screen.getByPlaceholderText('Add note...');
await user.type(input, 'Some text{escape}');
expect(screen.queryByPlaceholderText('Add note...')).not.toBeInTheDocument();
});
it('should not submit empty note', async () => {
const user = userEvent.setup();
render(<AnnotationToolbar {...defaultProps} />);
// Click Note to show input
const noteButton = screen.getByTitle('Note');
fireEvent.click(noteButton);
// Try to submit empty
const input = screen.getByPlaceholderText('Add note...');
await user.type(input, '{enter}');
expect(api.annotations.add).not.toHaveBeenCalled();
});
it('should trim whitespace from note text', async () => {
const user = userEvent.setup();
const mockAnnotation = {
id: 'ann-5',
meeting_id: 'meeting-123',
annotation_type: 'note',
text: 'Trimmed note',
start_time: 10.0,
end_time: 15.0,
segment_ids: [1],
created_at: Date.now() / 1000,
};
vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation);
render(<AnnotationToolbar {...defaultProps} />);
const noteButton = screen.getByTitle('Note');
fireEvent.click(noteButton);
const input = screen.getByPlaceholderText('Add note...');
await user.type(input, ' Trimmed note {enter}');
await waitFor(() => {
expect(api.annotations.add).toHaveBeenCalledWith(
'meeting-123',
'note',
'Trimmed note',
10.0,
15.0,
[1]
);
});
});
it('should close input after successful submission', async () => {
const user = userEvent.setup();
const mockAnnotation = {
id: 'ann-6',
meeting_id: 'meeting-123',
annotation_type: 'note',
text: 'Test note',
start_time: 10.0,
end_time: 15.0,
segment_ids: [1],
created_at: Date.now() / 1000,
};
vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation);
render(<AnnotationToolbar {...defaultProps} />);
const noteButton = screen.getByTitle('Note');
fireEvent.click(noteButton);
const input = screen.getByPlaceholderText('Add note...');
await user.type(input, 'Test note{enter}');
await waitFor(() => {
expect(screen.queryByPlaceholderText('Add note...')).not.toBeInTheDocument();
});
});
});
describe('loading state', () => {
it('should disable buttons while loading', async () => {
// Make add hang to simulate loading
vi.mocked(api.annotations.add).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(<AnnotationToolbar {...defaultProps} />);
const actionButton = screen.getByTitle('Action');
fireEvent.click(actionButton);
// All buttons should be disabled
await waitFor(() => {
expect(screen.getByTitle('Action')).toBeDisabled();
expect(screen.getByTitle('Decision')).toBeDisabled();
expect(screen.getByTitle('Note')).toBeDisabled();
expect(screen.getByTitle('Risk')).toBeDisabled();
});
});
});
describe('error handling', () => {
it('should handle API error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.annotations.add).mockRejectedValue(new Error('Server error'));
render(<AnnotationToolbar {...defaultProps} />);
const actionButton = screen.getByTitle('Action');
fireEvent.click(actionButton);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to add annotation:',
expect.any(Error)
);
});
// Buttons should be re-enabled after error
await waitFor(() => {
expect(screen.getByTitle('Action')).not.toBeDisabled();
});
consoleSpy.mockRestore();
});
});
describe('props usage', () => {
it('should use correct meeting ID', async () => {
const mockAnnotation = {
id: 'ann-7',
meeting_id: 'different-meeting',
annotation_type: 'action_item',
text: '',
start_time: 0,
end_time: 5,
segment_ids: [42],
created_at: Date.now() / 1000,
};
vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation);
render(
<AnnotationToolbar
meetingId="different-meeting"
segmentId={42}
startTime={0}
endTime={5}
/>
);
const actionButton = screen.getByTitle('Action');
fireEvent.click(actionButton);
await waitFor(() => {
expect(api.annotations.add).toHaveBeenCalledWith(
'different-meeting',
'action_item',
'',
0,
5,
[42]
);
});
});
});
});

View File

@@ -1,110 +0,0 @@
import { AlertCircle, AlertTriangle, CheckSquare, Plus, StickyNote } from 'lucide-react';
import { useState } from 'react';
import { api } from '@/lib/tauri';
import { useStore } from '@/store';
import type { AnnotationType } from '@/types/annotation';
interface AnnotationToolbarProps {
meetingId: string;
segmentId: number;
startTime: number;
endTime: number;
}
const ANNOTATION_TYPES: Array<{
type: AnnotationType;
icon: typeof CheckSquare;
label: string;
color: string;
}> = [
{ type: 'action_item', icon: CheckSquare, label: 'Action', color: 'text-green-500' },
{ type: 'decision', icon: AlertCircle, label: 'Decision', color: 'text-purple-500' },
{ type: 'note', icon: StickyNote, label: 'Note', color: 'text-blue-500' },
{ type: 'risk', icon: AlertTriangle, label: 'Risk', color: 'text-red-500' },
];
export function AnnotationToolbar({
meetingId,
segmentId,
startTime,
endTime,
}: AnnotationToolbarProps) {
const { addAnnotation } = useStore();
const [showNoteInput, setShowNoteInput] = useState(false);
const [noteText, setNoteText] = useState('');
const [loading, setLoading] = useState(false);
const handleAddAnnotation = async (type: AnnotationType, text?: string) => {
setLoading(true);
try {
const annotation = await api.annotations.add(
meetingId,
type,
text ?? '',
startTime,
endTime,
[segmentId],
);
addAnnotation(annotation);
setShowNoteInput(false);
setNoteText('');
} catch (error) {
console.error('Failed to add annotation:', error);
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center gap-1 border-t border-border pt-1">
{ANNOTATION_TYPES.map(({ type, icon: Icon, label, color }) => (
<button
type="button"
key={type}
onClick={() => {
if (type === 'note') {
setShowNoteInput(true);
} else {
handleAddAnnotation(type);
}
}}
disabled={loading}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted disabled:opacity-50 ${color}`}
title={label}
>
<Icon className="h-3 w-3" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
{showNoteInput && (
<div className="ml-2 flex items-center gap-1">
<input
type="text"
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
placeholder="Add note..."
className="w-32 rounded border border-border bg-background px-2 py-1 text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter' && noteText.trim()) {
handleAddAnnotation('note', noteText.trim());
}
if (e.key === 'Escape') {
setShowNoteInput(false);
setNoteText('');
}
}}
/>
<button
type="button"
onClick={() => handleAddAnnotation('note', noteText.trim())}
disabled={!noteText.trim() || loading}
className="rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Plus className="h-3 w-3" />
</button>
</div>
)}
</div>
);
}

View File

@@ -1,3 +0,0 @@
export { AnnotationBadge } from './AnnotationBadge';
export { AnnotationList } from './AnnotationList';
export { AnnotationToolbar } from './AnnotationToolbar';

View File

@@ -1,168 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { api } from '@/lib/tauri';
import { ConnectionPanel } from './ConnectionPanel';
// Mock the store
vi.mock('@/store', () => ({
useStore: vi.fn(),
}));
// Mock the tauri API
vi.mock('@/lib/tauri', () => ({
api: {
connection: {
connect: vi.fn(),
disconnect: vi.fn(),
},
},
}));
import { useStore } from '@/store';
const mockUseStore = vi.mocked(useStore);
describe('ConnectionPanel', () => {
const mockSetConnectionState = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('when disconnected', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
connected: false,
serverAddress: 'localhost:50051',
serverInfo: null,
setConnectionState: mockSetConnectionState,
});
});
it('should show disconnected status', () => {
render(<ConnectionPanel />);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
it('should show connect button', () => {
render(<ConnectionPanel />);
expect(screen.getByTitle('Connect to server')).toBeInTheDocument();
});
it('should call connect when clicking connect button', async () => {
const mockServerInfo = {
version: '1.0.0',
asr_model: 'whisper-large-v3',
asr_ready: true,
uptime_seconds: 3600,
active_meetings: 0,
diarization_enabled: true,
diarization_ready: true,
};
vi.mocked(api.connection.connect).mockResolvedValue(mockServerInfo);
render(<ConnectionPanel />);
const connectButton = screen.getByTitle('Connect to server');
fireEvent.click(connectButton);
await waitFor(() => {
expect(api.connection.connect).toHaveBeenCalledWith('localhost:50051');
});
});
it('should update connection state on successful connect', async () => {
const mockServerInfo = {
version: '1.0.0',
asr_model: 'whisper-large-v3',
asr_ready: true,
uptime_seconds: 3600,
active_meetings: 0,
diarization_enabled: true,
diarization_ready: true,
};
vi.mocked(api.connection.connect).mockResolvedValue(mockServerInfo);
render(<ConnectionPanel />);
const connectButton = screen.getByTitle('Connect to server');
fireEvent.click(connectButton);
await waitFor(() => {
expect(mockSetConnectionState).toHaveBeenCalledWith(
true,
'localhost:50051',
mockServerInfo
);
});
});
it('should handle connection failure', async () => {
vi.mocked(api.connection.connect).mockRejectedValue(new Error('Connection failed'));
render(<ConnectionPanel />);
const connectButton = screen.getByTitle('Connect to server');
fireEvent.click(connectButton);
await waitFor(() => {
expect(mockSetConnectionState).toHaveBeenCalledWith(false, 'localhost:50051');
});
});
});
describe('when connected', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
connected: true,
serverAddress: 'localhost:50051',
serverInfo: {
version: '1.0.0',
asr_model: 'whisper-large-v3',
asr_ready: true,
uptime_seconds: 3600,
active_meetings: 0,
diarization_enabled: true,
diarization_ready: true,
},
setConnectionState: mockSetConnectionState,
});
});
it('should show connected status', () => {
render(<ConnectionPanel />);
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('should show disconnect button', () => {
render(<ConnectionPanel />);
expect(screen.getByTitle('Disconnect')).toBeInTheDocument();
});
it('should show server address', () => {
render(<ConnectionPanel />);
expect(screen.getByText('localhost:50051')).toBeInTheDocument();
});
it('should call disconnect when clicking disconnect button', async () => {
vi.mocked(api.connection.disconnect).mockResolvedValue(undefined);
render(<ConnectionPanel />);
const disconnectButton = screen.getByTitle('Disconnect');
fireEvent.click(disconnectButton);
await waitFor(() => {
expect(api.connection.disconnect).toHaveBeenCalled();
});
});
it('should update connection state on disconnect', async () => {
vi.mocked(api.connection.disconnect).mockResolvedValue(undefined);
render(<ConnectionPanel />);
const disconnectButton = screen.getByTitle('Disconnect');
fireEvent.click(disconnectButton);
await waitFor(() => {
expect(mockSetConnectionState).toHaveBeenCalledWith(false, 'localhost:50051');
});
});
});
});

View File

@@ -1,86 +0,0 @@
import { RefreshCw, Wifi, WifiOff } from 'lucide-react';
import { useState } from 'react';
import { api } from '@/lib/tauri';
import { useStore } from '@/store';
export function ConnectionPanel() {
const { connected, serverAddress, serverInfo, setConnectionState } = useStore();
const [connecting, setConnecting] = useState(false);
const handleConnect = async () => {
setConnecting(true);
try {
const info = await api.connection.connect(serverAddress);
setConnectionState(true, serverAddress, info);
} catch (error) {
console.error('Connection failed:', error);
setConnectionState(false, serverAddress);
} finally {
setConnecting(false);
}
};
const handleDisconnect = async () => {
try {
await api.connection.disconnect();
setConnectionState(false, serverAddress);
} catch (error) {
console.error('Disconnect failed:', error);
}
};
return (
<div className="flex items-center gap-2">
{/* Status indicator */}
<div
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
connected
? 'bg-green-900/50 text-green-400'
: 'bg-red-900/50 text-red-400'
}`}
>
{connected ? (
<>
<Wifi className="h-3.5 w-3.5" />
<span>Connected</span>
</>
) : (
<>
<WifiOff className="h-3.5 w-3.5" />
<span>Disconnected</span>
</>
)}
</div>
{/* Reconnect button (shown when disconnected) */}
{!connected && (
<button
onClick={handleConnect}
disabled={connecting}
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
title="Connect to server"
>
<RefreshCw className={`h-4 w-4 ${connecting ? 'animate-spin' : ''}`} />
</button>
)}
{/* Disconnect button (shown when connected) */}
{connected && (
<button
onClick={handleDisconnect}
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
title="Disconnect"
>
<WifiOff className="h-4 w-4" />
</button>
)}
{/* Server info */}
{connected && serverInfo && (
<span className="text-xs text-muted-foreground" title={`ASR: ${serverInfo.asr_model}`}>
{serverAddress}
</span>
)}
</div>
);
}

View File

@@ -1,165 +0,0 @@
import { Calendar, Clock, Download, Loader2, MessageSquare, Search, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api } from '@/lib/tauri';
import { formatDateTime, formatTime } from '@/lib/utils';
import { useStore } from '@/store';
import type { MeetingInfo } from '@/types';
export function MeetingLibrary() {
const { meetings, setMeetings, setSelectedMeeting, setCurrentMeeting, setViewMode } = useStore();
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
useEffect(() => {
loadMeetings();
}, []);
const loadMeetings = async () => {
setLoading(true);
try {
const [meetingList] = await api.meetings.list(
['completed', 'stopped'],
100,
0,
true
);
setMeetings(meetingList);
} catch (error) {
console.error('Failed to load meetings:', error);
} finally {
setLoading(false);
}
};
const handleSelectMeeting = async (meeting: MeetingInfo) => {
setSelectedId(meeting.id);
try {
const details = await api.meetings.select(meeting.id);
setSelectedMeeting(details.meeting);
setCurrentMeeting(details.meeting);
setViewMode('recording');
} catch (error) {
console.error('Failed to select meeting:', error);
} finally {
setSelectedId(null);
}
};
const handleDeleteMeeting = async (meetingId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Are you sure you want to delete this meeting?')) return;
try {
await api.meetings.delete(meetingId);
setMeetings(meetings.filter((m) => m.id !== meetingId));
} catch (error) {
console.error('Failed to delete meeting:', error);
}
};
const handleExport = async (meeting: MeetingInfo, format: 'markdown' | 'html', e: React.MouseEvent) => {
e.stopPropagation();
try {
const result = await api.export.transcript(meeting.id, format);
const fileName = `${meeting.title.replace(/[^a-z0-9]/gi, '_')}.${result.file_extension}`;
await api.export.saveFile(result.content, fileName, result.file_extension);
} catch (error) {
console.error('Failed to export:', error);
}
};
const filteredMeetings = meetings.filter((m) =>
m.title.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="h-full flex flex-col p-4">
{/* Search bar */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search meetings..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-muted border border-border focus:border-primary focus:outline-none"
/>
</div>
<button
onClick={loadMeetings}
disabled={loading}
className="p-2 rounded-lg bg-muted hover:bg-muted/80"
>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<span className="text-sm">Refresh</span>
)}
</button>
</div>
{/* Meeting list */}
{loading && meetings.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredMeetings.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
{searchQuery ? 'No matching meetings found' : 'No meetings yet'}
</div>
) : (
<div className="flex-1 overflow-auto space-y-2">
{filteredMeetings.map((meeting) => (
<div
key={meeting.id}
onClick={() => handleSelectMeeting(meeting)}
className={`p-4 rounded-lg bg-muted/50 hover:bg-muted cursor-pointer transition-colors ${
selectedId === meeting.id ? 'opacity-50' : ''
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-medium">{meeting.title}</h3>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDateTime(meeting.created_at)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{formatTime(meeting.duration_seconds)}
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{meeting.segment_count} segments
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<button
onClick={(e) => handleExport(meeting, 'markdown', e)}
className="p-1.5 rounded hover:bg-background"
title="Export as Markdown"
>
<Download className="h-4 w-4" />
</button>
<button
onClick={(e) => handleDeleteMeeting(meeting.id, e)}
className="p-1.5 rounded hover:bg-background text-red-400"
title="Delete meeting"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,261 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { api } from '@/lib/tauri';
import { PlaybackControls } from './PlaybackControls';
// Mock the store
vi.mock('@/store', () => ({
useStore: vi.fn(),
}));
// Mock the tauri API
vi.mock('@/lib/tauri', () => ({
api: {
playback: {
play: vi.fn(),
pause: vi.fn(),
stop: vi.fn(),
seek: vi.fn(),
},
},
}));
import { useStore } from '@/store';
const mockUseStore = vi.mocked(useStore);
describe('PlaybackControls', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when stopped', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
playbackState: 'stopped',
playbackPosition: 0,
playbackDuration: 120,
});
});
it('should show play button', () => {
render(<PlaybackControls />);
// Play icon should be visible (not Pause)
const playButton = screen.getAllByRole('button')[0];
expect(playButton).toBeInTheDocument();
});
it('should display time as 00:00', () => {
render(<PlaybackControls />);
expect(screen.getByText('00:00')).toBeInTheDocument();
});
it('should display duration', () => {
render(<PlaybackControls />);
expect(screen.getByText('02:00')).toBeInTheDocument();
});
it('should call play when clicking play button', async () => {
vi.mocked(api.playback.play).mockResolvedValue(undefined);
render(<PlaybackControls />);
const playButton = screen.getAllByRole('button')[0];
fireEvent.click(playButton);
await waitFor(() => {
expect(api.playback.play).toHaveBeenCalled();
});
});
it('should have disabled stop button', () => {
render(<PlaybackControls />);
const stopButton = screen.getAllByRole('button')[1];
expect(stopButton).toBeDisabled();
});
});
describe('when playing', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
playbackState: 'playing',
playbackPosition: 30.5,
playbackDuration: 120,
});
});
it('should show pause button', () => {
render(<PlaybackControls />);
// Pause icon should be visible when playing
const pauseButton = screen.getAllByRole('button')[0];
expect(pauseButton).toBeInTheDocument();
});
it('should display current position', () => {
render(<PlaybackControls />);
expect(screen.getByText('00:30')).toBeInTheDocument();
});
it('should call pause when clicking pause button', async () => {
vi.mocked(api.playback.pause).mockResolvedValue(undefined);
render(<PlaybackControls />);
const pauseButton = screen.getAllByRole('button')[0];
fireEvent.click(pauseButton);
await waitFor(() => {
expect(api.playback.pause).toHaveBeenCalled();
});
});
it('should have enabled stop button', () => {
render(<PlaybackControls />);
const stopButton = screen.getAllByRole('button')[1];
expect(stopButton).not.toBeDisabled();
});
it('should call stop when clicking stop button', async () => {
vi.mocked(api.playback.stop).mockResolvedValue(undefined);
render(<PlaybackControls />);
const stopButton = screen.getAllByRole('button')[1];
fireEvent.click(stopButton);
await waitFor(() => {
expect(api.playback.stop).toHaveBeenCalled();
});
});
});
describe('when paused', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
playbackState: 'paused',
playbackPosition: 45,
playbackDuration: 120,
});
});
it('should show play button (not pause)', () => {
render(<PlaybackControls />);
const playButton = screen.getAllByRole('button')[0];
expect(playButton).toBeInTheDocument();
});
it('should preserve position display', () => {
render(<PlaybackControls />);
expect(screen.getByText('00:45')).toBeInTheDocument();
});
it('should have enabled stop button', () => {
render(<PlaybackControls />);
const stopButton = screen.getAllByRole('button')[1];
expect(stopButton).not.toBeDisabled();
});
});
describe('seek functionality', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
playbackState: 'playing',
playbackPosition: 30,
playbackDuration: 120,
});
});
it('should call seek when slider changes', async () => {
vi.mocked(api.playback.seek).mockResolvedValue(undefined);
render(<PlaybackControls />);
const slider = screen.getByRole('slider');
fireEvent.change(slider, { target: { value: '60' } });
await waitFor(() => {
expect(api.playback.seek).toHaveBeenCalledWith(60);
});
});
it('should have correct slider range', () => {
render(<PlaybackControls />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('min', '0');
expect(slider).toHaveAttribute('max', '120');
});
});
describe('error handling', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
playbackState: 'stopped',
playbackPosition: 0,
playbackDuration: 120,
});
});
it('should handle play error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.playback.play).mockRejectedValue(new Error('Playback failed'));
render(<PlaybackControls />);
const playButton = screen.getAllByRole('button')[0];
fireEvent.click(playButton);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to play:', expect.any(Error));
});
consoleSpy.mockRestore();
});
it('should handle seek error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.playback.seek).mockRejectedValue(new Error('Seek failed'));
render(<PlaybackControls />);
const slider = screen.getByRole('slider');
fireEvent.change(slider, { target: { value: '60' } });
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to seek:', expect.any(Error));
});
consoleSpy.mockRestore();
});
});
describe('edge cases', () => {
it('should handle zero duration', () => {
mockUseStore.mockReturnValue({
playbackState: 'stopped',
playbackPosition: 0,
playbackDuration: 0,
});
render(<PlaybackControls />);
expect(screen.getAllByText('00:00')).toHaveLength(2);
});
it('should handle very long duration', () => {
mockUseStore.mockReturnValue({
playbackState: 'stopped',
playbackPosition: 0,
playbackDuration: 7200, // 2 hours
});
render(<PlaybackControls />);
// Format may be "2:00:00" or "02:00:00" depending on implementation
expect(screen.getByText(/2:00:00$/)).toBeInTheDocument();
});
it('should handle position beyond duration', () => {
mockUseStore.mockReturnValue({
playbackState: 'playing',
playbackPosition: 150,
playbackDuration: 120,
});
render(<PlaybackControls />);
// Should still render without crashing
expect(screen.getByText('02:30')).toBeInTheDocument();
});
});
});

View File

@@ -1,83 +0,0 @@
import { Pause, Play, Square } from 'lucide-react';
import { api } from '@/lib/tauri';
import { formatTime } from '@/lib/utils';
import { useStore } from '@/store';
export function PlaybackControls() {
const { playbackState, playbackPosition, playbackDuration } = useStore();
const handlePlay = async () => {
try {
await api.playback.play();
} catch (error) {
console.error('Failed to play:', error);
}
};
const handlePause = async () => {
try {
await api.playback.pause();
} catch (error) {
console.error('Failed to pause:', error);
}
};
const handleStop = async () => {
try {
await api.playback.stop();
} catch (error) {
console.error('Failed to stop:', error);
}
};
const handleSeek = async (e: React.ChangeEvent<HTMLInputElement>) => {
const position = parseFloat(e.target.value);
try {
await api.playback.seek(position);
} catch (error) {
console.error('Failed to seek:', error);
}
};
const isPlaying = playbackState === 'playing';
const isStopped = playbackState === 'stopped';
return (
<div className="flex items-center gap-4">
{/* Play/Pause button */}
<button
onClick={isPlaying ? handlePause : handlePlay}
className="rounded-full p-2 bg-primary text-primary-foreground hover:bg-primary/90"
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</button>
{/* Stop button */}
<button
onClick={handleStop}
disabled={isStopped}
className="rounded-full p-2 bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50"
>
<Square className="h-5 w-5" />
</button>
{/* Timeline */}
<div className="flex-1 flex items-center gap-2">
<span className="text-xs text-muted-foreground font-mono w-12">
{formatTime(playbackPosition)}
</span>
<input
type="range"
min={0}
max={playbackDuration || 100}
value={playbackPosition}
onChange={handleSeek}
className="flex-1 h-2 rounded-lg appearance-none cursor-pointer bg-muted"
/>
<span className="text-xs text-muted-foreground font-mono w-12">
{formatTime(playbackDuration)}
</span>
</div>
</div>
);
}

View File

@@ -1,303 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { api } from '@/lib/tauri';
import { RecordingPanel } from './RecordingPanel';
// Mock child components to isolate tests
vi.mock('./VuMeter', () => ({
VuMeter: ({ level }: { level: number }) => <div data-testid="vu-meter">Level: {level}</div>,
}));
vi.mock('./RecordingTimer', () => ({
RecordingTimer: ({ seconds }: { seconds: number }) => <div data-testid="timer">{seconds}s</div>,
}));
vi.mock('@/components/playback/PlaybackControls', () => ({
PlaybackControls: () => <div data-testid="playback-controls">Playback Controls</div>,
}));
// Mock the store
vi.mock('@/store', () => ({
useStore: vi.fn(),
}));
// Mock the tauri API
vi.mock('@/lib/tauri', () => ({
api: {
recording: {
start: vi.fn(),
stop: vi.fn(),
},
},
}));
import { useStore } from '@/store';
const mockUseStore = vi.mocked(useStore);
const mockSetRecording = vi.fn();
const mockSetCurrentMeeting = vi.fn();
const mockClearTranscript = vi.fn();
const defaultStoreState = {
recording: false,
currentMeeting: null,
connected: true,
audioLevel: 0.5,
elapsedSeconds: 0,
setRecording: mockSetRecording,
setCurrentMeeting: mockSetCurrentMeeting,
clearTranscript: mockClearTranscript,
};
describe('RecordingPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseStore.mockReturnValue(defaultStoreState);
});
describe('when disconnected', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
...defaultStoreState,
connected: false,
});
});
it('should show disabled start button', () => {
render(<RecordingPanel />);
const startButton = screen.getByRole('button', { name: /start recording/i });
expect(startButton).toBeDisabled();
});
it('should show VU meter', () => {
render(<RecordingPanel />);
expect(screen.getByTestId('vu-meter')).toBeInTheDocument();
});
});
describe('when connected and not recording', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
...defaultStoreState,
connected: true,
recording: false,
});
});
it('should show enabled Start Recording button', () => {
render(<RecordingPanel />);
const startButton = screen.getByRole('button', { name: /start recording/i });
expect(startButton).not.toBeDisabled();
});
it('should not show recording timer', () => {
render(<RecordingPanel />);
expect(screen.queryByTestId('timer')).not.toBeInTheDocument();
});
it('should not show playback controls without meeting', () => {
render(<RecordingPanel />);
expect(screen.queryByTestId('playback-controls')).not.toBeInTheDocument();
});
it('should show playback controls with meeting', () => {
mockUseStore.mockReturnValue({
...defaultStoreState,
currentMeeting: { id: 'meeting-1', title: 'Test Meeting' },
});
render(<RecordingPanel />);
expect(screen.getByTestId('playback-controls')).toBeInTheDocument();
});
});
describe('start recording flow', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
...defaultStoreState,
connected: true,
recording: false,
});
});
it('should call api.recording.start on click', async () => {
const mockMeeting = {
id: 'meeting-123',
title: 'Meeting 1/1/2025',
state: 'recording',
};
vi.mocked(api.recording.start).mockResolvedValue(mockMeeting as any);
render(<RecordingPanel />);
const startButton = screen.getByRole('button', { name: /start recording/i });
fireEvent.click(startButton);
await waitFor(() => {
expect(api.recording.start).toHaveBeenCalled();
});
});
it('should update store state after successful start', async () => {
const mockMeeting = {
id: 'meeting-123',
title: 'Test Meeting',
state: 'recording',
};
vi.mocked(api.recording.start).mockResolvedValue(mockMeeting as any);
render(<RecordingPanel />);
const startButton = screen.getByRole('button', { name: /start recording/i });
fireEvent.click(startButton);
await waitFor(() => {
expect(mockSetCurrentMeeting).toHaveBeenCalledWith(mockMeeting);
expect(mockSetRecording).toHaveBeenCalledWith(true);
expect(mockClearTranscript).toHaveBeenCalled();
});
});
it('should show loading state while starting', async () => {
vi.mocked(api.recording.start).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({} as any), 100))
);
render(<RecordingPanel />);
const startButton = screen.getByRole('button', { name: /start recording/i });
fireEvent.click(startButton);
// Button should be disabled during loading
expect(startButton).toBeDisabled();
});
it('should handle start error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.recording.start).mockRejectedValue(new Error('Start failed'));
render(<RecordingPanel />);
const startButton = screen.getByRole('button', { name: /start recording/i });
fireEvent.click(startButton);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to start recording:', expect.any(Error));
});
// Button should be re-enabled
await waitFor(() => {
expect(startButton).not.toBeDisabled();
});
consoleSpy.mockRestore();
});
});
describe('when recording', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
...defaultStoreState,
connected: true,
recording: true,
elapsedSeconds: 45,
currentMeeting: { id: 'meeting-1', title: 'Test' },
});
});
it('should show Stop button instead of Start', () => {
render(<RecordingPanel />);
expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /start recording/i })).not.toBeInTheDocument();
});
it('should show recording timer', () => {
render(<RecordingPanel />);
expect(screen.getByTestId('timer')).toBeInTheDocument();
expect(screen.getByText('45s')).toBeInTheDocument();
});
it('should not show playback controls while recording', () => {
render(<RecordingPanel />);
expect(screen.queryByTestId('playback-controls')).not.toBeInTheDocument();
});
});
describe('stop recording flow', () => {
beforeEach(() => {
mockUseStore.mockReturnValue({
...defaultStoreState,
connected: true,
recording: true,
currentMeeting: { id: 'meeting-1', title: 'Test' },
});
});
it('should call api.recording.stop on click', async () => {
const mockMeeting = {
id: 'meeting-123',
title: 'Test Meeting',
state: 'stopped',
};
vi.mocked(api.recording.stop).mockResolvedValue(mockMeeting as any);
render(<RecordingPanel />);
const stopButton = screen.getByRole('button', { name: /stop/i });
fireEvent.click(stopButton);
await waitFor(() => {
expect(api.recording.stop).toHaveBeenCalled();
});
});
it('should update store state after successful stop', async () => {
const mockMeeting = {
id: 'meeting-123',
title: 'Test Meeting',
state: 'stopped',
};
vi.mocked(api.recording.stop).mockResolvedValue(mockMeeting as any);
render(<RecordingPanel />);
const stopButton = screen.getByRole('button', { name: /stop/i });
fireEvent.click(stopButton);
await waitFor(() => {
expect(mockSetCurrentMeeting).toHaveBeenCalledWith(mockMeeting);
expect(mockSetRecording).toHaveBeenCalledWith(false);
});
});
it('should handle stop error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.recording.stop).mockRejectedValue(new Error('Stop failed'));
render(<RecordingPanel />);
const stopButton = screen.getByRole('button', { name: /stop/i });
fireEvent.click(stopButton);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to stop recording:', expect.any(Error));
});
consoleSpy.mockRestore();
});
});
describe('VU meter integration', () => {
it('should pass audio level to VU meter', () => {
mockUseStore.mockReturnValue({
...defaultStoreState,
audioLevel: 0.75,
});
render(<RecordingPanel />);
expect(screen.getByText('Level: 0.75')).toBeInTheDocument();
});
it('should handle zero audio level', () => {
mockUseStore.mockReturnValue({
...defaultStoreState,
audioLevel: 0,
});
render(<RecordingPanel />);
expect(screen.getByText('Level: 0')).toBeInTheDocument();
});
});
});

View File

@@ -1,104 +0,0 @@
import { Loader2, Mic, Square } from 'lucide-react';
import { useState } from 'react';
import { PlaybackControls } from '@/components/playback/PlaybackControls';
import { api } from '@/lib/tauri';
import { useStore } from '@/store';
import { RecordingTimer } from './RecordingTimer';
import { VuMeter } from './VuMeter';
export function RecordingPanel() {
const {
recording,
currentMeeting,
connected,
audioLevel,
elapsedSeconds,
setRecording,
setCurrentMeeting,
clearTranscript,
} = useStore();
const [loading, setLoading] = useState(false);
const handleStartRecording = async () => {
setLoading(true);
try {
const meeting = await api.recording.start(`Meeting ${new Date().toLocaleString()}`);
setCurrentMeeting(meeting);
setRecording(true);
clearTranscript();
} catch (error) {
console.error('Failed to start recording:', error);
} finally {
setLoading(false);
}
};
const handleStopRecording = async () => {
setLoading(true);
try {
const meeting = await api.recording.stop();
setCurrentMeeting(meeting);
setRecording(false);
} catch (error) {
console.error('Failed to stop recording:', error);
} finally {
setLoading(false);
}
};
const canRecord = connected && !recording;
const canStop = recording;
return (
<div className="border-b border-border p-4">
<div className="flex items-center gap-4">
{/* Recording controls */}
<div className="flex items-center gap-2">
{!recording ? (
<button
onClick={handleStartRecording}
disabled={!canRecord || loading}
className="flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50"
>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Mic className="h-5 w-5" />
)}
<span>Start Recording</span>
</button>
) : (
<button
onClick={handleStopRecording}
disabled={!canStop || loading}
className="flex items-center gap-2 rounded-lg bg-gray-600 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-700 disabled:opacity-50"
>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Square className="h-5 w-5" />
)}
<span>Stop</span>
</button>
)}
</div>
{/* VU Meter */}
<div className="flex-1">
<VuMeter level={audioLevel} />
</div>
{/* Recording timer */}
{recording && <RecordingTimer seconds={elapsedSeconds} />}
</div>
{/* Playback controls (when not recording and have a meeting) */}
{!recording && currentMeeting && (
<div className="mt-4">
<PlaybackControls />
</div>
)}
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { cn, formatTime } from '@/lib/utils';
interface RecordingTimerProps {
/** Elapsed seconds to display */
seconds: number;
/** Whether to show the pulsing recording indicator */
showIndicator?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Recording timer display component.
*
* Displays elapsed time in MM:SS or HH:MM:SS format with an optional
* pulsing red indicator to show active recording.
*
* Uses formatTime() from lib/utils for consistent time formatting.
*/
export function RecordingTimer({
seconds,
showIndicator = true,
className,
}: RecordingTimerProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
{showIndicator && (
<div className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />
)}
<span className="font-mono text-lg">{formatTime(seconds)}</span>
</div>
);
}

View File

@@ -1,101 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { VuMeter } from './VuMeter';
describe('VuMeter', () => {
describe('rendering', () => {
it('should render with default segments', () => {
render(<VuMeter level={0.5} />);
// Default is 20 segments
const container = screen.getByText(/-?\d+ dB/).parentElement;
expect(container?.querySelectorAll('div > div').length).toBeGreaterThanOrEqual(20);
});
it('should render with custom segment count', () => {
render(<VuMeter level={0.5} segments={10} />);
const container = screen.getByText(/-?\d+ dB/).parentElement;
expect(container?.querySelectorAll('div > div').length).toBeGreaterThanOrEqual(10);
});
it('should display dB level', () => {
render(<VuMeter level={0.5} />);
expect(screen.getByText(/dB/)).toBeInTheDocument();
});
});
describe('level display', () => {
it('should show -60 dB for zero level', () => {
render(<VuMeter level={0} />);
expect(screen.getByText('-60 dB')).toBeInTheDocument();
});
it('should show 0 dB for full level', () => {
render(<VuMeter level={1} />);
expect(screen.getByText('0 dB')).toBeInTheDocument();
});
it('should show intermediate dB values', () => {
render(<VuMeter level={0.1} />);
// 20 * log10(0.1) = -20 dB
expect(screen.getByText('-20 dB')).toBeInTheDocument();
});
it('should clamp values below -60 dB', () => {
render(<VuMeter level={0.000001} />);
expect(screen.getByText('-60 dB')).toBeInTheDocument();
});
});
describe('segment activation', () => {
it('should have no active segments at zero level', () => {
render(<VuMeter level={0} segments={10} />);
// At -60 dB, normalized level is 0, so 0 active segments
// All segments should have inactive colors
const container = screen.getByText(/-?\d+ dB/).parentElement;
const segments = container?.querySelectorAll('div > div');
segments?.forEach((segment) => {
// Inactive segments have /30 opacity classes
expect(segment.className).toMatch(/bg-\w+-900\/30/);
});
});
it('should have all segments active at full level', () => {
render(<VuMeter level={1} segments={10} />);
// At 0 dB, normalized level is 1, so all segments active
const container = screen.getByText('0 dB').parentElement;
const segments = container?.querySelectorAll('div > div');
// All segments should have active colors (bg-X-500)
let activeCount = 0;
segments?.forEach((segment) => {
if (segment.className.match(/bg-(green|yellow|red)-500(?!\/)/) ||
!segment.className.match(/\/30/)) {
activeCount++;
}
});
expect(activeCount).toBeGreaterThan(0);
});
});
describe('color zones', () => {
it('should use green for low levels', () => {
const { container } = render(<VuMeter level={0.05} segments={10} />);
const segments = container.querySelectorAll('.h-4');
// First segments should be green
expect(segments[0]?.className).toMatch(/green/);
});
it('should use yellow for medium-high levels', () => {
render(<VuMeter level={0.7} segments={10} />);
// At higher levels, we should see yellow segments
const container = screen.getByText(/-?\d+ dB/).parentElement;
expect(container?.innerHTML).toMatch(/yellow/);
});
it('should use red for peak levels', () => {
render(<VuMeter level={1} segments={10} />);
// At full level, we should see red segments
const container = screen.getByText('0 dB').parentElement;
expect(container?.innerHTML).toMatch(/red/);
});
});
});

View File

@@ -1,52 +0,0 @@
import { useMemo } from 'react';
interface VuMeterProps {
level: number; // 0.0 to 1.0
segments?: number;
}
export function VuMeter({ level, segments = 20 }: VuMeterProps) {
// Convert linear level to dB for more natural display
const dbLevel = useMemo(() => {
if (level <= 0) return -60;
const db = 20 * Math.log10(level);
return Math.max(-60, Math.min(0, db));
}, [level]);
// Normalize to 0-1 range (-60dB to 0dB)
const normalizedLevel = (dbLevel + 60) / 60;
// Calculate active segments
const activeSegments = Math.round(normalizedLevel * segments);
// Segment colors: green -> yellow -> red
const getSegmentColor = (index: number): string => {
const position = index / segments;
if (position < 0.6) return 'bg-green-500';
if (position < 0.8) return 'bg-yellow-500';
return 'bg-red-500';
};
const getInactiveColor = (index: number): string => {
const position = index / segments;
if (position < 0.6) return 'bg-green-900/30';
if (position < 0.8) return 'bg-yellow-900/30';
return 'bg-red-900/30';
};
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: segments }, (_, i) => (
<div
key={i}
className={`h-4 w-1.5 rounded-sm transition-colors duration-75 ${
i < activeSegments ? getSegmentColor(i) : getInactiveColor(i)
}`}
/>
))}
<span className="ml-2 text-xs text-muted-foreground font-mono w-12">
{dbLevel.toFixed(0)} dB
</span>
</div>
);
}

View File

@@ -1,17 +0,0 @@
interface SettingsFieldProps {
label: string;
description?: string;
children: React.ReactNode;
}
export function SettingsField({ label, description, children }: SettingsFieldProps) {
return (
<div className="flex items-start justify-between gap-4 py-3">
<div className="flex-1">
<span className="text-sm font-medium">{label}</span>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}

View File

@@ -1,220 +0,0 @@
import { Brain, HardDrive, Loader2, Save, Server, Shield, Zap } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api } from '@/lib/tauri';
import type { SummarizationProvider, UserPreferences } from '@/types';
import { SettingsField } from './SettingsField';
import { SettingsSection } from './SettingsSection';
const DEFAULT_PREFERENCES: UserPreferences = {
serverUrl: 'localhost:50051',
dataDirectory: '',
encryptionEnabled: true,
autoStartEnabled: true,
triggerConfidenceThreshold: 0.7,
summarizationProvider: 'none',
cloudApiKey: '',
ollamaUrl: 'http://localhost:11434',
};
export function SettingsPanel() {
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadPrefs = async () => {
try {
const prefs = await api.preferences.get();
setPreferences(prefs);
} catch (err) {
setError(`Failed to load preferences: ${err}`);
} finally {
setLoading(false);
}
};
loadPrefs();
}, []);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await api.preferences.save(preferences);
} catch (err) {
setError(`Failed to save: ${err}`);
} finally {
setSaving(false);
}
};
const updateField = <K extends keyof UserPreferences>(field: K, value: UserPreferences[K]) => {
setPreferences((prev) => ({ ...prev, [field]: value }));
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="h-full overflow-auto p-6">
<div className="mx-auto max-w-2xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Settings</h2>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</button>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
)}
<SettingsSection icon={Server} title="Server">
<SettingsField label="gRPC Server URL" description="Address of the NoteFlow gRPC server">
<input
type="text"
value={preferences.serverUrl}
onChange={(e) => updateField('serverUrl', e.target.value)}
className="w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm"
placeholder="localhost:50051"
/>
</SettingsField>
</SettingsSection>
<SettingsSection icon={HardDrive} title="Storage">
<SettingsField
label="Data Directory"
description="Location for meeting recordings and data"
>
<input
type="text"
value={preferences.dataDirectory}
onChange={(e) => updateField('dataDirectory', e.target.value)}
className="w-64 rounded-md border border-border bg-background px-3 py-1.5 text-sm"
placeholder="/path/to/data"
/>
</SettingsField>
</SettingsSection>
<SettingsSection icon={Shield} title="Security">
<SettingsField label="Encryption" description="Encrypt audio files at rest">
<ToggleSwitch
checked={preferences.encryptionEnabled}
onChange={(v) => updateField('encryptionEnabled', v)}
/>
</SettingsField>
</SettingsSection>
<SettingsSection icon={Zap} title="Triggers">
<SettingsField
label="Auto-start Detection"
description="Automatically detect when to start recording"
>
<ToggleSwitch
checked={preferences.autoStartEnabled}
onChange={(v) => updateField('autoStartEnabled', v)}
/>
</SettingsField>
<SettingsField
label="Confidence Threshold"
description="Minimum confidence to trigger auto-start"
>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.1"
value={preferences.triggerConfidenceThreshold}
onChange={(e) =>
updateField('triggerConfidenceThreshold', parseFloat(e.target.value))
}
className="w-24"
/>
<span className="w-10 text-sm text-muted-foreground">
{Math.round(preferences.triggerConfidenceThreshold * 100)}%
</span>
</div>
</SettingsField>
</SettingsSection>
<SettingsSection icon={Brain} title="Summarization">
<SettingsField label="Provider" description="AI provider for generating summaries">
<select
value={preferences.summarizationProvider}
onChange={(e) =>
updateField('summarizationProvider', e.target.value as SummarizationProvider)
}
className="w-32 rounded-md border border-border bg-background px-3 py-1.5 text-sm"
>
<option value="none">None</option>
<option value="cloud">Cloud</option>
<option value="ollama">Ollama</option>
</select>
</SettingsField>
{preferences.summarizationProvider === 'cloud' && (
<SettingsField label="API Key" description="Cloud provider API key">
<input
type="password"
value={preferences.cloudApiKey}
onChange={(e) => updateField('cloudApiKey', e.target.value)}
className="w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm"
placeholder="sk-..."
/>
</SettingsField>
)}
{preferences.summarizationProvider === 'ollama' && (
<SettingsField label="Ollama URL" description="Local Ollama server address">
<input
type="text"
value={preferences.ollamaUrl}
onChange={(e) => updateField('ollamaUrl', e.target.value)}
className="w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm"
placeholder="http://localhost:11434"
/>
</SettingsField>
)}
</SettingsSection>
</div>
</div>
);
}
function ToggleSwitch({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
checked ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
checked ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
);
}

View File

@@ -1,19 +0,0 @@
import type { LucideIcon } from 'lucide-react';
interface SettingsSectionProps {
icon: LucideIcon;
title: string;
children: React.ReactNode;
}
export function SettingsSection({ icon: Icon, title, children }: SettingsSectionProps) {
return (
<div className="rounded-lg border border-border bg-card">
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
<div className="divide-y divide-border px-4">{children}</div>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export { SettingsField } from './SettingsField';
export { SettingsPanel } from './SettingsPanel';
export { SettingsSection } from './SettingsSection';

View File

@@ -1,188 +0,0 @@
import { AlertCircle, CheckCircle, Loader2, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { api } from '@/lib/tauri';
import { useStore } from '@/store';
export function SummaryPanel() {
const {
currentMeeting,
summary,
summaryLoading,
summaryError,
segments,
setSummary,
setSummaryLoading,
setSummaryError,
} = useStore();
const [forceRegenerate] = useState(false);
const handleGenerateSummary = async () => {
if (!currentMeeting) return;
setSummaryLoading(true);
setSummaryError(null);
try {
const result = await api.summary.generate(currentMeeting.id, forceRegenerate);
setSummary(result);
} catch (error) {
setSummaryError(String(error));
} finally {
setSummaryLoading(false);
}
};
const handleCitationClick = async (segmentIds: number[]) => {
if (segmentIds.length === 0) return;
// Find the segment and seek to it
const segment = segments.find((s) => s.segment_id === segmentIds[0]);
if (segment) {
try {
await api.playback.seek(segment.start_time);
} catch (error) {
console.error('Failed to seek:', error);
}
}
};
return (
<div className="h-full flex flex-col p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Summary</h2>
{currentMeeting && (
<button
onClick={handleGenerateSummary}
disabled={summaryLoading || segments.length === 0}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{summaryLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{summary ? 'Regenerate' : 'Generate'}
</button>
)}
</div>
{/* Error state */}
{summaryError && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/20 text-red-400 mb-4">
<AlertCircle className="h-5 w-5" />
<span className="text-sm">{summaryError}</span>
</div>
)}
{/* Loading state */}
{summaryLoading && (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
)}
{/* No summary yet */}
{!summary && !summaryLoading && (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
{segments.length === 0
? 'Record a meeting to generate a summary'
: 'Click Generate to create a summary'}
</div>
)}
{/* Summary content */}
{summary && !summaryLoading && (
<div className="flex-1 overflow-auto space-y-4">
{/* Executive summary */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Executive Summary
</h3>
<p className="text-sm">{summary.executive_summary}</p>
</div>
{/* Key points */}
{summary.key_points.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Key Points
</h3>
<ul className="space-y-2">
{summary.key_points.map((point, index) => (
<li key={index} className="flex gap-2">
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
<div>
<p className="text-sm">{point.text}</p>
{point.segment_ids.length > 0 && (
<button
onClick={() => handleCitationClick(point.segment_ids)}
className="text-xs text-primary hover:underline"
>
[#{point.segment_ids[0]}]
</button>
)}
</div>
</li>
))}
</ul>
</div>
)}
{/* Action items */}
{summary.action_items.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Action Items
</h3>
<ul className="space-y-2">
{summary.action_items.map((item, index) => (
<li
key={index}
className="flex gap-2 p-2 rounded-lg bg-muted/50"
>
<div className="flex-1">
<p className="text-sm">{item.text}</p>
<div className="flex items-center gap-2 mt-1">
{item.assignee && (
<span className="text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{item.assignee}
</span>
)}
{item.priority > 0 && (
<span
className={`text-xs px-1.5 py-0.5 rounded ${
item.priority === 3
? 'bg-red-500/20 text-red-400'
: item.priority === 2
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-blue-500/20 text-blue-400'
}`}
>
{item.priority === 3
? 'High'
: item.priority === 2
? 'Medium'
: 'Low'}
</span>
)}
{item.segment_ids.length > 0 && (
<button
onClick={() => handleCitationClick(item.segment_ids)}
className="text-xs text-primary hover:underline"
>
[#{item.segment_ids[0]}]
</button>
)}
</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,120 +0,0 @@
import { useEffect, useRef } from 'react';
import { AnnotationList, AnnotationToolbar } from '@/components/annotations';
import { api } from '@/lib/tauri';
import { cn, formatTime, getSpeakerColor } from '@/lib/utils';
import { useStore } from '@/store';
export function TranscriptView() {
const {
segments,
partialText,
highlightedSegment,
recording,
annotations,
currentMeeting,
removeAnnotation,
} = useStore();
const containerRef = useRef<HTMLDivElement>(null);
const highlightedRef = useRef<HTMLDivElement>(null);
// Auto-scroll to highlighted segment
useEffect(() => {
if (highlightedRef.current && containerRef.current) {
highlightedRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [highlightedSegment]);
const handleSegmentClick = async (startTime: number) => {
try {
await api.playback.seek(startTime);
} catch (error) {
console.error('Failed to seek:', error);
}
};
return (
<div ref={containerRef} className="flex-1 overflow-auto p-4">
{segments.length === 0 && !partialText && (
<div className="flex h-full items-center justify-center text-muted-foreground">
{recording ? 'Listening...' : 'No transcript yet'}
</div>
)}
<div className="space-y-2">
{segments.map((segment, index) => {
const isHighlighted = highlightedSegment === index;
const speakerColor = getSpeakerColor(segment.speaker_id);
const segmentAnnotations = annotations.filter((a) =>
a.segment_ids.includes(segment.segment_id)
);
const handleRemoveAnnotation = async (annotationId: string) => {
try {
await api.annotations.delete(annotationId);
removeAnnotation(annotationId);
} catch (error) {
console.error('Failed to remove annotation:', error);
}
};
return (
<div
key={segment.segment_id}
ref={isHighlighted ? highlightedRef : null}
className={cn(
'group rounded-lg p-3 transition-colors',
isHighlighted
? 'bg-primary/20 border border-primary'
: 'bg-muted/50 hover:bg-muted'
)}
>
<div
onClick={() => handleSegmentClick(segment.start_time)}
className="cursor-pointer"
>
<div className="flex items-center gap-2 mb-1">
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ backgroundColor: speakerColor, color: 'white' }}
>
{segment.speaker_id || 'Unknown'}
</span>
<span className="text-xs text-muted-foreground">
{formatTime(segment.start_time)} - {formatTime(segment.end_time)}
</span>
</div>
<p className="text-sm">{segment.text}</p>
</div>
<AnnotationList
annotations={segmentAnnotations}
onRemove={handleRemoveAnnotation}
/>
{currentMeeting && (
<div className="mt-2 hidden group-hover:block">
<AnnotationToolbar
meetingId={currentMeeting.id}
segmentId={segment.segment_id}
startTime={segment.start_time}
endTime={segment.end_time}
/>
</div>
)}
</div>
);
})}
{/* Partial text */}
{partialText && (
<div className="rounded-lg p-3 bg-muted/30 border border-dashed border-muted-foreground/30">
<p className="text-sm text-muted-foreground italic">{partialText}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,135 +0,0 @@
import { AlertCircle, Clock, Play, X } from 'lucide-react';
import { api } from '@/lib/tauri';
import { useStore } from '@/store';
export function TriggerDialog() {
const {
triggerPending,
triggerDecision,
setTriggerPending,
setTriggerDecision,
setRecording,
setCurrentMeeting,
} = useStore();
if (!triggerPending || !triggerDecision) {
return null;
}
const handleAccept = async () => {
try {
const meeting = await api.triggers.accept();
setCurrentMeeting(meeting);
setRecording(true);
setTriggerPending(false);
setTriggerDecision(null);
} catch (error) {
console.error('Failed to start recording:', error);
}
};
const handleSnooze = async (minutes: number) => {
try {
await api.triggers.snooze(minutes);
setTriggerPending(false);
setTriggerDecision(null);
} catch (error) {
console.error('Failed to snooze:', error);
}
};
const handleDismiss = async () => {
try {
await api.triggers.dismiss();
setTriggerPending(false);
setTriggerDecision(null);
} catch (error) {
console.error('Failed to dismiss:', error);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-full bg-primary/20 text-primary">
<AlertCircle className="h-6 w-6" />
</div>
<div>
<h2 className="text-lg font-semibold">Meeting Detected</h2>
<p className="text-sm text-muted-foreground">
{triggerDecision.detected_app
? `${triggerDecision.detected_app} appears to be active`
: 'A meeting appears to be starting'}
</p>
</div>
<button
onClick={handleDismiss}
className="ml-auto p-1 rounded hover:bg-muted"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Confidence */}
<div className="mb-4 p-3 rounded-lg bg-muted/50">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-muted-foreground">Confidence</span>
<span className="text-sm font-medium">
{Math.round(triggerDecision.confidence * 100)}%
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary"
style={{ width: `${triggerDecision.confidence * 100}%` }}
/>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<button
onClick={handleAccept}
className="flex items-center justify-center gap-2 w-full py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90"
>
<Play className="h-5 w-5" />
Start Recording
</button>
<div className="flex gap-2">
<button
onClick={() => handleSnooze(5)}
className="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-muted hover:bg-muted/80"
>
<Clock className="h-4 w-4" />
5 min
</button>
<button
onClick={() => handleSnooze(15)}
className="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-muted hover:bg-muted/80"
>
<Clock className="h-4 w-4" />
15 min
</button>
<button
onClick={() => handleSnooze(60)}
className="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-muted hover:bg-muted/80"
>
<Clock className="h-4 w-4" />
1 hour
</button>
</div>
<button
onClick={handleDismiss}
className="w-full py-2 text-sm text-muted-foreground hover:text-foreground"
>
Dismiss
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTauriEvents } from './useTauriEvents';
// Mock the Tauri event API
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(),
}));
// Mock the store
vi.mock('@/store', () => ({
useStore: vi.fn(() => ({
setAudioLevel: vi.fn(),
setElapsedSeconds: vi.fn(),
addSegment: vi.fn(),
setPartialText: vi.fn(),
setPlaybackPosition: vi.fn(),
setPlaybackState: vi.fn(),
setHighlightedSegment: vi.fn(),
setConnectionState: vi.fn(),
setTriggerDecision: vi.fn(),
setTriggerPending: vi.fn(),
})),
}));
import { listen } from '@tauri-apps/api/event';
const mockListen = vi.mocked(listen);
describe('useTauriEvents', () => {
const mockUnlisten = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockListen.mockResolvedValue(mockUnlisten);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should register event listeners on mount', async () => {
renderHook(() => useTauriEvents());
// Wait for listeners to be registered
await vi.waitFor(() => {
expect(mockListen).toHaveBeenCalled();
});
// Should register listeners for various events
const registeredEvents = mockListen.mock.calls.map((call) => call[0]);
expect(registeredEvents.length).toBeGreaterThan(0);
});
it('should unregister listeners on unmount', async () => {
const { unmount } = renderHook(() => useTauriEvents());
// Wait for listeners to be set up
await vi.waitFor(() => {
expect(mockListen).toHaveBeenCalled();
});
// Unmount
unmount();
// Wait a bit for cleanup
await vi.waitFor(() => {
// Unlisten functions should be called
expect(mockUnlisten).toHaveBeenCalled();
});
});
it('should handle listen failures gracefully', async () => {
// Make listen fail
mockListen.mockRejectedValue(new Error('Listen failed'));
// Should not throw
expect(() => {
renderHook(() => useTauriEvents());
}).not.toThrow();
});
});

View File

@@ -1,107 +0,0 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useEffect, useRef } from 'react';
import { Events, PlaybackStates } from '@/lib/constants';
import { useStore } from '@/store';
import type { ConnectionEvent, PlaybackState, TranscriptUpdate, TriggerDecision } from '@/types';
export function useTauriEvents() {
const store = useStore();
// Use ref to avoid re-subscribing on store changes
const storeRef = useRef(store);
storeRef.current = store;
useEffect(() => {
// Track whether the effect has been cleaned up
let cancelled = false;
// Store unlisten functions that need cleanup
const unlistenFns: UnlistenFn[] = [];
// Register all listeners in parallel, then handle cleanup atomically
const listenerPromises = [
// Transcript updates
listen<TranscriptUpdate>(Events.TRANSCRIPT_UPDATE, (event) => {
if (cancelled) return;
const update = event.payload;
if (update.update_type === 'final' && update.segment) {
storeRef.current.addSegment(update.segment);
} else if (update.update_type === 'partial') {
storeRef.current.setPartialText(update.partial_text);
}
}),
// Audio level
listen<number>(Events.AUDIO_LEVEL, (event) => {
if (cancelled) return;
storeRef.current.setAudioLevel(event.payload);
}),
// Playback position
listen<number>(Events.PLAYBACK_POSITION, (event) => {
if (cancelled) return;
storeRef.current.setPlaybackPosition(event.payload);
}),
// Playback state
listen<string>(Events.PLAYBACK_STATE, (event) => {
if (cancelled) return;
const state = event.payload as PlaybackState;
if (Object.values(PlaybackStates).includes(state)) {
storeRef.current.setPlaybackState(state);
}
}),
// Highlight change
listen<number | null>(Events.HIGHLIGHT_CHANGE, (event) => {
if (cancelled) return;
storeRef.current.setHighlightedSegment(event.payload);
}),
// Connection change
listen<ConnectionEvent>(Events.CONNECTION_CHANGE, (event) => {
if (cancelled) return;
const { connected, server_address } = event.payload;
storeRef.current.setConnectionState(connected, server_address);
}),
// Meeting detected (trigger)
listen<TriggerDecision>(Events.MEETING_DETECTED, (event) => {
if (cancelled) return;
storeRef.current.setTriggerDecision(event.payload);
storeRef.current.setTriggerPending(true);
}),
// Recording timer
listen<number>(Events.RECORDING_TIMER, (event) => {
if (cancelled) return;
storeRef.current.setElapsedSeconds(event.payload);
}),
];
// Wait for all listeners to be registered, then store unlisten functions
// Use allSettled to handle individual listener failures gracefully
Promise.allSettled(listenerPromises).then((results) => {
const fns = results
.filter((r): r is PromiseFulfilledResult<UnlistenFn> => r.status === 'fulfilled')
.map((r) => r.value);
// Log failures but don't leak listeners
results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.forEach((r) => console.error('Failed to register Tauri listener:', r.reason));
if (cancelled) {
// Component unmounted during setup - clean up immediately
fns.forEach((unlisten) => unlisten());
} else {
// Store for cleanup on unmount
unlistenFns.push(...fns);
}
});
// Cleanup: mark as cancelled and clean up any already-registered listeners
return () => {
cancelled = true;
unlistenFns.forEach((unlisten) => unlisten());
};
}, []); // Empty deps - only setup once
}

View File

@@ -1,66 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 220 14% 10%;
--foreground: 210 20% 98%;
--muted: 215 20% 20%;
--muted-foreground: 215 20% 65%;
--popover: 220 14% 12%;
--popover-foreground: 210 20% 98%;
--card: 220 14% 12%;
--card-foreground: 210 20% 98%;
--border: 215 20% 25%;
--input: 215 20% 25%;
--primary: 217 91% 60%;
--primary-foreground: 210 20% 98%;
--secondary: 215 20% 20%;
--secondary-foreground: 210 20% 98%;
--accent: 215 20% 20%;
--accent-foreground: 210 20% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 20% 98%;
--ring: 217 91% 60%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-muted;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}

View File

@@ -1,314 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type Cache, CacheKey, type CacheStats, getCache, resetCache } from './cache';
// Helper to create a test MemoryCache with controlled timing
class TestableMemoryCache implements Cache {
private entries = new Map<string, { value: unknown; expiresAt: number }>();
private hits = 0;
private misses = 0;
private maxItems: number;
private defaultTtlSecs: number;
constructor(maxItems = 1000, defaultTtlSecs = 300) {
this.maxItems = maxItems;
this.defaultTtlSecs = defaultTtlSecs;
}
async get<T>(key: string): Promise<T | null> {
const entry = this.entries.get(key);
if (!entry) {
this.misses++;
return null;
}
if (Date.now() > entry.expiresAt) {
this.entries.delete(key);
this.misses++;
return null;
}
this.hits++;
return entry.value as T;
}
async set<T>(key: string, value: T, ttlSecs?: number): Promise<void> {
if (this.maxItems <= 0) return;
const ttl = ttlSecs ?? this.defaultTtlSecs;
const expiresAt = Date.now() + ttl * 1000;
this.entries.set(key, { value, expiresAt });
}
async delete(key: string): Promise<boolean> {
return this.entries.delete(key);
}
async deleteByPrefix(prefix: string): Promise<number> {
const keysToDelete: string[] = [];
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.entries.delete(key);
}
return keysToDelete.length;
}
async exists(key: string): Promise<boolean> {
const entry = this.entries.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.entries.delete(key);
return false;
}
return true;
}
async clear(): Promise<void> {
this.entries.clear();
this.hits = 0;
this.misses = 0;
}
stats(): CacheStats {
return {
hits: this.hits,
misses: this.misses,
items: this.entries.size,
bytes: 0,
};
}
}
describe('CacheKey', () => {
describe('meeting', () => {
it('should format meeting key correctly', () => {
expect(CacheKey.meeting('123')).toBe('meeting:123');
});
it('should handle UUID meeting IDs', () => {
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
expect(CacheKey.meeting(uuid)).toBe(`meeting:${uuid}`);
});
});
describe('meetingList', () => {
it('should return consistent meeting list key', () => {
expect(CacheKey.meetingList()).toBe('meetings:list');
});
});
describe('segments', () => {
it('should format segments key correctly', () => {
expect(CacheKey.segments('meeting-123')).toBe('segments:meeting-123');
});
});
describe('summary', () => {
it('should format summary key correctly', () => {
expect(CacheKey.summary('meeting-456')).toBe('summary:meeting-456');
});
});
describe('serverInfo', () => {
it('should return consistent server info key', () => {
expect(CacheKey.serverInfo()).toBe('server:info');
});
});
describe('custom', () => {
it('should create custom keys with prefix and id', () => {
expect(CacheKey.custom('config', 'audio')).toBe('config:audio');
expect(CacheKey.custom('user', 'preferences')).toBe('user:preferences');
});
});
});
describe('MemoryCache', () => {
let cache: TestableMemoryCache;
beforeEach(() => {
cache = new TestableMemoryCache(1000, 300);
});
describe('get/set', () => {
it('should store and retrieve values', async () => {
await cache.set('key1', { foo: 'bar' });
const result = await cache.get('key1');
expect(result).toEqual({ foo: 'bar' });
});
it('should return null for missing keys', async () => {
const result = await cache.get('nonexistent');
expect(result).toBeNull();
});
it('should handle different value types', async () => {
await cache.set('string', 'hello');
await cache.set('number', 42);
await cache.set('boolean', true);
await cache.set('array', [1, 2, 3]);
await cache.set('object', { nested: { value: 'test' } });
expect(await cache.get('string')).toBe('hello');
expect(await cache.get('number')).toBe(42);
expect(await cache.get('boolean')).toBe(true);
expect(await cache.get('array')).toEqual([1, 2, 3]);
expect(await cache.get('object')).toEqual({ nested: { value: 'test' } });
});
it('should overwrite existing keys', async () => {
await cache.set('key', 'first');
await cache.set('key', 'second');
expect(await cache.get('key')).toBe('second');
});
});
describe('expiration', () => {
it('should expire entries after TTL', async () => {
vi.useFakeTimers();
await cache.set('expiring', 'value', 1); // 1 second TTL
expect(await cache.get('expiring')).toBe('value');
vi.advanceTimersByTime(2000); // Advance 2 seconds
expect(await cache.get('expiring')).toBeNull();
vi.useRealTimers();
});
});
describe('delete', () => {
it('should delete existing keys', async () => {
await cache.set('key', 'value');
const deleted = await cache.delete('key');
expect(deleted).toBe(true);
expect(await cache.get('key')).toBeNull();
});
it('should return false for missing keys', async () => {
const deleted = await cache.delete('nonexistent');
expect(deleted).toBe(false);
});
});
describe('deleteByPrefix', () => {
it('should delete all keys with matching prefix', async () => {
await cache.set('user:1', 'Alice');
await cache.set('user:2', 'Bob');
await cache.set('user:3', 'Charlie');
await cache.set('other:1', 'Other');
const count = await cache.deleteByPrefix('user:');
expect(count).toBe(3);
expect(await cache.get('user:1')).toBeNull();
expect(await cache.get('user:2')).toBeNull();
expect(await cache.get('user:3')).toBeNull();
expect(await cache.get('other:1')).toBe('Other');
});
it('should return 0 when no keys match', async () => {
await cache.set('key1', 'value1');
const count = await cache.deleteByPrefix('nonexistent:');
expect(count).toBe(0);
});
});
describe('exists', () => {
it('should return true for existing keys', async () => {
await cache.set('key', 'value');
expect(await cache.exists('key')).toBe(true);
});
it('should return false for missing keys', async () => {
expect(await cache.exists('nonexistent')).toBe(false);
});
it('should return false for expired keys', async () => {
vi.useFakeTimers();
await cache.set('expiring', 'value', 1);
vi.advanceTimersByTime(2000);
expect(await cache.exists('expiring')).toBe(false);
vi.useRealTimers();
});
});
describe('clear', () => {
it('should remove all entries', async () => {
await cache.set('key1', 'value1');
await cache.set('key2', 'value2');
await cache.set('key3', 'value3');
await cache.clear();
expect(await cache.get('key1')).toBeNull();
expect(await cache.get('key2')).toBeNull();
expect(await cache.get('key3')).toBeNull();
});
it('should reset statistics', async () => {
await cache.set('key', 'value');
await cache.get('key');
await cache.get('missing');
await cache.clear();
const stats = cache.stats();
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
expect(stats.items).toBe(0);
});
});
describe('stats', () => {
it('should track hits and misses', async () => {
await cache.set('key1', 'value1');
await cache.get('key1'); // hit
await cache.get('key1'); // hit
await cache.get('missing'); // miss
const stats = cache.stats();
expect(stats.hits).toBe(2);
expect(stats.misses).toBe(1);
});
it('should track item count', async () => {
await cache.set('key1', 'value1');
await cache.set('key2', 'value2');
const stats = cache.stats();
expect(stats.items).toBe(2);
});
});
});
describe('cache module integration', () => {
beforeEach(() => {
resetCache();
});
afterEach(() => {
resetCache();
});
it('should create cache instance on first access', () => {
const cache = getCache();
expect(cache).toBeDefined();
expect(cache.get).toBeDefined();
expect(cache.set).toBeDefined();
});
it('should return same instance on subsequent accesses', () => {
const cache1 = getCache();
const cache2 = getCache();
expect(cache1).toBe(cache2);
});
it('should create new instance after reset', () => {
const cache1 = getCache();
resetCache();
const cache2 = getCache();
expect(cache1).not.toBe(cache2);
});
});

View File

@@ -1,523 +0,0 @@
/**
* Cache abstraction layer
*
* Provides a unified caching interface with support for multiple backends:
* - In-memory cache (default)
* - Redis cache (via backend command)
* - No-op cache (disabled)
*/
import { config } from './config';
/**
* Cache entry with metadata
*/
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
/**
* Cache statistics
*/
export interface CacheStats {
hits: number;
misses: number;
items: number;
bytes: number;
}
/**
* Cache interface that all backends implement
*/
export interface Cache {
/**
* Get a value by key
*/
get<T>(key: string): Promise<T | null>;
/**
* Set a value with optional TTL (in seconds)
*/
set<T>(key: string, value: T, ttlSecs?: number): Promise<void>;
/**
* Delete a value by key
*/
delete(key: string): Promise<boolean>;
/**
* Delete all values with keys matching the given prefix
*/
deleteByPrefix(prefix: string): Promise<number>;
/**
* Check if a key exists
*/
exists(key: string): Promise<boolean>;
/**
* Clear all cached values
*/
clear(): Promise<void>;
/**
* Get cache statistics
*/
stats(): CacheStats;
}
/**
* In-memory cache implementation with LRU eviction
*/
class MemoryCache implements Cache {
private entries = new Map<string, CacheEntry<unknown>>();
private accessOrder: string[] = [];
private hits = 0;
private misses = 0;
constructor(
private maxItems: number = 1000,
private defaultTtlSecs: number = 300
) {}
async get<T>(key: string): Promise<T | null> {
const entry = this.entries.get(key);
if (!entry) {
this.misses++;
return null;
}
if (Date.now() > entry.expiresAt) {
this.entries.delete(key);
this.removeFromAccessOrder(key);
this.misses++;
return null;
}
this.hits++;
this.updateAccessOrder(key);
return entry.value as T;
}
async set<T>(key: string, value: T, ttlSecs?: number): Promise<void> {
// Guard against infinite loop if maxItems is zero or negative
if (this.maxItems <= 0) {
return;
}
const ttl = ttlSecs ?? this.defaultTtlSecs;
const expiresAt = Date.now() + ttl * 1000;
// Evict expired entries
this.evictExpired();
// Evict LRU if at capacity
while (this.entries.size >= this.maxItems) {
this.evictLru();
}
this.entries.set(key, { value, expiresAt });
this.updateAccessOrder(key);
}
async delete(key: string): Promise<boolean> {
const existed = this.entries.delete(key);
if (existed) {
this.removeFromAccessOrder(key);
}
return existed;
}
async deleteByPrefix(prefix: string): Promise<number> {
// Collect keys first to avoid mutating map during iteration
const keysToDelete: string[] = [];
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.entries.delete(key);
this.removeFromAccessOrder(key);
}
return keysToDelete.length;
}
async exists(key: string): Promise<boolean> {
const entry = this.entries.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.entries.delete(key);
this.removeFromAccessOrder(key);
return false;
}
return true;
}
async clear(): Promise<void> {
this.entries.clear();
this.accessOrder = [];
this.hits = 0;
this.misses = 0;
}
stats(): CacheStats {
let bytes = 0;
for (const [key, entry] of this.entries) {
bytes += key.length * 2; // Approximate string size
try {
bytes += JSON.stringify(entry.value).length * 2;
} catch {
// Handle non-serializable values (circular refs, etc.)
bytes += 100; // Estimate for non-serializable values
}
}
return {
hits: this.hits,
misses: this.misses,
items: this.entries.size,
bytes,
};
}
private updateAccessOrder(key: string): void {
this.removeFromAccessOrder(key);
this.accessOrder.push(key);
}
private removeFromAccessOrder(key: string): void {
const index = this.accessOrder.indexOf(key);
if (index > -1) {
this.accessOrder.splice(index, 1);
}
}
private evictExpired(): void {
const now = Date.now();
for (const [key, entry] of this.entries) {
if (now > entry.expiresAt) {
this.entries.delete(key);
this.removeFromAccessOrder(key);
}
}
}
private evictLru(): void {
if (this.accessOrder.length > 0) {
const lruKey = this.accessOrder.shift();
if (lruKey) {
this.entries.delete(lruKey);
}
}
}
}
/**
* No-op cache implementation (when caching is disabled)
*/
class NoOpCache implements Cache {
async get<T>(_key: string): Promise<T | null> {
return null;
}
async set<T>(_key: string, _value: T, _ttlSecs?: number): Promise<void> {}
async delete(_key: string): Promise<boolean> {
return false;
}
async deleteByPrefix(_prefix: string): Promise<number> {
return 0;
}
async exists(_key: string): Promise<boolean> {
return false;
}
async clear(): Promise<void> {}
stats(): CacheStats {
return { hits: 0, misses: 0, items: 0, bytes: 0 };
}
}
/**
* Redis cache implementation (delegates to Tauri backend)
*
* Uses atomic fallback switching to prevent race conditions when
* multiple concurrent requests fail and try to switch to memory cache.
*/
class RedisCache implements Cache {
private memoryFallback: MemoryCache;
private usesFallback = false;
private fallbackSwitchPromise: Promise<void> | null = null;
constructor(
private defaultTtlSecs: number = 300,
maxItems: number = 1000
) {
this.memoryFallback = new MemoryCache(maxItems, defaultTtlSecs);
}
/**
* Atomically switch to fallback mode, ensuring only one switch happens
* Note: Error details are not logged to avoid exposing sensitive connection info
*/
private async switchToFallback(): Promise<void> {
if (this.usesFallback) return;
// Use a promise to serialize fallback switching
if (!this.fallbackSwitchPromise) {
this.fallbackSwitchPromise = (async () => {
console.warn('Redis cache unavailable, falling back to memory cache');
this.usesFallback = true;
})();
}
await this.fallbackSwitchPromise;
}
/**
* Execute a Redis operation with automatic fallback to memory cache on failure
*/
private async withFallback<T>(
primary: () => Promise<T>,
fallback: () => Promise<T>
): Promise<T> {
if (this.usesFallback) {
return fallback();
}
try {
return await primary();
} catch {
await this.switchToFallback();
return fallback();
}
}
async get<T>(key: string): Promise<T | null> {
return this.withFallback(
async () => {
const { invoke } = await import('@tauri-apps/api/core');
const result = await invoke<string | null>('cache_get', { key });
if (result === null) return null;
try {
return JSON.parse(result) as T;
} catch {
// Treat as cache miss (corrupt value), not backend failure.
// Best-effort delete to prevent repeated parse failures.
try {
await invoke('cache_delete', { key });
} catch {
// ignore delete failure
}
return null;
}
},
() => this.memoryFallback.get<T>(key)
);
}
async set<T>(key: string, value: T, ttlSecs?: number): Promise<void> {
// Pre-serialize to catch errors before triggering fallback
let serialized: string;
try {
serialized = JSON.stringify(value);
} catch {
// Non-serializable values shouldn't trigger Redis fallback, but can still be cached in memory
await this.memoryFallback.set(key, value, ttlSecs);
return;
}
return this.withFallback(
async () => {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('cache_set', {
key,
value: serialized,
ttlSecs: ttlSecs ?? this.defaultTtlSecs,
});
},
() => this.memoryFallback.set(key, value, ttlSecs)
);
}
async delete(key: string): Promise<boolean> {
return this.withFallback(
async () => {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<boolean>('cache_delete', { key });
},
() => this.memoryFallback.delete(key)
);
}
async deleteByPrefix(prefix: string): Promise<number> {
return this.withFallback(
async () => {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<number>('cache_delete_prefix', { prefix });
},
() => this.memoryFallback.deleteByPrefix(prefix)
);
}
async exists(key: string): Promise<boolean> {
return this.withFallback(
async () => {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<boolean>('cache_exists', { key });
},
() => this.memoryFallback.exists(key)
);
}
async clear(): Promise<void> {
return this.withFallback(
async () => {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('cache_clear');
},
() => this.memoryFallback.clear()
);
}
stats(): CacheStats {
// Redis stats would need a backend call
// For now, return fallback stats
return this.memoryFallback.stats();
}
}
/**
* Create a cache instance based on configuration
*/
function createCache(): Cache {
const cacheConfig = config.getSection('cache');
switch (cacheConfig.backend) {
case 'redis':
return new RedisCache(cacheConfig.defaultTtlSecs, cacheConfig.maxMemoryItems);
case 'none':
return new NoOpCache();
case 'memory':
default:
return new MemoryCache(cacheConfig.maxMemoryItems, cacheConfig.defaultTtlSecs);
}
}
/** Global cache instance */
let cacheInstance: Cache | null = null;
/**
* Get the cache instance (creates on first access)
*/
export function getCache(): Cache {
if (!cacheInstance) {
cacheInstance = createCache();
}
return cacheInstance;
}
/**
* Reset the cache instance (useful for testing or config changes)
*/
export function resetCache(): void {
cacheInstance = null;
}
/**
* Cache key builder for consistent key formatting
*/
export const CacheKey = {
/** Build a key for meeting data */
meeting: (meetingId: string) => `meeting:${meetingId}`,
/** Build a key for meeting list */
meetingList: () => 'meetings:list',
/** Build a key for segments */
segments: (meetingId: string) => `segments:${meetingId}`,
/** Build a key for summary */
summary: (meetingId: string) => `summary:${meetingId}`,
/** Build a key for server info */
serverInfo: () => 'server:info',
/** Build a custom key with prefix */
custom: (prefix: string, id: string) => `${prefix}:${id}`,
} as const;
/**
* Helper to wrap an async function with caching
*/
export function withCache<T, Args extends unknown[]>(
keyFn: (...args: Args) => string,
fn: (...args: Args) => Promise<T>,
ttlSecs?: number
): (...args: Args) => Promise<T> {
return async (...args: Args): Promise<T> => {
const cache = getCache();
const key = keyFn(...args);
// Try cache first
const cached = await cache.get<T>(key);
if (cached !== null) {
return cached;
}
// Call the function
const result = await fn(...args);
// Cache the result (skip null/undefined to avoid permanent misses)
if (result != null) {
await cache.set(key, result, ttlSecs);
}
return result;
};
}
/**
* Decorator-style cache wrapper for class methods
*/
export function cached<T>(
key: string | ((args: unknown[]) => string),
ttlSecs?: number
) {
return (
_target: unknown,
_propertyKey: string,
descriptor: PropertyDescriptor
) => {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: unknown[]) {
const cache = getCache();
const cacheKey = typeof key === 'function' ? key(args) : key;
const cached = await cache.get<T>(cacheKey);
if (cached !== null) {
return cached;
}
const result = await originalMethod.apply(this, args);
// Skip caching null/undefined to avoid permanent misses
if (result != null) {
await cache.set(cacheKey, result, ttlSecs);
}
return result;
};
return descriptor;
};
}

View File

@@ -1,368 +0,0 @@
/**
* Application configuration
*
* Centralized configuration loaded from environment variables or localStorage.
* All configurable values should be defined here rather than hardcoded.
*/
/** Storage key for persisted config */
const CONFIG_STORAGE_KEY = 'noteflow:config';
/**
* Server connection configuration
*/
export interface ServerConfig {
/** Default server address */
defaultAddress: string;
/** Connection timeout in milliseconds */
connectTimeoutMs: number;
/** Request timeout in milliseconds */
requestTimeoutMs: number;
/** Maximum retry attempts */
maxRetries: number;
}
/**
* Audio configuration
*/
export interface AudioConfig {
/** Default sample rate (Hz) */
sampleRate: number;
/** Default number of channels */
channels: number;
/** VU meter update rate (Hz) */
vuUpdateRate: number;
}
/**
* UI configuration
*/
export interface UIConfig {
/** Theme preference */
theme: 'light' | 'dark' | 'system';
/** Show VU meter during recording */
showVuMeter: boolean;
/** Auto-scroll transcript during recording */
autoScrollTranscript: boolean;
/** Highlight duration in ms when segment is clicked */
highlightDurationMs: number;
}
/**
* Trigger configuration
*/
export interface TriggerConfig {
/** Enable trigger detection */
enabled: boolean;
/** Default snooze duration in minutes */
snoozeDurationMinutes: number;
}
/**
* Cache configuration
*/
export interface CacheConfig {
/** Cache backend type */
backend: 'memory' | 'redis' | 'none';
/** Redis URL (if using Redis) */
redisUrl?: string;
/** Default TTL for cached items (seconds) */
defaultTtlSecs: number;
/** Maximum memory cache size (items) */
maxMemoryItems: number;
}
/**
* Full application configuration
*/
export interface AppConfig {
server: ServerConfig;
audio: AudioConfig;
ui: UIConfig;
triggers: TriggerConfig;
cache: CacheConfig;
}
/**
* Get Vite environment variable safely
*/
function getEnv(key: string): string | undefined {
try {
// Access via dynamic property to avoid TypeScript errors
const meta = import.meta as unknown as { env?: Record<string, string> };
return meta.env?.[key];
} catch {
return undefined;
}
}
/**
* Default configuration values
*/
const defaultConfig: AppConfig = {
server: {
defaultAddress: getEnv('VITE_SERVER_ADDRESS') ?? 'localhost:50051',
connectTimeoutMs: 5000,
requestTimeoutMs: 30000,
maxRetries: 3,
},
audio: {
sampleRate: 16000,
channels: 1,
vuUpdateRate: 20,
},
ui: {
theme: 'system',
showVuMeter: true,
autoScrollTranscript: true,
highlightDurationMs: 2000,
},
triggers: {
enabled: false,
snoozeDurationMinutes: 5,
},
cache: {
backend: 'memory',
redisUrl: getEnv('VITE_REDIS_URL'),
defaultTtlSecs: 300,
maxMemoryItems: 1000,
},
};
/**
* Clamp a number to a range
*/
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Validate and sanitize server config
*/
function validateServerConfig(config: Partial<ServerConfig> | undefined, defaults: ServerConfig): ServerConfig {
return {
defaultAddress: typeof config?.defaultAddress === 'string' && config.defaultAddress.length > 0
? config.defaultAddress
: defaults.defaultAddress,
connectTimeoutMs: typeof config?.connectTimeoutMs === 'number'
? clamp(config.connectTimeoutMs, 1000, 60000)
: defaults.connectTimeoutMs,
requestTimeoutMs: typeof config?.requestTimeoutMs === 'number'
? clamp(config.requestTimeoutMs, 1000, 300000)
: defaults.requestTimeoutMs,
maxRetries: typeof config?.maxRetries === 'number'
? clamp(Math.floor(config.maxRetries), 0, 10)
: defaults.maxRetries,
};
}
/**
* Validate and sanitize audio config
*/
function validateAudioConfig(config: Partial<AudioConfig> | undefined, defaults: AudioConfig): AudioConfig {
return {
sampleRate: typeof config?.sampleRate === 'number'
? clamp(Math.floor(config.sampleRate), 8000, 48000)
: defaults.sampleRate,
channels: typeof config?.channels === 'number'
? clamp(Math.floor(config.channels), 1, 2)
: defaults.channels,
vuUpdateRate: typeof config?.vuUpdateRate === 'number'
? clamp(Math.floor(config.vuUpdateRate), 1, 60)
: defaults.vuUpdateRate,
};
}
/**
* Validate and sanitize UI config
*/
function validateUIConfig(config: Partial<UIConfig> | undefined, defaults: UIConfig): UIConfig {
const validThemes = ['light', 'dark', 'system'] as const;
return {
theme: config?.theme && validThemes.includes(config.theme as typeof validThemes[number])
? config.theme
: defaults.theme,
showVuMeter: typeof config?.showVuMeter === 'boolean'
? config.showVuMeter
: defaults.showVuMeter,
autoScrollTranscript: typeof config?.autoScrollTranscript === 'boolean'
? config.autoScrollTranscript
: defaults.autoScrollTranscript,
highlightDurationMs: typeof config?.highlightDurationMs === 'number'
? clamp(Math.floor(config.highlightDurationMs), 500, 10000)
: defaults.highlightDurationMs,
};
}
/**
* Validate and sanitize trigger config
*/
function validateTriggerConfig(config: Partial<TriggerConfig> | undefined, defaults: TriggerConfig): TriggerConfig {
return {
enabled: typeof config?.enabled === 'boolean'
? config.enabled
: defaults.enabled,
snoozeDurationMinutes: typeof config?.snoozeDurationMinutes === 'number'
? clamp(Math.floor(config.snoozeDurationMinutes), 1, 120)
: defaults.snoozeDurationMinutes,
};
}
/**
* Validate and sanitize cache config
*/
function validateCacheConfig(config: Partial<CacheConfig> | undefined, defaults: CacheConfig): CacheConfig {
const validBackends = ['memory', 'redis', 'none'] as const;
return {
backend: config?.backend && validBackends.includes(config.backend as typeof validBackends[number])
? config.backend
: defaults.backend,
// redisUrl is intentionally not loaded from localStorage for security
redisUrl: defaults.redisUrl,
defaultTtlSecs: typeof config?.defaultTtlSecs === 'number'
? clamp(Math.floor(config.defaultTtlSecs), 1, 86400)
: defaults.defaultTtlSecs,
maxMemoryItems: typeof config?.maxMemoryItems === 'number'
? clamp(Math.floor(config.maxMemoryItems), 10, 100000)
: defaults.maxMemoryItems,
};
}
/**
* Configuration manager class
*/
class ConfigManager {
private config: AppConfig;
private listeners: Set<(config: AppConfig) => void> = new Set();
constructor() {
this.config = this.loadConfig();
}
/**
* Load configuration from localStorage, merging with defaults
* Validates all values against expected types and bounds
*/
private loadConfig(): AppConfig {
try {
const stored = localStorage.getItem(CONFIG_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppConfig>;
return this.validateAndMergeConfig(defaultConfig, parsed);
}
} catch {
console.warn('Failed to load config from localStorage, using defaults');
}
return { ...defaultConfig };
}
/**
* Validate and merge configuration objects with type checking and bounds
*/
private validateAndMergeConfig(base: AppConfig, override: Partial<AppConfig>): AppConfig {
return {
server: validateServerConfig(override.server, base.server),
audio: validateAudioConfig(override.audio, base.audio),
ui: validateUIConfig(override.ui, base.ui),
triggers: validateTriggerConfig(override.triggers, base.triggers),
cache: validateCacheConfig(override.cache, base.cache),
};
}
/**
* Simple merge for internal updates (already validated)
*/
private mergeConfig(base: AppConfig, override: Partial<AppConfig>): AppConfig {
return this.validateAndMergeConfig(base, override);
}
/**
* Get current configuration
*/
get(): AppConfig {
return { ...this.config };
}
/**
* Get a specific config section
*/
getSection<K extends keyof AppConfig>(section: K): AppConfig[K] {
return { ...this.config[section] };
}
/**
* Update configuration
*/
set(updates: Partial<AppConfig>): void {
this.config = this.mergeConfig(this.config, updates);
this.persist();
this.notifyListeners();
}
/**
* Update a specific section (validates updates through mergeConfig)
*/
setSection<K extends keyof AppConfig>(
section: K,
updates: Partial<AppConfig[K]>
): void {
this.config = this.mergeConfig(this.config, {
[section]: { ...this.config[section], ...updates },
} as Partial<AppConfig>);
this.persist();
this.notifyListeners();
}
/**
* Reset configuration to defaults
*/
reset(): void {
this.config = { ...defaultConfig };
localStorage.removeItem(CONFIG_STORAGE_KEY);
this.notifyListeners();
}
/**
* Subscribe to configuration changes
*/
subscribe(listener: (config: AppConfig) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* Persist configuration to localStorage
*/
private persist(): void {
try {
localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(this.config));
} catch {
console.warn('Failed to persist config to localStorage');
}
}
/**
* Notify all listeners of configuration changes
*/
private notifyListeners(): void {
const config = this.get();
this.listeners.forEach((listener) => listener(config));
}
}
/** Global configuration instance */
export const config = new ConfigManager();
/**
* React hook for using configuration
*/
export function useConfig(): AppConfig;
export function useConfig<K extends keyof AppConfig>(section: K): AppConfig[K];
export function useConfig<K extends keyof AppConfig>(section?: K) {
// This would integrate with React state management
// For now, just return the current config
if (section) {
return config.getSection(section);
}
return config.get();
}

View File

@@ -1,103 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
AnnotationTypes,
Defaults,
Events,
ExportFormats,
MeetingStates,
PlaybackStates,
ViewModes,
} from './constants';
describe('Events', () => {
it('should have uppercase event names', () => {
Object.values(Events).forEach((event) => {
expect(event).toBe(event.toUpperCase());
});
});
it('should have all required events', () => {
expect(Events.TRANSCRIPT_UPDATE).toBeDefined();
expect(Events.AUDIO_LEVEL).toBeDefined();
expect(Events.PLAYBACK_POSITION).toBeDefined();
expect(Events.PLAYBACK_STATE).toBeDefined();
expect(Events.HIGHLIGHT_CHANGE).toBeDefined();
expect(Events.CONNECTION_CHANGE).toBeDefined();
expect(Events.MEETING_DETECTED).toBeDefined();
expect(Events.RECORDING_TIMER).toBeDefined();
expect(Events.ERROR).toBeDefined();
expect(Events.SUMMARY_PROGRESS).toBeDefined();
expect(Events.DIARIZATION_PROGRESS).toBeDefined();
});
it('should have unique event names', () => {
const values = Object.values(Events);
const uniqueValues = new Set(values);
expect(uniqueValues.size).toBe(values.length);
});
});
describe('PlaybackStates', () => {
it('should have all playback states', () => {
expect(PlaybackStates.STOPPED).toBe('stopped');
expect(PlaybackStates.PLAYING).toBe('playing');
expect(PlaybackStates.PAUSED).toBe('paused');
});
it('should have exactly 3 states', () => {
expect(Object.keys(PlaybackStates)).toHaveLength(3);
});
});
describe('AnnotationTypes', () => {
it('should have all annotation types', () => {
expect(AnnotationTypes.ACTION_ITEM).toBe('action_item');
expect(AnnotationTypes.DECISION).toBe('decision');
expect(AnnotationTypes.NOTE).toBe('note');
expect(AnnotationTypes.RISK).toBe('risk');
});
});
describe('ExportFormats', () => {
it('should have markdown format', () => {
expect(ExportFormats.MARKDOWN).toBe('markdown');
});
it('should have html format', () => {
expect(ExportFormats.HTML).toBe('html');
});
});
describe('MeetingStates', () => {
it('should have all meeting states', () => {
expect(MeetingStates.UNSPECIFIED).toBe('unspecified');
expect(MeetingStates.CREATED).toBe('created');
expect(MeetingStates.RECORDING).toBe('recording');
expect(MeetingStates.STOPPED).toBe('stopped');
expect(MeetingStates.COMPLETED).toBe('completed');
expect(MeetingStates.ERROR).toBe('error');
expect(MeetingStates.STOPPING).toBe('stopping');
});
});
describe('ViewModes', () => {
it('should have all view modes', () => {
expect(ViewModes.RECORDING).toBe('recording');
expect(ViewModes.REVIEW).toBe('review');
expect(ViewModes.LIBRARY).toBe('library');
});
});
describe('Defaults', () => {
it('should have default server address', () => {
expect(Defaults.SERVER_ADDRESS).toBe('localhost:50051');
});
it('should have default recording title prefix', () => {
expect(Defaults.RECORDING_TITLE_PREFIX).toBe('Meeting');
});
it('should have snooze duration', () => {
expect(Defaults.SNOOZE_MINUTES).toBe(5);
});
});

View File

@@ -1,114 +0,0 @@
/**
* Application-wide constants
*
* Centralized location for all hardcoded values to ensure consistency
* between TypeScript frontend and Rust backend.
*/
/**
* Event names emitted from backend to frontend
* Must match the event names in src-tauri/src/constants.rs
*/
export const Events = {
/** Transcript update (partial or final segment) */
TRANSCRIPT_UPDATE: 'TRANSCRIPT_UPDATE',
/** Audio level change (normalized 0.0-1.0) */
AUDIO_LEVEL: 'AUDIO_LEVEL',
/** Playback position change (seconds) */
PLAYBACK_POSITION: 'PLAYBACK_POSITION',
/** Playback state change (playing/paused/stopped) */
PLAYBACK_STATE: 'PLAYBACK_STATE',
/** Highlighted segment index change */
HIGHLIGHT_CHANGE: 'HIGHLIGHT_CHANGE',
/** Connection state change */
CONNECTION_CHANGE: 'CONNECTION_CHANGE',
/** Meeting detected by trigger system */
MEETING_DETECTED: 'MEETING_DETECTED',
/** Recording timer tick (elapsed seconds) */
RECORDING_TIMER: 'RECORDING_TIMER',
/** Error event */
ERROR: 'ERROR',
/** Summary generation progress */
SUMMARY_PROGRESS: 'SUMMARY_PROGRESS',
/** Diarization job progress */
DIARIZATION_PROGRESS: 'DIARIZATION_PROGRESS',
} as const;
/** Type for event names */
export type EventName = (typeof Events)[keyof typeof Events];
/**
* Playback states
*/
export const PlaybackStates = {
STOPPED: 'stopped',
PLAYING: 'playing',
PAUSED: 'paused',
} as const;
/** Type for playback states */
export type PlaybackStateName = (typeof PlaybackStates)[keyof typeof PlaybackStates];
/**
* Annotation types
*/
export const AnnotationTypes = {
ACTION_ITEM: 'action_item',
DECISION: 'decision',
NOTE: 'note',
RISK: 'risk',
} as const;
/** Type for annotation types */
export type AnnotationTypeName = (typeof AnnotationTypes)[keyof typeof AnnotationTypes];
/**
* Export formats
*/
export const ExportFormats = {
MARKDOWN: 'markdown',
HTML: 'html',
} as const;
/** Type for export formats */
export type ExportFormatName = (typeof ExportFormats)[keyof typeof ExportFormats];
/**
* Meeting states
*/
export const MeetingStates = {
UNSPECIFIED: 'unspecified',
CREATED: 'created',
RECORDING: 'recording',
STOPPED: 'stopped',
COMPLETED: 'completed',
ERROR: 'error',
STOPPING: 'stopping',
} as const;
/** Type for meeting states */
export type MeetingStateName = (typeof MeetingStates)[keyof typeof MeetingStates];
/**
* View modes for the main application
*/
export const ViewModes = {
RECORDING: 'recording',
REVIEW: 'review',
LIBRARY: 'library',
} as const;
/** Type for view modes */
export type ViewModeName = (typeof ViewModes)[keyof typeof ViewModes];
/**
* Default values
*/
export const Defaults = {
/** Default server address */
SERVER_ADDRESS: 'localhost:50051',
/** Default recording title prefix */
RECORDING_TITLE_PREFIX: 'Meeting',
/** Trigger snooze duration in minutes */
SNOOZE_MINUTES: 5,
} as const;

View File

@@ -1,380 +0,0 @@
/**
* Tauri API facade - Service-based API for Tauri commands
*
* This module provides typed service classes for all Tauri IPC communication.
* Services are organized by domain for better maintainability and type safety.
*/
import { invoke } from '@tauri-apps/api/core';
import type {
AnnotationInfo,
AppStatus,
AudioDeviceInfo,
DiarizationResult,
ExportResult,
MeetingDetails,
MeetingInfo,
PlaybackInfo,
RenameSpeakerResult,
ServerInfo,
SummaryInfo,
TriggerStatus,
UserPreferences,
} from '@/types';
import { CacheKey, getCache } from './cache';
/** Cache TTL constants (in seconds) */
const CACHE_TTL = {
SERVER_INFO: 60,
MEETING_LIST: 30,
MEETING_DETAILS: 120,
SUMMARY: 300,
} as const;
/**
* Connection service - manages server connection lifecycle
*/
class ConnectionService {
async connect(address: string): Promise<ServerInfo> {
// Clear cached server info on new connection
const cache = getCache();
await cache.delete(CacheKey.serverInfo());
return invoke('connect', { address });
}
async disconnect(): Promise<void> {
// Clear cached server info on disconnect
const cache = getCache();
await cache.delete(CacheKey.serverInfo());
return invoke('disconnect');
}
async getServerInfo(): Promise<ServerInfo | null> {
const cache = getCache();
const key = CacheKey.serverInfo();
// Try cache first
const cached = await cache.get<ServerInfo>(key);
if (cached) return cached;
// Fetch from backend
const info = await invoke<ServerInfo | null>('get_server_info');
if (info) {
await cache.set(key, info, CACHE_TTL.SERVER_INFO);
}
return info;
}
getStatus(): Promise<AppStatus> {
return invoke('get_status');
}
}
/**
* Recording service - manages recording sessions
*/
class RecordingService {
start(title: string): Promise<MeetingInfo> {
return invoke('start_recording', { title });
}
async stop(): Promise<MeetingInfo> {
const result = await invoke<MeetingInfo>('stop_recording');
// Invalidate all meeting list cache variants after recording stops
const cache = getCache();
await cache.deleteByPrefix('meetings:list');
return result;
}
}
/**
* Meeting service - manages meetings CRUD operations
*/
class MeetingService {
async list(
states?: string[],
limit?: number,
offset?: number,
sortDesc?: boolean
): Promise<[MeetingInfo[], number]> {
// Meeting list can be cached with a short TTL for quick navigation
const cache = getCache();
const key = CacheKey.custom('meetings:list', `${states?.join(',') ?? 'all'}:${limit ?? 0}:${offset ?? 0}:${sortDesc ?? false}`);
const cached = await cache.get<[MeetingInfo[], number]>(key);
if (cached) return cached;
const result = await invoke<[MeetingInfo[], number]>('list_meetings', { states, limit, offset, sort_desc: sortDesc });
await cache.set(key, result, CACHE_TTL.MEETING_LIST);
return result;
}
async get(
meetingId: string,
includeSegments: boolean,
includeSummary: boolean
): Promise<MeetingDetails> {
const cache = getCache();
const key = CacheKey.custom('meeting', `${meetingId}:${includeSegments}:${includeSummary}`);
const cached = await cache.get<MeetingDetails>(key);
if (cached) return cached;
const result = await invoke<MeetingDetails>('get_meeting', { meeting_id: meetingId, include_segments: includeSegments, include_summary: includeSummary });
await cache.set(key, result, CACHE_TTL.MEETING_DETAILS);
return result;
}
async delete(meetingId: string): Promise<boolean> {
// Perform the delete operation first
const result = await invoke<boolean>('delete_meeting', { meeting_id: meetingId });
// Only invalidate caches after successful deletion
if (result) {
const cache = getCache();
// Remove meeting-specific cached variants (avoid prefix-collisions across ids)
await cache.delete(CacheKey.meeting(meetingId));
await cache.deleteByPrefix(`meeting:${meetingId}:`);
await cache.delete(CacheKey.segments(meetingId));
await cache.delete(CacheKey.summary(meetingId));
// Invalidate all meeting list cache variants (parameterized by states/limit/offset/sort)
await cache.deleteByPrefix('meetings:list');
}
return result;
}
select(meetingId: string): Promise<MeetingDetails> {
return invoke('select_meeting', { meeting_id: meetingId });
}
/**
* Invalidate meeting-related caches without affecting unrelated entries (e.g. serverInfo)
* @param meetingId - If provided, invalidates caches for this specific meeting
*/
async invalidateCache(meetingId?: string): Promise<void> {
const cache = getCache();
// Invalidate per-meeting caches if a specific meetingId is provided
if (meetingId) {
await cache.delete(CacheKey.meeting(meetingId));
await cache.delete(CacheKey.segments(meetingId));
await cache.delete(CacheKey.summary(meetingId));
}
// Invalidate all meeting list cache variants without touching unrelated caches
await cache.deleteByPrefix('meetings:list');
await cache.deleteByPrefix('meeting:');
}
}
/**
* Annotation service - manages meeting annotations
*/
class AnnotationService {
add(
meetingId: string,
annotationType: string,
text: string,
startTime: number,
endTime: number,
segmentIds?: number[]
): Promise<AnnotationInfo> {
return invoke('add_annotation', {
meeting_id: meetingId,
annotation_type: annotationType,
text,
start_time: startTime,
end_time: endTime,
segment_ids: segmentIds,
});
}
get(annotationId: string): Promise<AnnotationInfo> {
return invoke('get_annotation', { annotation_id: annotationId });
}
list(meetingId: string, startTime?: number, endTime?: number): Promise<AnnotationInfo[]> {
return invoke('list_annotations', { meeting_id: meetingId, start_time: startTime, end_time: endTime });
}
update(
annotationId: string,
annotationType?: string,
text?: string,
startTime?: number,
endTime?: number,
segmentIds?: number[]
): Promise<AnnotationInfo> {
return invoke('update_annotation', {
annotation_id: annotationId,
annotation_type: annotationType,
text,
start_time: startTime,
end_time: endTime,
segment_ids: segmentIds,
});
}
delete(annotationId: string): Promise<boolean> {
return invoke('delete_annotation', { annotation_id: annotationId });
}
}
/**
* Summary service - manages AI-generated summaries
*/
class SummaryService {
generate(meetingId: string, forceRegenerate: boolean): Promise<SummaryInfo> {
return invoke('generate_summary', { meeting_id: meetingId, force_regenerate: forceRegenerate });
}
}
/**
* Export service - manages transcript export
*/
class ExportService {
transcript(meetingId: string, format: string): Promise<ExportResult> {
return invoke('export_transcript', { meeting_id: meetingId, format });
}
saveFile(content: string, defaultName: string, extension: string): Promise<boolean> {
return invoke('save_export_file', { content, default_name: defaultName, extension });
}
}
/**
* Diarization service - manages speaker diarization
*/
class DiarizationService {
refine(meetingId: string, numSpeakers?: number): Promise<DiarizationResult> {
return invoke('refine_speaker_diarization', { meeting_id: meetingId, num_speakers: numSpeakers });
}
getJobStatus(jobId: string): Promise<DiarizationResult> {
return invoke('get_diarization_job_status', { job_id: jobId });
}
renameSpeaker(
meetingId: string,
oldSpeakerId: string,
newSpeakerName: string
): Promise<RenameSpeakerResult> {
return invoke('rename_speaker', { meeting_id: meetingId, old_speaker_id: oldSpeakerId, new_speaker_name: newSpeakerName });
}
}
/**
* Playback service - manages audio playback controls
*/
class PlaybackService {
play(): Promise<void> {
return invoke('play');
}
pause(): Promise<void> {
return invoke('pause');
}
stop(): Promise<void> {
return invoke('stop');
}
seek(position: number): Promise<void> {
return invoke('seek', { position });
}
getState(): Promise<PlaybackInfo> {
return invoke('get_playback_state');
}
}
/**
* Trigger service - manages auto-start triggers
*/
class TriggerService {
setEnabled(enabled: boolean): Promise<void> {
return invoke('set_trigger_enabled', { enabled });
}
snooze(minutes?: number): Promise<void> {
return invoke('snooze_triggers', { minutes });
}
resetSnooze(): Promise<void> {
return invoke('reset_snooze');
}
getStatus(): Promise<TriggerStatus> {
return invoke('get_trigger_status');
}
dismiss(): Promise<void> {
return invoke('dismiss_trigger');
}
accept(title?: string): Promise<MeetingInfo> {
return invoke('accept_trigger', { title });
}
}
/**
* Audio service - manages audio device configuration
*/
class AudioService {
listDevices(): Promise<AudioDeviceInfo[]> {
return invoke('list_audio_devices');
}
selectDevice(deviceId?: number): Promise<void> {
const payload: Record<string, unknown> = {};
if (deviceId !== undefined) payload.device_id = deviceId;
return invoke('select_audio_device', payload);
}
getCurrentDevice(): Promise<AudioDeviceInfo | null> {
return invoke('get_current_device');
}
}
/**
* Preferences service - manages user preferences
*/
class PreferencesService {
get(): Promise<UserPreferences> {
return invoke('get_preferences');
}
save(preferences: UserPreferences): Promise<void> {
return invoke('save_preferences', { preferences });
}
}
/**
* NoteFlow API facade - unified interface for all Tauri commands
*
* Usage:
* ```ts
* import { api } from '@/lib/tauri';
*
* await api.connection.connect('localhost:50051');
* await api.recording.start('My Meeting');
* await api.playback.play();
* ```
*/
class NoteFlowApi {
readonly connection = new ConnectionService();
readonly recording = new RecordingService();
readonly meetings = new MeetingService();
readonly annotations = new AnnotationService();
readonly summary = new SummaryService();
readonly export = new ExportService();
readonly diarization = new DiarizationService();
readonly playback = new PlaybackService();
readonly triggers = new TriggerService();
readonly audio = new AudioService();
readonly preferences = new PreferencesService();
}
/** Singleton API instance */
export const api = new NoteFlowApi();

Some files were not shown because too many files have changed in this diff Show More