- 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>
232 lines
7.5 KiB
C#
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;
|
|
}
|
|
}
|