Compare commits

...

26 Commits

Author SHA1 Message Date
Alexey Golub 42bb4e72d2 Remove HtmlAgilityPack from readme 2017-09-30 17:27:09 +03:00
Alexey Golub a79d72f975 Update version 2017-09-30 17:25:20 +03:00
Alexey Golub b110413c08 Fix div.pre output styling 2017-09-30 17:24:46 +03:00
Alexey Golub a556926fe6 Rework HTML export
Fixes #8
2017-09-30 17:18:41 +03:00
Alexey Golub 5a57b4a6b1 Generalize HTTP exceptions 2017-09-29 20:24:02 +03:00
Alexey Golub d3b311b88f Update readme screenshot link 2017-09-28 23:07:27 +03:00
Alexey Golub d8db4fca90 ExportService is pseudo async now 2017-09-28 22:48:24 +03:00
Alexey Golub b7979d6d1f Fix message count in exported file 2017-09-28 22:45:25 +03:00
Alexey Golub 749a55a30e Round guild icons 2017-09-28 22:32:29 +03:00
Alexey Golub 7b9de314ab Refactor message grouping out and add a limit setting 2017-09-28 22:16:38 +03:00
Alexey Golub 69184a74fe Add support for custom date formats in settings 2017-09-28 22:05:16 +03:00
Alexey Golub efae0e99b8 Add a dialog that shows when export completes 2017-09-28 21:57:57 +03:00
Alexey Golub 8afe9852fb Add some error handling 2017-09-28 21:36:17 +03:00
Alexey Golub f2178a0445 Refactor 2017-09-28 20:51:32 +03:00
Alexey Golub c01b9d0e5f Don't disable everything when busy 2017-09-28 20:21:00 +03:00
Alexey Golub 151c5e2f23 Remove mentions of wiki from readme, add other libraries used 2017-09-28 01:28:10 +03:00
Alexey Golub 6d7a8ae063 Add GUI (#7)
* 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
2017-09-28 01:24:45 +03:00
Alexey Golub d8bbe8c8c8 Update Readme.md 2017-09-13 18:48:43 +03:00
Alexey Golub 9a1a257f1e Update Readme.md 2017-09-11 20:31:51 +03:00
Alexey Golub 5e0a39df5e Small cleanup 2017-09-09 22:22:15 +03:00
Alexey Golub fb3b14f1ce Use var 2017-09-01 21:16:18 +03:00
Alexey Golub 34afce2a66 Make Dark the default theme
Light is burning my eyes
2017-09-01 20:04:40 +03:00
Alexey Golub 95d581797b Add dark theme and theme switch (#4)
* Add dark theme

* Refactor a bit

* Add back Light theme and add a switch

* Update readme
2017-09-01 15:45:19 +03:00
Alexey Golub 22d7dd43ec Refactor project file 2017-08-29 20:23:17 +03:00
Alexey Golub dd942824ec Improve token extraction steps 2017-08-29 20:02:08 +03:00
Alexey Golub 0974dd77bf Add download info to readme 2017-08-29 00:45:04 +03:00
58 changed files with 2477 additions and 516 deletions
+4 -1
View File
@@ -258,4 +258,7 @@ paket-files/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
*.pyc
# Ammy auto-generated XAML
*.g.xaml
+10 -7
View File
@@ -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
+69
View File
@@ -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")
}
// Container
Container Key="Container" { }
}
}
+17
View File
@@ -0,0 +1,17 @@
using System.Windows;
namespace DiscordChatExporter
{
public partial class App
{
private void App_Startup(object sender, StartupEventArgs e)
{
Container.Init();
}
private void App_Exit(object sender, ExitEventArgs e)
{
Container.Cleanup();
}
}
}
+6
View File
@@ -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>
+41
View File
@@ -0,0 +1,41 @@
using DiscordChatExporter.Services;
using DiscordChatExporter.ViewModels;
using GalaSoft.MvvmLight.Ioc;
using Microsoft.Practices.ServiceLocation;
namespace DiscordChatExporter
{
public class Container
{
public static void Init()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
// View models
SimpleIoc.Default.Register<IErrorViewModel, ErrorViewModel>(true);
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
SimpleIoc.Default.Register<IExportDoneViewModel, ExportDoneViewModel>(true);
// Load settings
ServiceLocator.Current.GetInstance<ISettingsService>().Load();
}
public static void Cleanup()
{
// Save settings
ServiceLocator.Current.GetInstance<ISettingsService>().Save();
}
public IErrorViewModel ErrorViewModel => ServiceLocator.Current.GetInstance<IErrorViewModel>();
public IMainViewModel MainViewModel => ServiceLocator.Current.GetInstance<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance<ISettingsViewModel>();
public IExportDoneViewModel ExportDoneViewModel => ServiceLocator.Current.GetInstance<IExportDoneViewModel>();
}
}
+189 -20
View File
@@ -1,26 +1,195 @@
<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>
<AssemblyVersion>1.0.0</AssemblyVersion>
<FileVersion>$(AssemblyVersion)</FileVersion>
<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="Services\ExportTemplate.html" />
</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" />
</ItemGroup>
<ItemGroup>
<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="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>
<Compile Include="Exceptions\HttpErrorStatusCodeException.cs" />
<Compile Include="Messages\ShowErrorMessage.cs" />
<Compile Include="Messages\ShowExportDoneMessage.cs" />
<Compile Include="Messages\ShowSettingsMessage.cs" />
<Compile Include="Models\AttachmentType.cs" />
<Compile Include="Models\ChannelChatLog.cs" />
<Compile Include="Models\ChannelType.cs" />
<Compile Include="Services\IMessageGroupService.cs" />
<Compile Include="Services\MessageGroupService.cs" />
<Compile Include="ViewModels\ErrorViewModel.cs" />
<Compile Include="ViewModels\IErrorViewModel.cs" />
<Compile Include="ViewModels\ISettingsViewModel.cs" />
<Compile Include="ViewModels\IExportDoneViewModel.cs" />
<Compile Include="ViewModels\SettingsViewModel.cs" />
<Compile Include="ViewModels\ExportDoneViewModel.cs" />
<Compile Include="Views\ErrorDialog.ammy.cs">
<DependentUpon>ErrorDialog.ammy</DependentUpon>
</Compile>
<Compile Include="Views\ExportDoneDialog.ammy.cs">
<DependentUpon>ExportDoneDialog.ammy</DependentUpon>
</Compile>
<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\ErrorDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>ErrorDialog.ammy</DependentUpon>
</Page>
<Page Include="Views\ExportDoneDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>ExportDoneDialog.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="Container.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>
<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\ErrorDialog.ammy" />
<None Include="Views\ExportDoneDialog.ammy" />
<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" />
</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,15 @@
using System;
using System.Net;
namespace DiscordChatExporter.Exceptions
{
public class HttpErrorStatusCodeException : Exception
{
public HttpStatusCode StatusCode { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode)
{
StatusCode = statusCode;
}
}
}
@@ -0,0 +1,12 @@
namespace DiscordChatExporter.Messages
{
public class ShowErrorMessage
{
public string Message { get; }
public ShowErrorMessage(string message)
{
Message = message;
}
}
}
@@ -0,0 +1,12 @@
namespace DiscordChatExporter.Messages
{
public class ShowExportDoneMessage
{
public string FilePath { get; }
public ShowExportDoneMessage(string filePath)
{
FilePath = filePath;
}
}
}
@@ -0,0 +1,6 @@
namespace DiscordChatExporter.Messages
{
public class ShowSettingsMessage
{
}
}
+6 -3
View File
@@ -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
{
Other,
Image
}
}
+23
View File
@@ -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,24 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Models
{
public class ChannelChatLog
{
public Guild Guild { get; }
public Channel Channel { get; }
public IReadOnlyList<MessageGroup> MessageGroups { get; }
public int TotalMessageCount { get; }
public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList<MessageGroup> messageGroups,
int totalMessageCount)
{
Guild = guild;
Channel = channel;
MessageGroups = messageGroups;
TotalMessageCount = totalMessageCount;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace DiscordChatExporter.Models
{
public enum ChannelType
{
GuildTextChat,
DirectTextChat,
GuildVoiceChat,
DirectGroupTextChat,
Category
}
}
-22
View File
@@ -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();
}
}
}
+29
View File
@@ -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;
}
}
}
+7 -6
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models
{
@@ -8,24 +7,26 @@ 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, IReadOnlyList<Attachment> attachments)
{
Id = id;
Author = author;
TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp;
Author = author;
Content = content;
Attachments = attachments.ToArray();
Attachments = attachments;
}
public override string ToString()
+4 -5
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models
{
@@ -8,15 +7,15 @@ 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, IReadOnlyList<Message> messages)
{
Author = author;
FirstTimeStamp = firstTimeStamp;
Messages = messages.ToArray();
TimeStamp = timeStamp;
Messages = messages;
}
}
}
-15
View File
@@ -1,15 +0,0 @@
namespace DiscordChatExporter.Models
{
public class Options
{
public string Token { get; }
public string ChannelId { get; }
public Options(string token, string channelId)
{
Token = token;
ChannelId = channelId;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Models
{
public enum Theme
{
Dark,
Light
}
}
+7 -4
View File
@@ -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}";
}
}
}
+7 -54
View File
@@ -1,66 +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 DiscordApiService = new DiscordApiService();
private static readonly ExportService ExportService = new ExportService();
private static Options GetOptions(string[] args)
[STAThread]
public static void Main()
{
// Parse the arguments
var argsDic = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (string arg in args)
{
var match = Regex.Match(arg, "/(.*?):\"?(.*?)\"?$");
string key = match.Groups[1].Value;
string value = match.Groups[2].Value;
var app = new App();
app.InitializeComponent();
if (key.IsBlank())
continue;
RuntimeUpdateHandler.Register(app, $"/{Ammy.GetAssemblyName(app)};component/App.g.xaml");
argsDic[key] = value;
}
// Extract required arguments
string token = argsDic.GetOrDefault("token");
string channelId = argsDic.GetOrDefault("channelId");
// Verify arguments
if (token.IsBlank() || channelId.IsBlank())
throw new ArgumentException("Some or all required command line arguments are missing");
// Create option set
return new Options(token, channelId);
}
private static async Task MainAsync(string[] args)
{
// Parse cmd args
var options = GetOptions(args);
// Get messages
Console.WriteLine("Getting messages...");
var messages = await DiscordApiService.GetMessagesAsync(options.Token, options.ChannelId);
var chatLog = new ChatLog(options.ChannelId, messages);
// Export
Console.WriteLine("Exporting messages...");
ExportService.Export($"{options.ChannelId}.html", chatLog);
}
public static void Main(string[] args)
{
Console.Title = "Discord Chat Exporter";
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.1")]
[assembly: AssemblyFileVersion("2.1")]
+71
View File
@@ -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>
@@ -0,0 +1,115 @@
body {
background-color: #36393E;
color: rgba(255, 255, 255, 0.7);
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
color: #0096CF;
text-decoration: none;
}
a:hover { text-decoration: underline; }
div.pre {
background-color: #2F3136;
font-family: Consolas, Courier New, Courier, Monospace;
padding: 8px;
}
span.pre {
background-color: #2F3136;
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
}
div#info {
display: flex;
margin-bottom: 10px;
margin-left: 5px;
margin-right: 5px;
max-width: 100%;
}
div#log { max-width: 100%; }
img.guild-icon {
max-height: 64px;
max-width: 64px;
}
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-left: 10px;
margin-right: 10px;
padding-bottom: 15px;
padding-top: 15px;
}
div.msg-left {
height: 40px;
width: 40px;
}
img.msg-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
div.msg-right {
flex: 1;
margin-left: 20px;
}
span.msg-user {
color: #FFFFFF;
font-size: 1rem;
}
span.msg-date {
color: rgba(255, 255, 255, 0.2);
font-size: .75rem;
margin-left: 5px;
}
span.msg-edited {
color: rgba(255, 255, 255, 0.2);
font-size: .8rem;
margin-left: 5px;
}
div.msg-content {
font-size: .9375rem;
padding-top: 5px;
}
div.msg-attachment {
margin-bottom: 5px;
margin-top: 5px;
}
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
@@ -0,0 +1,115 @@
body {
background-color: #FFFFFF;
color: #737F8D;
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
color: #00B0F4;
text-decoration: none;
}
a:hover { text-decoration: underline; }
div.pre {
background-color: #F9F9F9;
font-family: Consolas, Courier New, Courier, Monospace;
padding: 8px;
}
span.pre {
background-color: #F9F9F9;
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
}
div#info {
display: flex;
margin-bottom: 10px;
margin-left: 5px;
margin-right: 5px;
max-width: 100%;
}
div#log { max-width: 100%; }
img.guild-icon {
max-height: 64px;
max-width: 64px;
}
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-left: 10px;
margin-right: 10px;
padding-bottom: 15px;
padding-top: 15px;
}
div.msg-left {
height: 40px;
width: 40px;
}
img.msg-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
div.msg-right {
flex: 1;
margin-left: 20px;
}
span.msg-user {
color: #2F3136;
font-size: 1rem;
}
span.msg-date {
color: #99AAB5;
font-size: .75rem;
margin-left: 5px;
}
span.msg-edited {
color: #99AAB5;
font-size: .8rem;
margin-left: 5px;
}
div.msg-content {
font-size: .9375rem;
padding-top: 5px;
}
div.msg-attachment {
margin-bottom: 5px;
margin-top: 5px;
}
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
+208
View File
@@ -0,0 +1,208 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using DiscordChatExporter.Exceptions;
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();
private async Task<string> GetStringAsync(string url)
{
using (var response = await _httpClient.GetAsync(url))
{
// Check status code
// We throw our own exception here because default one doesn't have status code
if (!response.IsSuccessStatusCode)
throw new HttpErrorStatusCodeException(response.StatusCode);
// Get content
return await response.Content.ReadAsStringAsync();
}
}
public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
// Get response
var content = await GetStringAsync(url);
// Parse
var guilds = JArray.Parse(content).Select(ParseGuild).ToArray();
return guilds;
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<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 content = await GetStringAsync(url);
// Parse
var messages = JArray.Parse(content).Select(ParseMessage);
// Add messages to list
var currentMessageId = default(string);
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.Other;
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,99 +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
{
private const string ApiRoot = "https://discordapp.com/api";
private readonly HttpClient _httpClient = new HttpClient();
private IEnumerable<Message> ParseMessages(string json)
{
var messagesJson = JArray.Parse(json);
foreach (var messageJson in messagesJson)
{
// Get basic data
string id = messageJson.Value<string>("id");
var timeStamp = messageJson.Value<DateTime>("timestamp");
var editedTimeStamp = messageJson.Value<DateTime?>("edited_timestamp");
string content = messageJson.Value<string>("content");
// Lazy workaround for calls
if (messageJson["call"] != null)
content = "Started a call.";
// Get author
var authorJson = messageJson["author"];
string authorId = authorJson.Value<string>("id");
string authorName = authorJson.Value<string>("username");
string authorAvatarHash = authorJson.Value<string>("avatar");
// Get attachment
var attachmentsJson = messageJson["attachments"];
var attachments = new List<Attachment>();
foreach (var attachmentJson in attachmentsJson)
{
string attachmentId = attachmentJson.Value<string>("id");
string attachmentUrl = attachmentJson.Value<string>("url");
string attachmentFileName = attachmentJson.Value<string>("filename");
bool 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
string url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
if (beforeId.IsNotBlank())
url += $"&before={beforeId}";
// Get response
string 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;
}
}
}
+144 -128
View File
@@ -1,73 +1,172 @@
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Net;
using System.Reflection;
using System.Resources;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using HtmlAgilityPack;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
{
public class ExportService
public partial class ExportService : IExportService
{
private HtmlDocument GetTemplate()
private readonly ISettingsService _settingsService;
public ExportService(ISettingsService settingsService)
{
const string templateName = "DiscordChatExporter.Services.ExportTemplate.html";
var assembly = Assembly.GetExecutingAssembly();
using (var stream = assembly.GetManifestResourceStream(templateName))
{
var doc = new HtmlDocument();
doc.Load(stream);
return doc;
}
_settingsService = settingsService;
}
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
public async Task ExportAsync(string filePath, ChannelChatLog log, Theme theme)
{
var result = new List<MessageGroup>();
var themeCss = GetThemeCss(theme);
var dateFormat = _settingsService.DateFormat;
// Group adjacent messages by timestamp and author
var groupBuffer = new List<Message>();
foreach (var message in messages)
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128*1024))
{
var groupFirst = groupBuffer.FirstOrDefault();
// Generation info
await writer.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Group break condition
bool breakCondition =
groupFirst != null &&
(
message.Author.Id != groupFirst.Author.Id ||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour
);
// Html start
await writer.WriteLineAsync("<!DOCTYPE html>");
await writer.WriteLineAsync("<html lang=\"en\">");
// If condition is true - flush buffer
if (breakCondition)
// HEAD
await writer.WriteLineAsync("<head>");
await writer.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
await writer.WriteLineAsync("<meta charset=\"utf-8\" />");
await writer.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await writer.WriteLineAsync($"<style>{themeCss}</style>");
await writer.WriteLineAsync("</head>");
// Body start
await writer.WriteLineAsync("<body>");
// Guild and channel info
await writer.WriteLineAsync("<div id=\"info\">");
await writer.WriteLineAsync("<div class=\"info-left\">");
await writer.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await writer.WriteLineAsync("</div>"); // info-left
await writer.WriteLineAsync("<div class=\"info-right\">");
await writer.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
await writer.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
await writer.WriteLineAsync($"<div class=\"misc\">{log.TotalMessageCount:N0} messages</div>");
await writer.WriteLineAsync("</div>"); // info-right
await writer.WriteLineAsync("</div>"); // info
// Chat log
await writer.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
result.Add(group);
groupBuffer.Clear();
await writer.WriteLineAsync("<div class=\"msg\">");
await writer.WriteLineAsync("<div class=\"msg-left\">");
await writer.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
await writer.WriteLineAsync("</div>");
await writer.WriteLineAsync("<div class=\"msg-right\">");
await writer.WriteLineAsync($"<span class=\"msg-user\">{HtmlEncode(group.Author.Name)}</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(dateFormat));
await writer.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Message
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
await writer.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContent(message.Content);
await writer.WriteAsync(contentFormatted);
// Edited timestamp
if (message.EditedTimeStamp != null)
{
var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(dateFormat));
await writer.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
await writer.WriteLineAsync("</div>"); // msg-content
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.Type == AttachmentType.Image)
{
await writer.WriteLineAsync("<div class=\"msg-attachment\">");
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await writer.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await writer.WriteLineAsync("</a>");
await writer.WriteLineAsync("</div>");
}
else
{
await writer.WriteLineAsync("<div class=\"msg-attachment\">");
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
await writer.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
await writer.WriteLineAsync("</a>");
await writer.WriteLineAsync("</div>");
}
}
}
await writer.WriteLineAsync("</div>"); // msg-right
await writer.WriteLineAsync("</div>"); // msg
}
await writer.WriteLineAsync("</div>"); // log
// Add message to buffer
groupBuffer.Add(message);
await writer.WriteLineAsync("</body>");
await writer.WriteLineAsync("</html>");
}
}
}
// Add what's remaining in buffer
if (groupBuffer.Any())
public partial class ExportService
{
private static string GetThemeCss(Theme theme)
{
var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css";
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream == null)
throw new MissingManifestResourceException("Could not find style resource");
using (stream)
using (var reader = new StreamReader(stream))
{
var groupFirst = groupBuffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
result.Add(group);
return reader.ReadToEnd();
}
return result;
}
private string FormatMessageContent(string content)
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static string FormatFileSize(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 string FormatMessageContent(string content)
{
// Encode HTML
content = HtmlDocument.HtmlEncode(content);
content = HtmlEncode(content);
// Links from URLs
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
@@ -75,7 +174,7 @@ namespace DiscordChatExporter.Services
// Preformatted multiline
content = Regex.Replace(content, "```([^`]*?)```",
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
m => "<div class=\"pre\">" + m.Groups[1].Value.Trim('\n') + "</div>");
// Preformatted inline
content = Regex.Replace(content, "`([^`]*?)`",
@@ -94,92 +193,9 @@ namespace DiscordChatExporter.Services
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
// New lines
content = content.Replace("\n", "</br>");
content = content.Replace("\n", "<br />");
return content;
}
public void Export(string filePath, ChatLog chatLog)
{
var doc = GetTemplate();
// Info
var infoHtml = doc.GetElementbyId("info");
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
string 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
string authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
// Date
string 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())
{
string 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);
}
}
}
@@ -1,122 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Discord Chat Log</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 15px;
background-color: #fff;
}
a {
text-decoration: none;
color: #37bcf7;
}
a:hover {
text-decoration: underline;
}
div.pre, span.pre {
font-family: Consolas, Courier New, Courier, Monospace;
padding-right: 2px;
padding-left: 2px;
background-color: #f9f9f9;
}
div#info {
max-width: 100%;
margin-bottom: 20px;
color: #939799;
}
div#log {
max-width: 100%;
}
div.msg {
display: flex;
margin-right: 10px;
margin-left: 10px;
padding-top: 15px;
padding-bottom: 15px;
border-top: 1px solid #eceeef;
}
div.msg-avatar {
width: 40px;
height: 40px;
}
img.msg-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
div.msg-body {
margin-left: 15px;
flex: 1;
}
span.msg-user {
font-size: 1.15em;
color: #2f3136;
}
span.msg-date {
font-size: .8em;
font-weight: 200;
margin-left: 5px;
color: #b7bcbf;
}
span.msg-edited {
font-size: .8em;
font-weight: 200;
margin-left: 5px;
color: #b7bcbf;
}
div.msg-content {
padding-top: 5px;
color: #939799;
}
div.msg-attachment {
margin-top: 5px;
margin-bottom: 5px;
}
img.msg-attachment {
max-width: 50%;
max-height: 500px;
}
</style>
</head>
<body>
<div id="info"></div>
<div id="log"></div>
</body>
</html>
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Services
{
public interface IDataService
{
Task<IReadOnlyList<Guild>> GetGuildsAsync(string token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId);
}
}
@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Services
{
public interface IExportService
{
Task ExportAsync(string filePath, ChannelChatLog log, Theme theme);
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Services
{
public interface IMessageGroupService
{
IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages);
}
}
@@ -0,0 +1,15 @@
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Services
{
public interface ISettingsService
{
string Token { get; set; }
Theme Theme { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
void Load();
void Save();
}
}
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Services
{
public class MessageGroupService : IMessageGroupService
{
private readonly ISettingsService _settingsService;
public MessageGroupService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
{
var groupLimit = _settingsService.MessageGroupLimit;
var result = new List<MessageGroup>();
// Group adjacent messages by timestamp and author
var groupBuffer = new List<Message>();
foreach (var message in messages)
{
var groupFirst = groupBuffer.FirstOrDefault();
// Group break condition
var breakCondition =
groupFirst != null &&
(
message.Author.Id != groupFirst.Author.Id ||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour ||
groupBuffer.Count >= groupLimit
);
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
result.Add(group);
groupBuffer.Clear();
}
// Add message to buffer
groupBuffer.Add(message);
}
// Add what's remaining in buffer
if (groupBuffer.Any())
{
var groupFirst = groupBuffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
result.Add(group);
}
return result;
}
}
}
@@ -0,0 +1,20 @@
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 string DateFormat { get; set; } = "dd-MMM-yy hh:mm";
public int MessageGroupLimit { get; set; } = 20;
public SettingsService()
{
Configuration.StorageSpace = StorageSpace.Instance;
Configuration.SubDirectoryPath = "";
Configuration.FileName = "Settings.dat";
}
}
}
@@ -0,0 +1,15 @@
using DiscordChatExporter.Messages;
using GalaSoft.MvvmLight;
namespace DiscordChatExporter.ViewModels
{
public class ErrorViewModel : ViewModelBase, IErrorViewModel
{
public string Message { get; private set; }
public ErrorViewModel()
{
MessengerInstance.Register<ShowErrorMessage>(this, m => Message = m.Message);
}
}
}
@@ -0,0 +1,28 @@
using System.Diagnostics;
using DiscordChatExporter.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.ViewModels
{
public class ExportDoneViewModel : ViewModelBase, IExportDoneViewModel
{
private string _filePath;
// Commands
public RelayCommand OpenCommand { get; }
public ExportDoneViewModel()
{
MessengerInstance.Register<ShowExportDoneMessage>(this, m => _filePath = m.FilePath);
// Commands
OpenCommand = new RelayCommand(Open);
}
private void Open()
{
Process.Start(_filePath);
}
}
}
@@ -0,0 +1,7 @@
namespace DiscordChatExporter.ViewModels
{
public interface IErrorViewModel
{
string Message { get; }
}
}
@@ -0,0 +1,9 @@
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.ViewModels
{
public interface IExportDoneViewModel
{
RelayCommand OpenCommand { get; }
}
}
@@ -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,13 @@
using System.Collections.Generic;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.ViewModels
{
public interface ISettingsViewModel
{
IReadOnlyList<Theme> AvailableThemes { get; }
Theme Theme { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
}
}
@@ -0,0 +1,204 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using DiscordChatExporter.Exceptions;
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 IMessageGroupService _messageGroupService;
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;
private string _cachedToken;
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,
IMessageGroupService messageGroupService, IExportService exportService)
{
_settingsService = settingsService;
_dataService = dataService;
_messageGroupService = messageGroupService;
_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;
_cachedToken = Token;
// Clear existing
_guildChannelsMap.Clear();
try
{
// Get DM channels
{
var channels = await _dataService.GetDirectMessageChannelsAsync(_cachedToken);
var guild = new Guild("@me", "Direct Messages", null);
_guildChannelsMap[guild] = channels.ToArray();
}
// Get guild channels
{
var guilds = await _dataService.GetGuildsAsync(_cachedToken);
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(_cachedToken, guild.Id);
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
}
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
const string message = "Could not authorize using the given token. Make sure it's valid.";
MessengerInstance.Send(new ShowErrorMessage(message));
}
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
SelectedGuild = AvailableGuilds.FirstOrDefault();
IsBusy = false;
}
private async void ExportChannel(Channel channel)
{
IsBusy = true;
// Get safe file names
var safeGuildName = SelectedGuild.Name.Replace(Path.GetInvalidFileNameChars(), '_');
var safeChannelName = channel.Name.Replace(Path.GetInvalidFileNameChars(), '_');
// Ask for path
var sfd = new SaveFileDialog
{
FileName = $"{safeGuildName} - {safeChannelName}.html",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
AddExtension = true
};
if (sfd.ShowDialog() != true)
{
IsBusy = false;
return;
}
// Export
try
{
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(_cachedToken, channel.Id);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
// Export
await _exportService.ExportAsync(sfd.FileName, chatLog, _settingsService.Theme);
// Show dialog
MessengerInstance.Send(new ShowExportDoneMessage(sfd.FileName));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
const string message = "You don't have access to the messages in that channel.";
MessengerInstance.Send(new ShowErrorMessage(message));
}
IsBusy = false;
}
private void ShowSettings()
{
MessengerInstance.Send(new ShowSettingsMessage());
}
private void ShowAbout()
{
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
}
}
}
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
using GalaSoft.MvvmLight;
using Tyrrrz.Extensions;
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 string DateFormat
{
get => _settingsService.DateFormat;
set => _settingsService.DateFormat = value;
}
public int MessageGroupLimit
{
get => _settingsService.MessageGroupLimit;
set => _settingsService.MessageGroupLimit = value.ClampMin(0);
}
public SettingsViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
// Defaults
AvailableThemes = Enum.GetValues(typeof(Theme)).Cast<Theme>().ToArray();
}
}
}
@@ -0,0 +1,25 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.ErrorDialog" {
DataContext: bind ErrorViewModel from $resource Container
Width: 250
StackPanel {
// Message
TextBlock {
Margin: 16
FontSize: 16
TextWrapping: WrapWithOverflow
Text: bind Message
}
// OK
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "OK"
HorizontalAlignment: Right
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DiscordChatExporter.Views
{
public partial class ErrorDialog
{
public ErrorDialog()
{
InitializeComponent();
}
}
}
@@ -0,0 +1,37 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.ExportDoneDialog" {
DataContext: bind ExportDoneViewModel from $resource Container
Width: 250
StackPanel {
// Message
TextBlock {
Margin: 16
FontSize: 16
TextWrapping: WrapWithOverflow
Text: "Finished exporting chat log"
}
// Buttons
@StackPanelHorizontal {
HorizontalAlignment: Right
// Open
Button {
Command: bind OpenCommand
Content: "OPEN IT"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
// Dismiss
Button {
Command: DialogHost.CloseDialogCommand
Content: "DISMISS"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
}
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DiscordChatExporter.Views
{
public partial class ExportDoneDialog
{
public ExportDoneDialog()
{
InitializeComponent();
}
}
}
+267
View File
@@ -0,0 +1,267 @@
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 Container
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 {
// 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"
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
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: "12 4 12 4"
Width: 48
Height: 48
OpacityMask: RadialGradientBrush {
GradientStops: [
GradientStop { Color: "#FF000000", Offset: 0 }
GradientStop { Color: "#FF000000", Offset: 0.96 }
GradientStop { Color: "#00000000", Offset: 1 }
]
}
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: "16 7 0 6"
Kind: PackIconKind.Pound
VerticalAlignment: Center
}
TextBlock {
Margin: "3 8 8 8"
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,34 @@
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<ShowErrorMessage>(this, m => DialogHost.Show(new ErrorDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this, m => DialogHost.Show(new SettingsDialog()).Forget());
Messenger.Default.Register<ShowExportDoneMessage>(this, m => DialogHost.Show(new ExportDoneDialog()).Forget());
}
public void TokenTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
// Execute command
ViewModel.PullDataCommand.Execute(null);
}
}
}
}
@@ -0,0 +1,43 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.SettingsDialog" {
DataContext: bind SettingsViewModel from $resource Container
Width: 250
StackPanel {
// Theme
ComboBox {
Margin: "16 16 16 8"
HintAssist.Hint: "Theme"
HintAssist.IsFloating: true
IsReadOnly: true
ItemsSource: bind AvailableThemes
SelectedItem: bind Theme
}
// Date format
TextBox {
Margin: "16 8 16 8"
HintAssist.Hint: "Date format"
HintAssist.IsFloating: true
Text: bind DateFormat
}
// Group limit
TextBox {
Margin: "16 8 16 8"
HintAssist.Hint: "Message group limit"
HintAssist.IsFloating: true
Text: bind MessageGroupLimit
}
// Save
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "SAVE"
HorizontalAlignment: Right
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Views
{
public partial class SettingsDialog
{
public SettingsDialog()
{
InitializeComponent();
}
}
}
+238
View File
@@ -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 }
}
+12
View File
@@ -0,0 +1,12 @@
<?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="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>
+13 -30
View File
@@ -1,14 +1,19 @@
# DiscordChatExporter
Command line executable that can export [Discord](https://discordapp.com) channel chat logs to a pretty HTML file.
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
![](http://www.tyrrrz.me/projects/images/discordchatexporter_1.png)
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/3.png)
## Download
- [See releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
## Features
- Produces output styled similar to the Discord's light theme
- Exports to a self-contained HTML file
- Supports both dark and light theme
- Displays user avatars
- Groups messages by author and time
- Handles Discord markdown characters
@@ -17,33 +22,11 @@ Command line executable that can export [Discord](https://discordapp.com) channe
- Inserts download links for other types of attached files
- Marks edited messages with a timestamp
## Usage
The program expects an access token and channel ID as parameters.
`DiscordChatExporter.exe /token:REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 /channelId:459360869055190534`
#### Getting access token:
- Open Discord desktop or web client
- Press `Ctrl+Shift+I`
- Navigate to `Storage > LocalStorage > 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`
## Libraries used
- [HtmlAgilityPack](https://github.com/zzzprojects/html-agility-pack)
- [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json)
- [AmmyUI](https://github.com/AmmyUI/AmmyUI)
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)