From 5bec9e3ce7efbc7f5084ccd09e62eb2940e98d4c Mon Sep 17 00:00:00 2001 From: rambros Date: Thu, 5 Mar 2026 11:47:24 +0530 Subject: [PATCH] change backup file structure --- BACKUP.md | 120 ++++++++++++++++++++++++----------- src/core/backup_reader.py | 44 +++++++------ src/disco_reaper/exporter.py | 89 ++++++++++++++------------ src/ui/backup_ops.py | 8 +-- 4 files changed, 159 insertions(+), 102 deletions(-) diff --git a/BACKUP.md b/BACKUP.md index 1d5b00c..1989de5 100644 --- a/BACKUP.md +++ b/BACKUP.md @@ -20,6 +20,35 @@ graph TD B --> F[User Cache Object] ``` +### File Tree Structure + +``` +DISCORD_BACKUP-{ServerID}/ +├── server_profile/ +│ ├── profile.json # Server metadata (name, ID, icon/banner paths) +│ ├── roles.json # All server roles (permissions, colors, positions) +│ ├── structure.json # Full category and channel hierarchy +│ ├── assets.json # Index of custom emojis and stickers +│ └── assets/ # Binary media files +│ ├── server_icon.png +│ ├── server_banner.png +│ ├── emoji_{name}_{id}.png +│ └── sticker_{name}_{id}.png +└── message_backup/ + ├── users/ + │ ├── user_info.json # Deduplicated user profile cache + │ └── avatars/ # User avatar images + │ └── {user_id}.png + └── {channel_id}/ + ├── messages.json # Channel message history + metadata + ├── attachments/ # Channel-level attachments + │ └── {filename}-{id_last_5}.{ext} + └── {thread_id}/ # Thread nested inside parent channel + ├── thread_messages.json + └── thread_attachments/ + └── {filename}-{id_last_5}.{ext} +``` + --- ## 2. Data Lifecycle & Serialization @@ -27,7 +56,7 @@ graph TD ### 2.1 Incremental Synchronization Algorithm To achieve idempotency and efficiency, the system implements an incremental sync strategy using Discord's snowflake IDs. -1. **State Loading**: The `Exporter` reads the existing `{channel_id}.json` (if present). +1. **State Loading**: The `Exporter` reads the existing `{channel_id}/messages.json` (if present). 2. **Snowflake Extraction**: It extracts the `lastMessageID` from the metadata. 3. **Filtered Fetch**: It calls `fetch_message_history(after_id=last_id)`. 4. **In-Memory Merge**: New messages are appended to the existing list. @@ -37,7 +66,7 @@ To achieve idempotency and efficiency, the system implements an incremental sync The system avoids redundant storage of user metadata (usernames, roles, colors) by using a global `user_cache` map. - **Key**: `userID` (Snowflake). - **Policy**: Users are added to the cache only on their first appearance in any channel's history. -- **Avatar Persistence**: User avatars are stored in a centralized `user_avatars/` directory and referenced by relative paths in the JSON schemas. +- **Avatar Persistence**: User avatars are stored in `message_backup/users/avatars/` and referenced by relative paths in the JSON schemas. --- @@ -46,10 +75,10 @@ The system avoids redundant storage of user metadata (usernames, roles, colors) ### 3.1 Forum Channels & Threads Forums present a hierarchical challenge where the "starter message" and the "conversation" exist in separate contexts. -- **Forum Index (`{channel_id}.json`)**: Contains an enriched list of "starter messages" representing each thread. These entries include thread titles, applied tags, and total attachment stats (summed from the entire thread). -- **Thread Persistence**: - - **Regular Threads**: `message_backup/threads/{thread_id}.json` - - **Forum Threads**: `message_backup/{forum_id}/{thread_id}.json` +- **Forum Index (`{forum_id}/messages.json`)**: Contains an enriched list of "starter messages" representing each thread. These entries include thread titles, applied tags, and total attachment stats (summed from the entire thread). +- **Thread Persistence**: All threads nest inside their parent channel directory: + - **Forum Threads**: `message_backup/{forum_id}/{thread_id}/thread_messages.json` + - **Regular Threads**: `message_backup/{parent_channel_id}/{thread_id}/thread_messages.json` - **Starter Identification**: The system uses `thread.history(limit=1, after=snowflake(thread_id - 1))` to reliably capture the first post even if it has been edited or pinned. --- @@ -84,7 +113,7 @@ The internal representation of a message focuses on portability: | `content` | `String` | Raw markdown content (or snapshot content for forwards) | | `userID` | `String` | Reference to `user_info.json` | | `attachments`| `Array` | List of local file references and metadata | -| `embeds` | `Array` | Raw Dicord-formatted embed objects | +| `embeds` | `Array` | Raw Discord-formatted embed objects | | `stickers` | `Array` | List of Message Sticker objects (see below) | | `reactions` | `Array` | List of Reaction objects | @@ -94,7 +123,7 @@ The internal representation of a message focuses on portability: | `id` | `String` | Sticker Snowflake ID | | `name` | `String` | Sticker name | | `format` | `String` | File format (PNG, APNG, LOTTIE, GIF) | -| `localPath` | `String` | Relative path to local file in `{channel_id}/` | +| `localPath` | `String` | Relative path to local file in `{channel_id}/attachments/` | #### Reaction Object | Field | Type | Description | @@ -108,17 +137,21 @@ To prevent filename collisions (e.g., multiple files named `image.png`), the sys Example: `sunset-54321.png` -### 5.3 `server_profile.json` Specification +### 5.3 `profile.json` Specification +Path: `server_profile/profile.json` + | Field | Type | Description | | :--- | :--- | :--- | | `name` | `String` | Original Discord guild name | | `id` | `String` | Guild Snowflake ID | -| `icon` | `String` | Relative path to local guild icon in `server_media/` | -| `banner` | `String` | Relative path to local guild banner in `server_media/` | +| `icon` | `String` | Relative path to local guild icon in `server_profile/assets/` | +| `banner` | `String` | Relative path to local guild banner in `server_profile/assets/` | | `last_backup` | `ISO8601` | Timestamp of the last successful backup run | | `ignore_channels` | `Array` | List of channel Snowflakes explicitly excluded from backup | -### 5.4 `server_roles.json` Specification (Array of objects) +### 5.4 `roles.json` Specification (Array of objects) +Path: `server_profile/roles.json` + | Field | Type | Description | | :--- | :--- | :--- | | `id` | `String` | Role Snowflake ID | @@ -129,24 +162,28 @@ Example: `sunset-54321.png` | `hoist` | `Boolean` | Whether the role is displayed separately in the sidebar | | `mentionable`| `Boolean` | Whether the role can be mentioned | -### 5.5 `server_assets.json` Specification +### 5.5 `assets.json` Specification +Path: `server_profile/assets.json` Contains two primary arrays: `emojis` and `stickers`. + #### Emoji Object | Field | Type | Description | | :--- | :--- | :--- | | `id` | `String` | Emoji Snowflake ID | | `name` | `String` | Emoji name (without colons) | | `animated` | `Boolean` | True if the emoji is a GIF | -| `filename` | `String` | Filename within `server_media/` | +| `filename` | `String` | Filename within `server_profile/assets/` | #### Sticker Object | Field | Type | Description | | :--- | :--- | :--- | | `id` | `String` | Sticker Snowflake ID | | `name` | `String` | Sticker name | -| `filename` | `String` | Filename within `server_media/` | +| `filename` | `String` | Filename within `server_profile/assets/` | + +### 5.6 `structure.json` Specification (Array of Category objects) +Path: `server_profile/structure.json` -### 5.6 `server_structure.json` Specification (Array of Category objects) #### Category Object | Field | Type | Description | | :--- | :--- | :--- | @@ -177,6 +214,8 @@ Contains two primary arrays: `emojis` and `stickers`. | `emoji_name` | `String` | Name of the tag's emoji | ### 5.7 `user_info.json` Specification (Array of User objects) +Path: `message_backup/users/user_info.json` + | Field | Type | Description | | :--- | :--- | :--- | | `userID` | `String` | User Snowflake ID | @@ -185,10 +224,10 @@ Contains two primary arrays: `emojis` and `stickers`. | `userColor` | `String` | Role-derived color for the user | | `userIsBot` | `Boolean` | True if the account is a bot | | `userRoles` | `Array` | List of role snippets (name, id, color, position) | -| `userAvatar` | `String` | Relative path to local avatar in `user_avatars/` | +| `userAvatar` | `String` | Relative path to local avatar in `users/avatars/` | ### 5.8 Channel History JSON Specification -File: `message_backup/{channel_id}.json` +Path: `message_backup/{channel_id}/messages.json` This file contains the full history of a channel along with synchronization metadata. @@ -206,6 +245,11 @@ This file contains the full history of a channel along with synchronization meta | `messages` | `Array` | The message objects (see Section 5.1) | | `parentID` | `String` | (If Thread) Snowflake of the parent channel | +### 5.9 Thread History JSON Specification +Path: `message_backup/{channel_id}/{thread_id}/thread_messages.json` + +Same schema as Section 5.8, with `channelType` set to `"Thread"` and `parentID` always present. + --- ## 7. Backup Reader Implementation Guide @@ -215,8 +259,8 @@ This section is a technical manual for developers building third-party tools (vi ### 7.1 Entry Point Discovery A reader should start by identifying the backup root directory (prefixed with `DISCORD_BACKUP-`). -1. **Parse `server_profile.json`**: Extract the server name, ID, and assets (icon/banner). -2. **Load `server_structure.json`**: This defines the navigation tree for your UI. +1. **Parse `server_profile/profile.json`**: Extract the server name, ID, and assets (icon/banner). +2. **Load `server_profile/structure.json`**: This defines the navigation tree for your UI. - Iterate through categories. - Map channels to their respective types (text, voice, forum). - Store the `position` to preserve the original visual order. @@ -224,11 +268,11 @@ A reader should start by identifying the backup root directory (prefixed with `D ### 7.2 Relational Data Mapping The backup data is normalized to minimize duplication. A reader must implement the following resolve logic: -- **User Resolution**: When parsing a message in `{channel_id}.json`, the `userID` must be cross-referenced against the `userID` keys in `message_backup/user_info.json`. -- **Role Resolution**: Use the `userRoles` array (IDs) from the user object and resolve them against the role metadata in `server_roles.json` to get colors and names. +- **User Resolution**: When parsing a message in `{channel_id}/messages.json`, the `userID` must be cross-referenced against the `userID` keys in `message_backup/users/user_info.json`. +- **Role Resolution**: Use the `userRoles` array (IDs) from the user object and resolve them against the role metadata in `server_profile/roles.json` to get colors and names. - **Static Asset Resolution**: - - **Server Assets**: Prepend `server_media/` to filenames found in `server_assets.json`. - - **User Avatars**: Resolve `userAvatar` paths found in `user_info.json` (pointing to `user_avatars/`). + - **Server Assets**: Prepend `server_profile/assets/` to filenames found in `server_profile/assets.json`. + - **User Avatars**: Resolve `userAvatar` paths found in `user_info.json` (pointing to `users/avatars/`). ### 7.3 Message Rendering Logic When rendering the `messages` array from a channel JSON: @@ -236,21 +280,21 @@ When rendering the `messages` array from a channel JSON: | Feature | Reader Implementation Logic | | :--- | :--- | | **Markdown** | Content is raw Discord markdown. Use a library like `markdown-it` with discord-specific plugins. | -| **Attachments** | Resolve `url` field (`{channel_id}/{filename}`) relative to the `message_backup/` directory. | -| **Emojis/Stickers** | If a message contains custom emojis/stickers, resolve their metadata via `server_assets.json`. | +| **Attachments** | Resolve `url` field (`{channel_id}/attachments/{filename}`) relative to the `message_backup/` directory. | +| **Emojis/Stickers** | If a message contains custom emojis/stickers, resolve their metadata via `server_profile/assets.json`. | | **Replies** | Use the `reference` object to find the target `messageId`. Note: The target might be in the same file or a different channel/thread. | ### 7.4 Thread & Forum Reconstruction Reconstructing the hierarchy requires specific pointer logic: 1. **Forums**: - - Read `message_backup/{forum_id}.json`. + - Read `message_backup/{forum_id}/messages.json`. - Each message in this file is a `Thread_starter_message`. - The `messageID` of the starter message *is usually* the same as the `thread_id`. - - To load the full thread, open `message_backup/{forum_id}/{thread_id}.json`. + - To load the full thread, open `message_backup/{forum_id}/{thread_id}/thread_messages.json`. 2. **Regular Threads**: - - Discoverable via the `parentID` field in any message or by scanning `message_backup/threads/`. - - Match the `thread.id` in a `ThreadStarter` message to the respective JSON in the `threads/` folder. + - Discoverable via the `parentID` field in any message or by scanning for `thread_messages.json` inside channel directories. + - Match the `thread.id` in a `ThreadStarter` message to the respective subdirectory. --- @@ -259,16 +303,16 @@ Reconstructing the hierarchy requires specific pointer logic: If you are building a `discord.py` API-compatible wrapper to read these backups directly into familiar Discord objects, here is the explicit property mapping from the schema to the standard `discord.py` object attributes. ### 8.1 Base Server (Guild) -File: `server_profile.json` & `server_roles.json` & `server_structure.json` +File: `server_profile/profile.json` & `server_profile/roles.json` & `server_profile/structure.json` - **`discord.Guild`**: - `id`: Cast `id` (str) to `int`. - `name`: Mapped directly from `name`. - `icon` / `banner`: Represented as `discord.Asset` objects. Use the local file paths from `icon` / `banner` as the asset URL/filepath. - - `roles`: Hydrated from `server_roles.json`. - - `channels` / `categories`: Hydrated from `server_structure.json`. + - `roles`: Hydrated from `server_profile/roles.json`. + - `channels` / `categories`: Hydrated from `server_profile/structure.json`. ### 8.2 Roles (`discord.Role`) -File: `server_roles.json` +File: `server_profile/roles.json` - `id`: Cast `id` to `int`. - `name`: Mapped directly. - `color`: Parse the hex string to `discord.Color(value)`. @@ -278,7 +322,7 @@ File: `server_roles.json` - `mentionable`: Mapped directly to boolean. ### 8.3 Users & Members (`discord.Member` / `discord.User`) -File: `message_backup/user_info.json` +File: `message_backup/users/user_info.json` - `id`: Cast `userID` to `int`. - `name`: Mapped from `username`. - `display_name`: Mapped from `userNickname`. @@ -288,7 +332,7 @@ File: `message_backup/user_info.json` - `avatar`: Mocked `discord.Asset` using the `userAvatar` local path. ### 8.4 Channels (`discord.TextChannel`, `discord.CategoryChannel`, `discord.ForumChannel`) -File: `server_structure.json` +File: `server_profile/structure.json` - Iterate over the top-level array (Categories): - **`discord.CategoryChannel`**: - `id`: Cast `id` to `int`. @@ -305,7 +349,7 @@ File: `server_structure.json` - `nsfw`: Mapped directly to boolean. ### 8.5 Messages (`discord.Message`) -File: `message_backup/{channel_id}.json` (Iterating the `messages` array) +File: `message_backup/{channel_id}/messages.json` (Iterating the `messages` array) - `id`: Cast `messageID` to `int`. - `type`: Map the string `type` (e.g., "Default", "Reply") to `discord.MessageType`. - `created_at`: Parse `timestamp` (ISO-8601 string) into a timezone-aware `datetime` object. @@ -323,7 +367,7 @@ Nested within Message objects. - `id`: Cast `id` to `int`. - `filename`: Mapped from `fileName`. - `size`: Mapped from `fileSizeBytes`. -- `url` / `proxy_url`: Point to the local relative path (`{channel_id}/{resolved_filename}`). +- `url` / `proxy_url`: Point to the local relative path (`{channel_id}/attachments/{resolved_filename}`). ### 8.7 Reactions (`discord.Reaction` & `discord.PartialEmoji`) Nested within Message objects. diff --git a/src/core/backup_reader.py b/src/core/backup_reader.py index 1455c8a..ee3642e 100644 --- a/src/core/backup_reader.py +++ b/src/core/backup_reader.py @@ -687,17 +687,17 @@ class BackupReader: bp = self.backup_path # 1. Server profile -> BackupGuild - profile_file = bp / "server_profile.json" + profile_file = bp / "server_profile" / "profile.json" if profile_file.exists(): profile = json.loads(profile_file.read_text(encoding="utf-8")) self.guild = BackupGuild(profile, bp, reader=self) logger.info(f"[Backup] Loaded server profile: {self.guild.name} ({self.guild.id})") else: - logger.warning(f"[Backup] server_profile.json not found in {bp}") + logger.warning(f"[Backup] server_profile/profile.json not found in {bp}") self.guild = None # 2. Roles - roles_file = bp / "server_roles.json" + roles_file = bp / "server_profile" / "roles.json" if roles_file.exists(): roles_data = json.loads(roles_file.read_text(encoding="utf-8")) self._roles = [BackupRole(r) for r in roles_data] @@ -705,7 +705,7 @@ class BackupReader: logger.info(f"[Backup] Loaded {len(self._roles)} roles") # 3. Structure -> categories + channels - struct_file = bp / "server_structure.json" + struct_file = bp / "server_profile" / "structure.json" if struct_file.exists(): structure = json.loads(struct_file.read_text(encoding="utf-8")) for cat_data in structure: @@ -722,8 +722,8 @@ class BackupReader: f"{len(self._channels)} channels") # 4. Assets (emojis + stickers) - assets_file = bp / "server_assets.json" - media_dir = bp / "server_media" + assets_file = bp / "server_profile" / "assets.json" + media_dir = bp / "server_profile" / "assets" if assets_file.exists(): assets = json.loads(assets_file.read_text(encoding="utf-8")) self._emojis = [BackupEmoji(e, media_dir) for e in assets.get("emojis", [])] @@ -732,7 +732,7 @@ class BackupReader: f"{len(self._stickers)} stickers") # 5. Users - user_info_file = bp / "message_backup" / "user_info.json" + user_info_file = bp / "message_backup" / "users" / "user_info.json" if user_info_file.exists(): try: users = json.loads(user_info_file.read_text(encoding="utf-8")) @@ -764,7 +764,7 @@ class BackupReader: if not bp.exists() or not bp.is_dir(): return results - profile = bp / "server_profile.json" + profile = bp / "server_profile" / "profile.json" if profile.exists(): try: data = json.loads(profile.read_text(encoding="utf-8")) @@ -804,19 +804,23 @@ class BackupReader: return channels async def get_backed_up_channel_ids(self) -> List[int]: - """Returns a list of channel IDs that have corresponding backup JSON files.""" + """Returns a list of channel IDs that have corresponding backup directories.""" backup_dir = self.backup_path / "message_backup" if not backup_dir.exists(): return [] ids = [] - for f in backup_dir.glob("*.json"): - if f.name == "user_info.json": + for d in backup_dir.iterdir(): + if not d.is_dir(): continue - try: - ids.append(int(f.stem)) - except ValueError: - pass + if d.name == "users": + continue + # A backed-up channel has a messages.json inside its directory + if (d / "messages.json").exists(): + try: + ids.append(int(d.name)) + except ValueError: + pass return ids async def get_channel(self, channel_id: int) -> BackupChannel | None: @@ -857,12 +861,12 @@ class BackupReader: def _load_channel_messages(self, channel_id: int) -> list[dict]: """Loads the messages array from a channel JSON file.""" bp = self.backup_path / "message_backup" - json_file = bp / f"{channel_id}.json" + + # Primary: message_backup/{channel_id}/messages.json + json_file = bp / str(channel_id) / "messages.json" if not json_file.exists(): - for candidate in [ - bp / "threads" / f"{channel_id}.json", - *bp.glob(f"*/{channel_id}.json"), - ]: + # Fallback: search for thread_messages.json inside any parent channel + for candidate in bp.glob(f"*/{channel_id}/thread_messages.json"): if candidate.exists(): json_file = candidate break diff --git a/src/disco_reaper/exporter.py b/src/disco_reaper/exporter.py index 36125c4..effd875 100644 --- a/src/disco_reaper/exporter.py +++ b/src/disco_reaper/exporter.py @@ -31,8 +31,12 @@ class DiscordExporter: self.export_path = self.base_dir / f"DISCORD_BACKUP-{self.server_id}" self.export_path.mkdir(parents=True, exist_ok=True) - # Consolidate media into one folder - self.media_path = self.export_path / "server_media" + # Server profile directory for metadata and assets + self.profile_path = self.export_path / "server_profile" + self.profile_path.mkdir(exist_ok=True) + + # Consolidate media into server_profile/assets/ + self.media_path = self.profile_path / "assets" self.media_path.mkdir(exist_ok=True) logger.info(f"Export directory set to: {self.export_path}") @@ -56,13 +60,13 @@ class DiscordExporter: if self.reader.guild: if self.reader.guild.icon: ext = "gif" if self.reader.guild.icon.is_animated() else "png" - metadata["icon"] = f"server_media/server_icon.{ext}" + metadata["icon"] = f"server_profile/assets/server_icon.{ext}" else: metadata["icon"] = None if self.reader.guild.banner: ext = "gif" if self.reader.guild.banner.is_animated() else "png" - metadata["banner"] = f"server_media/server_banner.{ext}" + metadata["banner"] = f"server_profile/assets/server_banner.{ext}" else: metadata["banner"] = None @@ -70,7 +74,7 @@ class DiscordExporter: from datetime import datetime metadata["last_backup"] = datetime.now().isoformat() - output_file = self.export_path / "server_profile.json" + output_file = self.profile_path / "profile.json" # Preserve ignore_channels if the file already exists ignore_channels = [] @@ -80,7 +84,7 @@ class DiscordExporter: old_data = json.load(f) ignore_channels = old_data.get("ignore_channels", []) except Exception as e: - logger.warning(f"Could not read existing server_profile.json to preserve ignore_channels: {e}") + logger.warning(f"Could not read existing profile.json to preserve ignore_channels: {e}") metadata["ignore_channels"] = ignore_channels @@ -102,7 +106,7 @@ class DiscordExporter: "mentionable": r.mentionable }) - output_file = self.export_path / "server_roles.json" + output_file = self.profile_path / "roles.json" await self._save_json(output_file, role_data) return role_data @@ -148,7 +152,7 @@ class DiscordExporter: logger.info("No server banner found to download.") async def export_assets(self): - """Exports emojis, stickers, and server media to server_assets.json and server_media/.""" + """Exports emojis, stickers, and server media to assets.json and server_profile/assets/.""" await self.download_server_assets() emojis = await self.reader.get_emojis() @@ -201,7 +205,7 @@ class DiscordExporter: logger.error(f"Failed to download sticker {s.name}: {ex}") # Try to load existing customization to merge (if it exists) - custom_file = self.export_path / "server_assets.json" + custom_file = self.profile_path / "assets.json" customization = {"emojis": emoji_data, "stickers": sticker_data, "members": []} if custom_file.exists(): try: @@ -262,7 +266,7 @@ class DiscordExporter: # No need to increment cat_count for 'Uncategorized' usually, # but let's see if the user wants it. For now, cat_count is real Discord categories. - output_file = self.export_path / "server_structure.json" + output_file = self.profile_path / "structure.json" await self._save_json(output_file, structure) return structure, cat_count, chan_count @@ -313,31 +317,29 @@ class DiscordExporter: if is_thread: parent = await self.reader.get_channel(channel.parent_id) - if isinstance(parent, discord.ForumChannel): - # Forum thread: nested inside forum folder - backup_dir = backup_root / str(channel.parent_id) - avatar_rel_base = "../../user_avatars" - else: - # Regular thread - backup_dir = backup_root / "threads" - avatar_rel_base = "../user_avatars" + # All threads nest inside their parent channel directory + backup_dir = backup_root / str(channel.parent_id) / str(channel_id) + backup_dir.mkdir(parents=True, exist_ok=True) + avatar_rel_base = "../../users/avatars" elif is_forum: - # Forum metadata root - backup_dir = backup_root - avatar_rel_base = "user_avatars" + # Forum metadata root: message_backup/{forum_id}/ + backup_dir = backup_root / str(channel_id) + backup_dir.mkdir(parents=True, exist_ok=True) + avatar_rel_base = "../users/avatars" else: - # Regular channel - backup_dir = backup_root - avatar_rel_base = "user_avatars" + # Regular channel: message_backup/{channel_id}/ + backup_dir = backup_root / str(channel_id) + backup_dir.mkdir(parents=True, exist_ok=True) + avatar_rel_base = "../users/avatars" - backup_dir.mkdir(parents=True, exist_ok=True) - - # Shared avatars directory (always at root of message_backup) - avatar_dir = backup_root / "user_avatars" + # Shared avatars directory: message_backup/users/avatars/ + users_dir = backup_root / "users" + users_dir.mkdir(exist_ok=True) + avatar_dir = users_dir / "avatars" avatar_dir.mkdir(exist_ok=True) # Load existing user_info.json - user_info_file = backup_root / "user_info.json" + user_info_file = users_dir / "user_info.json" if not self.user_cache and user_info_file.exists(): try: with open(user_info_file, "r", encoding="utf-8") as f: @@ -346,9 +348,16 @@ class DiscordExporter: except Exception: self.user_cache = {} - base_filename = str(channel_id) - json_file = backup_dir / f"{base_filename}.json" - asset_dir = backup_dir / base_filename + # Determine file names based on type + if is_thread: + json_file = backup_dir / "thread_messages.json" + asset_dir = backup_dir / "thread_attachments" + # asset_prefix is the relative path from message_backup/ for URL references + asset_prefix = f"{channel.parent_id}/{channel_id}/thread_attachments" + else: + json_file = backup_dir / "messages.json" + asset_dir = backup_dir / "attachments" + asset_prefix = f"{channel_id}/attachments" if force and asset_dir.exists(): import shutil @@ -386,7 +395,7 @@ class DiscordExporter: async for msg in self.reader.fetch_message_history(channel_id, after_id=last_id): if not self.is_running: break await asyncio.sleep(0) # Yield control - msg_data = await self._format_message(msg, asset_dir, base_filename, avatar_dir, avatar_rel_base) + msg_data = await self._format_message(msg, asset_dir, asset_prefix, avatar_dir, avatar_rel_base) messages.append(msg_data) new_count += 1 accumulated_count += 1 @@ -547,7 +556,7 @@ class DiscordExporter: "userColor": str(author.color) if hasattr(author, "color") else None, "userIsBot": author.bot, "userRoles": roles, - "userAvatar": f"user_avatars/{user_id}.png" if author.avatar else None, + "userAvatar": f"users/avatars/{user_id}.png" if author.avatar else None, "userAvatarUrl": str(author.display_avatar.url) if author.avatar else None } @@ -684,9 +693,9 @@ class DiscordExporter: is_forum = isinstance(channel, discord.ForumChannel) backup_root = self.export_path / "message_backup" - forum_json_file = backup_root / f"{channel_id}.json" + forum_json_file = backup_root / str(channel_id) / "messages.json" forum_asset_dir = backup_root / str(channel_id) - avatar_dir = backup_root / "user_avatars" + avatar_dir = backup_root / "users" / "avatars" thread_count = 0 if all_threads: @@ -713,15 +722,15 @@ class DiscordExporter: logger.debug(f"Found starter message {msg.id} for {thread.name}") # Save assets in the thread's own directory inside the forum directory - thread_asset_dir = forum_asset_dir / str(thread.id) + thread_asset_dir = forum_asset_dir / str(thread.id) / "thread_attachments" thread_asset_dir.mkdir(parents=True, exist_ok=True) msg_data = await self._format_message( msg, thread_asset_dir, - f"{channel_id}/{thread.id}", # Full relative path from message_backup/ + f"{channel_id}/{thread.id}/thread_attachments", # Full relative path from message_backup/ avatar_dir, - "../../user_avatars" # Two levels up from {forum_id}/{thread_id}/ + "../../users/avatars" # Two levels up from {forum_id}/{thread_id}/ ) # Override type and add title for forum starter messages msg_data["type"] = "Thread_starter_message" @@ -732,7 +741,7 @@ class DiscordExporter: # Enrich totalFileSizeBytes with the child thread's totalAttachmentSizeBytes # (the thread JSON has already been written above) - thread_json = backup_root / str(channel_id) / f"{thread.id}.json" + thread_json = backup_root / str(channel_id) / str(thread.id) / "thread_messages.json" if thread_json.exists(): try: with open(thread_json, "r", encoding="utf-8") as f: diff --git a/src/ui/backup_ops.py b/src/ui/backup_ops.py index e9f79ea..f528099 100644 --- a/src/ui/backup_ops.py +++ b/src/ui/backup_ops.py @@ -91,7 +91,7 @@ class BackupPane(Container): self._update_ui(f"[red]Error: {e}[/red]", "", "", False) def _get_backup_info(self) -> str | None: - profile_file = Path(f"Reaper-{self.cfg_name}") / "server_profile.json" + profile_file = Path(f"Reaper-{self.cfg_name}") / "server_profile" / "profile.json" if profile_file.exists(): try: with open(profile_file, "r", encoding="utf-8") as f: @@ -195,7 +195,7 @@ class BackupPane(Container): any_found = False backed_up_ids = set() for chan in eligible_channels: - if (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists(): + if (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists(): any_found = True backed_up_ids.add(chan.id) @@ -271,7 +271,7 @@ class BackupPane(Container): break await asyncio.sleep(0.01) # Yield to UI thread to keep it responsive - backup_exists = (self.exporter.export_path / "message_backup" / f"{chan.id}.json").exists() + backup_exists = (self.exporter.export_path / "message_backup" / str(chan.id) / "messages.json").exists() is_sync = backup_exists and not force_overwrite label = "Syncing Backup" if is_sync else "Backing up" @@ -346,7 +346,7 @@ class BackupPane(Container): selected_channels = [ c for c in eligible_channels - if (self.exporter.export_path / "message_backup" / f"{c.id}.json").exists() + if (self.exporter.export_path / "message_backup" / str(c.id) / "messages.json").exists() ] if not selected_channels: