* Create a dummy WPF project

* Set up Ammy placeholders

* Don't track autogenerated files

* Basic layout

* Add Program.cs

* Implement basic workflow

* Autofocus token textbox and add Enter key handler

* Strip double quotes from token

* AmmyUI converters are slightly dumb :(

* Use CanExecute

* Add file path select and theme select, also refactor

* Persist token

* Trying to improve UI/UX - 1

* Rename stuff

* Finish improving UI/UX

* Remove data placeholder

* Remove border on middle grid

* Ok now i'm done

* Improve Discord API layer

* Add lots of stuff

* Show filesizes in export

* Improve export

* Animations

* Update readme

* Improving gui again

* Improve UI again

* Refactor
pull/17/head
Alexey Golub 7 years ago committed by GitHub
parent d8bbe8c8c8
commit 6d7a8ae063

5
.gitignore vendored

@ -258,4 +258,7 @@ paket-files/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
*.pyc
# Ammy auto-generated XAML
*.g.xaml

@ -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

@ -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" { }
}
}

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

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
</startup>
</configuration>

@ -1,27 +1,172 @@
<Project Sdk="Microsoft.NET.Sdk">
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net45</TargetFramework>
<Version>1.0.0</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017 Alexey Golub</Copyright>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{732A67AF-93DE-49DF-B10F-FD74710B7863}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>DiscordChatExporter</RootNamespace>
<AssemblyName>DiscordChatExporter</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<WarningLevel>4</WarningLevel>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\HtmlExportService\LightTheme.css" />
<EmbeddedResource Include="Resources\HtmlExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\HtmlExportService\Template.html" />
<Reference Include="AmmySidekick, Version=1.0.0.0, Culture=neutral, PublicKeyToken=7c1296d24569a67d, processorArchitecture=MSIL">
<HintPath>..\packages\Ammy.WPF.1.2.87\lib\net40\AmmySidekick.dll</HintPath>
</Reference>
<Reference Include="GalaSoft.MvvmLight, Version=5.3.0.19026, Culture=neutral, PublicKeyToken=e7570ab207bcb616, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.dll</HintPath>
</Reference>
<Reference Include="GalaSoft.MvvmLight.Extras, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=669f0b5e8f868abf, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Extras.dll</HintPath>
</Reference>
<Reference Include="GalaSoft.MvvmLight.Platform, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
</Reference>
<Reference Include="HtmlAgilityPack, Version=1.5.5.0, Culture=neutral, PublicKeyToken=bd319b19eaf3b43a, processorArchitecture=MSIL">
<HintPath>..\packages\HtmlAgilityPack.1.5.5\lib\Net45\HtmlAgilityPack.dll</HintPath>
</Reference>
<Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath>
</Reference>
<Reference Include="MaterialDesignThemes.Wpf, Version=2.3.1.953, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignThemes.2.3.1.953\lib\net45\MaterialDesignThemes.Wpf.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Practices.ServiceLocation, Version=1.3.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
<HintPath>..\packages\CommonServiceLocator.1.3\lib\portable-net4+sl5+netcore45+wpa81+wp8\Microsoft.Practices.ServiceLocation.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json, Version=10.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Windows.Interactivity, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\System.Windows.Interactivity.dll</HintPath>
</Reference>
<Reference Include="System.Xaml">
<RequiredTargetFramework>4.0</RequiredTargetFramework>
</Reference>
<Reference Include="System.Xml" />
<Reference Include="Tyrrrz.Extensions, Version=1.4.1.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Extensions.1.4.1\lib\net45\Tyrrrz.Extensions.dll</HintPath>
</Reference>
<Reference Include="Tyrrrz.Settings, Version=1.3.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Settings.1.3.0\lib\net45\Tyrrrz.Settings.dll</HintPath>
</Reference>
<Reference Include="WindowsBase" />
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.5.1" />
<PackageReference Include="Newtonsoft.json" Version="10.0.3" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.4.0" />
<Compile Include="Messages\ShowSettingsMessage.cs" />
<Compile Include="Models\AttachmentType.cs" />
<Compile Include="Models\ChannelChatLog.cs" />
<Compile Include="Models\ChannelType.cs" />
<Compile Include="ViewModels\ISettingsViewModel.cs" />
<Compile Include="ViewModels\SettingsViewModel.cs" />
<Compile Include="Views\SettingsDialog.ammy.cs">
<DependentUpon>SettingsDialog.ammy</DependentUpon>
</Compile>
<Page Include="App.g.xaml">
<SubType>Designer</SubType>
<Generator>XamlIntelliSenseFileGenerator</Generator>
<DependentUpon>App.ammy</DependentUpon>
</Page>
<Page Include="Views\MainWindow.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>MainWindow.ammy</DependentUpon>
</Page>
<Compile Include="App.ammy.cs">
<DependentUpon>App.ammy</DependentUpon>
</Compile>
<Compile Include="Locator.cs" />
<Compile Include="Models\Attachment.cs" />
<Compile Include="Models\Channel.cs" />
<Compile Include="Models\Guild.cs" />
<Compile Include="Models\Message.cs" />
<Compile Include="Models\MessageGroup.cs" />
<Compile Include="Models\Theme.cs" />
<Compile Include="Models\User.cs" />
<Compile Include="Program.cs" />
<Compile Include="Services\DataService.cs" />
<Compile Include="Services\ExportService.cs" />
<Compile Include="Services\IDataService.cs" />
<Compile Include="Services\IExportService.cs" />
<Compile Include="Services\ISettingsService.cs" />
<Compile Include="Services\SettingsService.cs" />
<Compile Include="ViewModels\IMainViewModel.cs" />
<Compile Include="ViewModels\MainViewModel.cs" />
<Compile Include="Views\MainWindow.ammy.cs">
<DependentUpon>MainWindow.ammy</DependentUpon>
</Compile>
<Page Include="Views\SettingsDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>SettingsDialog.ammy</DependentUpon>
</Page>
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
<Compile Include="Properties\AssemblyInfo.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="App.ammy" />
<None Include="lib.ammy" />
<None Include="packages.config">
<SubType>Designer</SubType>
</None>
<None Include="Views\MainWindow.ammy" />
<None Include="Views\SettingsDialog.ammy" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportService\Template.html" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\packages\Ammy.1.2.87\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>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}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Ammy.1.2.87\build\Ammy.targets'))" />
</Target>
</Project>

@ -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<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
// View models
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>();
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>();
// Load settings
ServiceLocator.Current.GetInstance<ISettingsService>().Load();
}
public static void Cleanup()
{
// Save settings
ServiceLocator.Current.GetInstance<ISettingsService>().Save();
}
public IMainViewModel MainViewModel => ServiceLocator.Current.GetInstance<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance<ISettingsViewModel>();
}
}

@ -0,0 +1,6 @@
namespace DiscordChatExporter.Messages
{
public class ShowSettingsMessage
{
}
}

@ -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;
}
}
}

@ -0,0 +1,8 @@
namespace DiscordChatExporter.Models
{
public enum AttachmentType
{
Unrecognized,
Image
}
}

@ -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;
}
}
}

@ -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<Message> Messages { get; }
public ChannelChatLog(Guild guild, Channel channel, IEnumerable<Message> messages)
{
Guild = guild;
Channel = channel;
Messages = messages.ToArray();
}
}
}

@ -0,0 +1,11 @@
namespace DiscordChatExporter.Models
{
public enum ChannelType
{
GuildTextChat,
DirectTextChat,
GuildVoiceChat,
DirectGroupTextChat,
Category
}
}

@ -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<User> Participants { get; }
public IReadOnlyList<Message> Messages { get; }
public ChatLog(string channelId, IEnumerable<Message> messages)
{
ChannelId = channelId;
Messages = messages.ToArray();
Participants = Messages.Select(m => m.Author).Distinct(a => a.Name).ToArray();
}
}
}

@ -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;
}
}
}

@ -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<Attachment> Attachments { get; }
public Message(string id, DateTime timeStamp, DateTime? editedTimeStamp, User author, string content,
IEnumerable<Attachment> attachments)
public Message(string id, User author,
DateTime timeStamp, DateTime? editedTimeStamp,
string content, IEnumerable<Attachment> attachments)
{
Id = id;
Author = author;
TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp;
Author = author;
Content = content;
Attachments = attachments.ToArray();
}

@ -8,14 +8,14 @@ namespace DiscordChatExporter.Models
{
public User Author { get; }
public DateTime FirstTimeStamp { get; }
public DateTime TimeStamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable<Message> messages)
public MessageGroup(User author, DateTime timeStamp, IEnumerable<Message> messages)
{
Author = author;
FirstTimeStamp = firstTimeStamp;
TimeStamp = timeStamp;
Messages = messages.ToArray();
}
}

@ -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;
}
}
}

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

@ -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<string, string>(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<Theme>();
// 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();
}
}
}

@ -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")]

@ -0,0 +1,71 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 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.
// </auto-generated>
//------------------------------------------------------------------------------
namespace DiscordChatExporter.Properties
{
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// 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()
{
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[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;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture
{
get
{
return resourceCulture;
}
set
{
resourceCulture = value;
}
}
}
}

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

@ -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%;
}

@ -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%;
}

@ -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<IEnumerable<Guild>> 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<IEnumerable<Channel>> 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<IEnumerable<Channel>> 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<IEnumerable<Message>> GetChannelMessagesAsync(string token, string channelId)
{
var result = new List<Message>();
// 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<string>("id");
var discriminator = token.Value<int>("discriminator");
var name = token.Value<string>("username");
var avatarHash = token.Value<string>("avatar");
return new User(id, discriminator, name, avatarHash);
}
private static Guild ParseGuild(JToken token)
{
var id = token.Value<string>("id");
var name = token.Value<string>("name");
var iconHash = token.Value<string>("icon");
return new Guild(id, name, iconHash);
}
private static Channel ParseChannel(JToken token)
{
// Get basic data
var id = token.Value<string>("id");
var type = (ChannelType) token.Value<int>("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<string>("name");
}
return new Channel(id, name, type);
}
private static Message ParseMessage(JToken token)
{
// Get basic data
var id = token.Value<string>("id");
var timeStamp = token.Value<DateTime>("timestamp");
var editedTimeStamp = token.Value<DateTime?>("edited_timestamp");
var content = token.Value<string>("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<Attachment>();
foreach (var attachmentJson in token["attachments"].EmptyIfNull())
{
var attachmentId = attachmentJson.Value<string>("id");
var attachmentUrl = attachmentJson.Value<string>("url");
var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image
: AttachmentType.Unrecognized;
var attachmentFileName = attachmentJson.Value<string>("filename");
var attachmentFileSize = attachmentJson.Value<long>("size");
var attachment = new Attachment(
attachmentId, attachmentType, attachmentUrl,
attachmentFileName, attachmentFileSize);
attachments.Add(attachment);
}
return new Message(id, author, timeStamp, editedTimeStamp, content, attachments);
}
}
}

@ -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<Message> ParseMessages(string json)
{
var messagesJson = JArray.Parse(json);
foreach (var messageJson in messagesJson)
{
// Get basic data
var id = messageJson.Value<string>("id");
var timeStamp = messageJson.Value<DateTime>("timestamp");
var editedTimeStamp = messageJson.Value<DateTime?>("edited_timestamp");
var content = messageJson.Value<string>("content");
// Lazy workaround for calls
if (messageJson["call"] != null)
content = "Started a call.";
// Get author
var authorJson = messageJson["author"];
var authorId = authorJson.Value<string>("id");
var authorName = authorJson.Value<string>("username");
var authorAvatarHash = authorJson.Value<string>("avatar");
// Get attachment
var attachments = new List<Attachment>();
foreach (var attachmentJson in messageJson["attachments"].EmptyIfNull())
{
var attachmentId = attachmentJson.Value<string>("id");
var attachmentUrl = attachmentJson.Value<string>("url");
var attachmentFileName = attachmentJson.Value<string>("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<IEnumerable<Message>> GetMessagesAsync(string token, string channelId)
{
var result = new List<Message>();
// 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);
}
}
}

@ -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("<div class=\"info-left\"></div>"));
infoLeftHtml.AppendChild(HtmlNode.CreateNode(
$"<img class=\"guild-icon\" src=\"{channelChatLog.Guild.IconUrl}\" />"));
var infoRightHtml = infoHtml.AppendChild(HtmlNode.CreateNode("<div class=\"info-right\"></div>"));
infoRightHtml.AppendChild(HtmlNode.CreateNode(
$"<div class=\"guild-name\">{channelChatLog.Guild.Name}</div>"));
infoRightHtml.AppendChild(HtmlNode.CreateNode(
$"<div class=\"channel-name\">{channelChatLog.Channel.Name}</div>"));
infoRightHtml.AppendChild(HtmlNode.CreateNode(
$"<div class=\"misc\">{channelChatLog.Messages.Count:N0} messages</div>"));
// Log
var logHtml = doc.GetElementbyId("log");
var messageGroups = GroupMessages(channelChatLog.Messages);
foreach (var messageGroup in messageGroups)
{
// Container
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
// Left
var messageLeftHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-left\"></div>"));
// Avatar
messageLeftHtml.AppendChild(
HtmlNode.CreateNode($"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />"));
// Right
var messageRightHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-right\"></div>"));
// Author
var authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
messageRightHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
// Date
var timeStamp = HtmlDocument.HtmlEncode(messageGroup.TimeStamp.ToString("g"));
messageRightHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
// Individual messages
foreach (var message in messageGroup.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var content = FormatMessageContent(message.Content);
var contentHtml =
messageRightHtml.AppendChild(
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
// Edited timestamp
if (message.EditedTimeStamp != null)
{
contentHtml.AppendChild(
HtmlNode.CreateNode(
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp:g}\">(edited)</span>"));
}
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.Type == AttachmentType.Image)
{
messageRightHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
"</a>" +
"</div>"));
}
else
{
messageRightHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"Attachment: {attachment.FileName} ({NormalizeFileSize(attachment.FileSize)})" +
"</a>" +
"</div>"));
}
}
}
}
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<MessageGroup> GroupMessages(IEnumerable<Message> 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<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{
var result = new List<MessageGroup>();
@ -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($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
var participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Participants: <b>{participants}</b></div>"));
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Messages: <b>{chatLog.Messages.Count:N0}</b></div>"));
// Log
var logHtml = doc.GetElementbyId("log");
var messageGroups = GroupMessages(chatLog.Messages);
foreach (var messageGroup in messageGroups)
{
// Container
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
// Avatar
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-avatar\">" +
$"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />" +
"</div>"));
// Body
var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-body\"></div>"));
// Author
var authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
// Date
var timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
// Individual messages
foreach (var message in messageGroup.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var content = FormatMessageContent(message.Content);
var contentHtml =
messageBodyHtml.AppendChild(
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
// Edited timestamp
if (message.EditedTimeStamp != null)
{
contentHtml.AppendChild(
HtmlNode.CreateNode(
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp:g}\">(edited)</span>"));
}
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.IsImage)
{
messageBodyHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
"</a>" +
"</div>"));
}
else
{
messageBodyHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"Attachment: {attachment.FileName}" +
"</a>" +
"</div>"));
}
}
}
}
doc.Save(filePath);
}
}
}

