This commit is contained in:
primetime43 2026-06-08 12:46:34 -04:00 committed by GitHub
commit e37cb748b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 817 additions and 17 deletions

View file

@ -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<Snowflake>();
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)");
}
}
}
}

View file

@ -220,6 +220,12 @@ public class DiscordClient(
return response?.Pipe(User.Parse); return response?.Pipe(User.Parse);
} }
public async ValueTask<User> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
var response = await GetJsonResponseAsync("users/@me", cancellationToken);
return User.Parse(response);
}
public async IAsyncEnumerable<Guild> GetUserGuildsAsync( public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default [EnumeratorCancellation] CancellationToken cancellationToken = default
) )
@ -820,4 +826,96 @@ public class DiscordClient(
yield break; yield break;
} }
} }
private async ValueTask<HttpResponseMessage> 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<HttpResponseMessage> DeleteResponseAsync(
string url,
CancellationToken cancellationToken = default
) =>
await DeleteResponseAsync(
url,
await ResolveTokenKindAsync(cancellationToken),
cancellationToken
);
public async ValueTask<bool> 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}"
);
}
} }

View file

@ -48,6 +48,7 @@ public partial class App : Application, IDisposable
services.AddTransient<MainViewModel>(); services.AddTransient<MainViewModel>();
services.AddTransient<DashboardViewModel>(); services.AddTransient<DashboardViewModel>();
services.AddTransient<ExportSetupViewModel>(); services.AddTransient<ExportSetupViewModel>();
services.AddTransient<DeleteSetupViewModel>();
services.AddTransient<MessageBoxViewModel>(); services.AddTransient<MessageBoxViewModel>();
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();

View file

@ -17,6 +17,7 @@ public partial class ViewManager
MainViewModel => new MainView(), MainViewModel => new MainView(),
DashboardViewModel => new DashboardView(), DashboardViewModel => new DashboardView(),
ExportSetupViewModel => new ExportSetupView(), ExportSetupViewModel => new ExportSetupView(),
DeleteSetupViewModel => new DeleteSetupView(),
MessageBoxViewModel => new MessageBoxView(), MessageBoxViewModel => new MessageBoxView(),
SettingsViewModel => new SettingsView(), SettingsViewModel => new SettingsView(),
_ => null, _ => null,

View file

@ -29,6 +29,19 @@ public class ViewModelManager(IServiceProvider services, LocalizationManager loc
return viewModel; return viewModel;
} }
public DeleteSetupViewModel GetDeleteSetupViewModel(
Guild guild,
IReadOnlyList<Channel> channels
)
{
var viewModel = services.GetRequiredService<DeleteSetupViewModel>();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
public MessageBoxViewModel GetMessageBoxViewModel( public MessageBoxViewModel GetMessageBoxViewModel(
string title, string title,
string message, string message,

View file

@ -11,6 +11,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Pull available servers and channels (Enter)", [nameof(PullGuildsTooltip)] = "Pull available servers and channels (Enter)",
[nameof(SettingsTooltip)] = "Settings", [nameof(SettingsTooltip)] = "Settings",
[nameof(LastMessageSentTooltip)] = "Last message sent:", [nameof(LastMessageSentTooltip)] = "Last message sent:",
[nameof(ExportTooltip)] = "Export selected channels",
[nameof(DeleteMessagesTooltip)] = "Delete messages from selected channels",
[nameof(TokenPlaceholderText)] = "Token", [nameof(TokenPlaceholderText)] = "Token",
// Token instructions (personal account) // Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "To get the token for your 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.", "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(AdvancedOptionsTooltip)] = "Toggle advanced options",
[nameof(ExportButton)] = "EXPORT", [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 // Common buttons
[nameof(CloseButton)] = "CLOSE", [nameof(CloseButton)] = "CLOSE",
[nameof(CancelButton)] = "CANCEL", [nameof(CancelButton)] = "CANCEL",
@ -152,5 +164,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Error pulling channels", [nameof(ErrorPullingChannelsTitle)] = "Error pulling channels",
[nameof(ErrorExportingTitle)] = "Error exporting channel(s)", [nameof(ErrorExportingTitle)] = "Error exporting channel(s)",
[nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)", [nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)",
[nameof(ErrorDeletingTitle)] = "Error deleting messages",
[nameof(SuccessfulDeletionMessage)] = "Deleted {0} message(s), {1} failed",
}; };
} }

View file

@ -13,6 +13,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Charger les serveurs et canaux disponibles (Entrée)", [nameof(PullGuildsTooltip)] = "Charger les serveurs et canaux disponibles (Entrée)",
[nameof(SettingsTooltip)] = "Paramètres", [nameof(SettingsTooltip)] = "Paramètres",
[nameof(LastMessageSentTooltip)] = "Dernier message envoyé :", [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", [nameof(TokenPlaceholderText)] = "Token",
// Token instructions (personal account) // Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "Obtenir le token pour votre compte personnel :", [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.", "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(AdvancedOptionsTooltip)] = "Basculer les options avancées",
[nameof(ExportButton)] = "EXPORTER", [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 // Common buttons
[nameof(CloseButton)] = "FERMER", [nameof(CloseButton)] = "FERMER",
[nameof(CancelButton)] = "ANNULER", [nameof(CancelButton)] = "ANNULER",
@ -154,5 +170,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Erreur lors du chargement des canaux", [nameof(ErrorPullingChannelsTitle)] = "Erreur lors du chargement des canaux",
[nameof(ErrorExportingTitle)] = "Erreur lors de l'exportation des canaux", [nameof(ErrorExportingTitle)] = "Erreur lors de l'exportation des canaux",
[nameof(SuccessfulExportMessage)] = "{0} canal(-aux) exporté(s) avec succès", [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)",
}; };
} }

View file

@ -13,6 +13,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Verfügbare Server und Kanäle laden (Enter)", [nameof(PullGuildsTooltip)] = "Verfügbare Server und Kanäle laden (Enter)",
[nameof(SettingsTooltip)] = "Einstellungen", [nameof(SettingsTooltip)] = "Einstellungen",
[nameof(LastMessageSentTooltip)] = "Letzte Nachricht gesendet:", [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", [nameof(TokenPlaceholderText)] = "Token",
// Token instructions (personal account) // Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "Token für Ihr persönliches Konto abrufen:", [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.", "Assets in dieses Verzeichnis herunterladen. Wenn nicht angegeben, wird der Asset-Verzeichnispfad vom Ausgabepfad abgeleitet.",
[nameof(AdvancedOptionsTooltip)] = "Erweiterte Optionen umschalten", [nameof(AdvancedOptionsTooltip)] = "Erweiterte Optionen umschalten",
[nameof(ExportButton)] = "EXPORTIEREN", [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 // Common buttons
[nameof(CloseButton)] = "SCHLIESSEN", [nameof(CloseButton)] = "SCHLIESSEN",
[nameof(CancelButton)] = "ABBRECHEN", [nameof(CancelButton)] = "ABBRECHEN",
@ -158,5 +174,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Fehler beim Laden der Kanäle", [nameof(ErrorPullingChannelsTitle)] = "Fehler beim Laden der Kanäle",
[nameof(ErrorExportingTitle)] = "Fehler beim Exportieren der Kanäle", [nameof(ErrorExportingTitle)] = "Fehler beim Exportieren der Kanäle",
[nameof(SuccessfulExportMessage)] = "{0} Kanal/-äle erfolgreich exportiert", [nameof(SuccessfulExportMessage)] = "{0} Kanal/-äle erfolgreich exportiert",
[nameof(ErrorDeletingTitle)] = "Fehler beim Löschen der Nachrichten",
[nameof(SuccessfulDeletionMessage)] = "{0} Nachricht(en) gelöscht, {1} fehlgeschlagen",
}; };
} }

View file

