diff --git a/.gitignore b/.gitignore index 3c4efe2..a788580 100644 --- a/.gitignore +++ b/.gitignore @@ -258,4 +258,7 @@ paket-files/ # Python Tools for Visual Studio (PTVS) __pycache__/ -*.pyc \ No newline at end of file +*.pyc + +# Ammy auto-generated XAML +*.g.xaml \ No newline at end of file diff --git a/DiscordChatExporter.sln b/DiscordChatExporter.sln index a046a46..d2d0590 100644 --- a/DiscordChatExporter.sln +++ b/DiscordChatExporter.sln @@ -1,28 +1,31 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26430.13 +VisualStudioVersion = 15.0.26730.15 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{4BE915D1-129C-49E2-860E-62045ACA5EAD}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}" ProjectSection(SolutionItems) = preProject License.txt = License.txt Readme.md = Readme.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.Build.0 = Release|Any CPU + {732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU + {732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.ActiveCfg = Release|Any CPU + {732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {51587D08-01E1-4511-AC57-A417D1A9162F} + EndGlobalSection EndGlobal diff --git a/DiscordChatExporter/App.ammy b/DiscordChatExporter/App.ammy new file mode 100644 index 0000000..990ea63 --- /dev/null +++ b/DiscordChatExporter/App.ammy @@ -0,0 +1,69 @@ +Application "DiscordChatExporter.App" { + StartupUri: "Views/MainWindow.g.xaml" + Startup: App_Startup + Exit: App_Exit + + Resources: ResourceDictionary { + // Material Design + #MergeDictionary("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml") + #MergeDictionary("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml") + + // Colors + Color Key="PrimaryColor" { "#343838" } + Color Key="PrimaryLightColor" { "#5E6262" } + Color Key="PrimaryDarkColor" { "#0D1212" } + Color Key="AccentColor" { "#F9A825" } + Color Key="TextColor" { "#000000" } + Color Key="InverseTextColor" { "#FFFFFF" } + + // Brushes + SolidColorBrush Key="PrimaryHueLightBrush" { Color: resource dyn "PrimaryLightColor" } + SolidColorBrush Key="PrimaryHueLightForegroundBrush" { Color: resource dyn "InverseTextColor" } + SolidColorBrush Key="PrimaryHueMidBrush" { Color: resource dyn "PrimaryColor" } + SolidColorBrush Key="PrimaryHueMidForegroundBrush" { Color: resource dyn "InverseTextColor" } + SolidColorBrush Key="PrimaryHueDarkBrush" { Color: resource dyn "PrimaryDarkColor" } + SolidColorBrush Key="PrimaryHueDarkForegroundBrush" { Color: resource dyn "InverseTextColor" } + SolidColorBrush Key="SecondaryAccentBrush" { Color: resource dyn "AccentColor" } + SolidColorBrush Key="SecondaryAccentForegroundBrush" { Color: resource dyn "TextColor" } + SolidColorBrush Key="PrimaryTextBrush" { Color: resource dyn "TextColor", Opacity: 0.87 } + SolidColorBrush Key="SecondaryTextBrush" { Color: resource dyn "TextColor", Opacity: 0.64 } + SolidColorBrush Key="DimTextBrush" { Color: resource dyn "TextColor", Opacity: 0.45 } + SolidColorBrush Key="PrimaryInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 1 } + SolidColorBrush Key="SecondaryInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.7 } + SolidColorBrush Key="DimInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.52 } + SolidColorBrush Key="AccentTextBrush" { Color: resource dyn "AccentColor", Opacity: 1 } + SolidColorBrush Key="DividerBrush" { Color: resource dyn "TextColor", Opacity: 0.12 } + SolidColorBrush Key="InverseDividerBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.12 } + + // Styles + Style { + TargetType: "Image" + #Setter("RenderOptions.BitmapScalingMode", "HighQuality") + } + + Style { + TargetType: "ProgressBar" + BasedOn: resource "MaterialDesignLinearProgressBar" + #Setter("Foreground", resource dyn "SecondaryAccentBrush") + #Setter("Height", 2) + #Setter("Minimum", 0) + #Setter("Maximum", 1) + #Setter("BorderThickness", 0) + } + + Style { + TargetType: "TextBox" + BasedOn: resource "MaterialDesignTextBox" + #Setter("Foreground", resource dyn "PrimaryTextBrush") + } + + Style { + TargetType: "ComboBox" + BasedOn: resource "MaterialDesignComboBox" + #Setter("Foreground", resource dyn "PrimaryTextBrush") + } + + // Locator + Locator Key="Locator" { } + } +} \ No newline at end of file diff --git a/DiscordChatExporter/App.ammy.cs b/DiscordChatExporter/App.ammy.cs new file mode 100644 index 0000000..86edf4b --- /dev/null +++ b/DiscordChatExporter/App.ammy.cs @@ -0,0 +1,17 @@ +using System.Windows; + +namespace DiscordChatExporter +{ + public partial class App + { + private void App_Startup(object sender, StartupEventArgs e) + { + Locator.Init(); + } + + private void App_Exit(object sender, ExitEventArgs e) + { + Locator.Cleanup(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter/App.config b/DiscordChatExporter/App.config new file mode 100644 index 0000000..731f6de --- /dev/null +++ b/DiscordChatExporter/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/DiscordChatExporter/DiscordChatExporter.csproj b/DiscordChatExporter/DiscordChatExporter.csproj index ee724c3..f10486b 100644 --- a/DiscordChatExporter/DiscordChatExporter.csproj +++ b/DiscordChatExporter/DiscordChatExporter.csproj @@ -1,27 +1,172 @@ - - + + + - Exe - net45 - 1.0.0 - Tyrrrz - Copyright (c) 2017 Alexey Golub + Debug + AnyCPU + {732A67AF-93DE-49DF-B10F-FD74710B7863} + WinExe + DiscordChatExporter + DiscordChatExporter + v4.6.1 + 512 + {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 4 + true + + + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 - - - - + + ..\packages\Ammy.WPF.1.2.87\lib\net40\AmmySidekick.dll + + + ..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.dll + + + ..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Extras.dll + + + ..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Platform.dll + + + ..\packages\HtmlAgilityPack.1.5.5\lib\Net45\HtmlAgilityPack.dll + + + ..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll + + + ..\packages\MaterialDesignThemes.2.3.1.953\lib\net45\MaterialDesignThemes.Wpf.dll + + + ..\packages\CommonServiceLocator.1.3\lib\portable-net4+sl5+netcore45+wpa81+wp8\Microsoft.Practices.ServiceLocation.dll + + + ..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + + + + + + ..\packages\MvvmLightLibs.5.3.0.0\lib\net45\System.Windows.Interactivity.dll + + + 4.0 + + + + ..\packages\Tyrrrz.Extensions.1.4.1\lib\net45\Tyrrrz.Extensions.dll + + + ..\packages\Tyrrrz.Settings.1.3.0\lib\net45\Tyrrrz.Settings.dll + + + + - - - - + + + + + + + + SettingsDialog.ammy + + + Designer + XamlIntelliSenseFileGenerator + App.ammy + + + Designer + MSBuild:Compile + MainWindow.ammy + + + App.ammy + + + + + + + + + + + + + + + + + + + + MainWindow.ammy + + + Designer + MSBuild:Compile + SettingsDialog.ammy + - - + + Code + + + True + True + Resources.resx + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + Designer + + + + + + + + + + + - + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + \ No newline at end of file diff --git a/DiscordChatExporter/Locator.cs b/DiscordChatExporter/Locator.cs new file mode 100644 index 0000000..c711103 --- /dev/null +++ b/DiscordChatExporter/Locator.cs @@ -0,0 +1,36 @@ +using DiscordChatExporter.Services; +using DiscordChatExporter.ViewModels; +using GalaSoft.MvvmLight.Ioc; +using Microsoft.Practices.ServiceLocation; + +namespace DiscordChatExporter +{ + public class Locator + { + public static void Init() + { + ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default); + + // Services + SimpleIoc.Default.Register(); + SimpleIoc.Default.Register(); + SimpleIoc.Default.Register(); + + // View models + SimpleIoc.Default.Register(); + SimpleIoc.Default.Register(); + + // Load settings + ServiceLocator.Current.GetInstance().Load(); + } + + public static void Cleanup() + { + // Save settings + ServiceLocator.Current.GetInstance().Save(); + } + + public IMainViewModel MainViewModel => ServiceLocator.Current.GetInstance(); + public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance(); + } +} \ No newline at end of file diff --git a/DiscordChatExporter/Messages/ShowSettingsMessage.cs b/DiscordChatExporter/Messages/ShowSettingsMessage.cs new file mode 100644 index 0000000..e6cf6a7 --- /dev/null +++ b/DiscordChatExporter/Messages/ShowSettingsMessage.cs @@ -0,0 +1,6 @@ +namespace DiscordChatExporter.Messages +{ + public class ShowSettingsMessage + { + } +} \ No newline at end of file diff --git a/DiscordChatExporter/Models/Attachment.cs b/DiscordChatExporter/Models/Attachment.cs index 130d780..ff71fa7 100644 --- a/DiscordChatExporter/Models/Attachment.cs +++ b/DiscordChatExporter/Models/Attachment.cs @@ -4,18 +4,21 @@ { public string Id { get; } + public AttachmentType Type { get; } + public string Url { get; } public string FileName { get; } - public bool IsImage { get; } + public long FileSize { get; } - public Attachment(string id, string url, string fileName, bool isImage) + public Attachment(string id, AttachmentType type, string url, string fileName, long fileSize) { Id = id; + Type = type; Url = url; FileName = fileName; - IsImage = isImage; + FileSize = fileSize; } } } \ No newline at end of file diff --git a/DiscordChatExporter/Models/AttachmentType.cs b/DiscordChatExporter/Models/AttachmentType.cs new file mode 100644 index 0000000..13fcccc --- /dev/null +++ b/DiscordChatExporter/Models/AttachmentType.cs @@ -0,0 +1,8 @@ +namespace DiscordChatExporter.Models +{ + public enum AttachmentType + { + Unrecognized, + Image + } +} \ No newline at end of file diff --git a/DiscordChatExporter/Models/Channel.cs b/DiscordChatExporter/Models/Channel.cs new file mode 100644 index 0000000..5071cca --- /dev/null +++ b/DiscordChatExporter/Models/Channel.cs @@ -0,0 +1,23 @@ +namespace DiscordChatExporter.Models +{ + public class Channel + { + public string Id { get; } + + public string Name { get; } + + public ChannelType Type { get; } + + public Channel(string id, string name, ChannelType type) + { + Id = id; + Name = name; + Type = type; + } + + public override string ToString() + { + return Name; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter/Models/ChannelChatLog.cs b/DiscordChatExporter/Models/ChannelChatLog.cs new file mode 100644 index 0000000..620b249 --- /dev/null +++ b/DiscordChatExporter/Models/ChannelChatLog.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Linq; + +namespace DiscordChatExporter.Models +{ + public class ChannelChatLog + { + public Guild Guild { get; } + + public Channel Channel { get; } + + public IReadOnlyList Messages { get; } + + public ChannelChatLog(Guild guild, Channel channel, IEnumerable messages) + { + Guild = guild; + Channel = channel; + Messages = messages.ToArray(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter/Models/ChannelType.cs b/DiscordChatExporter/Models/ChannelType.cs new file mode 100644 index 0000000..0ada6bf --- /dev/null +++ b/DiscordChatExporter/Models/ChannelType.cs @@ -0,0 +1,11 @@ +namespace DiscordChatExporter.Models +{ + public enum ChannelType + { + GuildTextChat, + DirectTextChat, + GuildVoiceChat, + DirectGroupTextChat, + Category + } +} \ No newline at end of file diff --git a/DiscordChatExporter/Models/ChatLog.cs b/DiscordChatExporter/Models/ChatLog.cs deleted file mode 100644 index ca79d9b..0000000 --- a/DiscordChatExporter/Models/ChatLog.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Tyrrrz.Extensions; - -namespace DiscordChatExporter.Models -{ - public class ChatLog - { - public string ChannelId { get; } - - public IReadOnlyList Participants { get; } - - public IReadOnlyList Messages { get; } - - public ChatLog(string channelId, IEnumerable messages) - { - ChannelId = channelId; - Messages = messages.ToArray(); - Participants = Messages.Select(m => m.Author).Distinct(a => a.Name).ToArray(); - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter/Models/Guild.cs b/DiscordChatExporter/Models/Guild.cs new file mode 100644 index 0000000..37a95ae --- /dev/null +++ b/DiscordChatExporter/Models/Guild.cs @@ -0,0 +1,29 @@ +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Models +{ + public class Guild + { + public string Id { get; } + + public string Name { get; } + + public string IconHash { get; } + + public string IconUrl => IconHash.IsNotBlank() + ? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png" + : "https://cdn.discordapp.com/embed/avatars/0.png"; + + public Guild(string id, string name, string iconHash) + { + Id = id; + Name = name; + IconHash = iconHash; + } + + public override string ToString() + { + return Name; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter/Models/Message.cs b/DiscordChatExporter/Models/Message.cs index 40cf824..31adb5f 100644 --- a/DiscordChatExporter/Models/Message.cs +++ b/DiscordChatExporter/Models/Message.cs @@ -8,23 +8,24 @@ namespace DiscordChatExporter.Models { public string Id { get; } + public User Author { get; } + public DateTime TimeStamp { get; } public DateTime? EditedTimeStamp { get; } - public User Author { get; } - public string Content { get; } public IReadOnlyList Attachments { get; } - public Message(string id, DateTime timeStamp, DateTime? editedTimeStamp, User author, string content, - IEnumerable attachments) + public Message(string id, User author, + DateTime timeStamp, DateTime? editedTimeStamp, + string content, IEnumerable attachments) { Id = id; + Author = author; TimeStamp = timeStamp; EditedTimeStamp = editedTimeStamp; - Author = author; Content = content; Attachments = attachments.ToArray(); } diff --git a/DiscordChatExporter/Models/MessageGroup.cs b/DiscordChatExporter/Models/MessageGroup.cs index 6825616..5d394cd 100644 --- a/DiscordChatExporter/Models/MessageGroup.cs +++ b/DiscordChatExporter/Models/MessageGroup.cs @@ -8,14 +8,14 @@ namespace DiscordChatExporter.Models { public User Author { get; } - public DateTime FirstTimeStamp { get; } + public DateTime TimeStamp { get; } public IReadOnlyList Messages { get; } - public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable messages) + public MessageGroup(User author, DateTime timeStamp, IEnumerable messages) { Author = author; - FirstTimeStamp = firstTimeStamp; + TimeStamp = timeStamp; Messages = messages.ToArray(); } } diff --git a/DiscordChatExporter/Models/Options.cs b/DiscordChatExporter/Models/Options.cs deleted file mode 100644 index 8170638..0000000 --- a/DiscordChatExporter/Models/Options.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace DiscordChatExporter.Models -{ - public class Options - { - public string Token { get; } - - public string ChannelId { get; } - - public Theme Theme { get; } - - public Options(string token, string channelId, Theme theme) - { - Token = token; - ChannelId = channelId; - Theme = theme; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter/Models/User.cs b/DiscordChatExporter/Models/User.cs index 7de5509..beec8d5 100644 --- a/DiscordChatExporter/Models/User.cs +++ b/DiscordChatExporter/Models/User.cs @@ -6,24 +6,27 @@ namespace DiscordChatExporter.Models { public string Id { get; } + public int Discriminator { get; } + public string Name { get; } public string AvatarHash { get; } public string AvatarUrl => AvatarHash.IsNotBlank() - ? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size=256" - : "https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.png"; + ? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png" + : $"https://cdn.discordapp.com/embed/avatars/{Discriminator % 5}.png"; - public User(string id, string name, string avatarHash) + public User(string id, int discriminator, string name, string avatarHash) { Id = id; + Discriminator = discriminator; Name = name; AvatarHash = avatarHash; } public override string ToString() { - return Name; + return $"{Name}#{Discriminator}"; } } } \ No newline at end of file diff --git a/DiscordChatExporter/Program.cs b/DiscordChatExporter/Program.cs index 83fa3ea..6cead56 100644 --- a/DiscordChatExporter/Program.cs +++ b/DiscordChatExporter/Program.cs @@ -1,69 +1,19 @@ using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using DiscordChatExporter.Models; -using DiscordChatExporter.Services; -using Tyrrrz.Extensions; +using AmmySidekick; namespace DiscordChatExporter { public static class Program { - private static readonly DiscordApiService ApiService = new DiscordApiService(); - private static readonly HtmlExportService ExportService = new HtmlExportService(); - - private static Options GetOptions(string[] args) - { - // Parse the arguments - var argsDic = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var arg in args) - { - var match = Regex.Match(arg, "/(.*?):\"?(.*?)\"?$"); - var key = match.Groups[1].Value; - var value = match.Groups[2].Value; - - if (key.IsBlank()) - continue; - - argsDic[key] = value; - } - - // Extract required arguments - var token = argsDic.GetOrDefault("token"); - var channelId = argsDic.GetOrDefault("channelId"); - - // Verify arguments - if (token.IsBlank() || channelId.IsBlank()) - throw new ArgumentException("Some or all required command line arguments are missing"); - - // Exract optional arguments - var theme = argsDic.GetOrDefault("theme").ParseEnumOrDefault(); - - // Create option set - return new Options(token, channelId, theme); - } - - private static async Task MainAsync(string[] args) + [STAThread] + public static void Main() { - // Parse cmd args - var options = GetOptions(args); - - // Get messages - Console.WriteLine("Getting messages..."); - var messages = await ApiService.GetMessagesAsync(options.Token, options.ChannelId); - var chatLog = new ChatLog(options.ChannelId, messages); + var app = new App(); + app.InitializeComponent(); - // Export - Console.WriteLine("Exporting messages..."); - ExportService.Export($"{options.ChannelId}.html", chatLog, options.Theme); - } - - public static void Main(string[] args) - { - Console.Title = "Discord Chat Exporter"; + RuntimeUpdateHandler.Register(app, $"/{Ammy.GetAssemblyName(app)};component/App.g.xaml"); - MainAsync(args).GetAwaiter().GetResult(); + app.Run(); } } } \ No newline at end of file diff --git a/DiscordChatExporter/Properties/AssemblyInfo.cs b/DiscordChatExporter/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..917e829 --- /dev/null +++ b/DiscordChatExporter/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +[assembly: AssemblyTitle("DiscordChatExporter")] +[assembly: AssemblyCompany("Tyrrrz")] +[assembly: AssemblyCopyright("Copyright (c) 2017 Alexey Golub")] +[assembly: AssemblyVersion("2.0.0.0")] +[assembly: AssemblyFileVersion("2.0.0.0")] \ No newline at end of file diff --git a/DiscordChatExporter/Properties/Resources.Designer.cs b/DiscordChatExporter/Properties/Resources.Designer.cs new file mode 100644 index 0000000..072dcee --- /dev/null +++ b/DiscordChatExporter/Properties/Resources.Designer.cs @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace DiscordChatExporter.Properties +{ + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources + { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() + { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if ((resourceMan == null)) + { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DiscordChatExporter.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture + { + get + { + return resourceCulture; + } + set + { + resourceCulture = value; + } + } + } +} diff --git a/DiscordChatExporter/Properties/Resources.resx b/DiscordChatExporter/Properties/Resources.resx new file mode 100644 index 0000000..af7dbeb --- /dev/null +++ b/DiscordChatExporter/Properties/Resources.resx @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/DiscordChatExporter/Resources/HtmlExportService/DarkTheme.css b/DiscordChatExporter/Resources/ExportService/DarkTheme.css similarity index 72% rename from DiscordChatExporter/Resources/HtmlExportService/DarkTheme.css rename to DiscordChatExporter/Resources/ExportService/DarkTheme.css index e128a72..c53e302 100644 --- a/DiscordChatExporter/Resources/HtmlExportService/DarkTheme.css +++ b/DiscordChatExporter/Resources/ExportService/DarkTheme.css @@ -1,105 +1,109 @@ body { + background-color: #36393E; + color: rgba(255, 255, 255, 0.7); font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif; font-size: 16px; - - background-color: #36393E; } a { - text-decoration: none; - color: #0096CF; + text-decoration: none; } -a:hover { - text-decoration: underline; -} +a:hover { text-decoration: underline; } div.pre, span.pre { + background-color: #2F3136; font-family: Consolas, Courier New, Courier, Monospace; - - padding-right: 2px; padding-left: 2px; - - background-color: #2F3136; + padding-right: 2px; } div#info { + display: flex; + margin-bottom: 10px; + margin-left: 5px; + margin-right: 5px; max-width: 100%; - margin-bottom: 20px; +} - color: rgba(255, 255, 255, 0.7); +div#log { max-width: 100%; } + +img.guild-icon { + max-height: 64px; + max-width: 64px; } -div#log { - max-width: 100%; +div.info-right { + flex: 1; + margin-left: 10px; +} + +div.guild-name { + color: #FFFFFF; + font-size: 1.4rem; } +div.channel-name { + color: #FFFFFF; + font-size: 1.2rem; +} + +div.misc { margin-top: 2px; } + div.msg { + border-top: 1px solid rgba(255, 255, 255, 0.04); display: flex; - - margin-right: 10px; margin-left: 10px; - padding-top: 15px; + margin-right: 10px; padding-bottom: 15px; - - border-top: 1px solid rgba(255, 255, 255, 0.04); + padding-top: 15px; } -div.msg-avatar { - width: 40px; +div.msg-left { height: 40px; + width: 40px; } img.msg-avatar { - width: 40px; - height: 40px; - border-radius: 50%; + height: 40px; + width: 40px; } -div.msg-body { - margin-left: 20px; - +div.msg-right { flex: 1; + margin-left: 20px; } span.msg-user { - font-size: 1rem; - color: #FFFFFF; + font-size: 1rem; } span.msg-date { + color: rgba(255, 255, 255, 0.2); font-size: .75rem; - margin-left: 5px; - - color: rgba(255, 255, 255, 0.2); } span.msg-edited { + color: rgba(255, 255, 255, 0.2); font-size: .8rem; - margin-left: 5px; - - color: rgba(255, 255, 255, 0.2); } div.msg-content { font-size: .9375rem; - padding-top: 5px; - - color: rgba(255, 255, 255, 0.7); } div.msg-attachment { - margin-top: 5px; margin-bottom: 5px; + margin-top: 5px; } img.msg-attachment { - max-width: 50%; max-height: 500px; + max-width: 50%; } \ No newline at end of file diff --git a/DiscordChatExporter/Resources/HtmlExportService/LightTheme.css b/DiscordChatExporter/Resources/ExportService/LightTheme.css similarity index 71% rename from DiscordChatExporter/Resources/HtmlExportService/LightTheme.css rename to DiscordChatExporter/Resources/ExportService/LightTheme.css index c7d6022..4d5ca43 100644 --- a/DiscordChatExporter/Resources/HtmlExportService/LightTheme.css +++ b/DiscordChatExporter/Resources/ExportService/LightTheme.css @@ -1,105 +1,109 @@ body { + background-color: #FFFFFF; + color: #737F8D; font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif; font-size: 16px; - - background-color: #FFFFFF; } a { - text-decoration: none; - color: #00B0F4; + text-decoration: none; } -a:hover { - text-decoration: underline; -} +a:hover { text-decoration: underline; } div.pre, span.pre { + background-color: #F9F9F9; font-family: Consolas, Courier New, Courier, Monospace; - - padding-right: 2px; padding-left: 2px; - - background-color: #F9F9F9; + padding-right: 2px; } div#info { + display: flex; + margin-bottom: 10px; + margin-left: 5px; + margin-right: 5px; max-width: 100%; - margin-bottom: 20px; +} - color: #737F8D; +div#log { max-width: 100%; } + +img.guild-icon { + max-height: 64px; + max-width: 64px; } -div#log { - max-width: 100%; +div.info-right { + flex: 1; + margin-left: 10px; +} + +div.guild-name { + color: #2F3136; + font-size: 1.4rem; } +div.channel-name { + color: #2F3136; + font-size: 1.2rem; +} + +div.misc { margin-top: 2px; } + div.msg { + border-top: 1px solid #ECEEEF; display: flex; - - margin-right: 10px; margin-left: 10px; - padding-top: 15px; + margin-right: 10px; padding-bottom: 15px; - - border-top: 1px solid #ECEEEF; + padding-top: 15px; } -div.msg-avatar { - width: 40px; +div.msg-left { height: 40px; + width: 40px; } img.msg-avatar { - width: 40px; - height: 40px; - border-radius: 50%; + height: 40px; + width: 40px; } -div.msg-body { - margin-left: 20px; - +div.msg-right { flex: 1; + margin-left: 20px; } span.msg-user { - font-size: 1rem; - color: #2F3136; + font-size: 1rem; } span.msg-date { + color: #99AAB5; font-size: .75rem; - margin-left: 5px; - - color: #99AAB5; } span.msg-edited { + color: #99AAB5; font-size: .8rem; - margin-left: 5px; - - color: #99AAB5; } div.msg-content { font-size: .9375rem; - padding-top: 5px; - - color: #737F8D; } div.msg-attachment { - margin-top: 5px; margin-bottom: 5px; + margin-top: 5px; } img.msg-attachment { - max-width: 50%; max-height: 500px; + max-width: 50%; } \ No newline at end of file diff --git a/DiscordChatExporter/Resources/HtmlExportService/Template.html b/DiscordChatExporter/Resources/ExportService/Template.html similarity index 100% rename from DiscordChatExporter/Resources/HtmlExportService/Template.html rename to DiscordChatExporter/Resources/ExportService/Template.html diff --git a/DiscordChatExporter/Services/DataService.cs b/DiscordChatExporter/Services/DataService.cs new file mode 100644 index 0000000..48fc0cc --- /dev/null +++ b/DiscordChatExporter/Services/DataService.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using DiscordChatExporter.Models; +using Newtonsoft.Json.Linq; +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Services +{ + public partial class DataService : IDataService, IDisposable + { + private const string ApiRoot = "https://discordapp.com/api/v6"; + private readonly HttpClient _httpClient = new HttpClient(); + + public async Task> GetGuildsAsync(string token) + { + // Form request url + var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100"; + + // Get response + var response = await _httpClient.GetStringAsync(url); + + // Parse + var guilds = JArray.Parse(response).Select(ParseGuild); + + return guilds; + } + + public async Task> GetDirectMessageChannelsAsync(string token) + { + // Form request url + var url = $"{ApiRoot}/users/@me/channels?token={token}"; + + // Get response + var response = await _httpClient.GetStringAsync(url); + + // Parse + var channels = JArray.Parse(response).Select(ParseChannel); + + return channels; + } + + public async Task> GetGuildChannelsAsync(string token, string guildId) + { + // Form request url + var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}"; + + // Get response + var response = await _httpClient.GetStringAsync(url); + + // Parse + var channels = JArray.Parse(response).Select(ParseChannel); + + return channels; + } + + public async Task> GetChannelMessagesAsync(string token, string channelId) + { + var result = new List(); + + // 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 = JArray.Parse(response).Select(ParseMessage); + + // 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); + } + } + + public partial class DataService + { + private static User ParseUser(JToken token) + { + var id = token.Value("id"); + var discriminator = token.Value("discriminator"); + var name = token.Value("username"); + var avatarHash = token.Value("avatar"); + + return new User(id, discriminator, name, avatarHash); + } + + private static Guild ParseGuild(JToken token) + { + var id = token.Value("id"); + var name = token.Value("name"); + var iconHash = token.Value("icon"); + + return new Guild(id, name, iconHash); + } + + private static Channel ParseChannel(JToken token) + { + // Get basic data + var id = token.Value("id"); + var type = (ChannelType) token.Value("type"); + + // Extract name based on type + string name; + if (type.IsEither(ChannelType.DirectTextChat, ChannelType.DirectGroupTextChat)) + { + var recipients = token["recipients"].Select(ParseUser); + name = recipients.Select(r => r.Name).JoinToString(", "); + } + else + { + name = token.Value("name"); + } + + return new Channel(id, name, type); + } + + private static Message ParseMessage(JToken token) + { + // Get basic data + var id = token.Value("id"); + var timeStamp = token.Value("timestamp"); + var editedTimeStamp = token.Value("edited_timestamp"); + var content = token.Value("content"); + + // Lazy workaround for calls + if (token["call"] != null) + content = "Started a call."; + + // Get author + var author = ParseUser(token["author"]); + + // Get attachment + var attachments = new List(); + foreach (var attachmentJson in token["attachments"].EmptyIfNull()) + { + var attachmentId = attachmentJson.Value("id"); + var attachmentUrl = attachmentJson.Value("url"); + var attachmentType = attachmentJson["width"] != null + ? AttachmentType.Image + : AttachmentType.Unrecognized; + var attachmentFileName = attachmentJson.Value("filename"); + var attachmentFileSize = attachmentJson.Value("size"); + + var attachment = new Attachment( + attachmentId, attachmentType, attachmentUrl, + attachmentFileName, attachmentFileSize); + attachments.Add(attachment); + } + + return new Message(id, author, timeStamp, editedTimeStamp, content, attachments); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter/Services/DiscordApiService.cs b/DiscordChatExporter/Services/DiscordApiService.cs deleted file mode 100644 index 7bf8740..0000000 --- a/DiscordChatExporter/Services/DiscordApiService.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; -using DiscordChatExporter.Models; -using Newtonsoft.Json.Linq; -using Tyrrrz.Extensions; - -namespace DiscordChatExporter.Services -{ - public class DiscordApiService : IDisposable - { - private const string ApiRoot = "https://discordapp.com/api"; - private readonly HttpClient _httpClient = new HttpClient(); - - ~DiscordApiService() - { - Dispose(false); - } - - private IEnumerable ParseMessages(string json) - { - var messagesJson = JArray.Parse(json); - foreach (var messageJson in messagesJson) - { - // Get basic data - var id = messageJson.Value("id"); - var timeStamp = messageJson.Value("timestamp"); - var editedTimeStamp = messageJson.Value("edited_timestamp"); - var content = messageJson.Value("content"); - - // Lazy workaround for calls - if (messageJson["call"] != null) - content = "Started a call."; - - // Get author - var authorJson = messageJson["author"]; - var authorId = authorJson.Value("id"); - var authorName = authorJson.Value("username"); - var authorAvatarHash = authorJson.Value("avatar"); - - // Get attachment - var attachments = new List(); - foreach (var attachmentJson in messageJson["attachments"].EmptyIfNull()) - { - var attachmentId = attachmentJson.Value("id"); - var attachmentUrl = attachmentJson.Value("url"); - var attachmentFileName = attachmentJson.Value("filename"); - var attachmentIsImage = attachmentJson["width"] != null; - - var attachment = new Attachment(attachmentId, attachmentUrl, attachmentFileName, attachmentIsImage); - attachments.Add(attachment); - } - - var author = new User(authorId, authorName, authorAvatarHash); - var message = new Message(id, timeStamp, editedTimeStamp, author, content, attachments); - - yield return message; - } - } - - public async Task> GetMessagesAsync(string token, string channelId) - { - var result = new List(); - - // 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 359a0f8..f0f9bde 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 0000000..53b4421 --- /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 0000000..af7eef5 --- /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 0000000..2138eaf --- /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 0000000..965ba06 --- /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 0000000..d8d578e --- /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 0000000..1968287 --- /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 0000000..43cbbd0 --- /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 0000000..94caff0 --- /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 0000000..cbbc5c1 --- /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 0000000..f26df29 --- /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 0000000..096b064 --- /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 0000000..36a3ee6 --- /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 0000000..2b5a865 --- /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 0000000..f21e4b9 --- /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 52602c4..5e1933c 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