@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Services
{
public interface IDataService
{
Task<IEnumerable<Guild>> GetGuildsAsync(string token);
Task<IEnumerable<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IEnumerable<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IEnumerable<Message>> GetChannelMessagesAsync(string token, string channelId);
}
}

@ -0,0 +1,9 @@
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Services
{
public interface IExportService
{
void Export(string filePath, ChannelChatLog channelChatLog, Theme theme);
}
}

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

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

@ -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<Guild> AvailableGuilds { get; }
Guild SelectedGuild { get; set; }
IReadOnlyList<Channel> AvailableChannels { get; }
RelayCommand PullDataCommand { get; }
RelayCommand<Channel> ExportChannelCommand { get; }
RelayCommand ShowSettingsCommand { get; }
RelayCommand ShowAboutCommand { get; }
}
}

@ -0,0 +1,11 @@
using System.Collections.Generic;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.ViewModels
{
public interface ISettingsViewModel
{
IReadOnlyList<Theme> AvailableThemes { get; }
Theme Theme { get; set; }
}
}

@ -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<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy;
private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild;
private IReadOnlyList<Channel> _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<Guild> 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<Channel> AvailableChannels
{
get => _availableChannels;
private set => Set(ref _availableChannels, value);
}
public RelayCommand PullDataCommand { get; }
public RelayCommand<Channel> 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<Guild, IReadOnlyList<Channel>>();
// Commands
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
ExportChannelCommand = new RelayCommand<Channel>(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");
}
}
}

@ -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<Theme> 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<Theme>().ToArray();
}
}
}

@ -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<ItemsControl>
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: "&quot;token&quot;"
Foreground: resource dyn "PrimaryTextBrush"
}
Run {
Text: "under key and copy the value"
}
LineBreak { }
Run {
Text: "7. Paste the value in the textbox above"
}
}
}
}
}
}
}

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

@ -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"
}
}
}

@ -0,0 +1,10 @@
namespace DiscordChatExporter.Views
{
public partial class SettingsDialog
{
public SettingsDialog()
{
InitializeComponent();
}
}
}

@ -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 }
}

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Ammy" version="1.2.87" targetFramework="net461" />
<package id="Ammy.WPF" version="1.2.87" targetFramework="net461" />
<package id="CommonServiceLocator" version="1.3" targetFramework="net461" />
<package id="HtmlAgilityPack" version="1.5.5" targetFramework="net461" />
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.3.1.953" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.3.0.0" targetFramework="net461" />
<package id="Newtonsoft.Json" version="10.0.3" targetFramework="net461" />
<package id="Tyrrrz.Extensions" version="1.4.1" targetFramework="net461" />
<package id="Tyrrrz.Settings" version="1.3.0" targetFramework="net461" />
</packages>

@ -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

Loading…
Cancel
Save