diff --git a/DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs b/DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs new file mode 100644 index 00000000..9b6221e3 --- /dev/null +++ b/DiscordChatExporter.Cli/Commands/DeleteMessagesCommand.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Binding; +using CliFx.Infrastructure; +using DiscordChatExporter.Cli.Commands.Base; +using DiscordChatExporter.Core.Discord; +using DiscordChatExporter.Core.Exceptions; +using PowerKit.Extensions; + +namespace DiscordChatExporter.Cli.Commands; + +[Command("deletemessages", Description = "Deletes messages from a channel.")] +public partial class DeleteMessagesCommand : DiscordCommandBase +{ + [CommandOption( + "channel", + 'c', + Description = "Channel ID. Note: You can only delete your own messages." + )] + public required Snowflake ChannelId { get; set; } + + [CommandOption( + "before", + Description = "Limit to messages sent before this date (formatted using the current culture)." + )] + public DateTimeOffset? Before { get; set; } + + [CommandOption( + "after", + Description = "Limit to messages sent after this date (formatted using the current culture)." + )] + public DateTimeOffset? After { get; set; } + + public override async ValueTask ExecuteAsync(IConsole console) + { + await base.ExecuteAsync(console); + + var cancellationToken = console.RegisterCancellationHandler(); + + // Get current user + await console.Output.WriteLineAsync("Getting current user..."); + var currentUser = await Discord.GetCurrentUserAsync(cancellationToken); + await console.Output.WriteLineAsync($"Authenticated as: {currentUser.FullName}"); + await console.Output.WriteLineAsync(); + + // Resolve the channel + await console.Output.WriteLineAsync("Resolving channel..."); + var channel = await Discord.GetChannelAsync(ChannelId, cancellationToken); + + // Warning message + using (console.WithForegroundColor(ConsoleColor.Yellow)) + { + await console.Output.WriteLineAsync( + "WARNING: This will delete your messages from the channel." + ); + await console.Output.WriteLineAsync("Messages from other users will be skipped."); + } + + await console.Output.WriteLineAsync(); + await console.Output.WriteLineAsync($"Channel: {channel.Name}"); + + if (After is not null) + await console.Output.WriteLineAsync($"After: {After:g}"); + + if (Before is not null) + await console.Output.WriteLineAsync($"Before: {Before:g}"); + + await console.Output.WriteLineAsync(); + + // Count user's messages + await console.Output.WriteLineAsync("Counting your messages..."); + + var beforeSnowflake = Before?.Pipe(Snowflake.FromDate); + var afterSnowflake = After?.Pipe(Snowflake.FromDate); + + var userMessageIds = new List(); + await foreach ( + var message in Discord.GetMessagesAsync( + ChannelId, + afterSnowflake, + beforeSnowflake, + null, + cancellationToken + ) + ) + { + if (message.Author.Id == currentUser.Id) + { + userMessageIds.Add(message.Id); + } + } + + var totalUserMessages = userMessageIds.Count; + await console.Output.WriteLineAsync($"Found {totalUserMessages} of your messages"); + await console.Output.WriteLineAsync(); + + if (totalUserMessages == 0) + { + using (console.WithForegroundColor(ConsoleColor.Yellow)) + { + await console.Output.WriteLineAsync("No messages to delete."); + } + return; + } + + // Delete messages + await console.Output.WriteLineAsync("Deleting messages..."); + + var successCount = 0; + var failedCount = 0; + + foreach (var messageId in userMessageIds) + { + try + { + var deleted = await Discord.DeleteMessageAsync( + ChannelId, + messageId, + cancellationToken + ); + + if (deleted) + { + successCount++; + using (console.WithForegroundColor(ConsoleColor.Green)) + { + await console.Output.WriteAsync("\r"); + await console.Output.WriteAsync( + $"✓ Deleted {successCount} / {totalUserMessages} messages " + ); + } + } + else + { + failedCount++; + } + + // Discord's rate limit headers are handled automatically by DeleteMessageAsync + } + catch (DiscordChatExporterException ex) when (!ex.IsFatal) + { + failedCount++; + using (console.WithForegroundColor(ConsoleColor.Red)) + { + await console.Output.WriteAsync("\r"); + await console.Output.WriteAsync( + $"✗ Error | Deleted: {successCount} / {totalUserMessages}, Failed: {failedCount} " + ); + } + } + } + + // Clear the progress line + await console.Output.WriteLineAsync(); + await console.Output.WriteLineAsync(); + + // Report results + await console.Output.WriteLineAsync("Deletion complete!"); + + using (console.WithForegroundColor(ConsoleColor.Green)) + { + await console.Output.WriteLineAsync( + $"Successfully deleted: {successCount} / {totalUserMessages} message(s)" + ); + } + + if (failedCount > 0) + { + using (console.WithForegroundColor(ConsoleColor.Red)) + { + await console.Output.WriteLineAsync($"Failed to delete: {failedCount} message(s)"); + } + } + } +} diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index f435e282..d74ccd7b 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -220,6 +220,12 @@ public class DiscordClient( return response?.Pipe(User.Parse); } + public async ValueTask GetCurrentUserAsync(CancellationToken cancellationToken = default) + { + var response = await GetJsonResponseAsync("users/@me", cancellationToken); + return User.Parse(response); + } + public async IAsyncEnumerable GetUserGuildsAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default ) @@ -820,4 +826,96 @@ public class DiscordClient( yield break; } } + + private async ValueTask DeleteResponseAsync( + string url, + TokenKind tokenKind, + CancellationToken cancellationToken = default + ) + { + return await Http.ResponseResiliencePipeline.ExecuteAsync( + async innerCancellationToken => + { + using var request = new HttpRequestMessage( + HttpMethod.Delete, + new Uri(_baseUri, url) + ); + + request.Headers.TryAddWithoutValidation( + "Authorization", + tokenKind == TokenKind.Bot ? $"Bot {token}" : token + ); + + var response = await Http.Client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + innerCancellationToken + ); + + if (rateLimitPreference.IsRespectedFor(tokenKind)) + { + var remainingRequestCount = response + .Headers.TryGetValue("X-RateLimit-Remaining") + ?.Pipe(s => int.ParseOrNull(s, CultureInfo.InvariantCulture)); + + var resetAfterDelay = response + .Headers.TryGetValue("X-RateLimit-Reset-After") + ?.Pipe(s => double.ParseOrNull(s, CultureInfo.InvariantCulture)) + ?.Pipe(TimeSpan.FromSeconds); + + if (remainingRequestCount <= 0 && resetAfterDelay is not null) + { + var delay = (resetAfterDelay.Value + TimeSpan.FromSeconds(1)).Clamp( + TimeSpan.Zero, + TimeSpan.FromSeconds(60) + ); + + await Task.Delay(delay, innerCancellationToken); + } + } + + return response; + }, + cancellationToken + ); + } + + private async ValueTask DeleteResponseAsync( + string url, + CancellationToken cancellationToken = default + ) => + await DeleteResponseAsync( + url, + await ResolveTokenKindAsync(cancellationToken), + cancellationToken + ); + + public async ValueTask DeleteMessageAsync( + Snowflake channelId, + Snowflake messageId, + CancellationToken cancellationToken = default + ) + { + using var response = await DeleteResponseAsync( + $"channels/{channelId}/messages/{messageId}", + cancellationToken + ); + + // 204 No Content = successfully deleted + if (response.StatusCode == HttpStatusCode.NoContent) + return true; + + // 403 Forbidden = not authorized to delete (e.g., someone else's message) + // 404 Not Found = message already deleted or doesn't exist + if ( + response.StatusCode == HttpStatusCode.Forbidden + || response.StatusCode == HttpStatusCode.NotFound + ) + return false; + + // For other errors, throw an exception + throw new DiscordChatExporterException( + $"Failed to delete message #{messageId} in channel #{channelId}: {response.StatusCode}" + ); + } } diff --git a/DiscordChatExporter.Gui/App.axaml.cs b/DiscordChatExporter.Gui/App.axaml.cs index 37179c5c..37211e26 100644 --- a/DiscordChatExporter.Gui/App.axaml.cs +++ b/DiscordChatExporter.Gui/App.axaml.cs @@ -48,6 +48,7 @@ public partial class App : Application, IDisposable services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/DiscordChatExporter.Gui/Framework/ViewManager.cs b/DiscordChatExporter.Gui/Framework/ViewManager.cs index 5ae9e1a4..13a15842 100644 --- a/DiscordChatExporter.Gui/Framework/ViewManager.cs +++ b/DiscordChatExporter.Gui/Framework/ViewManager.cs @@ -17,6 +17,7 @@ public partial class ViewManager MainViewModel => new MainView(), DashboardViewModel => new DashboardView(), ExportSetupViewModel => new ExportSetupView(), + DeleteSetupViewModel => new DeleteSetupView(), MessageBoxViewModel => new MessageBoxView(), SettingsViewModel => new SettingsView(), _ => null, diff --git a/DiscordChatExporter.Gui/Framework/ViewModelManager.cs b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs index 0e25c2e7..2267f0f7 100644 --- a/DiscordChatExporter.Gui/Framework/ViewModelManager.cs +++ b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs @@ -29,6 +29,19 @@ public class ViewModelManager(IServiceProvider services, LocalizationManager loc return viewModel; } + public DeleteSetupViewModel GetDeleteSetupViewModel( + Guild guild, + IReadOnlyList channels + ) + { + var viewModel = services.GetRequiredService(); + + viewModel.Guild = guild; + viewModel.Channels = channels; + + return viewModel; + } + public MessageBoxViewModel GetMessageBoxViewModel( string title, string message, diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs index d85ea435..46a4e8d6 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs @@ -11,6 +11,8 @@ public partial class LocalizationManager [nameof(PullGuildsTooltip)] = "Pull available servers and channels (Enter)", [nameof(SettingsTooltip)] = "Settings", [nameof(LastMessageSentTooltip)] = "Last message sent:", + [nameof(ExportTooltip)] = "Export selected channels", + [nameof(DeleteMessagesTooltip)] = "Delete messages from selected channels", [nameof(TokenPlaceholderText)] = "Token", // Token instructions (personal account) [nameof(TokenPersonalHeader)] = "To get the token for your personal account:", @@ -123,6 +125,16 @@ public partial class LocalizationManager "Download assets to this directory. If not specified, the asset directory path will be derived from the output path.", [nameof(AdvancedOptionsTooltip)] = "Toggle advanced options", [nameof(ExportButton)] = "EXPORT", + // Delete Setup + [nameof(DeleteWarningTitle)] = + "⚠ WARNING: This will permanently delete messages from the channel.", + [nameof(DeleteWarningSubtext)] = + "You can only delete your own messages. Messages from other users will be skipped.", + [nameof(DeleteAfterDateTooltip)] = "Only delete messages sent after this date", + [nameof(DeleteBeforeDateTooltip)] = "Only delete messages sent before this date", + [nameof(DeleteAfterTimeTooltip)] = "Only delete messages sent after this time", + [nameof(DeleteBeforeTimeTooltip)] = "Only delete messages sent before this time", + [nameof(DeleteButton)] = "DELETE", // Common buttons [nameof(CloseButton)] = "CLOSE", [nameof(CancelButton)] = "CANCEL", @@ -152,5 +164,7 @@ public partial class LocalizationManager [nameof(ErrorPullingChannelsTitle)] = "Error pulling channels", [nameof(ErrorExportingTitle)] = "Error exporting channel(s)", [nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)", + [nameof(ErrorDeletingTitle)] = "Error deleting messages", + [nameof(SuccessfulDeletionMessage)] = "Deleted {0} message(s), {1} failed", }; } diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs index dbe2c65a..f46d058e 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs @@ -13,6 +13,8 @@ public partial class LocalizationManager [nameof(PullGuildsTooltip)] = "Charger les serveurs et canaux disponibles (Entrée)", [nameof(SettingsTooltip)] = "Paramètres", [nameof(LastMessageSentTooltip)] = "Dernier message envoyé :", + [nameof(ExportTooltip)] = "Exporter les canaux sélectionnés", + [nameof(DeleteMessagesTooltip)] = "Supprimer les messages des canaux sélectionnés", [nameof(TokenPlaceholderText)] = "Token", // Token instructions (personal account) [nameof(TokenPersonalHeader)] = "Obtenir le token pour votre compte personnel :", @@ -125,6 +127,20 @@ public partial class LocalizationManager "Télécharger les ressources dans ce dossier. Si non spécifié, le chemin sera dérivé du chemin de sortie.", [nameof(AdvancedOptionsTooltip)] = "Basculer les options avancées", [nameof(ExportButton)] = "EXPORTER", + // Delete Setup + [nameof(DeleteWarningTitle)] = + "⚠ AVERTISSEMENT : Cette action supprimera définitivement les messages du canal.", + [nameof(DeleteWarningSubtext)] = + "Vous ne pouvez supprimer que vos propres messages. Les messages des autres utilisateurs seront ignorés.", + [nameof(DeleteAfterDateTooltip)] = + "Supprimer uniquement les messages envoyés après cette date", + [nameof(DeleteBeforeDateTooltip)] = + "Supprimer uniquement les messages envoyés avant cette date", + [nameof(DeleteAfterTimeTooltip)] = + "Supprimer uniquement les messages envoyés après cette heure", + [nameof(DeleteBeforeTimeTooltip)] = + "Supprimer uniquement les messages envoyés avant cette heure", + [nameof(DeleteButton)] = "SUPPRIMER", // Common buttons [nameof(CloseButton)] = "FERMER", [nameof(CancelButton)] = "ANNULER", @@ -154,5 +170,7 @@ public partial class LocalizationManager [nameof(ErrorPullingChannelsTitle)] = "Erreur lors du chargement des canaux", [nameof(ErrorExportingTitle)] = "Erreur lors de l'exportation des canaux", [nameof(SuccessfulExportMessage)] = "{0} canal(-aux) exporté(s) avec succès", + [nameof(ErrorDeletingTitle)] = "Erreur lors de la suppression des messages", + [nameof(SuccessfulDeletionMessage)] = "{0} message(s) supprimé(s), {1} échec(s)", }; } diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs index 6ca87c78..89c36085 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs @@ -13,6 +13,8 @@ public partial class LocalizationManager [nameof(PullGuildsTooltip)] = "Verfügbare Server und Kanäle laden (Enter)", [nameof(SettingsTooltip)] = "Einstellungen", [nameof(LastMessageSentTooltip)] = "Letzte Nachricht gesendet:", + [nameof(ExportTooltip)] = "Ausgewählte Kanäle exportieren", + [nameof(DeleteMessagesTooltip)] = "Nachrichten aus ausgewählten Kanälen löschen", [nameof(TokenPlaceholderText)] = "Token", // Token instructions (personal account) [nameof(TokenPersonalHeader)] = "Token für Ihr persönliches Konto abrufen:", @@ -129,6 +131,20 @@ public partial class LocalizationManager "Assets in dieses Verzeichnis herunterladen. Wenn nicht angegeben, wird der Asset-Verzeichnispfad vom Ausgabepfad abgeleitet.", [nameof(AdvancedOptionsTooltip)] = "Erweiterte Optionen umschalten", [nameof(ExportButton)] = "EXPORTIEREN", + // Delete Setup + [nameof(DeleteWarningTitle)] = + "⚠ WARNUNG: Dadurch werden Nachrichten dauerhaft aus dem Kanal gelöscht.", + [nameof(DeleteWarningSubtext)] = + "Sie können nur Ihre eigenen Nachrichten löschen. Nachrichten anderer Benutzer werden übersprungen.", + [nameof(DeleteAfterDateTooltip)] = + "Nur Nachrichten löschen, die nach diesem Datum gesendet wurden", + [nameof(DeleteBeforeDateTooltip)] = + "Nur Nachrichten löschen, die vor diesem Datum gesendet wurden", + [nameof(DeleteAfterTimeTooltip)] = + "Nur Nachrichten löschen, die nach dieser Uhrzeit gesendet wurden", + [nameof(DeleteBeforeTimeTooltip)] = + "Nur Nachrichten löschen, die vor dieser Uhrzeit gesendet wurden", + [nameof(DeleteButton)] = "LÖSCHEN", // Common buttons [nameof(CloseButton)] = "SCHLIESSEN", [nameof(CancelButton)] = "ABBRECHEN", @@ -158,5 +174,7 @@ public partial class LocalizationManager [nameof(ErrorPullingChannelsTitle)] = "Fehler beim Laden der Kanäle", [nameof(ErrorExportingTitle)] = "Fehler beim Exportieren der Kanäle", [nameof(SuccessfulExportMessage)] = "{0} Kanal/-äle erfolgreich exportiert", + [nameof(ErrorDeletingTitle)] = "Fehler beim Löschen der Nachrichten", + [nameof(SuccessfulDeletionMessage)] = "{0} Nachricht(en) gelöscht, {1} fehlgeschlagen", }; } diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs index 622ef5c2..e151e5de 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs @@ -11,6 +11,8 @@ public partial class LocalizationManager [nameof(PullGuildsTooltip)] = "Cargar servidores y canales disponibles (Enter)", [nameof(SettingsTooltip)] = "Ajustes", [nameof(LastMessageSentTooltip)] = "Último mensaje enviado:", + [nameof(ExportTooltip)] = "Exportar canales seleccionados", + [nameof(DeleteMessagesTooltip)] = "Eliminar mensajes de los canales seleccionados", [nameof(TokenPlaceholderText)] = "Token", // Token instructions (personal account) [nameof(TokenPersonalHeader)] = "Cómo obtener el token para tu cuenta personal:", @@ -123,6 +125,20 @@ public partial class LocalizationManager "Descargar recursos en este directorio. Si no se especifica, la ruta se derivará de la ruta de salida.", [nameof(AdvancedOptionsTooltip)] = "Alternar opciones avanzadas", [nameof(ExportButton)] = "EXPORTAR", + // Delete Setup + [nameof(DeleteWarningTitle)] = + "⚠ ADVERTENCIA: Esto eliminará permanentemente los mensajes del canal.", + [nameof(DeleteWarningSubtext)] = + "Solo puedes eliminar tus propios mensajes. Se omitirán los mensajes de otros usuarios.", + [nameof(DeleteAfterDateTooltip)] = + "Eliminar solo los mensajes enviados después de esta fecha", + [nameof(DeleteBeforeDateTooltip)] = + "Eliminar solo los mensajes enviados antes de esta fecha", + [nameof(DeleteAfterTimeTooltip)] = + "Eliminar solo los mensajes enviados después de esta hora", + [nameof(DeleteBeforeTimeTooltip)] = + "Eliminar solo los mensajes enviados antes de esta hora", + [nameof(DeleteButton)] = "ELIMINAR", // Common buttons [nameof(CloseButton)] = "CERRAR", [nameof(CancelButton)] = "CANCELAR", @@ -152,5 +168,7 @@ public partial class LocalizationManager [nameof(ErrorPullingChannelsTitle)] = "Error al cargar canales", [nameof(ErrorExportingTitle)] = "Error al exportar canal(es)", [nameof(SuccessfulExportMessage)] = "{0} canal(es) exportado(s) con éxito", + [nameof(ErrorDeletingTitle)] = "Error al eliminar mensajes", + [nameof(SuccessfulDeletionMessage)] = "{0} mensaje(s) eliminado(s), {1} fallido(s)", }; } diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs index 6b2b1cbb..ce1c0515 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs @@ -11,6 +11,8 @@ public partial class LocalizationManager [nameof(PullGuildsTooltip)] = "Завантажити доступні сервери та канали (Enter)", [nameof(SettingsTooltip)] = "Налаштування", [nameof(LastMessageSentTooltip)] = "Останнє повідомлення:", + [nameof(ExportTooltip)] = "Експортувати вибрані канали", + [nameof(DeleteMessagesTooltip)] = "Видалити повідомлення з вибраних каналів", [nameof(TokenPlaceholderText)] = "Токен", // Token instructions (personal account) [nameof(TokenPersonalHeader)] = "Як отримати токен для персонального акаунту:", @@ -123,6 +125,19 @@ public partial class LocalizationManager "Завантажувати ресурси до цієї директорії. Якщо не вказано, шлях до директорії ресурсів буде визначено з шляху збереження.", [nameof(AdvancedOptionsTooltip)] = "Перемкнути розширені параметри", [nameof(ExportButton)] = "ЕКСПОРТУВАТИ", + // Delete Setup + [nameof(DeleteWarningTitle)] = "⚠ УВАГА: Це назавжди видалить повідомлення з каналу.", + [nameof(DeleteWarningSubtext)] = + "Ви можете видаляти лише власні повідомлення. Повідомлення інших користувачів буде пропущено.", + [nameof(DeleteAfterDateTooltip)] = + "Видаляти лише повідомлення, надіслані після цієї дати", + [nameof(DeleteBeforeDateTooltip)] = + "Видаляти лише повідомлення, надіслані до цієї дати", + [nameof(DeleteAfterTimeTooltip)] = + "Видаляти лише повідомлення, надіслані після цього часу", + [nameof(DeleteBeforeTimeTooltip)] = + "Видаляти лише повідомлення, надіслані до цього часу", + [nameof(DeleteButton)] = "ВИДАЛИТИ", // Common buttons [nameof(CloseButton)] = "ЗАКРИТИ", [nameof(CancelButton)] = "СКАСУВАТИ", @@ -151,5 +166,7 @@ public partial class LocalizationManager [nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів", [nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)", [nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)", + [nameof(ErrorDeletingTitle)] = "Помилка видалення повідомлень", + [nameof(SuccessfulDeletionMessage)] = "Видалено {0} повідомлення(-ь), {1} не вдалося", }; } diff --git a/DiscordChatExporter.Gui/Localization/LocalizationManager.cs b/DiscordChatExporter.Gui/Localization/LocalizationManager.cs index 8a386eae..fc97fa4b 100644 --- a/DiscordChatExporter.Gui/Localization/LocalizationManager.cs +++ b/DiscordChatExporter.Gui/Localization/LocalizationManager.cs @@ -75,6 +75,8 @@ public partial class LocalizationManager public string PullGuildsTooltip => Get(); public string SettingsTooltip => Get(); public string LastMessageSentTooltip => Get(); + public string ExportTooltip => Get(); + public string DeleteMessagesTooltip => Get(); public string TokenPlaceholderText => Get(); // Token instructions (personal account) @@ -141,6 +143,16 @@ public partial class LocalizationManager public string AdvancedOptionsTooltip => Get(); public string ExportButton => Get(); + // ---- Delete Setup ---- + + public string DeleteWarningTitle => Get(); + public string DeleteWarningSubtext => Get(); + public string DeleteAfterDateTooltip => Get(); + public string DeleteBeforeDateTooltip => Get(); + public string DeleteAfterTimeTooltip => Get(); + public string DeleteBeforeTimeTooltip => Get(); + public string DeleteButton => Get(); + // ---- Common buttons ---- public string CloseButton => Get(); @@ -162,4 +174,6 @@ public partial class LocalizationManager public string ErrorPullingChannelsTitle => Get(); public string ErrorExportingTitle => Get(); public string SuccessfulExportMessage => Get(); + public string ErrorDeletingTitle => Get(); + public string SuccessfulDeletionMessage => Get(); } diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index 542c9526..5a62a500 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -56,7 +56,11 @@ public partial class DashboardViewModel : ViewModelBase ), SelectedChannels.WatchProperty( o => o.Count, - _ => ExportCommand.NotifyCanExecuteChanged() + _ => + { + ExportCommand.NotifyCanExecuteChanged(); + DeleteMessagesCommand.NotifyCanExecuteChanged(); + } ) ); } @@ -66,6 +70,7 @@ public partial class DashboardViewModel : ViewModelBase [NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))] [NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))] [NotifyCanExecuteChangedFor(nameof(ExportCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteMessagesCommand))] public partial bool IsBusy { get; set; } public LocalizationManager LocalizationManager { get; } @@ -324,6 +329,129 @@ public partial class DashboardViewModel : ViewModelBase } } + private bool CanDeleteMessages() => + !IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels.Any(); + + [RelayCommand(CanExecute = nameof(CanDeleteMessages))] + private async Task DeleteMessagesAsync() + { + IsBusy = true; + + try + { + if (_discord is null || SelectedGuild is null || !SelectedChannels.Any()) + return; + + var currentUser = await _discord.GetCurrentUserAsync(); + + var dialog = _viewModelManager.GetDeleteSetupViewModel( + SelectedGuild, + SelectedChannels.Select(c => c.Channel).ToArray() + ); + + if (await _dialogManager.ShowDialogAsync(dialog) != true) + return; + + var channelProgressPairs = dialog + .Channels!.Select(c => new { Channel = c, Progress = _progressMuxer.CreateInput() }) + .ToArray(); + + var totalDeletedCount = 0; + var totalFailedCount = 0; + + await Parallel.ForEachAsync( + channelProgressPairs, + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit), + }, + async (pair, cancellationToken) => + { + var channel = pair.Channel; + var progress = pair.Progress; + + try + { + var userMessageIds = new List(); + await foreach ( + var message in _discord.GetMessagesAsync( + channel.Id, + dialog.After?.Pipe(Snowflake.FromDate), + dialog.Before?.Pipe(Snowflake.FromDate), + progress, + cancellationToken + ) + ) + { + if (message.Author.Id == currentUser.Id) + userMessageIds.Add(message.Id); + } + + if (userMessageIds.Count == 0) + return; + + var successCount = 0; + var failedCount = 0; + + foreach (var messageId in userMessageIds) + { + try + { + var deleted = await _discord.DeleteMessageAsync( + channel.Id, + messageId, + cancellationToken + ); + + if (deleted) + successCount++; + else + failedCount++; + } + catch (DiscordChatExporterException ex) when (!ex.IsFatal) + { + failedCount++; + _snackbarManager.Notify(ex.Message.TrimEnd('.')); + } + } + + Interlocked.Add(ref totalDeletedCount, successCount); + Interlocked.Add(ref totalFailedCount, failedCount); + } + catch (DiscordChatExporterException ex) when (!ex.IsFatal) + { + _snackbarManager.Notify(ex.Message.TrimEnd('.')); + } + finally + { + progress.ReportCompletion(); + } + } + ); + + _snackbarManager.Notify( + string.Format( + LocalizationManager.SuccessfulDeletionMessage, + totalDeletedCount, + totalFailedCount + ) + ); + } + catch (Exception ex) + { + var dialog = _viewModelManager.GetMessageBoxViewModel( + LocalizationManager.ErrorDeletingTitle, + ex.ToString() + ); + + await _dialogManager.ShowDialogAsync(dialog); + } + finally + { + IsBusy = false; + } + } + protected override void Dispose(bool disposing) { if (disposing) diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/DeleteSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/DeleteSetupViewModel.cs new file mode 100644 index 00000000..006a0461 --- /dev/null +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/DeleteSetupViewModel.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Gui.Framework; +using DiscordChatExporter.Gui.Localization; + +namespace DiscordChatExporter.Gui.ViewModels.Dialogs; + +public partial class DeleteSetupViewModel(LocalizationManager localizationManager) + : DialogViewModelBase +{ + public LocalizationManager LocalizationManager { get; } = localizationManager; + + [ObservableProperty] + public partial Guild? Guild { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSingleChannel))] + public partial IReadOnlyList? Channels { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAfterDateSet))] + [NotifyPropertyChangedFor(nameof(After))] + public partial DateTimeOffset? AfterDate { get; set; } + + [ObservableProperty] + public partial TimeSpan? AfterTime { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBeforeDateSet))] + [NotifyPropertyChangedFor(nameof(Before))] + public partial DateTimeOffset? BeforeDate { get; set; } + + [ObservableProperty] + public partial TimeSpan? BeforeTime { get; set; } + + public bool IsSingleChannel => Channels?.Count == 1; + + public bool IsAfterDateSet => AfterDate is not null; + + public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero); + + public bool IsBeforeDateSet => BeforeDate is not null; + + public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero); + + [RelayCommand] + private void Confirm() + { + Close(true); + } + + [RelayCommand] + private void Cancel() + { + Close(null); + } +} diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml index cd3ea111..a281aead 100644 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml @@ -284,24 +284,47 @@ - - + Margin="32,24" + Orientation="Horizontal" + Spacing="16"> + + + + + + diff --git a/DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml b/DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml new file mode 100644 index 00000000..254d6e34 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Dialogs/DeleteSetupView.axaml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +