From a9da9e8ab417acfc9604cb0d1e58849de88b2bbd Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:08:51 -0500 Subject: [PATCH 01/12] auto-claude: subtask-1-1 - Add delete_history_bulk method to DatabaseService --- src-pyloid/services/database.py | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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: From 2edbca6f2753a29249fdc6e135ad3130f5b5a0ea Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:11:19 -0500 Subject: [PATCH 02/12] auto-claude: subtask-2-1 - Add delete_history_bulk to AppController --- src-pyloid/app_controller.py | 4 ++++ 1 file changed, 4 insertions(+) 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() From 6a150f0962f2ecd4b031d51d02ad88ba90127cab Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:13:18 -0500 Subject: [PATCH 03/12] auto-claude: subtask-3-1 - Add delete_history_bulk RPC method to server.py --- .auto-claude-security.json | 217 +++++++++++++++++++++++++++++++++++++ .auto-claude-status | 25 +++++ .claude_settings.json | 39 +++++++ .gitignore | 3 + src-pyloid/server.py | 7 ++ 5 files changed, 291 insertions(+) create mode 100644 .auto-claude-security.json create mode 100644 .auto-claude-status create mode 100644 .claude_settings.json 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..e76fbd8 --- /dev/null +++ b/.auto-claude-status @@ -0,0 +1,25 @@ +{ + "active": true, + "spec": "002-bulk-delete-history-items", + "state": "building", + "subtasks": { + "completed": 2, + "total": 11, + "in_progress": 1, + "failed": 0 + }, + "phase": { + "current": "Backend RPC Layer", + "id": null, + "total": 1 + }, + "workers": { + "active": 0, + "max": 1 + }, + "session": { + "number": 3, + "started_at": "2026-01-14T23:06:52.356672" + }, + "last_update": "2026-01-14T23:11:58.885630" +} \ 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/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() From 15b14e80c320707e5502034d898a105a08cd5c6b Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:14:43 -0500 Subject: [PATCH 04/12] auto-claude: subtask-4-1 - Add deleteHistoryBulk method to api.ts --- src/lib/api.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 }); }, From e1c876ceca25a6d4f9135050aabc62182c4beb8d Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:16:35 -0500 Subject: [PATCH 05/12] auto-claude: subtask-5-1 - Add selection state management to HistoryPage --- src/components/HistoryPage.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/components/HistoryPage.tsx b/src/components/HistoryPage.tsx index c48d578..6fe23f3 100644 --- a/src/components/HistoryPage.tsx +++ b/src/components/HistoryPage.tsx @@ -24,6 +24,10 @@ 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()); + + // 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 +109,27 @@ 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 groupedHistory = groupByDate(history); const durationMs = audioMeta?.durationMs; From 69de8017cb9c439d5c7e11d023351330dbf1f9b7 Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:19:47 -0500 Subject: [PATCH 06/12] auto-claude: subtask-5-2 - Add checkbox UI to history cards in HistoryPage --- src/components/HistoryPage.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/HistoryPage.tsx b/src/components/HistoryPage.tsx index 6fe23f3..7f2f3e3 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, @@ -207,13 +208,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)} From 05760c5ab802febf0fdccc8228fc14214c003485 Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:21:35 -0500 Subject: [PATCH 07/12] auto-claude: subtask-5-3 - Add selection toolbar to HistoryPage --- .auto-claude-status | 10 +++---- src/components/HistoryPage.tsx | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/.auto-claude-status b/.auto-claude-status index e76fbd8..403e472 100644 --- a/.auto-claude-status +++ b/.auto-claude-status @@ -3,23 +3,23 @@ "spec": "002-bulk-delete-history-items", "state": "building", "subtasks": { - "completed": 2, + "completed": 6, "total": 11, "in_progress": 1, "failed": 0 }, "phase": { - "current": "Backend RPC Layer", + "current": "Frontend HistoryPage UI", "id": null, - "total": 1 + "total": 5 }, "workers": { "active": 0, "max": 1 }, "session": { - "number": 3, + "number": 7, "started_at": "2026-01-14T23:06:52.356672" }, - "last_update": "2026-01-14T23:11:58.885630" + "last_update": "2026-01-14T23:20:21.795077" } \ No newline at end of file diff --git a/src/components/HistoryPage.tsx b/src/components/HistoryPage.tsx index 7f2f3e3..7661d8b 100644 --- a/src/components/HistoryPage.tsx +++ b/src/components/HistoryPage.tsx @@ -131,6 +131,20 @@ export function HistoryPage() { setSelectedIds(new Set()); }; + const handleDeleteSelected = async () => { + const count = selectedIds.size; + try { + await Promise.all( + Array.from(selectedIds).map((id) => api.deleteHistory(id)) + ); + setHistory((prev) => prev.filter((h) => !selectedIds.has(h.id))); + setSelectedIds(new Set()); + toast.success(`${count} transcription${count === 1 ? "" : "s"} deleted`); + } catch (error) { + toast.error("Failed to delete selected transcriptions"); + } + }; + const groupedHistory = groupByDate(history); const durationMs = audioMeta?.durationMs; @@ -168,6 +182,47 @@ export function HistoryPage() {
+ {/* Selection Toolbar */} + {hasSelection && ( +
+
+
+ + + {selectedIds.size} item{selectedIds.size === 1 ? "" : "s"} selected + +
+
+
+ + + +
+
+ )} + {/* Content */}
{loading ? ( From f90352a23d16dbcaebf6aa60d676862ae65fab99 Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:23:40 -0500 Subject: [PATCH 08/12] auto-claude: subtask-5-4 - Add bulk delete confirmation dialog to HistoryPage --- .auto-claude-status | 6 +++--- src/components/HistoryPage.tsx | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.auto-claude-status b/.auto-claude-status index 403e472..2300046 100644 --- a/.auto-claude-status +++ b/.auto-claude-status @@ -3,7 +3,7 @@ "spec": "002-bulk-delete-history-items", "state": "building", "subtasks": { - "completed": 6, + "completed": 7, "total": 11, "in_progress": 1, "failed": 0 @@ -18,8 +18,8 @@ "max": 1 }, "session": { - "number": 7, + "number": 8, "started_at": "2026-01-14T23:06:52.356672" }, - "last_update": "2026-01-14T23:20:21.795077" + "last_update": "2026-01-14T23:21:59.118134" } \ No newline at end of file diff --git a/src/components/HistoryPage.tsx b/src/components/HistoryPage.tsx index 7661d8b..a645287 100644 --- a/src/components/HistoryPage.tsx +++ b/src/components/HistoryPage.tsx @@ -14,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"; @@ -26,6 +36,7 @@ export function HistoryPage() { 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; @@ -131,7 +142,7 @@ export function HistoryPage() { setSelectedIds(new Set()); }; - const handleDeleteSelected = async () => { + const confirmDeleteSelected = async () => { const count = selectedIds.size; try { await Promise.all( @@ -139,12 +150,17 @@ export function HistoryPage() { ); setHistory((prev) => prev.filter((h) => !selectedIds.has(h.id))); setSelectedIds(new Set()); + setShowDeleteDialog(false); toast.success(`${count} transcription${count === 1 ? "" : "s"} deleted`); } catch (error) { toast.error("Failed to delete selected transcriptions"); } }; + const handleDeleteSelected = () => { + setShowDeleteDialog(true); + }; + const groupedHistory = groupByDate(history); const durationMs = audioMeta?.durationMs; @@ -397,6 +413,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 + + + + ); } From 43bb09c331f141acc43c09c7f82139b688085263 Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:27:07 -0500 Subject: [PATCH 09/12] auto-claude: subtask-5-5 - Implement handleBulkDelete function in HistoryPage --- src/components/HistoryPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/HistoryPage.tsx b/src/components/HistoryPage.tsx index a645287..2b650e9 100644 --- a/src/components/HistoryPage.tsx +++ b/src/components/HistoryPage.tsx @@ -142,7 +142,7 @@ export function HistoryPage() { setSelectedIds(new Set()); }; - const confirmDeleteSelected = async () => { + const handleBulkDelete = async () => { const count = selectedIds.size; try { await Promise.all( @@ -153,6 +153,7 @@ export function HistoryPage() { 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"); } }; @@ -424,7 +425,7 @@ export function HistoryPage() { Cancel - + Delete From daccd820073b3a16dc589acc0c7364d72bc7045d Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:30:02 -0500 Subject: [PATCH 10/12] auto-claude: subtask-6-1 - Add bulk delete functionality to HistoryTab (mirror HistoryPage) --- .auto-claude-status | 10 +-- src/components/HistoryTab.tsx | 157 +++++++++++++++++++++++++++++++--- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/.auto-claude-status b/.auto-claude-status index 2300046..92dcd6b 100644 --- a/.auto-claude-status +++ b/.auto-claude-status @@ -3,23 +3,23 @@ "spec": "002-bulk-delete-history-items", "state": "building", "subtasks": { - "completed": 7, + "completed": 9, "total": 11, "in_progress": 1, "failed": 0 }, "phase": { - "current": "Frontend HistoryPage UI", + "current": "Frontend HistoryTab UI", "id": null, - "total": 5 + "total": 1 }, "workers": { "active": 0, "max": 1 }, "session": { - "number": 8, + "number": 10, "started_at": "2026-01-14T23:06:52.356672" }, - "last_update": "2026-01-14T23:21:59.118134" + "last_update": "2026-01-14T23:27:31.348283" } \ No newline at end of file diff --git a/src/components/HistoryTab.tsx b/src/components/HistoryTab.tsx index 412c886..0ed2797 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,54 @@ 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 Promise.all( + Array.from(selectedIds).map((id) => api.deleteHistory(id)) + ); + 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 +135,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 +231,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 +300,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 + + + + + ); } From 4cb81cc261ef03152ca274fc47e52b6a7e57078d Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:34:13 -0500 Subject: [PATCH 11/12] auto-claude: subtask-7-1 - End-to-end verification of bulk delete flow - Fixed critical issue: Changed handleBulkDelete to use api.deleteHistoryBulk() - Previously used Promise.all with multiple api.deleteHistory() calls - Now properly uses single bulk delete API call with transaction support - Applied fix to both HistoryPage.tsx and HistoryTab.tsx - Created comprehensive E2E verification document - Verified all implementation layers (database, controller, RPC, API, UI) - TypeScript compilation verified - Ready for live testing --- .auto-claude-status | 8 ++++---- src/components/HistoryPage.tsx | 4 +--- src/components/HistoryTab.tsx | 4 +--- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.auto-claude-status b/.auto-claude-status index 92dcd6b..4b815ee 100644 --- a/.auto-claude-status +++ b/.auto-claude-status @@ -3,13 +3,13 @@ "spec": "002-bulk-delete-history-items", "state": "building", "subtasks": { - "completed": 9, + "completed": 10, "total": 11, "in_progress": 1, "failed": 0 }, "phase": { - "current": "Frontend HistoryTab UI", + "current": "Integration Testing", "id": null, "total": 1 }, @@ -18,8 +18,8 @@ "max": 1 }, "session": { - "number": 10, + "number": 11, "started_at": "2026-01-14T23:06:52.356672" }, - "last_update": "2026-01-14T23:27:31.348283" + "last_update": "2026-01-14T23:30:24.885220" } \ No newline at end of file diff --git a/src/components/HistoryPage.tsx b/src/components/HistoryPage.tsx index 2b650e9..7d6c9ed 100644 --- a/src/components/HistoryPage.tsx +++ b/src/components/HistoryPage.tsx @@ -145,9 +145,7 @@ export function HistoryPage() { const handleBulkDelete = async () => { const count = selectedIds.size; try { - await Promise.all( - Array.from(selectedIds).map((id) => api.deleteHistory(id)) - ); + await api.deleteHistoryBulk(Array.from(selectedIds)); setHistory((prev) => prev.filter((h) => !selectedIds.has(h.id))); setSelectedIds(new Set()); setShowDeleteDialog(false); diff --git a/src/components/HistoryTab.tsx b/src/components/HistoryTab.tsx index 0ed2797..ec0e336 100644 --- a/src/components/HistoryTab.tsx +++ b/src/components/HistoryTab.tsx @@ -103,9 +103,7 @@ export function HistoryTab() { const handleBulkDelete = async () => { const count = selectedIds.size; try { - await Promise.all( - Array.from(selectedIds).map((id) => api.deleteHistory(id)) - ); + await api.deleteHistoryBulk(Array.from(selectedIds)); setHistory((prev) => prev.filter((h) => !selectedIds.has(h.id))); setSelectedIds(new Set()); setShowDeleteDialog(false); From 7b2db0a0f48e24a2e0974dfeb4b08dcdc15eb984 Mon Sep 17 00:00:00 2001 From: youngmrz Date: Wed, 14 Jan 2026 23:52:24 -0500 Subject: [PATCH 12/12] test: add unit tests for bulk delete functionality (qa-requested) - Create test_database.py with comprehensive bulk delete tests - Test multiple record deletion in transaction - Test audio file cleanup for all deleted entries - Test empty list edge case handling - Test invalid ID handling - Test transaction rollback on errors - Test mixed audio/no-audio entries QA Fix Session: 2 --- src-pyloid/tests/test_database.py | 237 ++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 src-pyloid/tests/test_database.py 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"