add backup delete option

This commit is contained in:
rambros 2026-03-26 23:02:23 +05:30
parent fa0865ef85
commit 6f94a00714
4 changed files with 266 additions and 25 deletions

View file

@ -464,39 +464,41 @@ class BackupDatabase:
INSERT OR REPLACE INTO media_pool (hash, local_path, size, mime_type, first_seen_url) INSERT OR REPLACE INTO media_pool (hash, local_path, size, mime_type, first_seen_url)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", (file_hash, str(local_path), size, mime_type, url)) """, (file_hash, str(local_path), size, mime_type, url))
def get_stats_by_channel(self) -> Dict[int, Dict[str, Any]]: def get_stats_by_channel(self) -> Dict[int, Dict[str, Any]]:
"""Returns aggregate stats for all channels with backups.""" """Returns aggregate stats for all channels with backups."""
with self._lock: with self._lock:
# 1. Message counts (aggregating threads into parent channel)
msg_rows = self._conn.execute(""" msg_rows = self._conn.execute("""
SELECT SELECT
COALESCE(t.parent_id, m.channel_id) as channel_id, COALESCE(t.parent_id, m.channel_id) as agg_channel_id,
COUNT(m.id) as msg_count COUNT(m.id) as msg_count
FROM messages m FROM messages m
LEFT JOIN threads t ON m.channel_id = t.id LEFT JOIN threads t ON m.channel_id = t.id
GROUP BY channel_id GROUP BY agg_channel_id
""").fetchall() """).fetchall()
# 2. Thread counts
thread_rows = self._conn.execute(""" thread_rows = self._conn.execute("""
SELECT parent_id, COUNT(*) as thread_count SELECT parent_id, COUNT(*) as thread_count
FROM threads FROM threads
GROUP BY parent_id GROUP BY parent_id
""").fetchall() """).fetchall()
# 3. Attachment counts and sizes
att_rows = self._conn.execute(""" att_rows = self._conn.execute("""
SELECT SELECT
COALESCE(t.parent_id, m.channel_id) as channel_id, COALESCE(t.parent_id, m.channel_id) as agg_channel_id,
COUNT(a.id) as att_count, COUNT(a.id) as att_count,
SUM(a.size) as total_size SUM(a.size) as total_size
FROM attachments a FROM attachments a
JOIN messages m ON a.message_id = m.id JOIN messages m ON a.message_id = m.id
LEFT JOIN threads t ON m.channel_id = t.id LEFT JOIN threads t ON m.channel_id = t.id
GROUP BY channel_id GROUP BY agg_channel_id
""").fetchall() """).fetchall()
stats = {} stats = {}
for r in msg_rows: for r in msg_rows:
cid = parse_snowflake(r["channel_id"]) cid = parse_snowflake(r["agg_channel_id"])
if cid is None: continue if cid is None: continue
stats[cid] = { stats[cid] = {
"message_count": r["msg_count"], "message_count": r["msg_count"],
@ -513,7 +515,7 @@ class BackupDatabase:
stats[cid]["thread_count"] = r["thread_count"] stats[cid]["thread_count"] = r["thread_count"]
for r in att_rows: for r in att_rows:
cid = parse_snowflake(r["channel_id"]) cid = parse_snowflake(r["agg_channel_id"])
if cid is None: continue if cid is None: continue
if cid not in stats: if cid not in stats:
stats[cid] = {"message_count": 0, "thread_count": 0, "attachment_count": 0, "total_size": 0} stats[cid] = {"message_count": 0, "thread_count": 0, "attachment_count": 0, "total_size": 0}
@ -696,6 +698,83 @@ class BackupDatabase:
return msg_list return msg_list
def delete_channel_messages(self, channel_id: Union[str, int]):
"""Deletes all messages and related metadata for a specific channel and its threads."""
cid = str(channel_id)
with self._lock:
# 1. Identify all channel IDs involved (parent + all threads)
target_ids = [cid]
thread_rows = self._conn.execute("SELECT id FROM threads WHERE parent_id = ?", (cid,)).fetchall()
for tr in thread_rows:
target_ids.append(tr["id"])
placeholders_chans = ",".join(["?"] * len(target_ids))
# 2. Get all message IDs for these channels
msg_ids = [r["id"] for r in self._conn.execute(
f"SELECT id FROM messages WHERE channel_id IN ({placeholders_chans})", target_ids
).fetchall()]
if not msg_ids:
return
placeholders_msgs = ",".join(["?"] * len(msg_ids))
# 3. Delete related metadata
self._conn.execute(f"DELETE FROM attachments WHERE message_id IN ({placeholders_msgs})", msg_ids)
self._conn.execute(f"DELETE FROM embeds WHERE message_id IN ({placeholders_msgs})", msg_ids)
self._conn.execute(f"DELETE FROM reactions WHERE message_id IN ({placeholders_msgs})", msg_ids)
self._conn.execute(f"DELETE FROM message_stickers WHERE message_id IN ({placeholders_msgs})", msg_ids)
# 4. Delete messages
self._conn.execute(f"DELETE FROM messages WHERE channel_id IN ({placeholders_chans})", target_ids)
# 5. Delete thread metadata (optional but consistent)
self._conn.execute(f"DELETE FROM threads WHERE parent_id = ?", (cid,))
self._conn.commit()
logger.info(f"Deleted messages, metadata, and threads for channel {cid}")
def purge_unused_media(self, backup_root: Path) -> int:
"""Removes media files from disk and DB that are no longer referenced by any message."""
purged_count = 0
with self._lock:
# 1. Find all hashes in use
used_hashes = set()
for r in self._conn.execute("SELECT DISTINCT local_hash FROM attachments WHERE local_hash IS NOT NULL").fetchall():
used_hashes.add(r[0])
for r in self._conn.execute("SELECT DISTINCT local_hash FROM message_stickers WHERE local_hash IS NOT NULL").fetchall():
used_hashes.add(r[0])
# 2. Get all hashes in pool
all_media = self._conn.execute("SELECT hash, local_path FROM media_pool").fetchall()
to_delete = []
for m in all_media:
m_hash = m["hash"]
if m_hash not in used_hashes:
to_delete.append(dict(m))
if not to_delete:
return 0
# 3. Delete from filesystem and DB
for m in to_delete:
try:
file_path = backup_root / m["local_path"]
if file_path.exists():
file_path.unlink()
self._conn.execute("DELETE FROM media_pool WHERE hash = ?", (m["hash"],))
purged_count += 1
except Exception as e:
logger.error(f"Failed to purge media {m['hash']}: {e}")
self._conn.commit()
logger.info(f"Purged {purged_count} unused media files")
return purged_count
def close(self): def close(self):
"""Commits any pending writes and closes the connection.""" """Commits any pending writes and closes the connection."""
with self._lock: with self._lock:

View file

@ -703,7 +703,7 @@ class DiscordExporter:
if tmp_path and tmp_path.exists(): if tmp_path and tmp_path.exists():
try: tmp_path.unlink() try: tmp_path.unlink()
except: pass except: pass
return None return None
async def export_threads(self, channel_id: int, progress_callback=None, force=False, accumulated_count=0, accumulated_threads=0, accumulated_files=0, after_id: int | None = None): async def export_threads(self, channel_id: int, progress_callback=None, force=False, accumulated_count=0, accumulated_threads=0, accumulated_files=0, after_id: int | None = None):
"""Exports active and archived threads for a channel to SQLite.""" """Exports active and archived threads for a channel to SQLite."""

View file

@ -5,10 +5,10 @@ from datetime import datetime
import asyncio import asyncio
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen, ModalScreen
from textual.containers import Container, Vertical, VerticalScroll, Horizontal from textual.containers import Container, Vertical, VerticalScroll, Horizontal, Grid
from textual.widgets import Button, Label, Rule, Tree from textual.widgets import Button, Label, Rule, Tree, Static
from textual import work from textual import work, on
from rich.text import Text from rich.text import Text
from rich.style import Style from rich.style import Style
@ -227,7 +227,7 @@ class BackupStatsScreen(Screen[None]):
# Add a header row to the tree root, purely for visual columns # Add a header row to the tree root, purely for visual columns
# Using depth=4 for header to compensate for root node toggle position delta # Using depth=4 for header to compensate for root node toggle position delta
header_text = self._format_tree_row("NAME", "MESSAGES", "THREADS", "FILES", "SIZE", depth=-1) header_text = self._format_tree_row("NAME", "MESSAGES", "THREADS", "FILES", "SIZE", "MANAGE", depth=-1)
header_text.stylize("bold") header_text.stylize("bold")
self.stats_tree.root.set_label(header_text) self.stats_tree.root.set_label(header_text)
self.stats_tree.show_root = True self.stats_tree.show_root = True
@ -238,6 +238,25 @@ class BackupStatsScreen(Screen[None]):
if event.button.id == "btn_back": if event.button.id == "btn_back":
self.dismiss() self.dismiss()
@on(Tree.NodeSelected)
def on_node_selected(self, event: Tree.NodeSelected) -> None:
"""Handle selection of a channel node to open management modal."""
node_data = event.node.data
if node_data and node_data.get("is_channel") and node_data.get("is_backed_up"):
self.app.push_screen(
ManageBackupModal(
node_data["name"],
node_data["id"],
node_data["stats"],
self.target_dir
),
callback=self._on_modal_close
)
def _on_modal_close(self, changed: bool) -> None:
if changed:
self.load_data()
def _format_size(self, size_bytes: int) -> str: def _format_size(self, size_bytes: int) -> str:
if size_bytes == 0: if size_bytes == 0:
return "0 B" return "0 B"
@ -249,7 +268,7 @@ class BackupStatsScreen(Screen[None]):
return f"{size_bytes / (1024 * 1024):.2f} MB" return f"{size_bytes / (1024 * 1024):.2f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def _format_tree_row(self, name: str, msgs, threads, files, size, depth=0) -> Text: def _format_tree_row(self, name: str, msgs, threads, files, size, manage="", depth=0) -> Text:
"""Pads and aligns columns for the tree view to simulate a table.""" """Pads and aligns columns for the tree view to simulate a table."""
# Textual Tree indents child nodes. Standard indent is 4 characters. # Textual Tree indents child nodes. Standard indent is 4 characters.
# To maintain vertical alignment of values, we subtract the indentation from the name column width. # To maintain vertical alignment of values, we subtract the indentation from the name column width.
@ -263,11 +282,16 @@ class BackupStatsScreen(Screen[None]):
col_thd = str(threads).rjust(12) col_thd = str(threads).rjust(12)
col_file = str(files).rjust(12) col_file = str(files).rjust(12)
col_size = str(size).rjust(15) col_size = str(size).rjust(15)
return Text(f"{col_name}{col_msg}{col_thd}{col_file}{col_size}") col_manage = str(manage).rjust(10)
return Text(f"{col_name}{col_msg}{col_thd}{col_file}{col_size}{col_manage}")
@work(exclusive=True) @work(exclusive=True)
async def load_data(self) -> None: async def load_data(self) -> None:
try: try:
# Clear old nodes
while self.stats_tree.root.children:
self.stats_tree.root.children[0].remove()
# Initialize BackupReader # Initialize BackupReader
reader = BackupReader(self.target_dir) reader = BackupReader(self.target_dir)
await reader.start() await reader.start()
@ -365,12 +389,15 @@ class BackupStatsScreen(Screen[None]):
is_bu = ch.id in backed_up_channel_ids is_bu = ch.id in backed_up_channel_ids
chan_nodes_data.append({ chan_nodes_data.append({
"id": ch.id,
"name": f"# {ch.name}", "name": f"# {ch.name}",
"msgs": stats["message_count"] if is_bu else "NA", "msgs": stats["message_count"] if is_bu else "NA",
"threads": stats["thread_count"] if is_bu else "NA", "threads": stats["thread_count"] if is_bu else "NA",
"files": stats["attachment_count"] if is_bu else "NA", "files": stats["attachment_count"] if is_bu else "NA",
"size": stats["total_size"] if is_bu else 0, "size": stats["total_size"] if is_bu else 0,
"is_backed_up": is_bu "is_backed_up": is_bu,
"is_channel": True,
"stats": stats
}) })
c_msgs += stats["message_count"] c_msgs += stats["message_count"]
@ -384,14 +411,16 @@ class BackupStatsScreen(Screen[None]):
for ch_data in chan_nodes_data: for ch_data in chan_nodes_data:
size_str = self._format_size(ch_data['size']) if ch_data['is_backed_up'] else "NA" size_str = self._format_size(ch_data['size']) if ch_data['is_backed_up'] else "NA"
ch_lbl = self._format_tree_row(ch_data['name'], ch_data['msgs'], ch_data['threads'], ch_data['files'], size_str, depth=2) manage_icon = "🖍️" if ch_data['is_backed_up'] else ""
ch_lbl = self._format_tree_row(ch_data['name'], ch_data['msgs'], ch_data['threads'], ch_data['files'], size_str, manage_icon, depth=2)
if ch_data['is_backed_up']: if ch_data['is_backed_up']:
ch_lbl.stylize("bold white") ch_lbl.stylize("bold white")
else: else:
ch_lbl.stylize("dim white") ch_lbl.stylize("dim white")
node.add_leaf(ch_lbl) newNode = node.add_leaf(ch_lbl)
newNode.data = ch_data
except Exception as e: except Exception as e:
import traceback import traceback
@ -403,3 +432,135 @@ class BackupStatsScreen(Screen[None]):
finally: finally:
if 'reader' in locals(): if 'reader' in locals():
await reader.close() await reader.close()
class ManageBackupModal(ModalScreen[bool]):
"""Modal for managing channel backup data."""
DEFAULT_CSS = """
ManageBackupModal {
align: center middle;
background: rgba(0, 0, 0, 0.6);
}
#mb_container {
width: 60;
height: auto;
max-height: 80%;
border: thick $accent;
background: $surface;
padding: 1 2;
}
#mb_title {
text-style: bold;
margin-bottom: 1;
}
.mb_info {
color: $text-muted;
margin-bottom: 1;
}
#mb_options {
margin-top: 1;
layout: vertical;
height: auto;
}
#mb_options Button {
width: 100%;
margin-bottom: 1;
}
#mb_footer {
margin-top: 1;
layout: vertical;
content-align: center middle;
height: auto;
}
#mb_confirm_text {
color: $error;
text-style: bold italic;
margin-bottom: 1;
height: 1;
content-align: center middle;
width: 100%;
}
#btn_cancel {
width: 16;
}
"""
def __init__(self, channel_name: str, channel_id: int, stats: dict, target_dir: Path, *args, **kwargs):
super().__init__(*args, **kwargs)
self.channel_name = channel_name
self.channel_id = channel_id
self.stats = stats
self.target_dir = target_dir
self.primed_button = None # Tracks which button is awaiting second click
def compose(self) -> ComposeResult:
with Vertical(id="mb_container"):
yield Label(f"Manage Backup: {self.channel_name}", id="mb_title")
msgs = self.stats.get("message_count", 0)
files = self.stats.get("attachment_count", 0)
yield Label(f"Currently contains {msgs} messages and {files} attachments.", classes="mb_info")
yield Rule()
with Vertical(id="mb_options"):
yield Button("Delete All Messages", id="btn_delete_msgs", variant="warning")
yield Button("Delete Messages & Purge Unused Attachments", id="btn_delete_purge", variant="warning")
with Vertical(id="mb_footer"):
yield Label("", id="mb_confirm_text")
yield Button("Cancel", id="btn_cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn_cancel":
self.dismiss(False)
return
btn_id = event.button.id
if btn_id not in ("btn_delete_msgs", "btn_delete_purge"):
return
if self.primed_button == btn_id:
# Second click, proceed
purge = (btn_id == "btn_delete_purge")
self.run_deletion(purge=purge)
else:
# First click, prime it
# Reset other buttons first
btn_msgs = self.query_one("#btn_delete_msgs", Button)
btn_purge = self.query_one("#btn_delete_purge", Button)
btn_msgs.variant = "warning"
btn_purge.variant = "warning"
# Prime current click
event.button.variant = "error"
self.primed_button = btn_id
self.query_one("#mb_confirm_text", Label).update("Click again to proceed")
@work(exclusive=True)
async def run_deletion(self, purge: bool = False) -> None:
try:
from src.core.backup_database import BackupDatabase
db_path = self.target_dir / "backup.db"
db = BackupDatabase(db_path)
# 1. Delete messages
db.delete_channel_messages(self.channel_id)
# 2. Optionally purge
if purge:
db.purge_unused_media(self.target_dir)
db.close()
self.dismiss(True)
except Exception as e:
logger.exception("Failed to delete channel data")
self.app.notify(f"Error: {e}", severity="error")

View file

@ -423,6 +423,7 @@ class OperationPane(Container):
except Exception as e: except Exception as e:
logger.error(f"Error in run_validate setup: {e}") logger.error(f"Error in run_validate setup: {e}")
info = self._get_backup_info()
self.validation_results = { self.validation_results = {
"discord_validating": True, "discord_validating": True,
"target_validating": True, "target_validating": True,
@ -435,11 +436,11 @@ class OperationPane(Container):
"target_permissions": {}, "target_permissions": {},
"target_error": None, "target_error": None,
"discord_timeout": False, "target_timeout": False, "discord_timeout": False, "target_timeout": False,
"backup_info_text": "", "backup_info_text": f"Last backup: [cyan]{info}[/cyan]" if info else "",
} }
self.tokens_valid = False self.tokens_valid = False
self.permissions_complete = False self.permissions_complete = False
self.has_backup = False self.has_backup = bool(info)
# Check what we have # Check what we have
has_d_token = bool(self.config.discord_bot_token) has_d_token = bool(self.config.discord_bot_token)
@ -542,11 +543,11 @@ class OperationPane(Container):
if self.view_mode == "backup": if self.view_mode == "backup":
self.tokens_valid = bool(discord_ok) self.tokens_valid = bool(discord_ok)
if self.tokens_valid: # Check for backup regardless of token validity
info = self._get_backup_info() info = self._get_backup_info()
if info: if info:
self.validation_results["backup_info_text"] = f"Last backup: [cyan]{info}[/cyan]" self.validation_results["backup_info_text"] = f"Last backup: [cyan]{info}[/cyan]"
self.has_backup = True self.has_backup = True
else: else:
target_ok = v.get("target_token") and v.get("target_community") target_ok = v.get("target_token") and v.get("target_community")
self.tokens_valid = bool(discord_ok and target_ok) self.tokens_valid = bool(discord_ok and target_ok)