using Microsoft.Extensions.Logging; namespace DiscordArchiveManager.Services; public class ArchiveService { private readonly ILogger _logger; public ArchiveService(ILogger logger) { _logger = logger; } /// /// Archives a JSON file and its associated _Files directory to the archive location. /// /// Path to the JSON file /// Root directory for archives /// Path to the archived JSON file public string ArchiveExport(string jsonFilePath, string archiveRoot) { if (!File.Exists(jsonFilePath)) { throw new FileNotFoundException($"JSON file not found: {jsonFilePath}"); } var jsonFileName = Path.GetFileName(jsonFilePath); var jsonDirectory = Path.GetDirectoryName(jsonFilePath)!; var filesDirectory = GetFilesDirectoryPath(jsonFilePath); // Create archive subdirectory based on date var archiveSubdir = DateTime.Now.ToString("yyyy-MM-dd"); var archivePath = Path.Combine(archiveRoot, archiveSubdir); Directory.CreateDirectory(archivePath); // Archive the JSON file var archivedJsonPath = Path.Combine(archivePath, jsonFileName); var uniqueJsonPath = GetUniquePath(archivedJsonPath); MoveFile(jsonFilePath, uniqueJsonPath); _logger.LogInformation("Archived JSON file to {Path}", uniqueJsonPath); // Archive the _Files directory if it exists if (Directory.Exists(filesDirectory)) { var filesDirectoryName = Path.GetFileName(filesDirectory); var archivedFilesPath = Path.Combine(archivePath, filesDirectoryName); var uniqueFilesPath = GetUniquePath(archivedFilesPath); MoveDirectory(filesDirectory, uniqueFilesPath); _logger.LogInformation("Archived files directory to {Path}", uniqueFilesPath); } return uniqueJsonPath; } /// /// Gets the path to the _Files directory associated with a JSON export file. /// DiscordChatExporter creates directories named like "filename.json_Files" /// public string GetFilesDirectoryPath(string jsonFilePath) { return jsonFilePath + "_Files"; } /// /// Gets the path to a specific file within the _Files directory. /// public string? GetAttachmentFilePath(string jsonFilePath, string attachmentUrl) { var filesDirectory = GetFilesDirectoryPath(jsonFilePath); if (!Directory.Exists(filesDirectory)) { return null; } // DiscordChatExporter stores files with their original filename // The URL format is usually like: https://cdn.discordapp.com/attachments/.../filename.ext string fileName; try { if (string.IsNullOrWhiteSpace(attachmentUrl)) { return null; } fileName = Path.GetFileName(new Uri(attachmentUrl).LocalPath); } catch (UriFormatException) { // If URL is malformed, try to extract filename directly fileName = Path.GetFileName(attachmentUrl); if (string.IsNullOrWhiteSpace(fileName)) { return null; } } var filePath = Path.Combine(filesDirectory, fileName); // Also check for URL-encoded versions var decodedFileName = Uri.UnescapeDataString(fileName); var decodedFilePath = Path.Combine(filesDirectory, decodedFileName); if (File.Exists(filePath)) { return filePath; } if (File.Exists(decodedFilePath)) { return decodedFilePath; } // Search for the file by partial match (in case of naming differences) var searchPattern = "*" + Path.GetExtension(fileName); var files = Directory.GetFiles(filesDirectory, searchPattern); // Try to find a file that contains the attachment ID from the URL var urlParts = attachmentUrl.Split('/'); foreach (var file in files) { var currentFileName = Path.GetFileName(file); if (currentFileName.Equals(fileName, StringComparison.OrdinalIgnoreCase) || currentFileName.Equals(decodedFileName, StringComparison.OrdinalIgnoreCase)) { return file; } } return null; } /// /// Lists all JSON export files in the input directory. /// public IEnumerable GetExportFiles(string inputDirectory) { if (!Directory.Exists(inputDirectory)) { _logger.LogWarning("Input directory does not exist: {Path}", inputDirectory); yield break; } foreach (var file in Directory.EnumerateFiles(inputDirectory, "*.json", SearchOption.AllDirectories)) { // Skip files in hidden directories (starting with .) var relativePath = Path.GetRelativePath(inputDirectory, file); if (relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) .Any(part => part.StartsWith('.'))) { _logger.LogDebug("Skipping file in hidden directory: {Path}", file); continue; } yield return file; } } /// /// Moves a file, falling back to copy+delete for cross-device moves. /// private static void MoveFile(string source, string destination) { try { File.Move(source, destination); } catch (IOException) { File.Copy(source, destination); File.Delete(source); } } /// /// Moves a directory, falling back to copy+delete for cross-device moves. /// private static void MoveDirectory(string source, string destination) { try { Directory.Move(source, destination); } catch (IOException) { CopyDirectory(source, destination); Directory.Delete(source, true); } } /// /// Recursively copies a directory. /// private static void CopyDirectory(string source, string destination) { Directory.CreateDirectory(destination); foreach (var file in Directory.GetFiles(source)) { var destFile = Path.Combine(destination, Path.GetFileName(file)); File.Copy(file, destFile); } foreach (var dir in Directory.GetDirectories(source)) { var destDir = Path.Combine(destination, Path.GetFileName(dir)); CopyDirectory(dir, destDir); } } /// /// Gets a unique file/directory path by appending a number if the path already exists. /// private static string GetUniquePath(string path) { if (!File.Exists(path) && !Directory.Exists(path)) { return path; } var directory = Path.GetDirectoryName(path)!; var fileName = Path.GetFileNameWithoutExtension(path); var extension = Path.GetExtension(path); var counter = 1; string newPath; do { newPath = Path.Combine(directory, $"{fileName}_{counter}{extension}"); counter++; } while (File.Exists(newPath) || Directory.Exists(newPath)); return newPath; } }