@ -11,6 +11,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Cargar servidores y canales disponibles (Enter)", [nameof(PullGuildsTooltip)] = "Cargar servidores y canales disponibles (Enter)",
[nameof(SettingsTooltip)] = "Ajustes", [nameof(SettingsTooltip)] = "Ajustes",
[nameof(LastMessageSentTooltip)] = "Último mensaje enviado:", [nameof(LastMessageSentTooltip)] = "Último mensaje enviado:",
[nameof(ExportTooltip)] = "Exportar canales seleccionados",
[nameof(DeleteMessagesTooltip)] = "Eliminar mensajes de los canales seleccionados",
[nameof(TokenPlaceholderText)] = "Token", [nameof(TokenPlaceholderText)] = "Token",
// Token instructions (personal account) // Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "Cómo obtener el token para tu cuenta personal:", [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.", "Descargar recursos en este directorio. Si no se especifica, la ruta se derivará de la ruta de salida.",
[nameof(AdvancedOptionsTooltip)] = "Alternar opciones avanzadas", [nameof(AdvancedOptionsTooltip)] = "Alternar opciones avanzadas",
[nameof(ExportButton)] = "EXPORTAR", [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 // Common buttons
[nameof(CloseButton)] = "CERRAR", [nameof(CloseButton)] = "CERRAR",
[nameof(CancelButton)] = "CANCELAR", [nameof(CancelButton)] = "CANCELAR",
@ -152,5 +168,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Error al cargar canales", [nameof(ErrorPullingChannelsTitle)] = "Error al cargar canales",
[nameof(ErrorExportingTitle)] = "Error al exportar canal(es)", [nameof(ErrorExportingTitle)] = "Error al exportar canal(es)",
[nameof(SuccessfulExportMessage)] = "{0} canal(es) exportado(s) con éxito", [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)",
}; };
} }

View file

@ -11,6 +11,8 @@ public partial class LocalizationManager
[nameof(PullGuildsTooltip)] = "Завантажити доступні сервери та канали (Enter)", [nameof(PullGuildsTooltip)] = "Завантажити доступні сервери та канали (Enter)",
[nameof(SettingsTooltip)] = "Налаштування", [nameof(SettingsTooltip)] = "Налаштування",
[nameof(LastMessageSentTooltip)] = "Останнє повідомлення:", [nameof(LastMessageSentTooltip)] = "Останнє повідомлення:",
[nameof(ExportTooltip)] = "Експортувати вибрані канали",
[nameof(DeleteMessagesTooltip)] = "Видалити повідомлення з вибраних каналів",
[nameof(TokenPlaceholderText)] = "Токен", [nameof(TokenPlaceholderText)] = "Токен",
// Token instructions (personal account) // Token instructions (personal account)
[nameof(TokenPersonalHeader)] = "Як отримати токен для персонального акаунту:", [nameof(TokenPersonalHeader)] = "Як отримати токен для персонального акаунту:",
@ -123,6 +125,19 @@ public partial class LocalizationManager
"Завантажувати ресурси до цієї директорії. Якщо не вказано, шлях до директорії ресурсів буде визначено з шляху збереження.", "Завантажувати ресурси до цієї директорії. Якщо не вказано, шлях до директорії ресурсів буде визначено з шляху збереження.",
[nameof(AdvancedOptionsTooltip)] = "Перемкнути розширені параметри", [nameof(AdvancedOptionsTooltip)] = "Перемкнути розширені параметри",
[nameof(ExportButton)] = "ЕКСПОРТУВАТИ", [nameof(ExportButton)] = "ЕКСПОРТУВАТИ",
// Delete Setup
[nameof(DeleteWarningTitle)] = "⚠ УВАГА: Це назавжди видалить повідомлення з каналу.",
[nameof(DeleteWarningSubtext)] =
"Ви можете видаляти лише власні повідомлення. Повідомлення інших користувачів буде пропущено.",
[nameof(DeleteAfterDateTooltip)] =
"Видаляти лише повідомлення, надіслані після цієї дати",
[nameof(DeleteBeforeDateTooltip)] =
"Видаляти лише повідомлення, надіслані до цієї дати",
[nameof(DeleteAfterTimeTooltip)] =
"Видаляти лише повідомлення, надіслані після цього часу",
[nameof(DeleteBeforeTimeTooltip)] =
"Видаляти лише повідомлення, надіслані до цього часу",
[nameof(DeleteButton)] = "ВИДАЛИТИ",
// Common buttons // Common buttons
[nameof(CloseButton)] = "ЗАКРИТИ", [nameof(CloseButton)] = "ЗАКРИТИ",
[nameof(CancelButton)] = "СКАСУВАТИ", [nameof(CancelButton)] = "СКАСУВАТИ",
@ -151,5 +166,7 @@ public partial class LocalizationManager
[nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів", [nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів",
[nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)", [nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)",
[nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)", [nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)",
[nameof(ErrorDeletingTitle)] = "Помилка видалення повідомлень",
[nameof(SuccessfulDeletionMessage)] = "Видалено {0} повідомлення(-ь), {1} не вдалося",
}; };
} }

View file

@ -75,6 +75,8 @@ public partial class LocalizationManager
public string PullGuildsTooltip => Get(); public string PullGuildsTooltip => Get();
public string SettingsTooltip => Get(); public string SettingsTooltip => Get();
public string LastMessageSentTooltip => Get(); public string LastMessageSentTooltip => Get();
public string ExportTooltip => Get();
public string DeleteMessagesTooltip => Get();
public string TokenPlaceholderText => Get(); public string TokenPlaceholderText => Get();
// Token instructions (personal account) // Token instructions (personal account)
@ -141,6 +143,16 @@ public partial class LocalizationManager
public string AdvancedOptionsTooltip => Get(); public string AdvancedOptionsTooltip => Get();
public string ExportButton => 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 ---- // ---- Common buttons ----
public string CloseButton => Get(); public string CloseButton => Get();
@ -162,4 +174,6 @@ public partial class LocalizationManager
public string ErrorPullingChannelsTitle => Get(); public string ErrorPullingChannelsTitle => Get();
public string ErrorExportingTitle => Get(); public string ErrorExportingTitle => Get();
public string SuccessfulExportMessage => Get(); public string SuccessfulExportMessage => Get();
public string ErrorDeletingTitle => Get();
public string SuccessfulDeletionMessage => Get();
} }

View file

@ -56,7 +56,11 @@ public partial class DashboardViewModel : ViewModelBase
), ),
SelectedChannels.WatchProperty( SelectedChannels.WatchProperty(
o => o.Count, o => o.Count,
_ => ExportCommand.NotifyCanExecuteChanged() _ =>
{
ExportCommand.NotifyCanExecuteChanged();
DeleteMessagesCommand.NotifyCanExecuteChanged();
}
) )
); );
} }
@ -66,6 +70,7 @@ public partial class DashboardViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))] [NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))] [NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportCommand))] [NotifyCanExecuteChangedFor(nameof(ExportCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteMessagesCommand))]
public partial bool IsBusy { get; set; } public partial bool IsBusy { get; set; }
public LocalizationManager LocalizationManager { get; } 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<Snowflake>();
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) protected override void Dispose(bool disposing)
{ {
if (disposing) if (disposing)

View file

@ -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<Channel>? 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);
}
}

View file

@ -284,24 +284,47 @@
</ScrollViewer> </ScrollViewer>
</Panel> </Panel>
<!-- Action buttons -->
<StackPanel
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="32,24"
Orientation="Horizontal"
Spacing="16">
<!-- Delete button -->
<Button
Width="56"
Height="56"
Padding="0"
Background="#FFE74C3C"
Command="{Binding DeleteMessagesCommand}"
Foreground="White"
IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}"
ToolTip.Tip="{Binding LocalizationManager.DeleteMessagesTooltip}">
<materialIcons:MaterialIcon
Width="32"
Height="32"
Kind="Delete" />
</Button>
<!-- Export button --> <!-- Export button -->
<Button <Button
Width="56" Width="56"
Height="56" Height="56"
Margin="32,24"
Padding="0" Padding="0"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Background="{DynamicResource MaterialSecondaryMidBrush}" Background="{DynamicResource MaterialSecondaryMidBrush}"
Command="{Binding ExportCommand}" Command="{Binding ExportCommand}"
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}" Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
IsVisible="{Binding $self.IsEffectivelyEnabled}" IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}"> Theme="{DynamicResource MaterialIconButton}"
ToolTip.Tip="{Binding LocalizationManager.ExportTooltip}">
<materialIcons:MaterialIcon <materialIcons:MaterialIcon
Width="32" Width="32"
Height="32" Height="32"
Kind="Download" /> Kind="Download" />
</Button> </Button>
</StackPanel>
</Panel> </Panel>
</DockPanel> </DockPanel>
</UserControl> </UserControl>

View file

@ -0,0 +1,180 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.DeleteSetupView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
x:Name="UserControl"
Width="380"
x:DataType="dialogs:DeleteSetupViewModel"
Loaded="UserControl_OnLoaded">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Guild/channel info -->
<Grid
Grid.Row="0"
Margin="16"
ColumnDefinitions="Auto,*">
<!-- Guild icon -->
<Ellipse
Grid.Column="0"
Width="32"
Height="32">
<Ellipse.Fill>
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding Guild.IconUrl}" />
</Ellipse.Fill>
</Ellipse>
<!-- Channel count (for multiple channels) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
IsVisible="{Binding !IsSingleChannel}"
TextTrimming="CharacterEllipsis">
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
<Run Text="{Binding LocalizationManager.ChannelsSelectedText}" />
</TextBlock>
<!-- Category and channel name (for single channel) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
IsVisible="{Binding IsSingleChannel}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
<TextBlock IsVisible="{Binding !!Channels[0].Parent}">
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
<Run Text="/" />
</TextBlock>
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock>
</Grid>
<Border
Grid.Row="1"
Padding="0,8"
BorderBrush="{DynamicResource MaterialDividerBrush}"
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Orientation="Vertical">
<!-- Warning message -->
<Border
Margin="16,8"
Padding="12"
Background="#33FFA500"
BorderBrush="#FFFFA500"
BorderThickness="2"
CornerRadius="4">
<TextBlock
FontSize="13"
FontWeight="SemiBold"
Foreground="#FFFFA500"
TextWrapping="Wrap">
<Run Text="{Binding LocalizationManager.DeleteWarningTitle}" />
<LineBreak />
<Run FontWeight="Normal" Text="{Binding LocalizationManager.DeleteWarningSubtext}" />
</TextBlock>
</Border>
<!-- Date limits -->
<Grid ColumnDefinitions="*,*" RowDefinitions="*,*">
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterDateLabel}"
SelectedDate="{Binding AfterDate}"
ToolTip.Tip="{Binding LocalizationManager.DeleteAfterDateTooltip}">
<DatePicker.Styles>
<Style Selector="DatePicker">
<Style Selector="^ /template/ TextBox#DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</DatePicker.Styles>
</DatePicker>
<DatePicker
Grid.Row="0"
Grid.Column="1"
Margin="8,8,16,8"
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeDateLabel}"
SelectedDate="{Binding BeforeDate}"
ToolTip.Tip="{Binding LocalizationManager.DeleteBeforeDateTooltip}">
<DatePicker.Styles>
<Style Selector="DatePicker">
<Style Selector="^ /template/ TextBox#DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</DatePicker.Styles>
</DatePicker>
<!-- Time limits -->
<TimePicker
Grid.Row="1"
Grid.Column="0"
Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterTimeLabel}"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime}"
ToolTip.Tip="{Binding LocalizationManager.DeleteAfterTimeTooltip}">
<TimePicker.Styles>
<Style Selector="TimePicker">
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</TimePicker.Styles>
</TimePicker>
<TimePicker
Grid.Row="1"
Grid.Column="1"
Margin="8,8,16,8"
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeTimeLabel}"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime}"
ToolTip.Tip="{Binding LocalizationManager.DeleteBeforeTimeTooltip}">
<TimePicker.Styles>
<Style Selector="TimePicker">
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</TimePicker.Styles>
</TimePicker>
</Grid>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Buttons -->
<Grid
Grid.Row="2"
Margin="16"
ColumnDefinitions="*,Auto,Auto">
<Button
Grid.Column="1"
Command="{Binding ConfirmCommand}"
Content="{Binding LocalizationManager.DeleteButton}"
IsDefault="True"
Theme="{DynamicResource MaterialOutlineButton}" />
<Button
Grid.Column="2"
Margin="16,0,0,0"
Command="{Binding CancelCommand}"
Content="{Binding LocalizationManager.CancelButton}"
IsCancel="True"
Theme="{DynamicResource MaterialOutlineButton}" />
</Grid>
</Grid>
</UserControl>

View file

@ -0,0 +1,20 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DiscordChatExporter.Gui.Views.Dialogs;
public partial class DeleteSetupView : UserControl
{
public DeleteSetupView()
{
InitializeComponent();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args)
{
// Nothing to do on load for now
}
}