diff --git a/.auto-claude-security.json b/.auto-claude-security.json new file mode 100644 index 0000000..bbd9da5 --- /dev/null +++ b/.auto-claude-security.json @@ -0,0 +1,217 @@ +{ + "base_commands": [ + ".", + "[", + "[[", + "ag", + "awk", + "basename", + "bash", + "bc", + "break", + "cat", + "cd", + "chmod", + "clear", + "cmp", + "column", + "comm", + "command", + "continue", + "cp", + "curl", + "cut", + "date", + "df", + "diff", + "dig", + "dirname", + "du", + "echo", + "egrep", + "env", + "eval", + "exec", + "exit", + "expand", + "export", + "expr", + "false", + "fd", + "fgrep", + "file", + "find", + "fmt", + "fold", + "gawk", + "gh", + "git", + "grep", + "gunzip", + "gzip", + "head", + "help", + "host", + "iconv", + "id", + "jobs", + "join", + "jq", + "kill", + "killall", + "less", + "let", + "ln", + "ls", + "lsof", + "man", + "mkdir", + "mktemp", + "more", + "mv", + "nl", + "paste", + "pgrep", + "ping", + "pkill", + "popd", + "printenv", + "printf", + "ps", + "pushd", + "pwd", + "read", + "readlink", + "realpath", + "reset", + "return", + "rev", + "rg", + "rm", + "rmdir", + "sed", + "seq", + "set", + "sh", + "shuf", + "sleep", + "sort", + "source", + "split", + "stat", + "tail", + "tar", + "tee", + "test", + "time", + "timeout", + "touch", + "tr", + "tree", + "true", + "type", + "uname", + "unexpand", + "uniq", + "unset", + "unzip", + "watch", + "wc", + "wget", + "whereis", + "which", + "whoami", + "xargs", + "yes", + "yq", + "zip", + "zsh" + ], + "stack_commands": [ + "ar", + "clang", + "clang++", + "cmake", + "composer", + "eslint", + "g++", + "gcc", + "ipython", + "jupyter", + "ld", + "make", + "meson", + "ninja", + "nm", + "node", + "notebook", + "npm", + "npx", + "objdump", + "pdb", + "php", + "pip", + "pip3", + "pipx", + "pudb", + "python", + "python3", + "react-scripts", + "strip", + "ts-node", + "tsc", + "tsx", + "vite" + ], + "script_commands": [ + "bun", + "npm", + "pnpm", + "yarn" + ], + "custom_commands": [], + "detected_stack": { + "languages": [ + "python", + "javascript", + "typescript", + "php", + "c", + "cpp" + ], + "package_managers": [ + "npm", + "pip" + ], + "frameworks": [ + "react", + "vite", + "eslint" + ], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [ + "dev", + "dev:watch", + "vite", + "pyloid", + "pyloid:watch", + "build", + "build:installer", + "setup" + ], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": "D:\\dev\\personal\\VoiceFlow-fresh", + "created_at": "2026-01-14T18:09:48.602484", + "project_hash": "f43790d42262b3ae0f34be772dfa0899", + "inherited_from": "D:\\dev\\personal\\VoiceFlow-fresh" +} \ No newline at end of file diff --git a/.auto-claude-status b/.auto-claude-status new file mode 100644 index 0000000..4b815ee --- /dev/null +++ b/.auto-claude-status @@ -0,0 +1,25 @@ +{ + "active": true, + "spec": "002-bulk-delete-history-items", + "state": "building", + "subtasks": { + "completed": 10, + "total": 11, + "in_progress": 1, + "failed": 0 + }, + "phase": { + "current": "Integration Testing", + "id": null, + "total": 1 + }, + "workers": { + "active": 0, + "max": 1 + }, + "session": { + "number": 11, + "started_at": "2026-01-14T23:06:52.356672" + }, + "last_update": "2026-01-14T23:30:24.885220" +} \ No newline at end of file diff --git a/.claude_settings.json b/.claude_settings.json new file mode 100644 index 0000000..481f501 --- /dev/null +++ b/.claude_settings.json @@ -0,0 +1,39 @@ +{ + "sandbox": { + "enabled": true, + "autoAllowBashIfSandboxed": true + }, + "permissions": { + "defaultMode": "acceptEdits", + "allow": [ + "Read(./**)", + "Write(./**)", + "Edit(./**)", + "Glob(./**)", + "Grep(./**)", + "Read(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude\\worktrees\\tasks\\002-bulk-delete-history-items/**)", + "Write(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude\\worktrees\\tasks\\002-bulk-delete-history-items/**)", + "Edit(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude\\worktrees\\tasks\\002-bulk-delete-history-items/**)", + "Glob(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude\\worktrees\\tasks\\002-bulk-delete-history-items/**)", + "Grep(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude\\worktrees\\tasks\\002-bulk-delete-history-items/**)", + "Read(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude\\worktrees\\tasks\\002-bulk-delete-history-items\\.auto-claude\\specs\\002-bulk-delete-history-items/**)", + "Write(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude\\worktrees\\tasks\\002-bulk-delete-history-items\\.auto-claude\\specs\\002-bulk-delete-history-items/**)", + "Edit(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude\\worktrees\\tasks\\002-bulk-delete-history-items\\.auto-claude\\specs\\002-bulk-delete-history-items/**)", + "Read(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude/**)", + "Write(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude/**)", + "Edit(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude/**)", + "Glob(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude/**)", + "Grep(D:\\dev\\personal\\VoiceFlow-fresh\\.auto-claude/**)", + "Bash(*)", + "WebFetch(*)", + "WebSearch(*)", + "mcp__context7__resolve-library-id(*)", + "mcp__context7__get-library-docs(*)", + "mcp__graphiti-memory__search_nodes(*)", + "mcp__graphiti-memory__search_facts(*)", + "mcp__graphiti-memory__add_episode(*)", + "mcp__graphiti-memory__get_episodes(*)", + "mcp__graphiti-memory__get_entity_edge(*)" + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index a653d5a..43a2828 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ docs/plans/ *.spec build_error_log.txt + +# Auto Claude data directory +.auto-claude/ diff --git a/src-pyloid/app_controller.py b/src-pyloid/app_controller.py index d4624a2..1606146 100644 --- a/src-pyloid/app_controller.py +++ b/src-pyloid/app_controller.py @@ -294,6 +294,10 @@ def get_history(self, limit: int = 100, offset: int = 0, search: str = None, inc def delete_history(self, history_id: int): self.db.delete_history(history_id) + def delete_history_bulk(self, history_ids: list): + info(f"Deleting {len(history_ids)} history items") + self.db.delete_history_bulk(history_ids) + def get_stats(self) -> dict: return self.db.get_stats() diff --git a/src-pyloid/server.py b/src-pyloid/server.py index 7fcc7bd..f51047b 100644 --- a/src-pyloid/server.py +++ b/src-pyloid/server.py @@ -243,6 +243,13 @@ async def delete_history(history_id: int): return {"success": True} +@server.method() +async def delete_history_bulk(history_ids: list): + controller = get_controller() + controller.delete_history_bulk(history_ids) + return {"success": True} + + @server.method() async def copy_to_clipboard(text: str): controller = get_controller() diff --git a/src-pyloid/services/database.py b/src-pyloid/services/database.py index 6facc08..4c57b91 100644 --- a/src-pyloid/services/database.py +++ b/src-pyloid/services/database.py @@ -241,6 +241,45 @@ def delete_history(self, history_id: int): conn.commit() conn.close() + def delete_history_bulk(self, history_ids: list[int]): + """Delete multiple history entries and their audio files in a transaction.""" + if not history_ids: + return + + conn = self._get_connection() + cursor = conn.cursor() + + try: + # Fetch all entries to get audio metadata + placeholders = ",".join("?" * len(history_ids)) + cursor.execute( + f""" + SELECT id, audio_relpath + FROM history + WHERE id IN ({placeholders}) + """, + history_ids, + ) + entries = cursor.fetchall() + + # Delete audio files for entries that have audio + for entry in entries: + if entry["audio_relpath"]: + self._delete_audio_file(entry["audio_relpath"]) + + # Delete all records in a transaction + cursor.execute( + f"DELETE FROM history WHERE id IN ({placeholders})", + history_ids, + ) + + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + def clear_old_history(self, days: int): """Clear history older than specified days. -1 means keep forever.""" if days < 0: diff --git a/src-pyloid/tests/test_database.py b/src-pyloid/tests/test_database.py new file mode 100644 index 0000000..6f57182 --- /dev/null +++ b/src-pyloid/tests/test_database.py @@ -0,0 +1,237 @@ +""" +Tests for database service bulk delete functionality. + +Requirements: +- Test delete_history_bulk deletes multiple records in transaction +- Test audio file cleanup for all deleted entries +- Test empty list edge case handling +- Test invalid IDs handling +""" +import pytest +import tempfile +from pathlib import Path +from unittest.mock import patch, call +from services.database import DatabaseService + + +@pytest.fixture +def db_service(): + """Create a temporary database for testing.""" + with tempfile.TemporaryDirectory() as temp_dir: + test_db_path = Path(temp_dir) / "test.db" + db = DatabaseService(str(test_db_path)) + yield db + # Cleanup handled by tempfile + + +def test_delete_history_bulk(db_service): + """Verify delete_history_bulk deletes multiple records in transaction.""" + # 1. Create test entries + ids = [] + for i in range(5): + entry_id = db_service.add_history( + text=f"Test transcription {i}", + audio_relpath=None, + audio_duration_ms=None, + audio_size_bytes=None, + audio_mime=None, + ) + ids.append(entry_id) + + # 2. Delete 3 entries + to_delete = [ids[0], ids[1], ids[2]] + db_service.delete_history_bulk(to_delete) + + # 3. Verify deleted entries are gone + for deleted_id in to_delete: + entry = db_service.get_history_entry(deleted_id) + assert entry is None, f"Entry {deleted_id} should be deleted" + + # 4. Verify remaining entries still exist + for kept_id in [ids[3], ids[4]]: + entry = db_service.get_history_entry(kept_id) + assert entry is not None, f"Entry {kept_id} should still exist" + assert entry["id"] == kept_id + assert "Test transcription" in entry["text"] + + +def test_delete_history_bulk_with_audio(db_service): + """Verify audio files are cleaned up for all deleted entries.""" + # 1. Create test entries with audio files + ids = [] + audio_paths = [] + for i in range(3): + audio_relpath = f"audio/test_{i}.wav" + audio_paths.append(audio_relpath) + entry_id = db_service.add_history( + text=f"Test transcription with audio {i}", + audio_relpath=audio_relpath, + audio_duration_ms=1000 + i * 100, + audio_size_bytes=5000 + i * 500, + audio_mime="audio/wav", + ) + ids.append(entry_id) + + # 2. Mock _delete_audio_file to verify it's called + with patch.object(db_service, '_delete_audio_file') as mock_delete_audio: + # 3. Delete all 3 entries + db_service.delete_history_bulk(ids) + + # 4. Verify _delete_audio_file was called for each audio file + assert mock_delete_audio.call_count == 3, "Should delete audio for all 3 entries" + + # Verify it was called with the correct paths + expected_calls = [call(audio_paths[0]), call(audio_paths[1]), call(audio_paths[2])] + mock_delete_audio.assert_has_calls(expected_calls, any_order=True) + + # 5. Verify all entries deleted from database + for deleted_id in ids: + entry = db_service.get_history_entry(deleted_id) + assert entry is None, f"Entry {deleted_id} should be deleted" + + +def test_delete_history_bulk_empty_list(db_service): + """Verify empty ID list doesn't error.""" + # Create a test entry to verify database still works + entry_id = db_service.add_history( + text="Test entry", + audio_relpath=None, + audio_duration_ms=None, + audio_size_bytes=None, + audio_mime=None, + ) + + # Should not raise any exception + db_service.delete_history_bulk([]) + + # Verify database still functions normally + entry = db_service.get_history_entry(entry_id) + assert entry is not None, "Entry should still exist after empty bulk delete" + assert entry["text"] == "Test entry" + + # Verify we can still add entries + new_id = db_service.add_history( + text="New entry after empty delete", + audio_relpath=None, + audio_duration_ms=None, + audio_size_bytes=None, + audio_mime=None, + ) + assert new_id is not None, "Should be able to add entries after empty bulk delete" + + +def test_delete_history_bulk_invalid_ids(db_service): + """Verify invalid IDs are handled gracefully.""" + # Create a test entry + entry_id = db_service.add_history( + text="Valid entry", + audio_relpath=None, + audio_duration_ms=None, + audio_size_bytes=None, + audio_mime=None, + ) + + # Try to delete non-existent IDs (should not raise exception) + db_service.delete_history_bulk([9999, 10000]) + + # Verify the valid entry still exists + entry = db_service.get_history_entry(entry_id) + assert entry is not None, "Valid entry should still exist" + assert entry["text"] == "Valid entry" + + +def test_delete_history_bulk_transaction_integrity(db_service): + """Verify bulk delete is transactional (all or nothing).""" + # 1. Create test entries + ids = [] + for i in range(3): + entry_id = db_service.add_history( + text=f"Test entry {i}", + audio_relpath=None, + audio_duration_ms=None, + audio_size_bytes=None, + audio_mime=None, + ) + ids.append(entry_id) + + # 2. Mock _delete_audio_file to raise an exception on second call + # This simulates a failure during bulk delete + call_count = [0] + + def side_effect_raise_on_second(relpath): + call_count[0] += 1 + if call_count[0] == 2: + raise Exception("Simulated audio deletion failure") + + with patch.object(db_service, '_delete_audio_file', side_effect=side_effect_raise_on_second): + # Add audio to the entries + for i, entry_id in enumerate(ids): + db_service.update_history_audio( + history_id=entry_id, + audio_relpath=f"audio/test_{i}.wav", + audio_duration_ms=1000, + audio_size_bytes=5000, + audio_mime="audio/wav", + ) + + # 3. Try to delete - should raise exception + with pytest.raises(Exception, match="Simulated audio deletion failure"): + db_service.delete_history_bulk(ids) + + # 4. Verify transaction was rolled back - all entries should still exist + for entry_id in ids: + entry = db_service.get_history_entry(entry_id) + assert entry is not None, f"Entry {entry_id} should still exist after rollback" + + +def test_delete_history_bulk_mixed_audio(db_service): + """Verify bulk delete handles mix of entries with and without audio.""" + # 1. Create entries - some with audio, some without + ids = [] + + # Entry with audio + id1 = db_service.add_history( + text="Entry with audio", + audio_relpath="audio/test1.wav", + audio_duration_ms=1000, + audio_size_bytes=5000, + audio_mime="audio/wav", + ) + ids.append(id1) + + # Entry without audio + id2 = db_service.add_history( + text="Entry without audio", + audio_relpath=None, + audio_duration_ms=None, + audio_size_bytes=None, + audio_mime=None, + ) + ids.append(id2) + + # Another entry with audio + id3 = db_service.add_history( + text="Another entry with audio", + audio_relpath="audio/test3.wav", + audio_duration_ms=2000, + audio_size_bytes=10000, + audio_mime="audio/wav", + ) + ids.append(id3) + + # 2. Mock _delete_audio_file + with patch.object(db_service, '_delete_audio_file') as mock_delete_audio: + # 3. Delete all entries + db_service.delete_history_bulk(ids) + + # 4. Verify _delete_audio_file called only for entries with audio + assert mock_delete_audio.call_count == 2, "Should delete audio for 2 entries only" + + # Verify called with correct paths + expected_calls = [call("audio/test1.wav"), call("audio/test3.wav")] + mock_delete_audio.assert_has_calls(expected_calls, any_order=True) + + # 5. Verify all entries deleted + for deleted_id in ids: + entry = db_service.get_history_entry(deleted_id) + assert entry is None, f"Entry {deleted_id} should be deleted" diff --git a/src/components/HistoryPage.tsx b/src/components/HistoryPage.tsx index c48d578..7d6c9ed 100644 --- a/src/components/HistoryPage.tsx +++ b/src/components/HistoryPage.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { base64ToBlobUrl, revokeUrl, isInvalidAudioPayload } from "@/lib/audio"; import { Dialog, @@ -13,6 +14,16 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { api } from "@/lib/api"; import type { HistoryEntry } from "@/lib/types"; @@ -24,6 +35,11 @@ export function HistoryPage() { const [audioUrl, setAudioUrl] = useState(null); const [audioMeta, setAudioMeta] = useState<{ fileName?: string; mime?: string; durationMs?: number } | null>(null); const [loadingAudioFor, setLoadingAudioFor] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // Derived state for selection + const hasSelection = selectedIds.size > 0; // Reusing the same load logic as HomePage for consistency const loadHistory = async (searchQuery?: string) => { @@ -105,6 +121,45 @@ export function HistoryPage() { } }; + const toggleSelect = (id: number) => { + setSelectedIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const selectAll = () => { + const allIds = new Set(history.map((entry) => entry.id)); + setSelectedIds(allIds); + }; + + const deselectAll = () => { + setSelectedIds(new Set()); + }; + + const handleBulkDelete = async () => { + const count = selectedIds.size; + try { + await api.deleteHistoryBulk(Array.from(selectedIds)); + setHistory((prev) => prev.filter((h) => !selectedIds.has(h.id))); + setSelectedIds(new Set()); + setShowDeleteDialog(false); + toast.success(`${count} transcription${count === 1 ? "" : "s"} deleted`); + } catch (error) { + console.error("Failed to delete:", error); + toast.error("Failed to delete selected transcriptions"); + } + }; + + const handleDeleteSelected = () => { + setShowDeleteDialog(true); + }; + const groupedHistory = groupByDate(history); const durationMs = audioMeta?.durationMs; @@ -142,6 +197,47 @@ export function HistoryPage() { + {/* Selection Toolbar */} + {hasSelection && ( +
+
+
+ + + {selectedIds.size} item{selectedIds.size === 1 ? "" : "s"} selected + +
+
+
+ + + +
+
+ )} + {/* Content */}
{loading ? ( @@ -182,13 +278,29 @@ export function HistoryPage() {
{entries.map((entry) => { const hasAudio = !!entry.has_audio; + const isSelected = selectedIds.has(entry.id); return (
+
e.stopPropagation()} + > + toggleSelect(entry.id)} + /> +
{formatTime(entry.created_at)} @@ -300,6 +412,23 @@ export function HistoryPage() { )} + + + + + Delete Selected Transcriptions? + + You are about to permanently delete {selectedIds.size} transcription{selectedIds.size === 1 ? "" : "s"}. This action cannot be undone. + + + + Cancel + + Delete + + + + ); } diff --git a/src/components/HistoryTab.tsx b/src/components/HistoryTab.tsx index 412c886..ec0e336 100644 --- a/src/components/HistoryTab.tsx +++ b/src/components/HistoryTab.tsx @@ -4,6 +4,17 @@ import { toast } from "sonner"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { api } from "@/lib/api"; import type { HistoryEntry } from "@/lib/types"; @@ -11,6 +22,11 @@ export function HistoryTab() { const [history, setHistory] = useState([]); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // Derived state for selection + const hasSelection = selectedIds.size > 0; const loadHistory = async (searchQuery?: string) => { setLoading(true); @@ -63,12 +79,52 @@ export function HistoryTab() { } }; + const toggleSelect = (id: number) => { + setSelectedIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const selectAll = () => { + const allIds = new Set(history.map((entry) => entry.id)); + setSelectedIds(allIds); + }; + + const deselectAll = () => { + setSelectedIds(new Set()); + }; + + const handleBulkDelete = async () => { + const count = selectedIds.size; + try { + await api.deleteHistoryBulk(Array.from(selectedIds)); + setHistory((prev) => prev.filter((h) => !selectedIds.has(h.id))); + setSelectedIds(new Set()); + setShowDeleteDialog(false); + toast.success(`${count} transcription${count === 1 ? "" : "s"} deleted`); + } catch (error) { + console.error("Failed to delete:", error); + toast.error("Failed to delete selected transcriptions"); + } + }; + + const handleDeleteSelected = () => { + setShowDeleteDialog(true); + }; + const groupedHistory = groupByDate(history); return ( + <>
- + {/* Header & Search */}
@@ -77,7 +133,7 @@ export function HistoryTab() { Browse and manage your past transcriptions.

- +
+ {/* Selection Toolbar */} + {hasSelection && ( +
+
+
+ + + {selectedIds.size} item{selectedIds.size === 1 ? "" : "s"} selected + +
+
+
+ + + +
+
+ )} + {/* Content */}
{loading ? ( @@ -132,17 +229,36 @@ export function HistoryTab() { {/* Grid */}
- {entries.map((entry) => ( + {entries.map((entry) => { + const isSelected = selectedIds.has(entry.id); + return ( - - - {formatTime(entry.created_at)} - -
+
+
e.stopPropagation()} + > + toggleSelect(entry.id)} + /> +
+ + + {formatTime(entry.created_at)} + +
+
))} @@ -181,6 +298,24 @@ export function HistoryTab() {
+ + + + + Delete Selected Transcriptions? + + You are about to permanently delete {selectedIds.size} transcription{selectedIds.size === 1 ? "" : "s"}. This action cannot be undone. + + + + Cancel + + Delete + + + + + ); } diff --git a/src/lib/api.ts b/src/lib/api.ts index 5d0e65a..6e0ae2b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -35,6 +35,10 @@ export const api = { await rpc.call("delete_history", { history_id: historyId }); }, + async deleteHistoryBulk(ids: number[]): Promise { + await rpc.call("delete_history_bulk", { history_ids: ids }); + }, + async copyToClipboard(text: string): Promise { await rpc.call("copy_to_clipboard", { text }); },