();
-
- // We are going backwards from last message to first
- // collecting everything between them in batches
- string beforeId = null;
- while (true)
- {
- // Form request url
- var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
- if (beforeId.IsNotBlank())
- url += $"&before={beforeId}";
-
- // Get response
- var response = await _httpClient.GetStringAsync(url);
-
- // Parse
- var messages = ParseMessages(response);
-
- // Add messages to list
- string currentMessageId = null;
- foreach (var message in messages)
- {
- result.Add(message);
- currentMessageId = message.Id;
- }
-
- // If no messages - break
- if (currentMessageId == null) break;
-
- // Otherwise offset the next request
- beforeId = currentMessageId;
- }
-
- // Messages appear newest first, we need to reverse
- result.Reverse();
-
- return result;
- }
-
- protected virtual void Dispose(bool disposing)
- {
- if (disposing)
- {
- _httpClient.Dispose();
- }
- }
-
- public void Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
- }
- }
-}
\ No newline at end of file
diff --git a/DiscordChatExporter/Services/HtmlExportService.cs b/DiscordChatExporter/Services/ExportService.cs
similarity index 67%
rename from DiscordChatExporter/Services/HtmlExportService.cs
rename to DiscordChatExporter/Services/ExportService.cs
index 359a0f8e..f0f9bde1 100644
--- a/DiscordChatExporter/Services/HtmlExportService.cs
+++ b/DiscordChatExporter/Services/ExportService.cs
@@ -10,11 +10,114 @@ using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
{
- public class HtmlExportService
+ public partial class ExportService : IExportService
{
- private HtmlDocument GetTemplate()
+ public void Export(string filePath, ChannelChatLog channelChatLog, Theme theme)
{
- var resourcePath = "DiscordChatExporter.Resources.HtmlExportService.Template.html";
+ var doc = GetTemplate();
+ var style = GetStyle(theme);
+
+ // Set theme
+ var themeHtml = doc.GetElementbyId("theme");
+ themeHtml.InnerHtml = style;
+
+ // Title
+ var titleHtml = doc.DocumentNode.Element("html").Element("head").Element("title");
+ titleHtml.InnerHtml = $"{channelChatLog.Guild.Name} - {channelChatLog.Channel.Name}";
+
+ // Info
+ var infoHtml = doc.GetElementbyId("info");
+ var infoLeftHtml = infoHtml.AppendChild(HtmlNode.CreateNode(""));
+ infoLeftHtml.AppendChild(HtmlNode.CreateNode(
+ $"
"));
+ var infoRightHtml = infoHtml.AppendChild(HtmlNode.CreateNode(""));
+ infoRightHtml.AppendChild(HtmlNode.CreateNode(
+ $"{channelChatLog.Guild.Name}
"));
+ infoRightHtml.AppendChild(HtmlNode.CreateNode(
+ $"{channelChatLog.Channel.Name}
"));
+ infoRightHtml.AppendChild(HtmlNode.CreateNode(
+ $"{channelChatLog.Messages.Count:N0} messages
"));
+
+ // Log
+ var logHtml = doc.GetElementbyId("log");
+ var messageGroups = GroupMessages(channelChatLog.Messages);
+ foreach (var messageGroup in messageGroups)
+ {
+ // Container
+ var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode(""));
+
+ // Left
+ var messageLeftHtml = messageHtml.AppendChild(HtmlNode.CreateNode(""));
+
+ // Avatar
+ messageLeftHtml.AppendChild(
+ HtmlNode.CreateNode($"
"));
+
+ // Right
+ var messageRightHtml = messageHtml.AppendChild(HtmlNode.CreateNode(""));
+
+ // Author
+ var authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
+ messageRightHtml.AppendChild(HtmlNode.CreateNode($"{authorName}"));
+
+ // Date
+ var timeStamp = HtmlDocument.HtmlEncode(messageGroup.TimeStamp.ToString("g"));
+ messageRightHtml.AppendChild(HtmlNode.CreateNode($"{timeStamp}"));
+
+ // Individual messages
+ foreach (var message in messageGroup.Messages)
+ {
+ // Content
+ if (message.Content.IsNotBlank())
+ {
+ var content = FormatMessageContent(message.Content);
+ var contentHtml =
+ messageRightHtml.AppendChild(
+ HtmlNode.CreateNode($"{content}
"));
+
+ // Edited timestamp
+ if (message.EditedTimeStamp != null)
+ {
+ contentHtml.AppendChild(
+ HtmlNode.CreateNode(
+ $"(edited)"));
+ }
+ }
+
+ // Attachments
+ foreach (var attachment in message.Attachments)
+ {
+ if (attachment.Type == AttachmentType.Image)
+ {
+ messageRightHtml.AppendChild(
+ HtmlNode.CreateNode(""));
+ }
+ else
+ {
+ messageRightHtml.AppendChild(
+ HtmlNode.CreateNode(""));
+ }
+ }
+ }
+ }
+
+ doc.Save(filePath);
+ }
+ }
+
+ public partial class ExportService
+ {
+ private static HtmlDocument GetTemplate()
+ {
+ var resourcePath = "DiscordChatExporter.Resources.ExportService.Template.html";
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
@@ -29,9 +132,9 @@ namespace DiscordChatExporter.Services
}
}
- private string GetStyle(Theme theme)
+ private static string GetStyle(Theme theme)
{
- var resourcePath = $"DiscordChatExporter.Resources.HtmlExportService.{theme}Theme.css";
+ var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css";
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
@@ -45,7 +148,22 @@ namespace DiscordChatExporter.Services
}
}
- private IEnumerable GroupMessages(IEnumerable messages)
+ private static string NormalizeFileSize(long fileSize)
+ {
+ string[] units = { "B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" };
+ double size = fileSize;
+ var unit = 0;
+
+ while (size >= 1024)
+ {
+ size /= 1024;
+ ++unit;
+ }
+
+ return $"{size:0.#} {units[unit]}";
+ }
+
+ private static IEnumerable GroupMessages(IEnumerable messages)
{
var result = new List();
@@ -87,7 +205,7 @@ namespace DiscordChatExporter.Services
return result;
}
- private string FormatMessageContent(string content)
+ private static string FormatMessageContent(string content)
{
// Encode HTML
content = HtmlDocument.HtmlEncode(content);
@@ -121,93 +239,5 @@ namespace DiscordChatExporter.Services
return content;
}
-
- public void Export(string filePath, ChatLog chatLog, Theme theme)
- {
- var doc = GetTemplate();
- var style = GetStyle(theme);
-
- // Set theme
- var themeHtml = doc.GetElementbyId("theme");
- themeHtml.InnerHtml = style;
-
- // Info
- var infoHtml = doc.GetElementbyId("info");
- infoHtml.AppendChild(HtmlNode.CreateNode($"Channel ID: {chatLog.ChannelId}
"));
- var participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
- infoHtml.AppendChild(HtmlNode.CreateNode($"Participants: {participants}
"));
- infoHtml.AppendChild(HtmlNode.CreateNode($"Messages: {chatLog.Messages.Count:N0}
"));
-
- // Log
- var logHtml = doc.GetElementbyId("log");
- var messageGroups = GroupMessages(chatLog.Messages);
- foreach (var messageGroup in messageGroups)
- {
- // Container
- var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode(""));
-
- // Avatar
- messageHtml.AppendChild(HtmlNode.CreateNode("" +
- $"

" +
- "
"));
-
- // Body
- var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode(""));
-
- // Author
- var authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
- messageBodyHtml.AppendChild(HtmlNode.CreateNode($"{authorName}"));
-
- // Date
- var timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
- messageBodyHtml.AppendChild(HtmlNode.CreateNode($"{timeStamp}"));
-
- // Individual messages
- foreach (var message in messageGroup.Messages)
- {
- // Content
- if (message.Content.IsNotBlank())
- {
- var content = FormatMessageContent(message.Content);
- var contentHtml =
- messageBodyHtml.AppendChild(
- HtmlNode.CreateNode($"{content}
"));
-
- // Edited timestamp
- if (message.EditedTimeStamp != null)
- {
- contentHtml.AppendChild(
- HtmlNode.CreateNode(
- $"(edited)"));
- }
- }
-
- // Attachments
- foreach (var attachment in message.Attachments)
- {
- if (attachment.IsImage)
- {
- messageBodyHtml.AppendChild(
- HtmlNode.CreateNode(""));
- }
- else
- {
- messageBodyHtml.AppendChild(
- HtmlNode.CreateNode(""));
- }
- }
- }
- }
-
- doc.Save(filePath);
- }
}
}
\ No newline at end of file
diff --git a/DiscordChatExporter/Services/IDataService.cs b/DiscordChatExporter/Services/IDataService.cs
new file mode 100644
index 00000000..53b4421e
--- /dev/null
+++ b/DiscordChatExporter/Services/IDataService.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using DiscordChatExporter.Models;
+
+namespace DiscordChatExporter.Services
+{
+ public interface IDataService
+ {
+ Task> GetGuildsAsync(string token);
+
+ Task> GetDirectMessageChannelsAsync(string token);
+
+ Task> GetGuildChannelsAsync(string token, string guildId);
+
+ Task> GetChannelMessagesAsync(string token, string channelId);
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/Services/IExportService.cs b/DiscordChatExporter/Services/IExportService.cs
new file mode 100644
index 00000000..af7eef51
--- /dev/null
+++ b/DiscordChatExporter/Services/IExportService.cs
@@ -0,0 +1,9 @@
+using DiscordChatExporter.Models;
+
+namespace DiscordChatExporter.Services
+{
+ public interface IExportService
+ {
+ void Export(string filePath, ChannelChatLog channelChatLog, Theme theme);
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/Services/ISettingsService.cs b/DiscordChatExporter/Services/ISettingsService.cs
new file mode 100644
index 00000000..2138eafc
--- /dev/null
+++ b/DiscordChatExporter/Services/ISettingsService.cs
@@ -0,0 +1,13 @@
+using DiscordChatExporter.Models;
+
+namespace DiscordChatExporter.Services
+{
+ public interface ISettingsService
+ {
+ string Token { get; set; }
+ Theme Theme { get; set; }
+
+ void Load();
+ void Save();
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/Services/SettingsService.cs b/DiscordChatExporter/Services/SettingsService.cs
new file mode 100644
index 00000000..965ba061
--- /dev/null
+++ b/DiscordChatExporter/Services/SettingsService.cs
@@ -0,0 +1,18 @@
+using DiscordChatExporter.Models;
+using Tyrrrz.Settings;
+
+namespace DiscordChatExporter.Services
+{
+ public class SettingsService : SettingsManager, ISettingsService
+ {
+ public string Token { get; set; }
+ public Theme Theme { get; set; }
+
+ public SettingsService()
+ {
+ Configuration.StorageSpace = StorageSpace.Instance;
+ Configuration.SubDirectoryPath = "";
+ Configuration.FileName = "Settings.dat";
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/ViewModels/IMainViewModel.cs b/DiscordChatExporter/ViewModels/IMainViewModel.cs
new file mode 100644
index 00000000..d8d578ef
--- /dev/null
+++ b/DiscordChatExporter/ViewModels/IMainViewModel.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+using DiscordChatExporter.Models;
+using GalaSoft.MvvmLight.CommandWpf;
+
+namespace DiscordChatExporter.ViewModels
+{
+ public interface IMainViewModel
+ {
+ bool IsBusy { get; }
+ bool IsDataAvailable { get; }
+
+ string Token { get; set; }
+
+ IReadOnlyList AvailableGuilds { get; }
+ Guild SelectedGuild { get; set; }
+ IReadOnlyList AvailableChannels { get; }
+
+ RelayCommand PullDataCommand { get; }
+ RelayCommand ExportChannelCommand { get; }
+ RelayCommand ShowSettingsCommand { get; }
+ RelayCommand ShowAboutCommand { get; }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/ViewModels/ISettingsViewModel.cs b/DiscordChatExporter/ViewModels/ISettingsViewModel.cs
new file mode 100644
index 00000000..19682872
--- /dev/null
+++ b/DiscordChatExporter/ViewModels/ISettingsViewModel.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using DiscordChatExporter.Models;
+
+namespace DiscordChatExporter.ViewModels
+{
+ public interface ISettingsViewModel
+ {
+ IReadOnlyList AvailableThemes { get; }
+ Theme Theme { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/ViewModels/MainViewModel.cs b/DiscordChatExporter/ViewModels/MainViewModel.cs
new file mode 100644
index 00000000..43cbbd09
--- /dev/null
+++ b/DiscordChatExporter/ViewModels/MainViewModel.cs
@@ -0,0 +1,179 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using DiscordChatExporter.Messages;
+using DiscordChatExporter.Models;
+using DiscordChatExporter.Services;
+using GalaSoft.MvvmLight;
+using GalaSoft.MvvmLight.CommandWpf;
+using Microsoft.Win32;
+using Tyrrrz.Extensions;
+
+namespace DiscordChatExporter.ViewModels
+{
+ public class MainViewModel : ViewModelBase, IMainViewModel
+ {
+ private readonly ISettingsService _settingsService;
+ private readonly IDataService _dataService;
+ private readonly IExportService _exportService;
+
+ private readonly Dictionary> _guildChannelsMap;
+
+ private bool _isBusy;
+
+ private IReadOnlyList _availableGuilds;
+ private Guild _selectedGuild;
+ private IReadOnlyList _availableChannels;
+
+ public bool IsBusy
+ {
+ get => _isBusy;
+ private set
+ {
+ Set(ref _isBusy, value);
+ PullDataCommand.RaiseCanExecuteChanged();
+ ExportChannelCommand.RaiseCanExecuteChanged();
+ }
+ }
+
+ public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
+
+ public string Token
+ {
+ get => _settingsService.Token;
+ set
+ {
+ // Remove invalid chars
+ value = value?.Trim('"');
+
+ _settingsService.Token = value;
+ PullDataCommand.RaiseCanExecuteChanged();
+ }
+ }
+
+ public IReadOnlyList AvailableGuilds
+ {
+ get => _availableGuilds;
+ private set
+ {
+ Set(ref _availableGuilds, value);
+ RaisePropertyChanged(() => IsDataAvailable);
+ }
+ }
+
+ public Guild SelectedGuild
+ {
+ get => _selectedGuild;
+ set
+ {
+ Set(ref _selectedGuild, value);
+ AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0];
+ ExportChannelCommand.RaiseCanExecuteChanged();
+ }
+ }
+
+ public IReadOnlyList AvailableChannels
+ {
+ get => _availableChannels;
+ private set => Set(ref _availableChannels, value);
+ }
+
+ public RelayCommand PullDataCommand { get; }
+ public RelayCommand ExportChannelCommand { get; }
+ public RelayCommand ShowSettingsCommand { get; }
+ public RelayCommand ShowAboutCommand { get; }
+
+ public MainViewModel(ISettingsService settingsService, IDataService dataService, IExportService exportService)
+ {
+ _settingsService = settingsService;
+ _dataService = dataService;
+ _exportService = exportService;
+
+ _guildChannelsMap = new Dictionary>();
+
+ // Commands
+ PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
+ ExportChannelCommand = new RelayCommand(ExportChannel, _ => !IsBusy);
+ ShowSettingsCommand = new RelayCommand(ShowSettings);
+ ShowAboutCommand = new RelayCommand(ShowAbout);
+ }
+
+ private async void PullData()
+ {
+ IsBusy = true;
+
+ // Clear existing
+ _guildChannelsMap.Clear();
+ AvailableGuilds = new Guild[0];
+ AvailableChannels = new Channel[0];
+ SelectedGuild = null;
+
+ // Get DM channels
+ {
+ var channels = await _dataService.GetDirectMessageChannelsAsync(Token);
+ var guild = new Guild("@me", "Direct Messages", null);
+ _guildChannelsMap[guild] = channels.ToArray();
+ }
+
+ // Get guild channels
+ {
+ var guilds = await _dataService.GetGuildsAsync(Token);
+ foreach (var guild in guilds)
+ {
+ var channels = await _dataService.GetGuildChannelsAsync(Token, guild.Id);
+ channels = channels.Where(c => c.Type == ChannelType.GuildTextChat);
+ _guildChannelsMap[guild] = channels.ToArray();
+ }
+ }
+
+ AvailableGuilds = _guildChannelsMap.Keys.ToArray();
+ SelectedGuild = AvailableGuilds.FirstOrDefault();
+ IsBusy = false;
+ }
+
+ private async void ExportChannel(Channel channel)
+ {
+ IsBusy = true;
+
+ // Get safe file names
+ var safeGroupName = SelectedGuild.Name.Replace(Path.GetInvalidFileNameChars(), '_');
+ var safeChannelName = channel.Name.Replace(Path.GetInvalidFileNameChars(), '_');
+
+ // Ask for path
+ var sfd = new SaveFileDialog
+ {
+ FileName = $"{safeGroupName} - {safeChannelName}.html",
+ Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
+ DefaultExt = "html",
+ AddExtension = true
+ };
+ if (sfd.ShowDialog() != true)
+ {
+ IsBusy = false;
+ return;
+ }
+
+ // Get messages
+ var messages = await _dataService.GetChannelMessagesAsync(Token, channel.Id);
+
+ // Create log
+ var chatLog = new ChannelChatLog(SelectedGuild, channel, messages);
+
+ // Export
+ _exportService.Export(sfd.FileName, chatLog, _settingsService.Theme);
+
+ IsBusy = false;
+ }
+
+ private void ShowSettings()
+ {
+ MessengerInstance.Send(new ShowSettingsMessage());
+ }
+
+ private void ShowAbout()
+ {
+ Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/ViewModels/SettingsViewModel.cs b/DiscordChatExporter/ViewModels/SettingsViewModel.cs
new file mode 100644
index 00000000..94caff0e
--- /dev/null
+++ b/DiscordChatExporter/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using DiscordChatExporter.Models;
+using DiscordChatExporter.Services;
+using GalaSoft.MvvmLight;
+
+namespace DiscordChatExporter.ViewModels
+{
+ public class SettingsViewModel : ViewModelBase, ISettingsViewModel
+ {
+ private readonly ISettingsService _settingsService;
+
+ public IReadOnlyList AvailableThemes { get; }
+
+ public Theme Theme
+ {
+ get => _settingsService.Theme;
+ set => _settingsService.Theme = value;
+ }
+
+ public SettingsViewModel(ISettingsService settingsService)
+ {
+ _settingsService = settingsService;
+
+ // Defaults
+ AvailableThemes = Enum.GetValues(typeof(Theme)).Cast().ToArray();
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/Views/MainWindow.ammy b/DiscordChatExporter/Views/MainWindow.ammy
new file mode 100644
index 00000000..cbbc5c13
--- /dev/null
+++ b/DiscordChatExporter/Views/MainWindow.ammy
@@ -0,0 +1,261 @@
+using MaterialDesignThemes.Wpf
+using MaterialDesignThemes.Wpf.Transitions
+
+Window "DiscordChatExporter.Views.MainWindow" {
+ Title: "DiscordChatExporter"
+ Width: 600
+ Height: 550
+ Background: resource dyn "MaterialDesignPaper"
+ DataContext: bind MainViewModel from $resource Locator
+ FocusManager.FocusedElement: bind from "TokenTextBox"
+ FontFamily: resource dyn "MaterialDesignFont"
+ SnapsToDevicePixels: true
+ TextElement.FontSize: 13
+ TextElement.FontWeight: Regular
+ TextElement.Foreground: resource dyn "SecondaryTextBrush"
+ TextOptions.TextFormattingMode: Ideal
+ TextOptions.TextRenderingMode: Auto
+ UseLayoutRounding: true
+ WindowStartupLocation: CenterScreen
+
+ DialogHost {
+ DockPanel {
+ IsEnabled: bind IsBusy
+ convert (bool b) => b ? false : true
+
+ // Toolbar
+ Border {
+ DockPanel.Dock: Top
+ Background: resource dyn "PrimaryHueMidBrush"
+ TextElement.Foreground: resource dyn "SecondaryInverseTextBrush"
+ StackPanel {
+ Grid {
+ #TwoColumns("*", "Auto")
+
+ Card {
+ Grid.Column: 0
+ Margin: "6 6 0 6"
+
+ Grid {
+ #TwoColumns("*", "Auto")
+
+ // Token
+ TextBox "TokenTextBox" {
+ Grid.Column: 0
+ Margin: 6
+ BorderThickness: 0
+ HintAssist.Hint: "Token"
+ KeyDown: TokenTextBox_KeyDown
+ FontSize: 16
+ Text: bind Token
+ set [ UpdateSourceTrigger: PropertyChanged ]
+ }
+
+ // Submit
+ Button {
+ Grid.Column: 1
+ Margin: "0 6 6 6"
+ Padding: 4
+ Command: bind PullDataCommand
+ Style: resource dyn "MaterialDesignFlatButton"
+
+ PackIcon {
+ Width: 24
+ Height: 24
+ Kind: PackIconKind.ArrowRight
+ }
+ }
+ }
+ }
+
+ // Popup menu
+ PopupBox {
+ Grid.Column: 1
+ Foreground: resource dyn "PrimaryHueMidForegroundBrush"
+ PlacementMode: LeftAndAlignTopEdges
+
+ StackPanel {
+ Button {
+ Command: bind ShowSettingsCommand
+ Content: "Settings"
+ }
+ Button {
+ Command: bind ShowAboutCommand
+ Content: "About"
+ }
+ }
+ }
+ }
+
+ // Progress
+ ProgressBar {
+ Background: Transparent
+ IsIndeterminate: true
+ Visibility: bind IsBusy
+ convert (bool b) => b ? Visibility.Visible : Visibility.Hidden
+ }
+ }
+ }
+
+ // Content
+ Grid {
+ DockPanel {
+ Background: resource dyn "MaterialDesignCardBackground"
+ Visibility: bind IsDataAvailable
+ convert (bool b) => b ? Visibility.Visible : Visibility.Hidden
+
+ // Guilds
+ Border {
+ DockPanel.Dock: Left
+ BorderBrush: resource dyn "DividerBrush"
+ BorderThickness: "0 0 1 0"
+
+ ListBox {
+ ItemsSource: bind AvailableGuilds
+ ScrollViewer.VerticalScrollBarVisibility: Hidden
+ SelectedItem: bind SelectedGuild
+ VirtualizingStackPanel.IsVirtualizing: false
+
+ ItemTemplate: DataTemplate {
+ TransitioningContent {
+ OpeningEffect: TransitionEffect {
+ Duration: "0:0:0.3"
+ Kind: SlideInFromRight
+ }
+
+ Border {
+ Margin: -8
+ Background: Transparent
+ Cursor: CursorType.Hand
+
+ Image {
+ Margin: 6
+ Width: 48
+ Height: 48
+ Source: bind IconUrl
+ ToolTip: bind Name
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Channels
+ Border {
+ ListBox {
+ ItemsSource: bind AvailableChannels
+ HorizontalContentAlignment: Stretch
+ VirtualizingStackPanel.IsVirtualizing: false
+
+ ItemTemplate: DataTemplate {
+ TransitioningContent {
+ OpeningEffect: TransitionEffect {
+ Duration: "0:0:0.3"
+ Kind: SlideInFromLeft
+ }
+
+ @StackPanelHorizontal {
+ Margin: -8
+ Background: Transparent
+ Cursor: CursorType.Hand
+ InputBindings: [
+ MouseBinding {
+ Command: bind DataContext.ExportChannelCommand from $ancestor
+ CommandParameter: bind
+ MouseAction: LeftClick
+ }
+ ]
+
+ PackIcon {
+ Margin: "4 7 0 6"
+ Kind: PackIconKind.Pound
+ VerticalAlignment: Center
+ }
+ TextBlock {
+ Margin: "3 6 6 6"
+ FontSize: 14
+ Text: bind Name
+ VerticalAlignment: Center
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Content placeholder
+ StackPanel {
+ Margin: "32 32 8 8"
+ Visibility: bind IsDataAvailable
+ convert (bool b) => b ? Visibility.Hidden : Visibility.Visible
+
+ TextBlock {
+ FontSize: 18
+ Text: "DiscordChatExporter needs your authorization token to work."
+ }
+
+ TextBlock {
+ Margin: "0 8 0 0"
+ FontSize: 16
+ Text: "To obtain it, follow these steps:"
+ }
+
+ TextBlock {
+ Margin: "8 0 0 0"
+ FontSize: 14
+
+ Run {
+ Text: "1. Open the Discord app"
+ }
+ LineBreak { }
+ Run {
+ Text: "2. Log in if you haven't"
+ }
+ LineBreak { }
+ Run {
+ Text: "3. Press"
+ }
+ Run {
+ Text: "Ctrl+Shift+I"
+ Foreground: resource dyn "PrimaryTextBrush"
+ }
+ LineBreak { }
+ Run {
+ Text: "4. Navigate to"
+ }
+ Run {
+ Text: "Application"
+ Foreground: resource dyn "PrimaryTextBrush"
+ }
+ Run { Text: "tab" }
+ LineBreak { }
+ Run {
+ Text: "5. Expand"
+ }
+ Run {
+ Text: "Storage > Local Storage > https://discordapp.com"
+ Foreground: resource dyn "PrimaryTextBrush"
+ }
+ LineBreak { }
+ Run {
+ Text: "6. Find"
+ }
+ Run {
+ Text: ""token""
+ Foreground: resource dyn "PrimaryTextBrush"
+ }
+ Run {
+ Text: "under key and copy the value"
+ }
+ LineBreak { }
+ Run {
+ Text: "7. Paste the value in the textbox above"
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/Views/MainWindow.ammy.cs b/DiscordChatExporter/Views/MainWindow.ammy.cs
new file mode 100644
index 00000000..f26df299
--- /dev/null
+++ b/DiscordChatExporter/Views/MainWindow.ammy.cs
@@ -0,0 +1,32 @@
+using System.Reflection;
+using System.Windows.Input;
+using DiscordChatExporter.Messages;
+using DiscordChatExporter.ViewModels;
+using GalaSoft.MvvmLight.Messaging;
+using MaterialDesignThemes.Wpf;
+using Tyrrrz.Extensions;
+
+namespace DiscordChatExporter.Views
+{
+ public partial class MainWindow
+ {
+ private IMainViewModel ViewModel => (IMainViewModel) DataContext;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
+
+ Messenger.Default.Register(this, m => DialogHost.Show(new SettingsDialog()).Forget());
+ }
+
+ public void TokenTextBox_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ // Execute command
+ ViewModel.PullDataCommand.Execute(null);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/Views/SettingsDialog.ammy b/DiscordChatExporter/Views/SettingsDialog.ammy
new file mode 100644
index 00000000..096b0643
--- /dev/null
+++ b/DiscordChatExporter/Views/SettingsDialog.ammy
@@ -0,0 +1,26 @@
+using MaterialDesignThemes.Wpf
+
+UserControl "DiscordChatExporter.Views.SettingsDialog" {
+ DataContext: bind SettingsViewModel from $resource Locator
+ Width: 250
+
+ StackPanel {
+ // Theme
+ ComboBox {
+ HintAssist.Hint: "Theme"
+ HintAssist.IsFloating: true
+ Margin: 8
+ IsReadOnly: true
+ ItemsSource: bind AvailableThemes
+ SelectedItem: bind Theme
+ }
+
+ // Save
+ Button {
+ Command: DialogHost.CloseDialogCommand
+ Content: "SAVE"
+ Margin: 8
+ Style: resource dyn "MaterialDesignFlatButton"
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/Views/SettingsDialog.ammy.cs b/DiscordChatExporter/Views/SettingsDialog.ammy.cs
new file mode 100644
index 00000000..36a3ee6a
--- /dev/null
+++ b/DiscordChatExporter/Views/SettingsDialog.ammy.cs
@@ -0,0 +1,10 @@
+namespace DiscordChatExporter.Views
+{
+ public partial class SettingsDialog
+ {
+ public SettingsDialog()
+ {
+ InitializeComponent();
+ }
+ }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/lib.ammy b/DiscordChatExporter/lib.ammy
new file mode 100644
index 00000000..2b5a8655
--- /dev/null
+++ b/DiscordChatExporter/lib.ammy
@@ -0,0 +1,238 @@
+mixin TwoColumns (one = "*", two = "*") for Grid {
+ combine ColumnDefinitions: [
+ ColumnDefinition { Width: $one }
+ ColumnDefinition { Width: $two }
+ ]
+}
+
+mixin ThreeColumns (one = none, two = none, three = none) for Grid {
+ #TwoColumns($one, $two)
+ combine ColumnDefinitions: ColumnDefinition { Width: $three }
+}
+
+mixin FourColumns (one = none, two = none, three = none, four = none) for Grid {
+ #ThreeColumns($one, $two, $three)
+ combine ColumnDefinitions: ColumnDefinition { Width: $four }
+}
+
+mixin FiveColumns (one = none, two = none, three = none, four = none, five = none) for Grid {
+ #FourColumns($one, $two, $three, $four)
+ combine ColumnDefinitions: ColumnDefinition { Width: $five }
+}
+
+mixin TwoRows (one = none, two = none) for Grid
+{
+ combine RowDefinitions: [
+ RowDefinition { Height: $one }
+ RowDefinition { Height: $two }
+ ]
+}
+
+mixin ThreeRows (one = none, two = none, three = none) for Grid
+{
+ #TwoRows($one, $two)
+ combine RowDefinitions: RowDefinition { Height: $three }
+}
+
+mixin FourRows (one = none, two = none, three = none, four = none) for Grid
+{
+ #ThreeRows($one, $two, $three)
+ combine RowDefinitions: RowDefinition { Height: $four }
+}
+
+mixin FiveRows (one = none, two = none, three = none, four = none, five = none) for Grid
+{
+ #FourRows($one, $two, $three, $four)
+ combine RowDefinitions: RowDefinition { Height: $five }
+}
+
+mixin Cell (row = none, column = none, rowSpan = none, columnSpan = none) for FrameworkElement {
+ Grid.Row: $row
+ Grid.Column: $column
+ Grid.RowSpan: $rowSpan
+ Grid.ColumnSpan: $columnSpan
+}
+
+alias ImageCached(source) {
+ Image {
+ Source: BitmapImage {
+ UriCachePolicy: "Revalidate"
+ UriSource: $source
+ }
+ }
+}
+
+mixin Setter(property, value, targetName=none) for Style {
+ Setter { Property: $property, Value: $value, TargetName: $targetName }
+}
+
+/*
+mixin AddSetter(property, value, targetName=none) for Style {
+ combine Setters: #Setter($property, $value, $targetName) {}
+}*/
+
+alias DataTrigger(binding, bindingValue) {
+ DataTrigger { Binding: $binding, Value: $bindingValue }
+}
+
+alias Trigger(property, value) {
+ Trigger { Property: $property, Value: $value }
+}
+
+alias EventTrigger(event, sourceName=none) {
+ EventTrigger { RoutedEvent: $event, SourceName: $sourceName }
+}
+
+alias DataTrigger_SetProperty(binding, bindingValue, property, propertyValue) {
+ @DataTrigger ($binding, $bindingValue) {
+ #Setter($property, $propertyValue)
+ }
+}
+
+alias Trigger_SetProperty(triggerProperty, triggerValue, property, propertyValue) {
+ @Trigger ($triggerProperty, $triggerValue) {
+ #Setter($property, $propertyValue)
+ }
+}
+
+alias EventTrigger_SetProperty(event, property, propertyValue) {
+ @EventTrigger ($event) {
+ #Setter($property, $propertyValue)
+ }
+}
+alias VisibleIf_DataTrigger(binding, valueForVisible) {
+ @DataTrigger_SetProperty($binding, $valueForVisible, "Visibility", "Visible") {}
+}
+
+alias CollapsedIf_DataTrigger(binding, valueForCollapsed) {
+ @DataTrigger_SetProperty($binding, $valueForCollapsed, "Visibility", "Collapsed") {}
+}
+
+alias StackPanelHorizontal() {
+ StackPanel {
+ Orientation: Horizontal
+ }
+}
+
+alias GridItemsControl() {
+ ItemsControl {
+ ScrollViewer.HorizontalScrollBarVisibility: Disabled,
+
+ ItemsPanel: ItemsPanelTemplate {
+ WrapPanel {
+ IsItemsHost: true
+ Orientation: Horizontal
+ }
+ }
+ }
+}
+
+////////////////
+// Animations //
+////////////////
+
+alias DoubleAnimation(property, frm = "0", to = "1", duration = "0:0:1", targetName=none, beginTime=none) {
+ DoubleAnimation {
+ Storyboard.TargetProperty: $property
+ Storyboard.TargetName: $targetName
+ From: $frm
+ To: $to
+ Duration: $duration
+ BeginTime: $beginTime
+ }
+}
+
+alias DoubleAnimationStoryboard (property, frm = "0", to = "1", duration = "0:0:1", targetName=none) {
+ BeginStoryboard {
+ Storyboard {
+ @DoubleAnimation($property, $frm, $to, $duration, $targetName) {}
+ }
+ }
+}
+
+mixin DoubleAnimation_PropertyTrigger(triggerProperty, triggerValue, animationProperty, frm, to, duration) for Style {
+ combine Triggers: @Trigger ($triggerProperty, $triggerValue) {
+ EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
+ }
+}
+
+mixin DoubleAnimation_PropertyTrigger_Toggle(triggerProperty, triggerValue, animationProperty, frm, to, duration) for Style {
+ combine Triggers: @Trigger ($triggerProperty, $triggerValue) {
+ EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
+ ExitActions: @DoubleAnimationStoryboard($animationProperty, $to, $frm, $duration) {}
+ }
+}
+
+mixin DoubleAnimation_EventTrigger(triggerEvent, animationProperty, frm, to, duration) for Style {
+ combine Triggers: EventTrigger {
+ RoutedEvent: $triggerEvent
+ @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
+ }
+}
+
+mixin DoubleAnimation_DataTrigger(binding, value, animationProperty, frm, to, duration) for Style {
+ combine Triggers: DataTrigger {
+ Binding: $binding
+ Value: $value
+ EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
+ }
+}
+
+mixin FadeIn_OnProperty(property, value, frm = "0", to = "1", duration = "0:0:1") for Style {
+ #DoubleAnimation_PropertyTrigger($property, $value, "Opacity", $frm, $to, $duration)
+}
+
+mixin FadeOut_OnProperty(property, value, frm = "1", to = "0", duration = "0:0:1") for Style {
+ #DoubleAnimation_PropertyTrigger($property, $value, "Opacity", $frm, $to, $duration)
+}
+
+mixin FadeIn_OnEvent(event, frm = "0", to = "1", duration = "0:0:1") for Style {
+ #DoubleAnimation_EventTrigger($event, "Opacity", $frm, $to, $duration)
+}
+
+mixin FadeOut_OnEvent(event, frm = "1", to = "0", duration = "0:0:1") for Style {
+ #DoubleAnimation_EventTrigger($event, "Opacity", $frm, $to, $duration)
+}
+
+mixin FadeIn_OnData(binding, value, from_ = "0", to = "1", duration = "0:0:1") for Style {
+ #DoubleAnimation_DataTrigger($binding, $value, "Opacity", $from_, $to, $duration)
+}
+
+mixin FadeOut_OnData(binding, value, from_ = "1", to = "0", duration = "0:0:1") for Style {
+ #DoubleAnimation_DataTrigger($binding, $value, "Opacity", $from_, $to, $duration)
+}
+
+mixin Property_OnBinding(binding, bindingValue, property, propertyValue, initialValue) for Style {
+ #Setter("Visibility", $initialValue)
+ combine Triggers: [
+ @DataTrigger_SetProperty($binding, $bindingValue, $property, $propertyValue) {}
+ ]
+}
+
+mixin Visibility_OnBinding(binding, bindingValue, visibilityValue="Visible", initialValue="Collapsed") for Style {
+ #Property_OnBinding($binding, $bindingValue, "Visibility", $visibilityValue, $initialValue)
+}
+
+mixin Fade_OnBinding(binding, bindingValue) for Style {
+ #Setter("Visibility", "Visible")
+ #Setter("Opacity", "0")
+
+ combine Triggers: [
+ @DataTrigger($binding, $bindingValue) {
+ EnterActions: [
+ @DoubleAnimationStoryboard("Opacity", 0, 1, "0:0:0.5") {}
+ ]
+ ExitActions: [
+ @DoubleAnimationStoryboard("Opacity", 1, 0, "0:0:0.5") {}
+ ]
+ #Setter("Opacity", 1)
+ }
+ @Trigger("Opacity", 0) {
+ #Setter("Visibility", "Hidden")
+ }
+ ]
+}
+
+mixin MergeDictionary (source) for ResourceDictionary {
+ combine MergedDictionaries: ResourceDictionary { Source: $source }
+}
\ No newline at end of file
diff --git a/DiscordChatExporter/packages.config b/DiscordChatExporter/packages.config
new file mode 100644
index 00000000..f21e4b9a
--- /dev/null
+++ b/DiscordChatExporter/packages.config
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Readme.md b/Readme.md
index 52602c4e..5e1933c8 100644
--- a/Readme.md
+++ b/Readme.md
@@ -1,6 +1,6 @@
# DiscordChatExporter
-DiscordChatExporter can be used to export message history from [Discord](https://discordapp.com) to an HTML file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments, and has an option to choose between light and dark themes.
+DiscordChatExporter can be used to export message history from [Discord](https://discordapp.com) to an HTML file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. There are options to configure the output, such as date format, color theme, message grouping limit, etc.
## Screenshots
@@ -12,6 +12,7 @@ DiscordChatExporter can be used to export message history from [Discord](https:/
## Features
+- Exports to a self-contained HTML file
- Supports both dark and light theme
- Displays user avatars
- Groups messages by author and time
@@ -23,32 +24,7 @@ DiscordChatExporter can be used to export message history from [Discord](https:/
## Usage
-The program expects an access token and channel ID as parameters. At minimum, the execution should look like this:
-
-`DiscordChatExporter.exe /token:REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 /channelId:459360869055190534`
-
-#### Getting access token
-
-- Open Discord desktop or web client
-- Press `Ctrl+Shift+I`
-- Navigate to `Application > Storage > Local Storage > https://discordapp.com`
-- Find the value for `token` and extract it
-
-#### Getting channel ID
-
-- Open Discord desktop or web client
-- Navigate to any DM or server channel
-- Extract the current URL:
- - If using desktop client, press `Ctrl+Shift+I`, type `window.location.href` in console and extract the result
- - If using web client, just take the current URL from the address bar
-- Pull the ID from the URL:
- - If it's a DM channel, the format looks like this: `https://discordapp.com/channels/@me/CHANNEL_ID`
- - If it's a server channel, the format looks like this:
- `https://discordapp.com/channels/WHATEVER/CHANNEL_ID`
-
-#### Optional arguments
-
-- `/theme:[Dark/Light]` - sets the style of the output
+Check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki) for helpful information on how to use this tool.
## Libraries used