mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2025-05-11 18:36:45 +02:00
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
This commit is contained in:
parent
d8bbe8c8c8
commit
6d7a8ae063
44 changed files with 1937 additions and 478 deletions
5
.gitignore
vendored
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
|
||||
|
|
69
DiscordChatExporter/App.ammy
Normal file
69
DiscordChatExporter/App.ammy
Normal 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")
|
||||
}
|
||||
|
||||
// Locator
|
||||
Locator Key="Locator" { }
|
||||
}
|
||||
}
|
17
DiscordChatExporter/App.ammy.cs
Normal file
17
DiscordChatExporter/App.ammy.cs
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
6
DiscordChatExporter/App.config
Normal file
6
DiscordChatExporter/App.config
Normal 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>
|
|
@ -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" />
|
||||
</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="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>
|
||||
<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>
|
||||
<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>
|
36
DiscordChatExporter/Locator.cs
Normal file
36
DiscordChatExporter/Locator.cs
Normal file
|
@ -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>();
|
||||
}
|
||||
}
|
6
DiscordChatExporter/Messages/ShowSettingsMessage.cs
Normal file
6
DiscordChatExporter/Messages/ShowSettingsMessage.cs
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
8
DiscordChatExporter/Models/AttachmentType.cs
Normal file
8
DiscordChatExporter/Models/AttachmentType.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public enum AttachmentType
|
||||
{
|
||||
Unrecognized,
|
||||
Image
|
||||
}
|
||||
}
|
23
DiscordChatExporter/Models/Channel.cs
Normal file
23
DiscordChatExporter/Models/Channel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
21
DiscordChatExporter/Models/ChannelChatLog.cs
Normal file
21
DiscordChatExporter/Models/ChannelChatLog.cs
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
11
DiscordChatExporter/Models/ChannelType.cs
Normal file
11
DiscordChatExporter/Models/ChannelType.cs
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
29
DiscordChatExporter/Models/Guild.cs
Normal file
29
DiscordChatExporter/Models/Guild.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
[STAThread]
|
||||
public static void Main()
|
||||
{
|
||||
// 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;
|
||||
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
|
||||
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)
|
||||
{
|
||||
// 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);
|
||||
|
||||
// Export
|
||||
Console.WriteLine("Exporting messages...");
|
||||
ExportService.Export($"{options.ChannelId}.html", chatLog, options.Theme);
|
||||
}
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Console.Title = "Discord Chat Exporter";
|
||||
|
||||
MainAsync(args).GetAwaiter().GetResult();
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
}
|
7
DiscordChatExporter/Properties/AssemblyInfo.cs
Normal file
7
DiscordChatExporter/Properties/AssemblyInfo.cs
Normal file
|
@ -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")]
|
71
DiscordChatExporter/Properties/Resources.Designer.cs
generated
Normal file
71
DiscordChatExporter/Properties/Resources.Designer.cs
generated
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
117
DiscordChatExporter/Properties/Resources.resx
Normal file
117
DiscordChatExporter/Properties/Resources.resx
Normal file
|
@ -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 {
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
|
||||
padding-right: 2px;
|
||||
padding-left: 2px;
|
||||
|
||||
background-color: #2F3136;
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
div#info {
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
div#log {
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
|
||||
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-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 {
|
||||
font-size: .75rem;
|
||||
|
||||
margin-left: 5px;
|
||||
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
font-size: .75rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
span.msg-edited {
|
||||
font-size: .8rem;
|
||||
|
||||
margin-left: 5px;
|
||||
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
font-size: .8rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
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 {
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
|
||||
padding-right: 2px;
|
||||
padding-left: 2px;
|
||||
|
||||
background-color: #F9F9F9;
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
div#info {
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
|
||||
color: #737F8D;
|
||||
}
|
||||
|
||||
div#log {
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
|
||||
border-top: 1px solid #ECEEEF;
|
||||
display: flex;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
padding-bottom: 15px;
|
||||
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 {
|
||||
font-size: .75rem;
|
||||
|
||||
margin-left: 5px;
|
||||
|
||||
color: #99AAB5;
|
||||
font-size: .75rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
span.msg-edited {
|
||||
font-size: .8rem;
|
||||
|
||||
margin-left: 5px;
|
||||
|
||||
color: #99AAB5;
|
||||
font-size: .8rem;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
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%;
|
||||
}
|
193
DiscordChatExporter/Services/DataService.cs
Normal file
193
DiscordChatExporter/Services/DataService.cs
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
17
DiscordChatExporter/Services/IDataService.cs
Normal file
17
DiscordChatExporter/Services/IDataService.cs
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
9
DiscordChatExporter/Services/IExportService.cs
Normal file
9
DiscordChatExporter/Services/IExportService.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using DiscordChatExporter.Models;
|
||||
|
||||
namespace DiscordChatExporter.Services
|
||||
{
|
||||
public interface IExportService
|
||||
{
|
||||
void Export(string filePath, ChannelChatLog channelChatLog, Theme theme);
|
||||
}
|
||||
}
|
13
DiscordChatExporter/Services/ISettingsService.cs
Normal file
13
DiscordChatExporter/Services/ISettingsService.cs
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
18
DiscordChatExporter/Services/SettingsService.cs
Normal file
18
DiscordChatExporter/Services/SettingsService.cs
Normal file
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
23
DiscordChatExporter/ViewModels/IMainViewModel.cs
Normal file
23
DiscordChatExporter/ViewModels/IMainViewModel.cs
Normal file
|
@ -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; }
|
||||
}
|
||||
}
|
11
DiscordChatExporter/ViewModels/ISettingsViewModel.cs
Normal file
11
DiscordChatExporter/ViewModels/ISettingsViewModel.cs
Normal file
|
@ -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; }
|
||||
}
|
||||
}
|
179
DiscordChatExporter/ViewModels/MainViewModel.cs
Normal file
179
DiscordChatExporter/ViewModels/MainViewModel.cs
Normal file
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
30
DiscordChatExporter/ViewModels/SettingsViewModel.cs
Normal file
30
DiscordChatExporter/ViewModels/SettingsViewModel.cs
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
261
DiscordChatExporter/Views/MainWindow.ammy
Normal file
261
DiscordChatExporter/Views/MainWindow.ammy
Normal file
|
@ -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: ""token""
|
||||
Foreground: resource dyn "PrimaryTextBrush"
|
||||
}
|
||||
Run {
|
||||
Text: "under key and copy the value"
|
||||
}
|
||||
LineBreak { }
|
||||
Run {
|
||||
Text: "7. Paste the value in the textbox above"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
DiscordChatExporter/Views/MainWindow.ammy.cs
Normal file
32
DiscordChatExporter/Views/MainWindow.ammy.cs
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26
DiscordChatExporter/Views/SettingsDialog.ammy
Normal file
26
DiscordChatExporter/Views/SettingsDialog.ammy
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
10
DiscordChatExporter/Views/SettingsDialog.ammy.cs
Normal file
10
DiscordChatExporter/Views/SettingsDialog.ammy.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace DiscordChatExporter.Views
|
||||
{
|
||||
public partial class SettingsDialog
|
||||
{
|
||||
public SettingsDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
238
DiscordChatExporter/lib.ammy
Normal file
238
DiscordChatExporter/lib.ammy
Normal 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 }
|
||||
}
|
13
DiscordChatExporter/packages.config
Normal file
13
DiscordChatExporter/packages.config
Normal file
|
@ -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>
|
30
Readme.md
30
Readme.md
|
@ -1,6 +1,6 @@
|
|||
# DiscordChatExporter
|
||||
|
||||
DiscordChatExporter can be used to export message history from [Discord](https://discordapp.com) to an HTML file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments, and has an option to choose between light and dark themes.
|
||||
DiscordChatExporter can be used to export message history from [Discord](https://discordapp.com) to an HTML file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. There are options to configure the output, such as date format, color theme, message grouping limit, etc.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
@ -12,6 +12,7 @@ DiscordChatExporter can be used to export message history from [Discord](https:/
|
|||
|
||||
## Features
|
||||
|
||||
- Exports to a self-contained HTML file
|
||||
- Supports both dark and light theme
|
||||
- Displays user avatars
|
||||
- Groups messages by author and time
|
||||
|
@ -23,32 +24,7 @@ DiscordChatExporter can be used to export message history from [Discord](https:/
|
|||
|
||||
## Usage
|
||||
|
||||
The program expects an access token and channel ID as parameters. At minimum, the execution should look like this:
|
||||
|
||||
`DiscordChatExporter.exe /token:REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 /channelId:459360869055190534`
|
||||
|
||||
#### Getting access token
|
||||
|
||||
- Open Discord desktop or web client
|
||||
- Press `Ctrl+Shift+I`
|
||||
- Navigate to `Application > Storage > Local Storage > https://discordapp.com`
|
||||
- Find the value for `token` and extract it
|
||||
|
||||
#### Getting channel ID
|
||||
|
||||
- Open Discord desktop or web client
|
||||
- Navigate to any DM or server channel
|
||||
- Extract the current URL:
|
||||
- If using desktop client, press `Ctrl+Shift+I`, type `window.location.href` in console and extract the result
|
||||
- If using web client, just take the current URL from the address bar
|
||||
- Pull the ID from the URL:
|
||||
- If it's a DM channel, the format looks like this: `https://discordapp.com/channels/@me/CHANNEL_ID`
|
||||
- If it's a server channel, the format looks like this:
|
||||
`https://discordapp.com/channels/WHATEVER/CHANNEL_ID`
|
||||
|
||||
#### Optional arguments
|
||||
|
||||
- `/theme:[Dark/Light]` - sets the style of the output
|
||||
Check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki) for helpful information on how to use this tool.
|
||||
|
||||
## Libraries used
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue