add backup delete option
This commit is contained in:
parent
fa0865ef85
commit
6f94a00714
4 changed files with 266 additions and 25 deletions
|
|
@ -464,39 +464,41 @@ class BackupDatabase:
|
|||
INSERT OR REPLACE INTO media_pool (hash, local_path, size, mime_type, first_seen_url)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (file_hash, str(local_path), size, mime_type, url))
|
||||
|
||||
def get_stats_by_channel(self) -> Dict[int, Dict[str, Any]]:
|
||||
"""Returns aggregate stats for all channels with backups."""
|
||||
with self._lock:
|
||||
# 1. Message counts (aggregating threads into parent channel)
|
||||
msg_rows = self._conn.execute("""
|
||||
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
|
||||
FROM messages m
|
||||
LEFT JOIN threads t ON m.channel_id = t.id
|
||||
GROUP BY channel_id
|
||||
GROUP BY agg_channel_id
|
||||
""").fetchall()
|
||||
|
||||
# 2. Thread counts
|
||||
thread_rows = self._conn.execute("""
|
||||
SELECT parent_id, COUNT(*) as thread_count
|
||||
FROM threads
|
||||
GROUP BY parent_id
|
||||
""").fetchall()
|
||||
|
||||
# 3. Attachment counts and sizes
|
||||
att_rows = self._conn.execute("""
|
||||
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,
|
||||
SUM(a.size) as total_size
|
||||
FROM attachments a
|
||||
JOIN messages m ON a.message_id = m.id
|
||||
LEFT JOIN threads t ON m.channel_id = t.id
|
||||
GROUP BY channel_id
|
||||
GROUP BY agg_channel_id
|
||||
""").fetchall()
|
||||
|
||||
stats = {}
|
||||
for r in msg_rows:
|
||||
cid = parse_snowflake(r["channel_id"])
|
||||
cid = parse_snowflake(r["agg_channel_id"])
|
||||
if cid is None: continue
|
||||
stats[cid] = {
|
||||
"message_count": r["msg_count"],
|
||||
|
|
@ -513,7 +515,7 @@ class BackupDatabase:
|
|||
stats[cid]["thread_count"] = r["thread_count"]
|
||||
|
||||
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 not in stats:
|
||||
stats[cid] = {"message_count": 0, "thread_count": 0, "attachment_count": 0, "total_size": 0}
|
||||
|
|
@ -696,6 +698,83 @@ class BackupDatabase:
|
|||
|
||||
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):
|
||||
"""Commits any pending writes and closes the connection."""
|
||||
with self._lock:
|
||||
|
|
|
|||
|
|
@ -703,7 +703,7 @@ class DiscordExporter:
|
|||
if tmp_path and tmp_path.exists():
|
||||
try: tmp_path.unlink()
|
||||
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):
|
||||
"""Exports active and archived threads for a channel to SQLite."""
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ from datetime import datetime
|
|||
import asyncio
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.containers import Container, Vertical, VerticalScroll, Horizontal
|
||||
from textual.widgets import Button, Label, Rule, Tree
|
||||
from textual import work
|
||||
from textual.screen import Screen, ModalScreen
|
||||
from textual.containers import Container, Vertical, VerticalScroll, Horizontal, Grid
|
||||
from textual.widgets import Button, Label, Rule, Tree, Static
|
||||
from textual import work, on
|
||||
from rich.text import Text
|
||||
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
|
||||
# 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")
|
||||
self.stats_tree.root.set_label(header_text)
|
||||
self.stats_tree.show_root = True
|
||||
|
|
@ -238,6 +238,25 @@ class BackupStatsScreen(Screen[None]):
|
|||
if event.button.id == "btn_back":
|
||||
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:
|
||||
if size_bytes == 0:
|
||||
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 * 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."""
|
||||
# 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.
|
||||
|
|
@ -263,11 +282,16 @@ class BackupStatsScreen(Screen[None]):
|
|||
col_thd = str(threads).rjust(12)
|
||||
col_file = str(files).rjust(12)
|
||||
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)
|
||||
async def load_data(self) -> None:
|
||||
try:
|
||||
# Clear old nodes
|
||||
while self.stats_tree.root.children:
|
||||
self.stats_tree.root.children[0].remove()
|
||||
|
||||
# Initialize BackupReader
|
||||
reader = BackupReader(self.target_dir)
|
||||
await reader.start()
|
||||
|
|
@ -365,12 +389,15 @@ class BackupStatsScreen(Screen[None]):
|
|||
is_bu = ch.id in backed_up_channel_ids
|
||||
|
||||
chan_nodes_data.append({
|
||||
"id": ch.id,
|
||||
"name": f"# {ch.name}",
|
||||
"msgs": stats["message_count"] if is_bu else "NA",
|
||||
"threads": stats["thread_count"] if is_bu else "NA",
|
||||
"files": stats["attachment_count"] if is_bu else "NA",
|
||||
"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"]
|
||||
|
|
@ -384,14 +411,16 @@ class BackupStatsScreen(Screen[None]):
|
|||
|
||||
for ch_data in chan_nodes_data:
|
||||
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']:
|
||||
ch_lbl.stylize("bold white")
|
||||
else:
|
||||
ch_lbl.stylize("dim white")
|
||||
|
||||
node.add_leaf(ch_lbl)
|
||||
newNode = node.add_leaf(ch_lbl)
|
||||
newNode.data = ch_data
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
|
@ -403,3 +432,135 @@ class BackupStatsScreen(Screen[None]):
|
|||
finally:
|
||||
if 'reader' in locals():
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ class OperationPane(Container):
|
|||
except Exception as e:
|
||||
logger.error(f"Error in run_validate setup: {e}")
|
||||
|
||||
info = self._get_backup_info()
|
||||
self.validation_results = {
|
||||
"discord_validating": True,
|
||||
"target_validating": True,
|
||||
|
|
@ -435,11 +436,11 @@ class OperationPane(Container):
|
|||
"target_permissions": {},
|
||||
"target_error": None,
|
||||
"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.permissions_complete = False
|
||||
self.has_backup = False
|
||||
self.has_backup = bool(info)
|
||||
|
||||
# Check what we have
|
||||
has_d_token = bool(self.config.discord_bot_token)
|
||||
|
|
@ -542,11 +543,11 @@ class OperationPane(Container):
|
|||
|
||||
if self.view_mode == "backup":
|
||||
self.tokens_valid = bool(discord_ok)
|
||||
if self.tokens_valid:
|
||||
info = self._get_backup_info()
|
||||
if info:
|
||||
self.validation_results["backup_info_text"] = f"Last backup: [cyan]{info}[/cyan]"
|
||||
self.has_backup = True
|
||||
# Check for backup regardless of token validity
|
||||
info = self._get_backup_info()
|
||||
if info:
|
||||
self.validation_results["backup_info_text"] = f"Last backup: [cyan]{info}[/cyan]"
|
||||
self.has_backup = True
|
||||
else:
|
||||
target_ok = v.get("target_token") and v.get("target_community")
|
||||
self.tokens_valid = bool(discord_ok and target_ok)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue