Compare commits
2 Commits
2633bbf37a
...
7896f9ef9a
| Author | SHA1 | Date | |
|---|---|---|---|
| 7896f9ef9a | |||
| 2da546fbd5 |
@@ -1,5 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
discord-archive:
|
discord-archive:
|
||||||
|
image: git.thecozycat.net/aj/discordarchivemanager:latest
|
||||||
build: .
|
build: .
|
||||||
volumes:
|
volumes:
|
||||||
- ./input:/app/input
|
- ./input:/app/input
|
||||||
|
|||||||
2
docker-publish.cmd
Normal file
2
docker-publish.cmd
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
docker build -t git.thecozycat.net/aj/discordarchivemanager:latest .
|
||||||
|
docker push git.thecozycat.net/aj/discordarchivemanager:latest
|
||||||
@@ -36,7 +36,7 @@ public class ArchiveService
|
|||||||
// Archive the JSON file
|
// Archive the JSON file
|
||||||
var archivedJsonPath = Path.Combine(archivePath, jsonFileName);
|
var archivedJsonPath = Path.Combine(archivePath, jsonFileName);
|
||||||
var uniqueJsonPath = GetUniquePath(archivedJsonPath);
|
var uniqueJsonPath = GetUniquePath(archivedJsonPath);
|
||||||
File.Move(jsonFilePath, uniqueJsonPath);
|
MoveFile(jsonFilePath, uniqueJsonPath);
|
||||||
_logger.LogInformation("Archived JSON file to {Path}", uniqueJsonPath);
|
_logger.LogInformation("Archived JSON file to {Path}", uniqueJsonPath);
|
||||||
|
|
||||||
// Archive the _Files directory if it exists
|
// Archive the _Files directory if it exists
|
||||||
@@ -45,7 +45,7 @@ public class ArchiveService
|
|||||||
var filesDirectoryName = Path.GetFileName(filesDirectory);
|
var filesDirectoryName = Path.GetFileName(filesDirectory);
|
||||||
var archivedFilesPath = Path.Combine(archivePath, filesDirectoryName);
|
var archivedFilesPath = Path.Combine(archivePath, filesDirectoryName);
|
||||||
var uniqueFilesPath = GetUniquePath(archivedFilesPath);
|
var uniqueFilesPath = GetUniquePath(archivedFilesPath);
|
||||||
Directory.Move(filesDirectory, uniqueFilesPath);
|
MoveDirectory(filesDirectory, uniqueFilesPath);
|
||||||
_logger.LogInformation("Archived files directory to {Path}", uniqueFilesPath);
|
_logger.LogInformation("Archived files directory to {Path}", uniqueFilesPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +152,58 @@ public class ArchiveService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// Gets a unique file/directory path by appending a number if the path already exists.
|
/// Gets a unique file/directory path by appending a number if the path already exists.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -171,12 +171,21 @@ public class JsonImportService
|
|||||||
|
|
||||||
private async Task<bool> ProcessMessageAsync(MessageInfo messageInfo, string channelId, string jsonFilePath, string imageRoot)
|
private async Task<bool> ProcessMessageAsync(MessageInfo messageInfo, string channelId, string jsonFilePath, string imageRoot)
|
||||||
{
|
{
|
||||||
// Skip if message already exists
|
// Skip if message already processed in this session
|
||||||
if (await _context.Messages.AnyAsync(m => m.Id == messageInfo.Id))
|
if (_processedMessages.Contains(messageInfo.Id))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip if message already exists in database
|
||||||
|
if (await _context.Messages.AnyAsync(m => m.Id == messageInfo.Id))
|
||||||
|
{
|
||||||
|
_processedMessages.Add(messageInfo.Id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_processedMessages.Add(messageInfo.Id);
|
||||||
|
|
||||||
// Upsert author
|
// Upsert author
|
||||||
await UpsertUserAsync(messageInfo.Author);
|
await UpsertUserAsync(messageInfo.Author);
|
||||||
|
|
||||||
@@ -210,7 +219,7 @@ public class JsonImportService
|
|||||||
// Process reactions
|
// Process reactions
|
||||||
foreach (var reaction in messageInfo.Reactions)
|
foreach (var reaction in messageInfo.Reactions)
|
||||||
{
|
{
|
||||||
ProcessReaction(reaction, messageInfo.Id);
|
await ProcessReactionAsync(reaction, messageInfo.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process mentions
|
// Process mentions
|
||||||
@@ -348,16 +357,252 @@ public class JsonImportService
|
|||||||
_context.Embeds.Add(embed);
|
_context.Embeds.Add(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ProcessReaction(ReactionInfo reactionInfo, string messageId)
|
// Track added items in current session to avoid duplicates
|
||||||
|
private readonly HashSet<string> _processedMessages = new();
|
||||||
|
private readonly HashSet<(string MessageId, string EmojiName, string UserId)> _addedReactions = new();
|
||||||
|
|
||||||
|
private async Task ProcessReactionAsync(ReactionInfo reactionInfo, string messageId)
|
||||||
{
|
{
|
||||||
|
// Create one Reaction record per user who reacted
|
||||||
|
if (reactionInfo.Users != null && reactionInfo.Users.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var userInfo in reactionInfo.Users)
|
||||||
|
{
|
||||||
|
// Normalize emoji name to handle potential Unicode differences
|
||||||
|
var normalizedEmojiName = reactionInfo.Emoji.Name.Normalize();
|
||||||
|
var key = (messageId, normalizedEmojiName, userInfo.Id);
|
||||||
|
|
||||||
|
// Check if already added in this session
|
||||||
|
if (_addedReactions.Contains(key)) continue;
|
||||||
|
|
||||||
|
// Check if exists in database
|
||||||
|
var existsInDb = await _context.Reactions
|
||||||
|
.AnyAsync(r => r.MessageId == messageId && r.EmojiName == normalizedEmojiName && r.UserId == userInfo.Id);
|
||||||
|
|
||||||
|
if (existsInDb)
|
||||||
|
{
|
||||||
|
_addedReactions.Add(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check the local change tracker for entities not yet saved
|
||||||
|
var existsLocal = _context.ChangeTracker.Entries<Reaction>()
|
||||||
|
.Any(e => e.Entity.MessageId == messageId &&
|
||||||
|
e.Entity.EmojiName == normalizedEmojiName &&
|
||||||
|
e.Entity.UserId == userInfo.Id);
|
||||||
|
|
||||||
|
if (existsLocal)
|
||||||
|
{
|
||||||
|
_addedReactions.Add(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the user exists
|
||||||
|
var user = await _context.Users.FindAsync(userInfo.Id);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
user = new User
|
||||||
|
{
|
||||||
|
Id = userInfo.Id,
|
||||||
|
Name = userInfo.Name,
|
||||||
|
Discriminator = userInfo.Discriminator,
|
||||||
|
IsBot = userInfo.IsBot
|
||||||
|
};
|
||||||
|
_context.Users.Add(user);
|
||||||
|
}
|
||||||
|
|
||||||
var reaction = new Reaction
|
var reaction = new Reaction
|
||||||
{
|
{
|
||||||
MessageId = messageId,
|
MessageId = messageId,
|
||||||
EmojiCode = reactionInfo.Emoji.Code,
|
EmojiCode = reactionInfo.Emoji.Code,
|
||||||
EmojiName = reactionInfo.Emoji.Name,
|
EmojiName = normalizedEmojiName,
|
||||||
Count = reactionInfo.Count
|
UserId = userInfo.Id
|
||||||
};
|
};
|
||||||
_context.Reactions.Add(reaction);
|
_context.Reactions.Add(reaction);
|
||||||
|
_addedReactions.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reimports all data from archived JSON files (for rebuilding database).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(int Success, int Errors)> ReimportFromArchiveAsync(string archiveDirectory, string imageDirectory)
|
||||||
|
{
|
||||||
|
var successCount = 0;
|
||||||
|
var errorCount = 0;
|
||||||
|
var jsonFiles = Directory.EnumerateFiles(archiveDirectory, "*.json", SearchOption.AllDirectories)
|
||||||
|
.OrderBy(f => f) // Process in order
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} archived JSON files to reimport", jsonFiles.Count);
|
||||||
|
|
||||||
|
// Preload existing data to avoid checking database repeatedly
|
||||||
|
_processedMessages.Clear();
|
||||||
|
foreach (var id in await _context.Messages.Select(m => m.Id).ToListAsync())
|
||||||
|
{
|
||||||
|
_processedMessages.Add(id);
|
||||||
|
}
|
||||||
|
_logger.LogInformation("Found {Count} existing messages in database", _processedMessages.Count);
|
||||||
|
|
||||||
|
_addedReactions.Clear();
|
||||||
|
foreach (var r in await _context.Reactions.Select(r => new { r.MessageId, r.EmojiName, r.UserId }).ToListAsync())
|
||||||
|
{
|
||||||
|
_addedReactions.Add((r.MessageId, r.EmojiName, r.UserId));
|
||||||
|
}
|
||||||
|
_logger.LogInformation("Found {Count} existing reactions in database", _addedReactions.Count);
|
||||||
|
|
||||||
|
foreach (var jsonFilePath in jsonFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(jsonFilePath);
|
||||||
|
var export = JsonSerializer.Deserialize<DiscordExport>(json, JsonOptions);
|
||||||
|
|
||||||
|
if (export == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to deserialize: {Path}", jsonFilePath);
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process in a transaction
|
||||||
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Upsert Guild
|
||||||
|
await UpsertGuildAsync(export.Guild);
|
||||||
|
|
||||||
|
// Upsert Channel
|
||||||
|
await UpsertChannelAsync(export.Channel, export.Guild.Id);
|
||||||
|
|
||||||
|
// Process messages
|
||||||
|
var processedCount = 0;
|
||||||
|
foreach (var message in export.Messages)
|
||||||
|
{
|
||||||
|
if (await ProcessMessageAsync(message, export.Channel.Id, jsonFilePath, imageDirectory))
|
||||||
|
{
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
|
if (processedCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Imported {Count} messages from {Path}", processedCount, jsonFilePath);
|
||||||
|
}
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
_logger.LogWarning(ex, "Failed to reimport: {Path}", jsonFilePath);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read file: {Path}", jsonFilePath);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (successCount, errorCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reprocesses archived JSON files to add missing reactions.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> ReprocessReactionsAsync(string archiveDirectory)
|
||||||
|
{
|
||||||
|
var totalAdded = 0;
|
||||||
|
var jsonFiles = Directory.EnumerateFiles(archiveDirectory, "*.json", SearchOption.AllDirectories).ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} archived JSON files to reprocess for reactions", jsonFiles.Count);
|
||||||
|
|
||||||
|
// Track existing reactions to avoid duplicates (MessageId, EmojiName, UserId)
|
||||||
|
var existingReactions = new HashSet<(string MessageId, string EmojiName, string UserId)>();
|
||||||
|
|
||||||
|
var existing = await _context.Reactions
|
||||||
|
.Select(r => new { r.MessageId, r.EmojiName, r.UserId })
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var r in existing)
|
||||||
|
{
|
||||||
|
existingReactions.Add((r.MessageId, r.EmojiName, r.UserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} existing reactions in database", existing.Count);
|
||||||
|
|
||||||
|
foreach (var jsonFilePath in jsonFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(jsonFilePath);
|
||||||
|
var export = JsonSerializer.Deserialize<DiscordExport>(json, JsonOptions);
|
||||||
|
|
||||||
|
if (export == null) continue;
|
||||||
|
|
||||||
|
var fileAdded = 0;
|
||||||
|
foreach (var message in export.Messages)
|
||||||
|
{
|
||||||
|
foreach (var reactionInfo in message.Reactions)
|
||||||
|
{
|
||||||
|
if (reactionInfo.Users == null || reactionInfo.Users.Count == 0) continue;
|
||||||
|
|
||||||
|
foreach (var userInfo in reactionInfo.Users)
|
||||||
|
{
|
||||||
|
// Normalize emoji name to handle potential Unicode differences
|
||||||
|
var normalizedEmojiName = reactionInfo.Emoji.Name.Normalize();
|
||||||
|
var key = (message.Id, normalizedEmojiName, userInfo.Id);
|
||||||
|
|
||||||
|
if (existingReactions.Contains(key)) continue;
|
||||||
|
|
||||||
|
// Ensure user exists
|
||||||
|
var user = await _context.Users.FindAsync(userInfo.Id);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
user = new User
|
||||||
|
{
|
||||||
|
Id = userInfo.Id,
|
||||||
|
Name = userInfo.Name,
|
||||||
|
Discriminator = userInfo.Discriminator,
|
||||||
|
IsBot = userInfo.IsBot
|
||||||
|
};
|
||||||
|
_context.Users.Add(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
var reaction = new Reaction
|
||||||
|
{
|
||||||
|
MessageId = message.Id,
|
||||||
|
EmojiCode = reactionInfo.Emoji.Code,
|
||||||
|
EmojiName = normalizedEmojiName,
|
||||||
|
UserId = userInfo.Id
|
||||||
|
};
|
||||||
|
_context.Reactions.Add(reaction);
|
||||||
|
existingReactions.Add(key);
|
||||||
|
fileAdded++;
|
||||||
|
totalAdded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileAdded > 0)
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_logger.LogDebug("Added {Count} reactions from {Path}", fileAdded, jsonFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to reprocess file: {Path}", jsonFilePath);
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalAdded;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessMentionAsync(MentionInfo mentionInfo, string messageId)
|
private async Task ProcessMentionAsync(MentionInfo mentionInfo, string messageId)
|
||||||
|
|||||||
Reference in New Issue
Block a user