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:
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
7223
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
7648
client/src-tauri/Cargo.lock
generated
7648
client/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 |
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
381
client/src-tauri/src/cache/memory.rs
vendored
381
client/src-tauri/src/cache/memory.rs
vendored
@@ -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));
|
||||
}
|
||||
}
|
||||
214
client/src-tauri/src/cache/mod.rs
vendored
214
client/src-tauri/src/cache/mod.rs
vendored
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))?
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { AnnotationBadge } from './AnnotationBadge';
|
||||
export { AnnotationList } from './AnnotationList';
|
||||
export { AnnotationToolbar } from './AnnotationToolbar';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { SettingsField } from './SettingsField';
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
export { SettingsSection } from './SettingsSection';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user