From c768caa02576a59f725a36b104bdc19214fbaa08 Mon Sep 17 00:00:00 2001 From: Joe Fleming Date: Sun, 8 Mar 2026 14:22:39 -0600 Subject: [PATCH] database id fixes --- app.py | 37 ++++++++-------- database.py | 120 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 114 insertions(+), 43 deletions(-) diff --git a/app.py b/app.py index df681ed..38c3e1a 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.widgets import Header, Footer, Input, Button, Select, ListView, ListItem, DataTable, Label, Static from textual.screen import ModalScreen -from typing import List, Optional +from typing import List, Optional, cast from database import Database, Session from datetime import datetime import os @@ -31,7 +31,8 @@ class MoveCopyDialog(ModalScreen): def compose(self) -> ComposeResult: # Get currently selected project from tree to exclude from destination list - current_project_id = self.app.filter_project_id if hasattr(self.app, 'filter_project_id') else None + main_app = cast("SessionMoverApp", self.app) + current_project_id = main_app.filter_project_id if hasattr(main_app, 'filter_project_id') else None project_options = sorted( [ @@ -68,7 +69,7 @@ class MoveCopyDialog(ModalScreen): yield Button("Confirm", variant="primary", id="confirm-btn", disabled=True) def on_mount(self) -> None: - self.query_one("#dialog").border_title = f"{'Copy' if self.is_copy else 'Move'} Sessions" + self.query_one("#dialog", Container).border_title = f"{'Copy' if self.is_copy else 'Move'} Sessions" def on_select_changed(self, event): if event.select.id == "project-select": @@ -77,12 +78,12 @@ class MoveCopyDialog(ModalScreen): self.target_workspace = event.value if event.value else None self.update_preview() - confirm_btn = self.query_one("#confirm-btn") + confirm_btn = self.query_one("#confirm-btn", Button) confirm_btn.disabled = not (self.target_project is not None) def update_preview(self): if not self.target_project: - preview = self.query_one("#sql-preview") + preview = self.query_one("#sql-preview", Static) preview.update("Select a project to see preview") return @@ -111,7 +112,7 @@ class MoveCopyDialog(ModalScreen): if self.is_copy: lines.append("\nNote: Copy creates new sessions with all related data.") - self.query_one("#sql-preview").update("\n".join(lines)) + self.query_one("#sql-preview", Static).update("\n".join(lines)) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "cancel-btn": @@ -141,13 +142,11 @@ class MoveCopyDialog(ModalScreen): ) if success: - self.app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information") - self.app.pop_screen() - # Get reference to main app and refresh - app = self.app - if hasattr(app, 'refresh_sessions'): - app.refresh_project_list() - app.refresh_sessions() + main_app = cast("SessionMoverApp", self.app) + main_app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information") + main_app.pop_screen() + main_app.refresh_project_list() + main_app.refresh_sessions() else: self.app.notify(f"Operation failed: {sql}", severity="error") @@ -194,7 +193,7 @@ class DeleteConfirmDialog(ModalScreen): yield Button("Delete", variant="error", id="confirm-btn") def on_mount(self) -> None: - self.query_one("#dialog").border_title = "Confirm Delete" + self.query_one("#dialog", Container).border_title = "Confirm Delete" def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "cancel-btn": @@ -470,8 +469,8 @@ class SessionMoverApp(App): def refresh_sessions(self) -> None: """Update the sessions table.""" - table = self.query_one("#sessions-table") - + table = self.query_one("#sessions-table", DataTable) + # Save cursor position before clearing cursor_row = table.cursor_row cursor_column = table.cursor_column @@ -509,7 +508,7 @@ class SessionMoverApp(App): # Update selection count count = len(self.selected_session_ids) - count_widget = self.query_one("#selection-count") + count_widget = self.query_one("#selection-count", Static) if count > 0: count_widget.update(f"✓ {count} session(s) selected") else: @@ -542,7 +541,7 @@ class SessionMoverApp(App): self.filter_project_id = proj_id self.filter_workspace_id = None - status = self.query_one("#current-selection") + status = self.query_one("#current-selection", Static) if proj: status.update(f"📁 {proj.worktree}") @@ -555,7 +554,7 @@ class SessionMoverApp(App): def action_toggle_selection(self) -> None: """Toggle selection of current row.""" - table = self.query_one("#sessions-table") + table = self.query_one("#sessions-table", DataTable) row_key = table.cursor_row if row_key is not None: sessions = self.db.get_sessions( diff --git a/database.py b/database.py index 3682099..def1d26 100644 --- a/database.py +++ b/database.py @@ -217,11 +217,43 @@ class Database: def get_session(self, session_id: str) -> Optional[Session]: """Get a single session by ID.""" - sessions = self.get_sessions(include_archived=True) - for s in sessions: - if s.id == session_id: - return s - return None + if self.conn is None: + raise RuntimeError("Database is not connected. Call connect() first.") + cursor = self.conn.cursor() + cursor.execute("SELECT * FROM session WHERE id = ?", (session_id,)) + row = cursor.fetchone() + if not row: + return None + + sess = Session( + id=row["id"], + project_id=row["project_id"], + workspace_id=row["workspace_id"], + parent_id=row["parent_id"], + slug=row["slug"], + directory=row["directory"], + title=row["title"], + version=row["version"], + time_created=row["time_created"], + time_updated=row["time_updated"], + time_archived=row["time_archived"], + ) + + # Message count + cursor.execute("SELECT COUNT(*) FROM message WHERE session_id = ?", (session_id,)) + sess.message_count = cursor.fetchone()[0] + + # Project name + if sess.project_id in self.projects: + proj = self.projects[sess.project_id] + sess.project_name = proj.name or proj.worktree + + # Workspace name + if sess.workspace_id and sess.workspace_id in self.workspaces: + ws = self.workspaces[sess.workspace_id] + sess.workspace_name = ws.name or ws.branch or ws.id + + return sess def move_sessions(self, session_ids: List[str], target_project_id: str, target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str]]: @@ -297,9 +329,7 @@ class Database: continue # Create new session ID - new_id = str(uuid.uuid4()).replace("-", "") - while new_id[:3] != "ses": - new_id = "ses_" + new_id + new_id = "ses_" + uuid.uuid4().hex # Insert new session cols = [k for k in row.keys() if k != "id"] @@ -325,28 +355,70 @@ class Database: cursor.execute(sql, [new_id] + values) sql_statements.append(f"INSERT INTO session ... VALUES ({new_id}, ...)") - # Copy related records - for table, foreign_key in [("message", "session_id"), ("part", "session_id"), ("todo", "session_id"), ("session_share", "session_id")]: - try: - cursor.execute(f"SELECT * FROM {table} WHERE {foreign_key} = ?", (sess_id,)) - except sqlite3.OperationalError: - continue # Table doesn't exist in this schema version - rows = cursor.fetchall() - for r in rows: - # Include foreign_key in cols so the new session_id is written + # Copy messages first, building old_id -> new_id map so parts can be relinked + msg_id_map: dict = {} + try: + cursor.execute("SELECT * FROM message WHERE session_id = ?", (sess_id,)) + except sqlite3.OperationalError: + pass + else: + for r in cursor.fetchall(): + new_msg_id = uuid.uuid4().hex + msg_id_map[r["id"]] = new_msg_id cols = [k for k in r.keys() if k != "id"] - values = [new_id if k == foreign_key else r[k] for k in cols] + values = [new_id if k == "session_id" else r[k] for k in cols] col_list = ", ".join(cols) placeholders = ", ".join(["?"] * len(cols)) + cursor.execute( + f"INSERT INTO message (id, {col_list}) VALUES (?, {placeholders})", + [new_msg_id] + values, + ) - # Generate new ID for tables with id column + # Copy parts, relinking both session_id and message_id + try: + cursor.execute("SELECT * FROM part WHERE session_id = ?", (sess_id,)) + except sqlite3.OperationalError: + pass + else: + for r in cursor.fetchall(): + new_part_id = uuid.uuid4().hex + cols = [k for k in r.keys() if k != "id"] + values = [] + for k in cols: + if k == "session_id": + values.append(new_id) + elif k == "message_id": + values.append(msg_id_map.get(r[k], r[k])) + else: + values.append(r[k]) + col_list = ", ".join(cols) + placeholders = ", ".join(["?"] * len(cols)) + cursor.execute( + f"INSERT INTO part (id, {col_list}) VALUES (?, {placeholders})", + [new_part_id] + values, + ) + + # Copy todo and session_share (session_id only, no secondary FKs) + for table in ("todo", "session_share"): + try: + cursor.execute(f"SELECT * FROM {table} WHERE session_id = ?", (sess_id,)) + except sqlite3.OperationalError: + continue + for r in cursor.fetchall(): + cols = [k for k in r.keys() if k != "id"] + values = [new_id if k == "session_id" else r[k] for k in cols] + col_list = ", ".join(cols) + placeholders = ", ".join(["?"] * len(cols)) if "id" in r.keys(): - new_table_id = str(uuid.uuid4()).replace("-", "") - sql = f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})" - cursor.execute(sql, [new_table_id] + values) + cursor.execute( + f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})", + [uuid.uuid4().hex] + values, + ) else: - sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})" - cursor.execute(sql, values) + cursor.execute( + f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})", + values, + ) new_session_ids.append(new_id)