Files
DiscordArchiveManager/src/DiscordArchiveManager/Services/ArchiveService.cs
AJ Isaacs 2da546fbd5 Add Gitea registry support and fix cross-device archiving
- Update docker-compose.yml to reference Gitea registry image
- Add docker-publish.cmd for building and pushing to Gitea
- Fix ArchiveService to handle cross-device moves by falling back
  to copy+delete when input and archive are on different volumes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:19:03 -05:00

232 lines
7.5 KiB
C#

using Microsoft.Extensions.Logging;
namespace DiscordArchiveManager.Services;
public class ArchiveService
{
private readonly ILogger<ArchiveService> _logger;
public ArchiveService(ILogger<ArchiveService> logger)
{
_logger = logger;
}
/// <summary>
/// Archives a JSON file and its associated _Files directory to the archive location.
/// </summary>
/// <param name="jsonFilePath">Path to the JSON file</param>
/// <param name="archiveRoot">Root directory for archives</param>
/// <returns>Path to the archived JSON file</returns>
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;
}
/// <summary>
/// Gets the path to the _Files directory associated with a JSON export file.
/// DiscordChatExporter creates directories named like "filename.json_Files"
/// </summary>
public string GetFilesDirectoryPath(string jsonFilePath)
{
return jsonFilePath + "_Files";
}
/// <summary>
/// Gets the path to a specific file within the _Files directory.
/// </summary>
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;
}
/// <summary>
/// Lists all JSON export files in the input directory.
/// </summary>
public IEnumerable<string> 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;
}
}
/// <summary>
/// Moves a file, falling back to copy+delete for cross-device moves.
/// </summary>
private static void MoveFile(string source, string destination)
{
try
{
File.Move(source, destination);
}
catch (IOException)
{
File.Copy(source, destination);
File.Delete(source);
}
}
/// <summary>
/// Moves a directory, falling back to copy+delete for cross-device moves.
/// </summary>
private static void MoveDirectory(string source, string destination)
{
try
{
Directory.Move(source, destination);
}
catch (IOException)
{
CopyDirectory(source, destination);
Directory.Delete(source, true);
}
}
/// <summary>
/// Recursively copies a directory.
/// </summary>
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);
}
}
/// <summary>
/// Gets a unique file/directory path by appending a number if the path already exists.
/// </summary>
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;
}
}