Compare commits
3 Commits
1af934cc9e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f072120961 | ||
|
|
c768caa025 | ||
|
|
d04af8d6dc |
41
README.md
41
README.md
@@ -1,12 +1,12 @@
|
|||||||
# opencode-session-manager
|
# opencode-session-manager
|
||||||
|
|
||||||
*USE AT YOUR OWN RUSK*
|
*USE AT YOUR OWN RISK*
|
||||||
|
|
||||||
⚠️ This is entirely vibe coded and mostly untested. It should backup yout database before changing anything, but you should make your own backup first ⚠️
|
⚠️ This is entirely vibe coded and mostly untested. It should backup your database before changing anything, but you should make your own backup first ⚠️
|
||||||
|
|
||||||
A terminal UI for managing [OpenCode](https://opencode.ai) sessions — move, copy, or delete sessions across projects without writing SQL.
|
A terminal UI for managing [OpenCode](https://opencode.ai) sessions — move, copy, or delete sessions across projects without writing SQL.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -20,22 +20,25 @@ OpenCode stores its data in an SQLite database, usually at `~/.local/share/openc
|
|||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies With mise
|
# With mise (installs Python, SQLite, and dependencies)
|
||||||
mise install
|
mise install && pip install -r requirements.txt
|
||||||
|
python main.py /path/to/opencode.db
|
||||||
|
|
||||||
# Install dependencies and run
|
# Without mise
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python main.py /path/to/opencode.db
|
python main.py /path/to/opencode.db
|
||||||
```
|
```
|
||||||
|
|
||||||
If no path is given it looks for `opencode.db` in the current directory.
|
If no path is given it looks for `opencode.db` in the current directory. If the database is not found, an error is shown in the UI and the app exits with a non-zero status code.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
The UI has two panels:
|
The UI has two panels:
|
||||||
|
|
||||||
- **Left — All Projects**: a list of your OpenCode projects with active session counts
|
- **Left — All Projects**: a list of your OpenCode projects, sorted alphabetically by directory name, with active session counts
|
||||||
- **Right — Sessions**: sessions for the selected project, with a filter bar at the top
|
- **Right — Sessions**: sessions for the selected project, sorted newest first, with a filter bar at the top
|
||||||
|
|
||||||
|
Select a project from the left panel to load its sessions. The session panel shows ID, title, project, workspace, message count, and creation date.
|
||||||
|
|
||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
@@ -65,12 +68,16 @@ Use `f` to filter sessions by title or slug (case-insensitive). Filtering and se
|
|||||||
1. Select one or more sessions with `Space`
|
1. Select one or more sessions with `Space`
|
||||||
2. Press `m` (move) or `c` (copy)
|
2. Press `m` (move) or `c` (copy)
|
||||||
3. Choose a destination project from the dropdown (sorted alphabetically, current project excluded)
|
3. Choose a destination project from the dropdown (sorted alphabetically, current project excluded)
|
||||||
4. Optionally choose a workspace within that project
|
4. Optionally choose a destination workspace
|
||||||
5. Click Confirm or press `Enter`
|
5. Click Confirm or press `Enter`
|
||||||
|
|
||||||
A timestamped backup is created automatically before the first write operation in a session: `opencode-YYYYMMDD-HHMMSS.db`
|
A timestamped backup is created automatically before the first write operation: `opencode-YYYYMMDD-HHMMSS.db`, written next to the source database.
|
||||||
|
|
||||||
Copy duplicates the session and all related records (messages, parts, todos). Move updates the session's project/workspace in place.
|
**Move** updates the session's `project_id` (and optionally `workspace_id`) in place. All related records stay attached to the session unchanged.
|
||||||
|
|
||||||
|
**Copy** duplicates the session and all related records (messages, message parts, todos) into the destination project. Copied sessions get new IDs. The original is not modified.
|
||||||
|
|
||||||
|
> Note: copied session IDs will not match OpenCode's native ID format. The sessions will work normally in the app but will look different in the database.
|
||||||
|
|
||||||
## Delete
|
## Delete
|
||||||
|
|
||||||
@@ -79,16 +86,20 @@ Copy duplicates the session and all related records (messages, parts, todos). Mo
|
|||||||
3. Review the list of sessions to be deleted in the confirmation dialog
|
3. Review the list of sessions to be deleted in the confirmation dialog
|
||||||
4. Click Delete to confirm, or Cancel / `Escape` to abort
|
4. Click Delete to confirm, or Cancel / `Escape` to abort
|
||||||
|
|
||||||
Deletion is permanent and removes all related records (messages, todos, etc). A backup is created automatically before the first delete.
|
Deletion is permanent and cascades to all related records (messages, parts, todos). A backup is created automatically before the first delete in a session.
|
||||||
|
|
||||||
## Archived Sessions
|
## Archived Sessions
|
||||||
|
|
||||||
OpenCode can archive sessions. By default they are hidden. Press `a` to show them alongside active sessions — the session panel header will update to indicate archived mode is on.
|
OpenCode can archive sessions. By default they are hidden. Press `a` to show them alongside active sessions — the session panel header will update to indicate archived mode is on. Archived sessions can be moved, copied, or deleted like any other session.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
A backup is created automatically the first time you perform any write operation (move, copy, or delete) in a given run of the app. You can also create one manually at any time with `b`. Backups are timestamped copies of the database file placed in the same directory as the source: `opencode-YYYYMMDD-HHMMSS.db`.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `main.py` | Entry point |
|
| `main.py` | Entry point, CLI argument handling |
|
||||||
| `app.py` | Textual TUI — layout, keybindings, UI logic |
|
| `app.py` | Textual TUI — layout, keybindings, UI logic |
|
||||||
| `database.py` | SQLite queries — sessions, move, copy, delete, backup |
|
| `database.py` | SQLite queries — sessions, move, copy, delete, backup |
|
||||||
|
|||||||
62
app.py
62
app.py
@@ -7,7 +7,7 @@ from textual.app import App, ComposeResult
|
|||||||
from textual.containers import Container, Horizontal, Vertical
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
from textual.widgets import Header, Footer, Input, Button, Select, ListView, ListItem, DataTable, Label, Static
|
from textual.widgets import Header, Footer, Input, Button, Select, ListView, ListItem, DataTable, Label, Static
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from typing import List, Optional
|
from typing import List, Optional, cast
|
||||||
from database import Database, Session
|
from database import Database, Session
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
@@ -31,7 +31,8 @@ class MoveCopyDialog(ModalScreen):
|
|||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
# Get currently selected project from tree to exclude from destination list
|
# 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(
|
project_options = sorted(
|
||||||
[
|
[
|
||||||
@@ -68,7 +69,7 @@ class MoveCopyDialog(ModalScreen):
|
|||||||
yield Button("Confirm", variant="primary", id="confirm-btn", disabled=True)
|
yield Button("Confirm", variant="primary", id="confirm-btn", disabled=True)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
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):
|
def on_select_changed(self, event):
|
||||||
if event.select.id == "project-select":
|
if event.select.id == "project-select":
|
||||||
@@ -77,12 +78,12 @@ class MoveCopyDialog(ModalScreen):
|
|||||||
self.target_workspace = event.value if event.value else None
|
self.target_workspace = event.value if event.value else None
|
||||||
|
|
||||||
self.update_preview()
|
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)
|
confirm_btn.disabled = not (self.target_project is not None)
|
||||||
|
|
||||||
def update_preview(self):
|
def update_preview(self):
|
||||||
if not self.target_project:
|
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")
|
preview.update("Select a project to see preview")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ class MoveCopyDialog(ModalScreen):
|
|||||||
if self.is_copy:
|
if self.is_copy:
|
||||||
lines.append("\nNote: Copy creates new sessions with all related data.")
|
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:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "cancel-btn":
|
if event.button.id == "cancel-btn":
|
||||||
@@ -141,13 +142,11 @@ class MoveCopyDialog(ModalScreen):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information")
|
main_app = cast("SessionMoverApp", self.app)
|
||||||
self.app.pop_screen()
|
main_app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information")
|
||||||
# Get reference to main app and refresh
|
main_app.pop_screen()
|
||||||
app = self.app
|
main_app.refresh_project_list()
|
||||||
if hasattr(app, 'refresh_sessions'):
|
main_app.refresh_sessions()
|
||||||
app.refresh_project_list()
|
|
||||||
app.refresh_sessions()
|
|
||||||
else:
|
else:
|
||||||
self.app.notify(f"Operation failed: {sql}", severity="error")
|
self.app.notify(f"Operation failed: {sql}", severity="error")
|
||||||
|
|
||||||
@@ -194,7 +193,7 @@ class DeleteConfirmDialog(ModalScreen):
|
|||||||
yield Button("Delete", variant="error", id="confirm-btn")
|
yield Button("Delete", variant="error", id="confirm-btn")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
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:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "cancel-btn":
|
if event.button.id == "cancel-btn":
|
||||||
@@ -282,6 +281,12 @@ class SessionMoverApp(App):
|
|||||||
color: $text;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#current-selection.error {
|
||||||
|
background: $error;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
#selection-count {
|
#selection-count {
|
||||||
height: auto;
|
height: auto;
|
||||||
padding: 1;
|
padding: 1;
|
||||||
@@ -385,10 +390,13 @@ class SessionMoverApp(App):
|
|||||||
self.db.connect()
|
self.db.connect()
|
||||||
self.db.load_reference_data()
|
self.db.load_reference_data()
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
self.notify(str(e), severity="error", timeout=10)
|
widget = self.query_one("#current-selection", Static)
|
||||||
self.set_timer(1, self.exit)
|
widget.add_class("error")
|
||||||
|
widget.update(f"Error: {e}\n\nPress q to quit.")
|
||||||
|
self._db_error = True
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._db_error = False
|
||||||
self._project_ids: List[str] = []
|
self._project_ids: List[str] = []
|
||||||
self.refresh_project_list()
|
self.refresh_project_list()
|
||||||
|
|
||||||
@@ -398,15 +406,14 @@ class SessionMoverApp(App):
|
|||||||
self.query_one("#archived-toggle", Button).can_focus = False
|
self.query_one("#archived-toggle", Button).can_focus = False
|
||||||
|
|
||||||
# Setup sessions table (but don't load data yet)
|
# Setup sessions table (but don't load data yet)
|
||||||
table = self.query_one("#sessions-table")
|
table = self.query_one("#sessions-table", DataTable)
|
||||||
table.zebra_stripes = True
|
table.zebra_stripes = True
|
||||||
table.cursor_type = "row"
|
table.cursor_type = "row"
|
||||||
table.show_cursor = False
|
table.show_cursor = False
|
||||||
table.add_columns(" ", "ID", "Title", "Project", "Workspace", "Msgs", "Created")
|
table.add_columns(" ", "ID", "Title", "Project", "Workspace", "Msgs", "Created")
|
||||||
|
|
||||||
# Don't load sessions until a project is selected
|
# Don't load sessions until a project is selected
|
||||||
status = self.query_one("#current-selection")
|
self.query_one("#current-selection", Static).update("Select a project from the left")
|
||||||
status.update("Select a project from the left")
|
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed) -> None:
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
if event.input.id == "filter-input":
|
if event.input.id == "filter-input":
|
||||||
@@ -462,8 +469,8 @@ class SessionMoverApp(App):
|
|||||||
|
|
||||||
def refresh_sessions(self) -> None:
|
def refresh_sessions(self) -> None:
|
||||||
"""Update the sessions table."""
|
"""Update the sessions table."""
|
||||||
table = self.query_one("#sessions-table")
|
table = self.query_one("#sessions-table", DataTable)
|
||||||
|
|
||||||
# Save cursor position before clearing
|
# Save cursor position before clearing
|
||||||
cursor_row = table.cursor_row
|
cursor_row = table.cursor_row
|
||||||
cursor_column = table.cursor_column
|
cursor_column = table.cursor_column
|
||||||
@@ -501,7 +508,7 @@ class SessionMoverApp(App):
|
|||||||
|
|
||||||
# Update selection count
|
# Update selection count
|
||||||
count = len(self.selected_session_ids)
|
count = len(self.selected_session_ids)
|
||||||
count_widget = self.query_one("#selection-count")
|
count_widget = self.query_one("#selection-count", Static)
|
||||||
if count > 0:
|
if count > 0:
|
||||||
count_widget.update(f"✓ {count} session(s) selected")
|
count_widget.update(f"✓ {count} session(s) selected")
|
||||||
else:
|
else:
|
||||||
@@ -534,7 +541,7 @@ class SessionMoverApp(App):
|
|||||||
self.filter_project_id = proj_id
|
self.filter_project_id = proj_id
|
||||||
self.filter_workspace_id = None
|
self.filter_workspace_id = None
|
||||||
|
|
||||||
status = self.query_one("#current-selection")
|
status = self.query_one("#current-selection", Static)
|
||||||
if proj:
|
if proj:
|
||||||
status.update(f"📁 {proj.worktree}")
|
status.update(f"📁 {proj.worktree}")
|
||||||
|
|
||||||
@@ -547,7 +554,7 @@ class SessionMoverApp(App):
|
|||||||
|
|
||||||
def action_toggle_selection(self) -> None:
|
def action_toggle_selection(self) -> None:
|
||||||
"""Toggle selection of current row."""
|
"""Toggle selection of current row."""
|
||||||
table = self.query_one("#sessions-table")
|
table = self.query_one("#sessions-table", DataTable)
|
||||||
row_key = table.cursor_row
|
row_key = table.cursor_row
|
||||||
if row_key is not None:
|
if row_key is not None:
|
||||||
sessions = self.db.get_sessions(
|
sessions = self.db.get_sessions(
|
||||||
@@ -655,6 +662,11 @@ class SessionMoverApp(App):
|
|||||||
self.refresh_sessions()
|
self.refresh_sessions()
|
||||||
self.notify("Data refreshed", severity="information")
|
self.notify("Data refreshed", severity="information")
|
||||||
|
|
||||||
|
async def action_quit(self) -> None:
|
||||||
|
"""Exit the app, with a non-zero return code if there was a DB error."""
|
||||||
|
return_code = 1 if getattr(self, "_db_error", False) else 0
|
||||||
|
self.exit(return_code)
|
||||||
|
|
||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
"""Cleanup on exit."""
|
"""Cleanup on exit."""
|
||||||
self.db.close()
|
self.db.close()
|
||||||
|
|||||||
138
database.py
138
database.py
@@ -74,7 +74,8 @@ class Database:
|
|||||||
|
|
||||||
def load_reference_data(self):
|
def load_reference_data(self):
|
||||||
"""Load projects and workspaces into memory for fast lookups."""
|
"""Load projects and workspaces into memory for fast lookups."""
|
||||||
assert self.conn is not None
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected. Call connect() first.")
|
||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
# Load projects
|
# Load projects
|
||||||
@@ -125,7 +126,8 @@ class Database:
|
|||||||
Returns:
|
Returns:
|
||||||
List of Session objects with computed counts
|
List of Session objects with computed counts
|
||||||
"""
|
"""
|
||||||
assert self.conn is not None, "Database not connected"
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected. Call connect() first.")
|
||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
@@ -205,7 +207,8 @@ class Database:
|
|||||||
|
|
||||||
def get_session_counts_by_project(self) -> dict:
|
def get_session_counts_by_project(self) -> dict:
|
||||||
"""Return a dict of project_id -> active session count."""
|
"""Return a dict of project_id -> active session count."""
|
||||||
assert self.conn is not None
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected. Call connect() first.")
|
||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT project_id, COUNT(*) FROM session WHERE time_archived IS NULL GROUP BY project_id"
|
"SELECT project_id, COUNT(*) FROM session WHERE time_archived IS NULL GROUP BY project_id"
|
||||||
@@ -214,11 +217,43 @@ class Database:
|
|||||||
|
|
||||||
def get_session(self, session_id: str) -> Optional[Session]:
|
def get_session(self, session_id: str) -> Optional[Session]:
|
||||||
"""Get a single session by ID."""
|
"""Get a single session by ID."""
|
||||||
sessions = self.get_sessions(include_archived=True)
|
if self.conn is None:
|
||||||
for s in sessions:
|
raise RuntimeError("Database is not connected. Call connect() first.")
|
||||||
if s.id == session_id:
|
cursor = self.conn.cursor()
|
||||||
return s
|
cursor.execute("SELECT * FROM session WHERE id = ?", (session_id,))
|
||||||
return None
|
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,
|
def move_sessions(self, session_ids: List[str], target_project_id: str,
|
||||||
target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
||||||
@@ -228,7 +263,8 @@ class Database:
|
|||||||
Returns:
|
Returns:
|
||||||
(success, list of SQL statements executed)
|
(success, list of SQL statements executed)
|
||||||
"""
|
"""
|
||||||
assert self.conn is not None, "Database not connected"
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected. Call connect() first.")
|
||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
# Verify target project exists
|
# Verify target project exists
|
||||||
@@ -265,7 +301,8 @@ class Database:
|
|||||||
Returns:
|
Returns:
|
||||||
(success, list of SQL statements, list of new session IDs)
|
(success, list of SQL statements, list of new session IDs)
|
||||||
"""
|
"""
|
||||||
assert self.conn is not None, "Database not connected"
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected. Call connect() first.")
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
@@ -292,9 +329,7 @@ class Database:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Create new session ID
|
# Create new session ID
|
||||||
new_id = str(uuid.uuid4()).replace("-", "")
|
new_id = "ses_" + uuid.uuid4().hex
|
||||||
while new_id[:3] != "ses":
|
|
||||||
new_id = "ses_" + new_id
|
|
||||||
|
|
||||||
# Insert new session
|
# Insert new session
|
||||||
cols = [k for k in row.keys() if k != "id"]
|
cols = [k for k in row.keys() if k != "id"]
|
||||||
@@ -320,28 +355,70 @@ class Database:
|
|||||||
cursor.execute(sql, [new_id] + values)
|
cursor.execute(sql, [new_id] + values)
|
||||||
sql_statements.append(f"INSERT INTO session ... VALUES ({new_id}, ...)")
|
sql_statements.append(f"INSERT INTO session ... VALUES ({new_id}, ...)")
|
||||||
|
|
||||||
# Copy related records
|
# Copy messages first, building old_id -> new_id map so parts can be relinked
|
||||||
for table, foreign_key in [("message", "session_id"), ("part", "session_id"), ("todo", "session_id"), ("session_share", "session_id")]:
|
msg_id_map: dict = {}
|
||||||
try:
|
try:
|
||||||
cursor.execute(f"SELECT * FROM {table} WHERE {foreign_key} = ?", (sess_id,))
|
cursor.execute("SELECT * FROM message WHERE session_id = ?", (sess_id,))
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
continue # Table doesn't exist in this schema version
|
pass
|
||||||
rows = cursor.fetchall()
|
else:
|
||||||
for r in rows:
|
for r in cursor.fetchall():
|
||||||
# Include foreign_key in cols so the new session_id is written
|
new_msg_id = uuid.uuid4().hex
|
||||||
|
msg_id_map[r["id"]] = new_msg_id
|
||||||
cols = [k for k in r.keys() if k != "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)
|
col_list = ", ".join(cols)
|
||||||
placeholders = ", ".join(["?"] * len(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():
|
if "id" in r.keys():
|
||||||
new_table_id = str(uuid.uuid4()).replace("-", "")
|
cursor.execute(
|
||||||
sql = f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})"
|
f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})",
|
||||||
cursor.execute(sql, [new_table_id] + values)
|
[uuid.uuid4().hex] + values,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})"
|
cursor.execute(
|
||||||
cursor.execute(sql, values)
|
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
|
||||||
new_session_ids.append(new_id)
|
new_session_ids.append(new_id)
|
||||||
|
|
||||||
@@ -355,7 +432,8 @@ class Database:
|
|||||||
Returns:
|
Returns:
|
||||||
(success, error_message)
|
(success, error_message)
|
||||||
"""
|
"""
|
||||||
assert self.conn is not None, "Database not connected"
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected. Call connect() first.")
|
||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
0
session-mover.py
Normal file → Executable file
0
session-mover.py
Normal file → Executable file
Reference in New Issue
Block a user