switch to sqlite for backups also

This commit is contained in:
rambros 2026-03-13 14:57:41 +05:30
parent de28ecdbb1
commit f402a36477
6 changed files with 1852 additions and 1010 deletions

762
src/core/backup_database.py Normal file
View file

@ -0,0 +1,762 @@
import sqlite3
import logging
import json
import threading
from pathlib import Path
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
class BackupDatabase:
"""Manages the SQLite database for local Discord backups."""
def __init__(self, db_path: Path | str):
self.db_path = Path(db_path)
self._lock = threading.Lock()
self._init_db()
def _get_conn(self):
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _init_db(self):
"""Initializes the database schema."""
with self._lock:
conn = self._get_conn()
try:
# Guild Profile
conn.execute("""
CREATE TABLE IF NOT EXISTS guild_profile (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
icon_file TEXT,
icon_url TEXT,
banner_file TEXT,
banner_url TEXT,
owner_id TEXT,
last_backup TEXT,
ignore_channels TEXT
)
""")
# Roles
conn.execute("""
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
name TEXT,
color INTEGER,
position INTEGER,
permissions TEXT,
hoist INTEGER,
mentionable INTEGER
)
""")
# Channels
conn.execute("""
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
name TEXT,
type INTEGER,
position INTEGER,
category_id TEXT,
topic TEXT,
nsfw INTEGER
)
""")
# Channel Permissions
conn.execute("""
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT,
target_id TEXT,
target_type TEXT,
allow INTEGER,
deny INTEGER
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_permissions_chan ON permissions(channel_id)")
# Users (Author cache)
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT,
display_name TEXT,
avatar_file TEXT,
avatar_url TEXT,
roles TEXT
)
""")
# Messages
conn.execute("""
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
channel_id TEXT,
author_id TEXT,
content TEXT,
timestamp TEXT,
type INTEGER,
message_reference TEXT,
is_pinned INTEGER,
extra_data TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)")
# Attachments
conn.execute("""
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id TEXT,
filename TEXT,
size INTEGER,
url TEXT,
content_type TEXT,
local_hash TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_attachments_msg ON attachments(message_id)")
# Embeds
conn.execute("""
CREATE TABLE IF NOT EXISTS embeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT,
title TEXT,
description TEXT,
url TEXT,
color INTEGER,
timestamp TEXT,
thumbnail_url TEXT,
image_url TEXT,
author_name TEXT,
author_url TEXT,
author_icon_url TEXT,
footer_text TEXT,
footer_icon_url TEXT,
fields TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_embeds_msg ON embeds(message_id)")
# Reactions
conn.execute("""
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT,
emoji_id TEXT,
emoji_name TEXT,
count INTEGER
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_reactions_msg ON reactions(message_id)")
# Message Stickers
conn.execute("""
CREATE TABLE IF NOT EXISTS message_stickers (
message_id TEXT,
sticker_id TEXT,
name TEXT,
url TEXT,
format_type INTEGER,
local_hash TEXT,
PRIMARY KEY (message_id, sticker_id)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_message_stickers_msg ON message_stickers(message_id)")
# Threads
conn.execute("""
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
name TEXT,
type INTEGER,
parent_id TEXT,
message_count INTEGER,
member_count INTEGER,
archived INTEGER,
archive_timestamp TEXT,
auto_archive_duration INTEGER,
locked INTEGER,
applied_tags TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_threads_parent ON threads(parent_id)")
# Forum Tags (Definitions for a forum channel)
conn.execute("""
CREATE TABLE IF NOT EXISTS forum_tags (
id TEXT PRIMARY KEY,
forum_id TEXT,
name TEXT,
moderated INTEGER,
emoji_id TEXT,
emoji_name TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_forum_tags_forum ON forum_tags(forum_id)")
# Media Pool (CAS)
# Maps content hashes to local storage paths
conn.execute("""
CREATE TABLE IF NOT EXISTS media_pool (
hash TEXT PRIMARY KEY,
local_path TEXT,
size INTEGER,
mime_type TEXT,
first_seen_url TEXT
)
""")
# Server Assets (Emojis, Stickers, etc.)
conn.execute("""
CREATE TABLE IF NOT EXISTS server_assets (
id TEXT PRIMARY KEY,
name TEXT,
type TEXT,
filename TEXT,
url TEXT,
mime_type TEXT
)
""")
conn.commit()
finally:
conn.close()
def set_guild_profile(self, data: Dict[str, Any]):
with self._lock:
conn = self._get_conn()
conn.execute("""
INSERT OR REPLACE INTO guild_profile (id, name, description, icon_file, icon_url, banner_file, banner_url, owner_id, last_backup, ignore_channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
str(data.get("id")), data.get("name"), data.get("description"),
data.get("icon_file"), data.get("icon_url"),
data.get("banner_file"), data.get("banner_url"),
str(data.get("owner_id")),
data.get("last_backup"), json.dumps(data.get("ignore_channels", []))
))
conn.commit()
conn.close()
def get_guild_profile(self) -> Optional[Dict[str, Any]]:
with self._lock:
conn = self._get_conn()
row = conn.execute("SELECT * FROM guild_profile LIMIT 1").fetchone()
conn.close()
if row:
data = dict(row)
if data.get("ignore_channels"):
data["ignore_channels"] = json.loads(data["ignore_channels"])
else:
data["ignore_channels"] = []
return data
return None
def save_roles(self, roles: List[Dict[str, Any]]):
with self._lock:
conn = self._get_conn()
# Ensure complex fields are strings if they aren't already
formatted = []
for r in roles:
formatted.append({
"id": str(r["id"]),
"name": r["name"],
"color": r["color"],
"position": r["position"],
"permissions": str(r["permissions"]),
"hoist": 1 if r["hoist"] else 0,
"mentionable": 1 if r["mentionable"] else 0
})
conn.executemany("""
INSERT OR REPLACE INTO roles (id, name, color, position, permissions, hoist, mentionable)
VALUES (:id, :name, :color, :position, :permissions, :hoist, :mentionable)
""", formatted)
conn.commit()
conn.close()
def save_channels(self, channels: List[Dict[str, Any]]):
with self._lock:
conn = self._get_conn()
conn.executemany("""
INSERT OR REPLACE INTO channels (id, name, type, position, category_id, topic, nsfw)
VALUES (:id, :name, :type, :position, :category_id, :topic, :nsfw)
""", channels)
conn.commit()
conn.close()
def save_permissions(self, permissions: List[Dict[str, Any]]):
"""Saves a batch of channel permission overwrites."""
with self._lock:
conn = self._get_conn()
try:
conn.executemany("""
INSERT INTO permissions (channel_id, target_id, target_type, allow, deny)
VALUES (:channel_id, :target_id, :target_type, :allow, :deny)
""", permissions)
conn.commit()
finally:
conn.close()
def save_users(self, users: List[Dict[str, Any]]):
"""Saves users to the author cache."""
with self._lock:
conn = self._get_conn()
conn.executemany("""
INSERT OR REPLACE INTO users (id, username, display_name, avatar_file, avatar_url, roles)
VALUES (:id, :username, :display_name, :avatar_file, :avatar_url, :roles)
""", users)
conn.commit()
conn.close()
def save_server_assets(self, assets: List[Dict[str, Any]]):
"""Saves a batch of server assets (emojis, stickers) to the database."""
with self._lock:
conn = self._get_conn()
formatted = []
for a in assets:
formatted.append({
"id": str(a["id"]),
"name": a.get("name"),
"type": a.get("type"),
"filename": a.get("filename"),
"url": a.get("url"),
"mime_type": a.get("mime_type")
})
conn.executemany("""
INSERT OR REPLACE INTO server_assets (id, name, type, filename, url, mime_type)
VALUES (:id, :name, :type, :filename, :url, :mime_type)
""", formatted)
conn.commit()
conn.close()
def save_threads(self, threads: List[Dict[str, Any]]):
"""Saves metadata for threads to the database."""
with self._lock:
conn = self._get_conn()
try:
conn.executemany("""
INSERT OR REPLACE INTO threads (id, name, type, parent_id, message_count, member_count, archived, archive_timestamp, auto_archive_duration, locked, applied_tags)
VALUES (:id, :name, :type, :parent_id, :message_count, :member_count, :archived, :archive_timestamp, :auto_archive_duration, :locked, :applied_tags)
""", threads)
conn.commit()
finally:
conn.close()
def save_forum_tags(self, tags: List[Dict[str, Any]]):
"""Saves definitions for forum tags."""
with self._lock:
conn = self._get_conn()
try:
conn.executemany("""
INSERT OR REPLACE INTO forum_tags (id, forum_id, name, moderated, emoji_id, emoji_name)
VALUES (:id, :forum_id, :name, :moderated, :emoji_id, :emoji_name)
""", tags)
conn.commit()
finally:
conn.close()
def save_messages_batch(self, messages: List[Dict[str, Any]]):
"""Batch inserts messages and their attachments."""
with self._lock:
conn = self._get_conn()
try:
# Insert messages
conn.executemany("""
INSERT OR REPLACE INTO messages (id, channel_id, author_id, content, timestamp, type, message_reference, is_pinned, extra_data)
VALUES (:id, :channel_id, :author_id, :content, :timestamp, :type, :message_reference, :is_pinned, :extra_data)
""", messages)
# Extract attachments, reactions, and stickers
all_attachments = []
all_reactions = []
all_stickers = []
for msg in messages:
# Attachments
if "attachments" in msg:
for att in msg["attachments"]:
att["message_id"] = msg["id"]
all_attachments.append(att)
# Reactions
if "reactions" in msg and msg["reactions"]:
for rea in msg["reactions"]:
all_reactions.append({
"message_id": msg["id"],
"emoji_id": str(rea["emoji_id"]) if rea.get("emoji_id") else None,
"emoji_name": rea.get("emoji_name"),
"count": rea.get("count", 0)
})
# Stickers
if "stickers" in msg and msg["stickers"]:
for st in msg["stickers"]:
all_stickers.append({
"message_id": msg["id"],
"sticker_id": str(st["id"]),
"name": st.get("name"),
"url": st.get("url"),
"format_type": st.get("format_type"),
"local_hash": st.get("local_hash")
})
if all_attachments:
conn.executemany("""
INSERT OR REPLACE INTO attachments (id, message_id, filename, size, url, content_type, local_hash)
VALUES (:id, :message_id, :filename, :size, :url, :content_type, :local_hash)
""", all_attachments)
# Save Embeds (Normalized with JSON Fields)
for msg in messages:
if "embeds" in msg and msg["embeds"]:
for emb in msg["embeds"]:
# Insert top-level embed
conn.execute("""
INSERT INTO embeds (
message_id, title, description, url, color, timestamp,
thumbnail_url, image_url, author_name, author_url,
author_icon_url, footer_text, footer_icon_url, fields
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
msg["id"], emb.get("title"), emb.get("description"), emb.get("url"),
emb.get("color"), emb.get("timestamp"),
emb.get("thumbnail", {}).get("url") if isinstance(emb.get("thumbnail"), dict) else None,
emb.get("image", {}).get("url") if isinstance(emb.get("image"), dict) else None,
emb.get("author", {}).get("name") if isinstance(emb.get("author"), dict) else None,
emb.get("author", {}).get("url") if isinstance(emb.get("author"), dict) else None,
emb.get("author", {}).get("icon_url") if isinstance(emb.get("author"), dict) else None,
emb.get("footer", {}).get("text") if isinstance(emb.get("footer"), dict) else None,
emb.get("footer", {}).get("icon_url") if isinstance(emb.get("footer"), dict) else None,
json.dumps(emb.get("fields", []))
))
if all_reactions:
conn.executemany("""
INSERT INTO reactions (message_id, emoji_id, emoji_name, count)
VALUES (:message_id, :emoji_id, :emoji_name, :count)
""", all_reactions)
if all_stickers:
conn.executemany("""
INSERT OR REPLACE INTO message_stickers (message_id, sticker_id, name, url, format_type, local_hash)
VALUES (:message_id, :sticker_id, :name, :url, :format_type, :local_hash)
""", all_stickers)
conn.commit()
finally:
conn.close()
def get_last_message_id(self, channel_id: str) -> Optional[str]:
with self._lock:
conn = self._get_conn()
row = conn.execute("SELECT id FROM messages WHERE channel_id = ? ORDER BY id DESC LIMIT 1", (str(channel_id),)).fetchone()
conn.close()
return row["id"] if row else None
def get_media_by_hash(self, file_hash: str) -> Optional[Dict[str, Any]]:
with self._lock:
conn = self._get_conn()
row = conn.execute("SELECT * FROM media_pool WHERE hash = ?", (file_hash,)).fetchone()
conn.close()
return dict(row) if row else None
def get_media_by_url(self, url: str) -> Optional[Dict[str, Any]]:
with self._lock:
conn = self._get_conn()
row = conn.execute("SELECT * FROM media_pool WHERE first_seen_url = ?", (url,)).fetchone()
conn.close()
return dict(row) if row else None
def add_media_to_pool(self, file_hash: str, local_path: str, size: int, mime_type: str, url: str):
with self._lock:
conn = self._get_conn()
conn.execute("""
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))
conn.commit()
conn.close()
def get_stats_by_channel(self) -> Dict[int, Dict[str, Any]]:
"""Returns aggregate stats for all channels with backups."""
with self._lock:
conn = self._get_conn()
# Summary of messages per effective channel (including threads rolled up)
msg_rows = conn.execute("""
SELECT
COALESCE(t.parent_id, m.channel_id) as 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
""").fetchall()
# Thread counts per parent
thread_rows = conn.execute("""
SELECT parent_id, COUNT(*) as thread_count
FROM threads
GROUP BY parent_id
""").fetchall()
# Summary of attachments per effective channel (including threads rolled up)
att_rows = conn.execute("""
SELECT
COALESCE(t.parent_id, m.channel_id) as 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
""").fetchall()
conn.close()
stats = {}
for r in msg_rows:
cid_raw = r["channel_id"]
if cid_raw is None or cid_raw == "None": continue
cid = int(cid_raw)
stats[cid] = {
"message_count": r["msg_count"],
"thread_count": 0,
"attachment_count": 0,
"total_size": 0
}
for r in thread_rows:
cid_raw = r["parent_id"]
if cid_raw is None or cid_raw == "None": continue
cid = int(cid_raw)
if cid not in stats:
stats[cid] = {"message_count": 0, "thread_count": 0, "attachment_count": 0, "total_size": 0}
stats[cid]["thread_count"] = r["thread_count"]
for r in att_rows:
cid_raw = r["channel_id"]
if cid_raw is None or cid_raw == "None": continue
cid = int(cid_raw)
if cid not in stats:
stats[cid] = {"message_count": 0, "thread_count": 0, "attachment_count": 0, "total_size": 0}
stats[cid]["attachment_count"] = r["att_count"]
stats[cid]["total_size"] = r["total_size"] or 0
return stats
def get_all_roles(self) -> List[Dict[str, Any]]:
with self._lock:
conn = self._get_conn()
rows = conn.execute("SELECT * FROM roles ORDER BY position DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_all_channels(self) -> List[Dict[str, Any]]:
with self._lock:
conn = self._get_conn()
rows = conn.execute("SELECT * FROM channels ORDER BY position ASC").fetchall()
# Fetch all permissions
chan_list = [dict(r) for r in rows]
if chan_list:
ids = [c["id"] for c in chan_list]
placeholders = ",".join(["?"] * len(ids))
perm_rows = conn.execute(f"SELECT * FROM permissions WHERE channel_id IN ({placeholders})", ids).fetchall()
perms_by_chan = {}
for pr in perm_rows:
cid = pr["channel_id"]
if cid not in perms_by_chan: perms_by_chan[cid] = []
perms_by_chan[cid].append({
"id": pr["target_id"],
"type": pr["target_type"],
"allow": pr["allow"],
"deny": pr["deny"]
})
for c in chan_list:
c["overwrites"] = perms_by_chan.get(c["id"], [])
# Fetch Forum Tags
tag_rows = conn.execute(f"SELECT * FROM forum_tags WHERE forum_id IN ({placeholders})", ids).fetchall()
tags_by_forum = {}
for tr in tag_rows:
fid = tr["forum_id"]
if fid not in tags_by_forum: tags_by_forum[fid] = []
tags_by_forum[fid].append(dict(tr))
for c in chan_list:
c["available_tags"] = tags_by_forum.get(c["id"], [])
conn.close()
return chan_list
def get_all_threads(self) -> List[Dict[str, Any]]:
"""Returns metadata for all threads in the backup."""
with self._lock:
conn = self._get_conn()
rows = conn.execute("SELECT * FROM threads").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_forum_tags(self, forum_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""Returns forum tag definitions."""
with self._lock:
conn = self._get_conn()
if forum_id:
rows = conn.execute("SELECT * FROM forum_tags WHERE forum_id = ?", (str(forum_id),)).fetchall()
else:
rows = conn.execute("SELECT * FROM forum_tags").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_threads_by_parent(self, parent_id: str) -> List[Dict[str, Any]]:
"""Returns all threads belonging to a parent channel."""
with self._lock:
conn = self._get_conn()
rows = conn.execute("SELECT * FROM threads WHERE parent_id = ?", (str(parent_id),)).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_thread(self, thread_id: str) -> Optional[Dict[str, Any]]:
"""Retrieves a single thread's metadata."""
with self._lock:
conn = self._get_conn()
row = conn.execute("SELECT * FROM threads WHERE id = ?", (str(thread_id),)).fetchone()
conn.close()
return dict(row) if row else None
def get_all_users(self) -> List[Dict[str, Any]]:
with self._lock:
conn = self._get_conn()
rows = conn.execute("SELECT * FROM users").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
conn = self._get_conn()
row = conn.execute("SELECT * FROM users WHERE id = ?", (str(user_id),)).fetchone()
conn.close()
if row:
data = dict(row)
if data.get("roles"):
data["roles"] = json.loads(data["roles"])
return data
return None
def get_server_assets(self, asset_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""Returns all server assets, optionally filtered by type."""
with self._lock:
conn = self._get_conn()
if asset_type:
rows = conn.execute("SELECT * FROM server_assets WHERE type = ?", (asset_type,)).fetchall()
else:
rows = conn.execute("SELECT * FROM server_assets").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_all_media(self) -> Dict[str, Dict[str, Any]]:
"""Returns the entire media pool as a dictionary indexed by hash."""
with self._lock:
conn = self._get_conn()
rows = conn.execute("SELECT * FROM media_pool").fetchall()
conn.close()
return {r["hash"]: dict(r) for r in rows}
def get_messages_paged(self, channel_id: str, limit: int = 100, offset: int = 0, after_id: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock:
conn = self._get_conn()
query = "SELECT * FROM messages WHERE channel_id = ?"
params = [str(channel_id)]
if after_id:
query += " AND id > ?"
params.append(str(after_id))
query += " ORDER BY id ASC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(query, params).fetchall()
msg_list = [dict(r) for r in rows]
if msg_list:
# Fetch attachments for these messages
msg_ids = [m["id"] for m in msg_list]
placeholders = ",".join(["?"] * len(msg_ids))
# Attachments
att_rows = conn.execute(f"SELECT * FROM attachments WHERE message_id IN ({placeholders})", msg_ids).fetchall()
atts_by_msg = {}
for ar in att_rows:
mid = ar["message_id"]
if mid not in atts_by_msg: atts_by_msg[mid] = []
atts_by_msg[mid].append(dict(ar))
# Embeds
emb_rows = conn.execute(f"SELECT * FROM embeds WHERE message_id IN ({placeholders})", msg_ids).fetchall()
embs_by_msg = {}
for er in emb_rows:
mid = er["message_id"]
if mid not in embs_by_msg: embs_by_msg[mid] = []
e_dict = {
"title": er["title"],
"description": er["description"],
"url": er["url"],
"color": er["color"],
"timestamp": er["timestamp"],
"thumbnail": {"url": er["thumbnail_url"]} if er["thumbnail_url"] else None,
"image": {"url": er["image_url"]} if er["image_url"] else None,
"author": {
"name": er["author_name"],
"url": er["author_url"],
"icon_url": er["author_icon_url"]
} if er["author_name"] else None,
"footer": {
"text": er["footer_text"],
"icon_url": er["footer_icon_url"]
} if er["footer_text"] else None,
"fields": json.loads(er["fields"]) if er["fields"] else []
}
embs_by_msg[mid].append(e_dict)
# Reactions
rea_rows = conn.execute(f"SELECT * FROM reactions WHERE message_id IN ({placeholders})", msg_ids).fetchall()
reas_by_msg = {}
for rr in rea_rows:
mid = rr["message_id"]
if mid not in reas_by_msg: reas_by_msg[mid] = []
reas_by_msg[mid].append(dict(rr))
# Stickers (Message-specific)
st_rows = conn.execute(f"SELECT * FROM message_stickers WHERE message_id IN ({placeholders})", msg_ids).fetchall()
sts_by_msg = {}
for sr in st_rows:
mid = sr["message_id"]
if mid not in sts_by_msg: sts_by_msg[mid] = []
sts_by_msg[mid].append(dict(sr))
for m in msg_list:
m_id = m["id"]
m["attachments"] = atts_by_msg.get(m_id, [])
m["embeds"] = embs_by_msg.get(m_id, [])
m["reactions"] = reas_by_msg.get(m_id, [])
m["stickers"] = sts_by_msg.get(m_id, [])
conn.close()
return msg_list
def close(self):
# We don't keep long-lived connections in this model to avoid locking issues,
# but the method is here for parity with other DB classes.
pass

File diff suppressed because it is too large Load diff

View file

@ -279,9 +279,14 @@ class DiscordReader:
try:
# In a forum, the starter message ID is the thread ID
starter = await thread.fetch_message(thread.id)
# Bind the thread so migrate_messages handles it properly
if not hasattr(starter, 'thread') or starter.thread is None:
starter.thread = thread
# Bind the thread if possible so downstream code has context
try:
if not hasattr(starter, 'thread') or starter.thread is None:
starter.thread = thread
except (AttributeError, TypeError):
# Some versions of discord.py don't allow setting the thread property
# or it might already be populated as a read-only property
pass
yield starter
except Exception as e:
logger.debug(f"Could not fetch starter message for forum thread {thread.id}: {e}")

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import json
import logging
from pathlib import Path
from datetime import datetime
import asyncio
@ -7,11 +8,15 @@ 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 src.ui.widgets import RamDisplay
from textual import work
from rich.text import Text
from rich.style import Style
from src.ui.widgets import RamDisplay
from src.core.backup_reader import BackupReader, ChannelType
logger = logging.getLogger(__name__)
class BackupStatsScreen(Screen[None]):
"""Full-screen view for displaying detailed backup statistics."""
@ -254,178 +259,133 @@ class BackupStatsScreen(Screen[None]):
@work(exclusive=True)
async def load_data(self) -> None:
try:
# Locate the correct backup directory passed from backup_ops
if not self.target_dir.exists():
raise FileNotFoundError(f"Config directory {self.target_dir} not found.")
# Initialize BackupReader
reader = BackupReader(self.target_dir)
await reader.start()
target_dir = self.target_dir
if not (target_dir / "server_profile" / "profile.json").exists():
if not reader.guild:
raise FileNotFoundError(f"No valid backup found in {self.target_dir}")
self.profile_path = target_dir / "server_profile"
self.backup_path = target_dir / "message_backup"
# 1. Profile / Server Info
server_name = "Unknown Server"
server_id = "0"
guild = reader.guild
server_name = guild.name
server_id = str(guild.id)
# Get last backup info from guild profile
profile = reader.db.get_guild_profile()
last_backup_str = "Never"
profile_file = self.profile_path / "profile.json"
if profile_file.exists():
with open(profile_file, "r", encoding="utf-8") as f:
data = json.load(f)
server_name = data.get("name", server_name)
server_id = data.get("id", server_id)
ts = data.get("last_backup")
if ts:
try:
dt = datetime.fromisoformat(ts)
last_backup_str = dt.strftime("%d %b, %Y - %H:%M")
except Exception:
last_backup_str = ts
ts = profile.get("last_backup")
if ts:
try:
dt = datetime.fromisoformat(ts)
last_backup_str = dt.strftime("%d %b, %Y - %H:%M")
except Exception:
last_backup_str = ts
self.query_one("#bs_name", Label).update(f"{server_name}")
self.query_one("#bs_id", Label).update(f"{server_id}")
self.query_one("#bs_last_backup", Label).update(f"{last_backup_str}")
# 2. Assets / Entities
member_count = 0
emoji_count = 0
sticker_count = 0
# Fetch members from users/user_info.json
if self.backup_path:
user_info_file = self.backup_path / "users" / "user_info.json"
if user_info_file.exists():
try:
with open(user_info_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
member_count = len(data)
elif isinstance(data, dict):
member_count = len(data)
except Exception:
pass
assets_file = self.profile_path / "assets.json"
if assets_file.exists():
with open(assets_file, "r", encoding="utf-8") as f:
data = json.load(f)
emoji_count = len(data.get("emojis", []))
sticker_count = len(data.get("stickers", []))
role_count = 0
roles_file = self.profile_path / "roles.json"
if roles_file.exists():
with open(roles_file, "r", encoding="utf-8") as f:
role_count = len(json.load(f))
# 2. Assets / Entities (using properties)
member_count = len(reader.members)
emoji_count = len(reader.emojis)
sticker_count = len(reader.stickers)
role_count = len(reader.roles)
self.query_one("#bs_val_members", Label).update(f"{member_count}")
self.query_one("#bs_val_roles", Label).update(f"{role_count}")
self.query_one("#bs_val_emojis", Label).update(f"{emoji_count}")
self.query_one("#bs_val_stickers", Label).update(f"{sticker_count}")
# 3. Structure & Per-Channel Stats
structure_file = self.profile_path / "structure.json"
total_channels = 0
backed_up_channels = 0
# 3. Aggregate Stats from DB
channel_stats = reader.db.get_stats_by_channel()
total_msgs = 0
total_threads = 0
total_files = 0
total_size = 0
total_msgs = sum(s["message_count"] for s in channel_stats.values())
total_threads = sum(s["thread_count"] for s in channel_stats.values())
total_files = sum(s["attachment_count"] for s in channel_stats.values())
total_size = sum(s["total_size"] for s in channel_stats.values())
cat_nodes = [] # Collect category data
backed_up_channel_ids = set(channel_stats.keys())
if structure_file.exists():
with open(structure_file, "r", encoding="utf-8") as f:
structure = json.load(f)
# 4. Structure & Per-Channel Stats
categories = reader.categories
all_channels = reader.channels
for cat in structure:
cat_name = cat.get("name", "Unknown Category").upper()
c_msgs = 0
c_thds = 0
c_files = 0
c_size = 0
total_channels = len([c for c in all_channels if c.type != ChannelType.category])
backed_up_channels = len([cid for cid in backed_up_channel_ids if any(c.id == cid for c in all_channels)])
chan_list = []
# Helper to map channels to categories
cat_map = {cat.id: {"cat": cat, "chans": []} for cat in categories}
cat_map[None] = {"cat": None, "chans": []} # Uncategorized
for chan in cat.get("channels", []):
total_channels += 1
ch_id = chan.get("id")
ch_name = f"# {chan.get('name', 'unknown')}"
for chan in all_channels:
if chan.type == ChannelType.category: continue
cid = chan.category_id
if cid not in cat_map: cid = None
cat_map[cid]["chans"].append(chan)
m_count = 0
t_count = 0
f_count = 0
s_bytes = 0
is_backed_up = False
msg_file = self.backup_path / str(ch_id) / "messages.json"
if msg_file.exists():
is_backed_up = True
backed_up_channels += 1
try:
with open(msg_file, "r", encoding="utf-8") as mf:
mdata = json.load(mf)
m_count = mdata.get("messageCount", 0)
t_count = mdata.get("threadCount", 0)
f_count = mdata.get("numberOfAttachments", 0)
s_bytes = mdata.get("totalAttachmentSizeBytes", 0)
except Exception:
pass
chan_list.append({
"name": ch_name,
"msgs": m_count if is_backed_up else "NA",
"threads": t_count if is_backed_up else "NA",
"files": f_count if is_backed_up else "NA",
"size": s_bytes,
"is_backed_up": is_backed_up
})
c_msgs += m_count
c_thds += t_count
c_files += f_count
c_size += s_bytes
total_msgs += c_msgs
total_threads += c_thds
total_files += c_files
total_size += c_size
cat_nodes.append({
"name": cat_name,
"channels": chan_list,
"msgs": c_msgs,
"threads": c_thds,
"files": c_files,
"size": c_size
})
# 4. Global Stats
# Global update
self.query_one("#bs_val_msgs", Label).update(f"{total_msgs}")
self.query_one("#bs_val_threads", Label).update(f"{total_threads}")
self.query_one("#bs_val_files", Label).update(f"{total_files}")
self.query_one("#bs_val_size", Label).update(f"{self._format_size(total_size)}")
self.query_one("#bs_val_coverage", Label).update(f"{backed_up_channels} / {total_channels}")
# 5. Build Tree
for cat in cat_nodes:
cat_lbl = self._format_tree_row(f"{cat['name']}", cat['msgs'], cat['threads'], cat['files'], self._format_size(cat['size']))
for cat_id, info in cat_map.items():
cat = info["cat"]
chans = info["chans"]
if not chans: continue
cat_name = cat.name.upper() if cat else "UNCATEGORIZED"
# Aggregate for category
c_msgs = 0
c_thds = 0
c_files = 0
c_size = 0
chan_nodes_data = []
for ch in chans:
stats = channel_stats.get(ch.id, {"message_count": 0, "thread_count": 0, "attachment_count": 0, "total_size": 0})
is_bu = ch.id in backed_up_channel_ids
chan_nodes_data.append({
"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
})
c_msgs += stats["message_count"]
c_thds += stats["thread_count"]
c_files += stats["attachment_count"]
c_size += stats["total_size"]
cat_lbl = self._format_tree_row(cat_name, c_msgs, c_thds, c_files, self._format_size(c_size))
cat_lbl.stylize("bold yellow")
node = self.stats_tree.root.add(cat_lbl, expand=True)
for ch in cat["channels"]:
size_str = self._format_size(ch['size']) if ch['is_backed_up'] else "NA"
ch_lbl = self._format_tree_row(f" {ch['name']}", ch['msgs'], ch['threads'], ch['files'], size_str)
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(f" {ch_data['name']}", ch_data['msgs'], ch_data['threads'], ch_data['files'], size_str)
if ch['is_backed_up']:
if ch_data['is_backed_up']:
ch_lbl.stylize("bold white")
else:
ch_lbl.stylize("dim white") # Textual 'dim' looks like a dull grey
ch_lbl.stylize("dim white")
node.add_leaf(ch_lbl)
except Exception as e:
import traceback
logger.exception("Failed to load backup stats")
trace_str = traceback.format_exc()
logger.error(f"Full Traceback: {trace_str}")
self.query_one("#bs_name", Label).update(f"[red]Error loading data[/red]")
self.query_one("#bs_id", Label).update(f"[red]{e}[/red]")
finally:
if 'reader' in locals():
await reader.close()

View file

@ -183,7 +183,6 @@ class OperationPane(Container):
self.exporter = DiscordExporter(self.engine.discord_reader, base_dir=self._base_dir())
def _get_backup_info(self) -> str | None:
import json
if not self.config or not self.config.discord_server_id:
return None
@ -191,14 +190,16 @@ class OperationPane(Container):
if not target_dir.exists():
return None
profile_file = target_dir / "server_profile" / "profile.json"
if not profile_file.exists():
db_file = target_dir / "backup.db"
if not db_file.exists():
return None
try:
from datetime import datetime
with open(profile_file, "r", encoding="utf-8") as f:
data = json.load(f)
ts_str = data.get("last_backup")
from src.core.backup_database import BackupDatabase
db = BackupDatabase(db_file)
profile = db.get_guild_profile()
if profile:
ts_str = profile.get("last_backup")
if ts_str:
dt = datetime.fromisoformat(ts_str)
return dt.strftime("%d-%b-%Y %H:%M")
@ -1767,10 +1768,12 @@ class OperationPane(Container):
any_found = False
backed_up_ids = set()
for chan in eligible_channels:
if (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists():
any_found = True
backed_up_ids.add(chan.id)
if self.exporter.db:
channel_stats = self.exporter.db.get_stats_by_channel()
for chan in eligible_channels:
if chan.id in channel_stats:
any_found = True
backed_up_ids.add(chan.id)
self.app.pop_screen()
@ -1867,6 +1870,8 @@ class OperationPane(Container):
modal_prog.write(f"[yellow]Starting backup for {total_chans} channels...[/yellow]")
accumulated_msgs = 0
accumulated_threads = 0
accumulated_files = 0
for i, chan in enumerate(selected_channels):
if not self.exporter.is_running:
@ -1874,7 +1879,7 @@ class OperationPane(Container):
break
await asyncio.sleep(0.01) # Yield to UI thread to keep it responsive
backup_exists = (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists()
backup_exists = chan.id in backed_up_ids
is_sync = backup_exists and not force_overwrite
label = "Syncing Backup" if is_sync else "Backing up"
@ -1884,20 +1889,17 @@ class OperationPane(Container):
logger.info(f"{label} for channel: #{chan.name} ({chan.id})")
_msg_log_counter = 0
async def update_msg_count(name, count, author_name=None, message_preview=None):
async def update_msg_count(name, count, author_name=None, message_preview=None, thread_count=0, file_count=0):
nonlocal _msg_log_counter
modal_prog.update_stats(messages=str(count))
modal_prog.update_stats(messages=str(count), threads=str(thread_count), files=str(file_count))
_msg_log_counter += 1
if author_name and message_preview and _msg_log_counter % 10 == 0:
modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}")
accumulated_msgs = await self.exporter.export_channel_messages(
accumulated_msgs, accumulated_threads, accumulated_files = await self.exporter.export_channel_messages(
chan.id, progress_callback=update_msg_count, force=force_overwrite,
accumulated_count=accumulated_msgs, after_id=after_id
)
accumulated_msgs = await self.exporter.export_threads(
chan.id, progress_callback=update_msg_count, force=force_overwrite,
accumulated_count=accumulated_msgs
accumulated_count=accumulated_msgs, accumulated_threads=accumulated_threads, accumulated_files=accumulated_files,
after_id=after_id
)
modal_prog.write(f"[green]Completed: {chan.name}[/green]")
@ -1981,9 +1983,14 @@ class OperationPane(Container):
]
]
# Get channels that have messages in the database
backed_up_channel_ids = set()
if self.exporter.db:
backed_up_channel_ids = set(self.exporter.db.get_stats_by_channel().keys())
selected_channels = [
c for c in eligible_channels
if (self.exporter.export_path / "message_backup" / str(c.id) / "messages.json").exists()
if c.id in backed_up_channel_ids
]
if not selected_channels:
@ -1999,6 +2006,8 @@ class OperationPane(Container):
modal_prog.cancel_callback = lambda: setattr(self.exporter, "is_running", False)
accumulated_msgs = 0
accumulated_threads = 0
accumulated_files = 0
for i, chan in enumerate(selected_channels):
if not self.exporter.is_running:
@ -2012,20 +2021,16 @@ class OperationPane(Container):
logger.info(f"Syncing backup for channel: #{chan.name} ({chan.id})")
_msg_log_counter = 0
async def update_msg_count(name, count, author_name=None, message_preview=None):
async def update_msg_count(name, count, author_name=None, message_preview=None, thread_count=0, file_count=0):
nonlocal _msg_log_counter
modal_prog.update_stats(messages=str(count))
modal_prog.update_stats(messages=str(count), threads=str(thread_count), files=str(file_count))
_msg_log_counter += 1
if author_name and message_preview and _msg_log_counter % 10 == 0:
modal_prog.write(f"[bold]{author_name}:[/bold] {message_preview}")
accumulated_msgs = await self.exporter.export_channel_messages(
accumulated_msgs, accumulated_threads, accumulated_files = await self.exporter.export_channel_messages(
chan.id, progress_callback=update_msg_count, force=False,
accumulated_count=accumulated_msgs
)
accumulated_msgs = await self.exporter.export_threads(
chan.id, progress_callback=update_msg_count, force=False,
accumulated_count=accumulated_msgs
accumulated_count=accumulated_msgs, accumulated_threads=accumulated_threads, accumulated_files=accumulated_files
)
modal_prog.write(f"[green]Synced: {chan.name}[/green]")