mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-09 15:39:34 +02:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d6edfd937 | |||
| 60a5a69aa4 | |||
| cc565598d3 | |||
| 6a7000cbc9 | |||
| 6706ff95f1 | |||
| ecc95c9c7f | |||
| d4f2049db6 | |||
| 4d5118de76 | |||
| 171718989a | |||
| 75e8d59cc3 | |||
| 1d7264a71b | |||
| 2c0a63352c | |||
| 42d4d64695 | |||
| 6968f987ce | |||
| 5ecbfd50b8 | |||
| e59a1ea8b4 | |||
| 540ce7f3c3 | |||
| a0d261f966 | |||
| 10446064d5 | |||
| 6709821324 | |||
| 705e60c76d | |||
| 8ac8c16db3 | |||
| 1850caba71 | |||
| 6ccaf3f106 | |||
| d7345e91d3 | |||
| e5e9d4c9ff | |||
| b280f8b736 | |||
| ca452d2421 | |||
| 9e7d4033e0 | |||
| 263a606bff | |||
| 1e853f11b0 | |||
| 77d0956f5d | |||
| d251e8ecf6 | |||
| 01f0e6952d | |||
| 28a67ab60d | |||
| 42bb4e72d2 | |||
| a79d72f975 | |||
| b110413c08 | |||
| a556926fe6 | |||
| 5a57b4a6b1 | |||
| d3b311b88f | |||
| d8db4fca90 | |||
| b7979d6d1f | |||
| 749a55a30e | |||
| 7b9de314ab | |||
| 69184a74fe | |||
| efae0e99b8 | |||
| 8afe9852fb | |||
| f2178a0445 | |||
| c01b9d0e5f | |||
| 151c5e2f23 | |||
| 6d7a8ae063 | |||
| d8bbe8c8c8 | |||
| 9a1a257f1e | |||
| 5e0a39df5e | |||
| fb3b14f1ce | |||
| 34afce2a66 | |||
| 95d581797b | |||
| 22d7dd43ec | |||
| dd942824ec | |||
| 0974dd77bf |
@@ -259,3 +259,9 @@ paket-files/
|
|||||||
# Python Tools for Visual Studio (PTVS)
|
# Python Tools for Visual Studio (PTVS)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Ammy auto-generated XAML
|
||||||
|
*.g.xaml
|
||||||
|
|
||||||
|
# Deploy output
|
||||||
|
Deploy/Output/
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
$path = "$PSScriptRoot\..\DiscordChatExporter\bin\Release\*"
|
||||||
|
$include = "*.exe", "*.dll", "*.config"
|
||||||
|
$outputDir = "$PSScriptRoot\Output"
|
||||||
|
$outputFile = "DiscordChatExporter.zip"
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
if (-Not (Test-Path $outputDir))
|
||||||
|
{
|
||||||
|
New-Item $outputDir -ItemType Directory
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete output if already exists
|
||||||
|
if (Test-Path("$outputDir\$outputFile"))
|
||||||
|
{
|
||||||
|
Remove-Item -Path "$outputDir\$outputFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get files
|
||||||
|
$files = Get-ChildItem -Path $path -Include $include
|
||||||
|
|
||||||
|
# Pack into archive
|
||||||
|
$files | Compress-Archive -DestinationPath "$outputDir\$outputFile"
|
||||||
+10
-7
@@ -1,28 +1,31 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio 15
|
# Visual Studio 15
|
||||||
VisualStudioVersion = 15.0.26430.13
|
VisualStudioVersion = 15.0.26730.15
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
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}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
License.txt = License.txt
|
License.txt = License.txt
|
||||||
Readme.md = Readme.md
|
Readme.md = Readme.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{732A67AF-93DE-49DF-B10F-FD74710B7863}.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}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {51587D08-01E1-4511-AC57-A417D1A9162F}
|
||||||
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
Application "DiscordChatExporter.App" {
|
||||||
|
StartupUri: "Views/MainWindow.g.xaml"
|
||||||
|
Startup: App_Startup
|
||||||
|
Exit: App_Exit
|
||||||
|
|
||||||
|
Resources: ResourceDictionary {
|
||||||
|
// Material Design
|
||||||
|
#MergeDictionary("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml")
|
||||||
|
#MergeDictionary("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml")
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
Color Key="PrimaryColor" { "#343838" }
|
||||||
|
Color Key="PrimaryLightColor" { "#5E6262" }
|
||||||
|
Color Key="PrimaryDarkColor" { "#0D1212" }
|
||||||
|
Color Key="AccentColor" { "#F9A825" }
|
||||||
|
Color Key="TextColor" { "#000000" }
|
||||||
|
Color Key="InverseTextColor" { "#FFFFFF" }
|
||||||
|
|
||||||
|
// Brushes
|
||||||
|
SolidColorBrush Key="PrimaryHueLightBrush" { Color: resource dyn "PrimaryLightColor" }
|
||||||
|
SolidColorBrush Key="PrimaryHueLightForegroundBrush" { Color: resource dyn "InverseTextColor" }
|
||||||
|
SolidColorBrush Key="PrimaryHueMidBrush" { Color: resource dyn "PrimaryColor" }
|
||||||
|
SolidColorBrush Key="PrimaryHueMidForegroundBrush" { Color: resource dyn "InverseTextColor" }
|
||||||
|
SolidColorBrush Key="PrimaryHueDarkBrush" { Color: resource dyn "PrimaryDarkColor" }
|
||||||
|
SolidColorBrush Key="PrimaryHueDarkForegroundBrush" { Color: resource dyn "InverseTextColor" }
|
||||||
|
SolidColorBrush Key="SecondaryAccentBrush" { Color: resource dyn "AccentColor" }
|
||||||
|
SolidColorBrush Key="SecondaryAccentForegroundBrush" { Color: resource dyn "TextColor" }
|
||||||
|
SolidColorBrush Key="PrimaryTextBrush" { Color: resource dyn "TextColor", Opacity: 0.87 }
|
||||||
|
SolidColorBrush Key="SecondaryTextBrush" { Color: resource dyn "TextColor", Opacity: 0.64 }
|
||||||
|
SolidColorBrush Key="DimTextBrush" { Color: resource dyn "TextColor", Opacity: 0.45 }
|
||||||
|
SolidColorBrush Key="PrimaryInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 1 }
|
||||||
|
SolidColorBrush Key="SecondaryInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.7 }
|
||||||
|
SolidColorBrush Key="DimInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.52 }
|
||||||
|
SolidColorBrush Key="AccentTextBrush" { Color: resource dyn "AccentColor", Opacity: 1 }
|
||||||
|
SolidColorBrush Key="DividerBrush" { Color: resource dyn "TextColor", Opacity: 0.12 }
|
||||||
|
SolidColorBrush Key="InverseDividerBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.12 }
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
Style {
|
||||||
|
TargetType: "Image"
|
||||||
|
#Setter("RenderOptions.BitmapScalingMode", "HighQuality")
|
||||||
|
}
|
||||||
|
|
||||||
|
Style {
|
||||||
|
TargetType: "ProgressBar"
|
||||||
|
BasedOn: resource "MaterialDesignLinearProgressBar"
|
||||||
|
#Setter("Foreground", resource dyn "SecondaryAccentBrush")
|
||||||
|
#Setter("Height", 2)
|
||||||
|
#Setter("Minimum", 0)
|
||||||
|
#Setter("Maximum", 1)
|
||||||
|
#Setter("BorderThickness", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Style {
|
||||||
|
TargetType: "TextBox"
|
||||||
|
BasedOn: resource "MaterialDesignTextBox"
|
||||||
|
#Setter("Foreground", resource dyn "PrimaryTextBrush")
|
||||||
|
}
|
||||||
|
|
||||||
|
Style {
|
||||||
|
TargetType: "ComboBox"
|
||||||
|
BasedOn: resource "MaterialDesignComboBox"
|
||||||
|
#Setter("Foreground", resource dyn "PrimaryTextBrush")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container
|
||||||
|
Container Key="Container" { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter
|
||||||
|
{
|
||||||
|
public partial class App
|
||||||
|
{
|
||||||
|
private void App_Startup(object sender, StartupEventArgs e)
|
||||||
|
{
|
||||||
|
Container.Init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void App_Exit(object sender, ExitEventArgs e)
|
||||||
|
{
|
||||||
|
Container.Cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using DiscordChatExporter.Services;
|
||||||
|
using DiscordChatExporter.ViewModels;
|
||||||
|
using GalaSoft.MvvmLight.Ioc;
|
||||||
|
using Microsoft.Practices.ServiceLocation;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter
|
||||||
|
{
|
||||||
|
public class Container
|
||||||
|
{
|
||||||
|
public static void Init()
|
||||||
|
{
|
||||||
|
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
|
||||||
|
ServiceLocator.Current.GetInstance<ISettingsService>().Load();
|
||||||
|
|
||||||
|
// Services
|
||||||
|
SimpleIoc.Default.Register<IDataService, DataService>();
|
||||||
|
SimpleIoc.Default.Register<IExportService, ExportService>();
|
||||||
|
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
|
||||||
|
|
||||||
|
// View models
|
||||||
|
SimpleIoc.Default.Register<IErrorViewModel, ErrorViewModel>(true);
|
||||||
|
SimpleIoc.Default.Register<IExportDoneViewModel, ExportDoneViewModel>(true);
|
||||||
|
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
|
||||||
|
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
|
||||||
|
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Cleanup()
|
||||||
|
{
|
||||||
|
// Settings
|
||||||
|
ServiceLocator.Current.GetInstance<ISettingsService>().Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IErrorViewModel ErrorViewModel => ServiceLocator.Current.GetInstance<IErrorViewModel>();
|
||||||
|
public IExportDoneViewModel ExportDoneViewModel => ServiceLocator.Current.GetInstance<IExportDoneViewModel>();
|
||||||
|
public IExportSetupViewModel ExportSetupViewModel => ServiceLocator.Current.GetInstance<IExportSetupViewModel>();
|
||||||
|
public IMainViewModel MainViewModel => ServiceLocator.Current.GetInstance<IMainViewModel>();
|
||||||
|
public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance<ISettingsViewModel>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,211 @@
|
|||||||
<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>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||||
<TargetFramework>net45</TargetFramework>
|
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||||
<AssemblyVersion>1.0.0</AssemblyVersion>
|
<ProjectGuid>{732A67AF-93DE-49DF-B10F-FD74710B7863}</ProjectGuid>
|
||||||
<FileVersion>$(AssemblyVersion)</FileVersion>
|
<OutputType>WinExe</OutputType>
|
||||||
<Company>Tyrrrz</Company>
|
<RootNamespace>DiscordChatExporter</RootNamespace>
|
||||||
<Copyright>Copyright (c) 2017 Alexey Golub</Copyright>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="Services\ExportTemplate.html" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.5.1" />
|
|
||||||
<PackageReference Include="Newtonsoft.json" Version="10.0.3" />
|
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.4.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Reference Include="AmmySidekick, Version=1.0.0.0, Culture=neutral, PublicKeyToken=7c1296d24569a67d, processorArchitecture=MSIL">
|
||||||
|
<HintPath>..\packages\Ammy.WPF.1.2.87\lib\net40\AmmySidekick.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="GalaSoft.MvvmLight, Version=5.3.0.19026, Culture=neutral, PublicKeyToken=e7570ab207bcb616, processorArchitecture=MSIL">
|
||||||
|
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="GalaSoft.MvvmLight.Extras, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=669f0b5e8f868abf, processorArchitecture=MSIL">
|
||||||
|
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Extras.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="GalaSoft.MvvmLight.Platform, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL">
|
||||||
|
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL">
|
||||||
|
<HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="MaterialDesignThemes.Wpf, Version=2.3.1.953, Culture=neutral, processorArchitecture=MSIL">
|
||||||
|
<HintPath>..\packages\MaterialDesignThemes.2.3.1.953\lib\net45\MaterialDesignThemes.Wpf.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Microsoft.Practices.ServiceLocation, Version=1.3.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
||||||
|
<HintPath>..\packages\CommonServiceLocator.1.3\lib\portable-net4+sl5+netcore45+wpa81+wp8\Microsoft.Practices.ServiceLocation.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Newtonsoft.Json, Version=10.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||||
|
<HintPath>..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="System" />
|
||||||
|
<Reference Include="System.Core" />
|
||||||
<Reference Include="System.Net.Http" />
|
<Reference Include="System.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>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Exceptions\HttpErrorStatusCodeException.cs" />
|
||||||
|
<Compile Include="Messages\ShowErrorMessage.cs" />
|
||||||
|
<Compile Include="Messages\ShowExportDoneMessage.cs" />
|
||||||
|
<Compile Include="Messages\ShowExportSetupMessage.cs" />
|
||||||
|
<Compile Include="Messages\ShowSettingsMessage.cs" />
|
||||||
|
<Compile Include="Messages\StartExportMessage.cs" />
|
||||||
|
<Compile Include="Models\AttachmentType.cs" />
|
||||||
|
<Compile Include="Models\ChannelChatLog.cs" />
|
||||||
|
<Compile Include="Models\ChannelType.cs" />
|
||||||
|
<Compile Include="Models\ExportFormat.cs" />
|
||||||
|
<Compile Include="Models\Extensions.cs" />
|
||||||
|
<Compile Include="Models\Role.cs" />
|
||||||
|
<Compile Include="Models\MessageType.cs" />
|
||||||
|
<Compile Include="Services\IMessageGroupService.cs" />
|
||||||
|
<Compile Include="Services\MessageGroupService.cs" />
|
||||||
|
<Compile Include="ViewModels\ErrorViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\ExportSetupViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\IErrorViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\ISettingsViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\IExportDoneViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\SettingsViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\ExportDoneViewModel.cs" />
|
||||||
|
<Compile Include="Views\ErrorDialog.ammy.cs">
|
||||||
|
<DependentUpon>ErrorDialog.ammy</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="Views\ExportDoneDialog.ammy.cs">
|
||||||
|
<DependentUpon>ExportDoneDialog.ammy</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="Views\ExportSetupDialog.ammy.cs">
|
||||||
|
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="Views\SettingsDialog.ammy.cs">
|
||||||
|
<DependentUpon>SettingsDialog.ammy</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Page Include="App.g.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>XamlIntelliSenseFileGenerator</Generator>
|
||||||
|
<DependentUpon>App.ammy</DependentUpon>
|
||||||
|
</Page>
|
||||||
|
<Page Include="Views\ErrorDialog.g.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<DependentUpon>ErrorDialog.ammy</DependentUpon>
|
||||||
|
</Page>
|
||||||
|
<Page Include="Views\ExportDoneDialog.g.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<DependentUpon>ExportDoneDialog.ammy</DependentUpon>
|
||||||
|
</Page>
|
||||||
|
<Page Include="Views\ExportSetupDialog.g.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
|
||||||
|
</Page>
|
||||||
|
<Page Include="Views\MainWindow.g.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<DependentUpon>MainWindow.ammy</DependentUpon>
|
||||||
|
</Page>
|
||||||
|
<Compile Include="App.ammy.cs">
|
||||||
|
<DependentUpon>App.ammy</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="Container.cs" />
|
||||||
|
<Compile Include="Models\Attachment.cs" />
|
||||||
|
<Compile Include="Models\Channel.cs" />
|
||||||
|
<Compile Include="Models\Guild.cs" />
|
||||||
|
<Compile Include="Models\Message.cs" />
|
||||||
|
<Compile Include="Models\MessageGroup.cs" />
|
||||||
|
<Compile Include="Models\User.cs" />
|
||||||
|
<Compile Include="Program.cs" />
|
||||||
|
<Compile Include="Services\DataService.cs" />
|
||||||
|
<Compile Include="Services\ExportService.cs" />
|
||||||
|
<Compile Include="Services\IDataService.cs" />
|
||||||
|
<Compile Include="Services\IExportService.cs" />
|
||||||
|
<Compile Include="Services\ISettingsService.cs" />
|
||||||
|
<Compile Include="Services\SettingsService.cs" />
|
||||||
|
<Compile Include="ViewModels\IMainViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\MainViewModel.cs" />
|
||||||
|
<Compile Include="Views\MainWindow.ammy.cs">
|
||||||
|
<DependentUpon>MainWindow.ammy</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Page Include="Views\SettingsDialog.g.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<DependentUpon>SettingsDialog.ammy</DependentUpon>
|
||||||
|
</Page>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Properties\AssemblyInfo.cs">
|
||||||
|
<SubType>Code</SubType>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="Properties\Resources.Designer.cs">
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
<DependentUpon>Resources.resx</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<EmbeddedResource Include="Properties\Resources.resx">
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<None Include="App.ammy" />
|
||||||
|
<None Include="lib.ammy" />
|
||||||
|
<None Include="packages.config">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
</None>
|
||||||
|
<None Include="Views\ErrorDialog.ammy" />
|
||||||
|
<None Include="Views\ExportDoneDialog.ammy" />
|
||||||
|
<None Include="Views\ExportSetupDialog.ammy" />
|
||||||
|
<None Include="Views\MainWindow.ammy" />
|
||||||
|
<None Include="Views\SettingsDialog.ammy" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="App.config" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
|
||||||
|
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||||
|
<Import Project="..\packages\Ammy.1.2.87\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" />
|
||||||
|
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||||
|
<PropertyGroup>
|
||||||
|
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Error Condition="!Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Ammy.1.2.87\build\Ammy.targets'))" />
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Exceptions
|
||||||
|
{
|
||||||
|
public class HttpErrorStatusCodeException : Exception
|
||||||
|
{
|
||||||
|
public HttpStatusCode StatusCode { get; }
|
||||||
|
|
||||||
|
public HttpErrorStatusCodeException(HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace DiscordChatExporter.Messages
|
||||||
|
{
|
||||||
|
public class ShowErrorMessage
|
||||||
|
{
|
||||||
|
public string Message { get; }
|
||||||
|
|
||||||
|
public ShowErrorMessage(string message)
|
||||||
|
{
|
||||||
|
Message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace DiscordChatExporter.Messages
|
||||||
|
{
|
||||||
|
public class ShowExportDoneMessage
|
||||||
|
{
|
||||||
|
public string FilePath { get; }
|
||||||
|
|
||||||
|
public ShowExportDoneMessage(string filePath)
|
||||||
|
{
|
||||||
|
FilePath = filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using DiscordChatExporter.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Messages
|
||||||
|
{
|
||||||
|
public class ShowExportSetupMessage
|
||||||
|
{
|
||||||
|
public Guild Guild { get; }
|
||||||
|
|
||||||
|
public Channel Channel { get; }
|
||||||
|
|
||||||
|
public ShowExportSetupMessage(Guild guild, Channel channel)
|
||||||
|
{
|
||||||
|
Guild = guild;
|
||||||
|
Channel = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace DiscordChatExporter.Messages
|
||||||
|
{
|
||||||
|
public class ShowSettingsMessage
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Messages
|
||||||
|
{
|
||||||
|
public class StartExportMessage
|
||||||
|
{
|
||||||
|
public Channel Channel { get; }
|
||||||
|
|
||||||
|
public string FilePath { get; }
|
||||||
|
|
||||||
|
public ExportFormat Format { get; }
|
||||||
|
|
||||||
|
public DateTime? From { get; }
|
||||||
|
|
||||||
|
public DateTime? To { get; }
|
||||||
|
|
||||||
|
public StartExportMessage(Channel channel, string filePath, ExportFormat format,
|
||||||
|
DateTime? from, DateTime? to)
|
||||||
|
{
|
||||||
|
Channel = channel;
|
||||||
|
FilePath = filePath;
|
||||||
|
Format = format;
|
||||||
|
From = from;
|
||||||
|
To = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,21 @@
|
|||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
|
public AttachmentType Type { get; }
|
||||||
|
|
||||||
public string Url { get; }
|
public string Url { get; }
|
||||||
|
|
||||||
public string FileName { 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;
|
Id = id;
|
||||||
|
Type = type;
|
||||||
Url = url;
|
Url = url;
|
||||||
FileName = fileName;
|
FileName = fileName;
|
||||||
IsImage = isImage;
|
FileSize = fileSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Models
|
||||||
|
{
|
||||||
|
public enum AttachmentType
|
||||||
|
{
|
||||||
|
Other,
|
||||||
|
Image
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace DiscordChatExporter.Models
|
||||||
|
{
|
||||||
|
public class Channel
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public ChannelType Type { get; }
|
||||||
|
|
||||||
|
public Channel(string id, string name, ChannelType type)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Models
|
||||||
|
{
|
||||||
|
public class ChannelChatLog
|
||||||
|
{
|
||||||
|
public Guild Guild { get; }
|
||||||
|
|
||||||
|
public Channel Channel { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<MessageGroup> MessageGroups { get; }
|
||||||
|
|
||||||
|
public int TotalMessageCount { get; }
|
||||||
|
|
||||||
|
public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList<MessageGroup> messageGroups,
|
||||||
|
int totalMessageCount)
|
||||||
|
{
|
||||||
|
Guild = guild;
|
||||||
|
Channel = channel;
|
||||||
|
MessageGroups = messageGroups;
|
||||||
|
TotalMessageCount = totalMessageCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace DiscordChatExporter.Models
|
||||||
|
{
|
||||||
|
public enum ChannelType
|
||||||
|
{
|
||||||
|
GuildTextChat,
|
||||||
|
DirectTextChat,
|
||||||
|
GuildVoiceChat,
|
||||||
|
DirectGroupTextChat,
|
||||||
|
Category
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Models
|
|
||||||
{
|
|
||||||
public class ChatLog
|
|
||||||
{
|
|
||||||
public string ChannelId { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<User> Participants { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Message> Messages { get; }
|
|
||||||
|
|
||||||
public ChatLog(string channelId, IEnumerable<Message> messages)
|
|
||||||
{
|
|
||||||
ChannelId = channelId;
|
|
||||||
Messages = messages.ToArray();
|
|
||||||
Participants = Messages.Select(m => m.Author).Distinct(a => a.Name).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace DiscordChatExporter.Models
|
||||||
|
{
|
||||||
|
public enum ExportFormat
|
||||||
|
{
|
||||||
|
PlainText,
|
||||||
|
HtmlDark,
|
||||||
|
HtmlLight
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Models
|
||||||
|
{
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
public static string GetFileExtension(this ExportFormat format)
|
||||||
|
{
|
||||||
|
if (format == ExportFormat.PlainText)
|
||||||
|
return "txt";
|
||||||
|
if (format == ExportFormat.HtmlDark)
|
||||||
|
return "html";
|
||||||
|
if (format == ExportFormat.HtmlLight)
|
||||||
|
return "html";
|
||||||
|
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDisplayName(this ExportFormat format)
|
||||||
|
{
|
||||||
|
if (format == ExportFormat.PlainText)
|
||||||
|
return "Plain Text";
|
||||||
|
if (format == ExportFormat.HtmlDark)
|
||||||
|
return "HTML (Dark)";
|
||||||
|
if (format == ExportFormat.HtmlLight)
|
||||||
|
return "HTML (Light)";
|
||||||
|
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Models
|
namespace DiscordChatExporter.Models
|
||||||
{
|
{
|
||||||
@@ -8,24 +7,37 @@ namespace DiscordChatExporter.Models
|
|||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
|
public User Author { get; }
|
||||||
|
|
||||||
public DateTime TimeStamp { get; }
|
public DateTime TimeStamp { get; }
|
||||||
|
|
||||||
public DateTime? EditedTimeStamp { get; }
|
public DateTime? EditedTimeStamp { get; }
|
||||||
|
|
||||||
public User Author { get; }
|
|
||||||
|
|
||||||
public string Content { get; }
|
public string Content { get; }
|
||||||
|
|
||||||
public IReadOnlyList<Attachment> Attachments { get; }
|
public IReadOnlyList<Attachment> Attachments { get; }
|
||||||
|
|
||||||
public Message(string id, DateTime timeStamp, DateTime? editedTimeStamp, User author, string content, IEnumerable<Attachment> attachments)
|
public IReadOnlyList<User> MentionedUsers { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Role> MentionedRoles { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Channel> MentionedChannels { get; }
|
||||||
|
|
||||||
|
public Message(string id, User author,
|
||||||
|
DateTime timeStamp, DateTime? editedTimeStamp,
|
||||||
|
string content, IReadOnlyList<Attachment> attachments,
|
||||||
|
IReadOnlyList<User> mentionedUsers, IReadOnlyList<Role> mentionedRoles,
|
||||||
|
IReadOnlyList<Channel> mentionedChannels)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
|
Author = author;
|
||||||
TimeStamp = timeStamp;
|
TimeStamp = timeStamp;
|
||||||
EditedTimeStamp = editedTimeStamp;
|
EditedTimeStamp = editedTimeStamp;
|
||||||
Author = author;
|
|
||||||
Content = content;
|
Content = content;
|
||||||
Attachments = attachments.ToArray();
|
Attachments = attachments;
|
||||||
|
MentionedUsers = mentionedUsers;
|
||||||
|
MentionedRoles = mentionedRoles;
|
||||||
|
MentionedChannels = mentionedChannels;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Models
|
namespace DiscordChatExporter.Models
|
||||||
{
|
{
|
||||||
@@ -8,15 +7,15 @@ namespace DiscordChatExporter.Models
|
|||||||
{
|
{
|
||||||
public User Author { get; }
|
public User Author { get; }
|
||||||
|
|
||||||
public DateTime FirstTimeStamp { get; }
|
public DateTime TimeStamp { get; }
|
||||||
|
|
||||||
public IReadOnlyList<Message> Messages { get; }
|
public IReadOnlyList<Message> Messages { get; }
|
||||||
|
|
||||||
public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable<Message> messages)
|
public MessageGroup(User author, DateTime timeStamp, IReadOnlyList<Message> messages)
|
||||||
{
|
{
|
||||||
Author = author;
|
Author = author;
|
||||||
FirstTimeStamp = firstTimeStamp;
|
TimeStamp = timeStamp;
|
||||||
Messages = messages.ToArray();
|
Messages = messages;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace DiscordChatExporter.Models
|
||||||
|
{
|
||||||
|
public enum MessageType
|
||||||
|
{
|
||||||
|
Default,
|
||||||
|
RecipientAdd,
|
||||||
|
RecipientRemove,
|
||||||
|
Call,
|
||||||
|
ChannelNameChange,
|
||||||
|
ChannelIconChange,
|
||||||
|
ChannelPinnedMessage,
|
||||||
|
GuildMemberJoin
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
namespace DiscordChatExporter.Models
|
|
||||||
{
|
|
||||||
public class Options
|
|
||||||
{
|
|
||||||
public string Token { get; }
|
|
||||||
|
|
||||||
public string ChannelId { get; }
|
|
||||||
|
|
||||||
public Options(string token, string channelId)
|
|
||||||
{
|
|
||||||
Token = token;
|
|
||||||
ChannelId = channelId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace DiscordChatExporter.Models
|
||||||
|
{
|
||||||
|
public class Role
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public Role(string id, string name)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,24 +6,27 @@ namespace DiscordChatExporter.Models
|
|||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
|
public int Discriminator { get; }
|
||||||
|
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
|
|
||||||
public string AvatarHash { get; }
|
public string AvatarHash { get; }
|
||||||
|
|
||||||
public string AvatarUrl => AvatarHash.IsNotBlank()
|
public string AvatarUrl => AvatarHash.IsNotBlank()
|
||||||
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size=256"
|
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
|
||||||
: "https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.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;
|
Id = id;
|
||||||
|
Discriminator = discriminator;
|
||||||
Name = name;
|
Name = name;
|
||||||
AvatarHash = avatarHash;
|
AvatarHash = avatarHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return Name;
|
return $"{Name}#{Discriminator}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,66 +1,36 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.IO;
|
||||||
using System.Text.RegularExpressions;
|
using System.Reflection;
|
||||||
using System.Threading.Tasks;
|
using System.Resources;
|
||||||
using DiscordChatExporter.Models;
|
using AmmySidekick;
|
||||||
using DiscordChatExporter.Services;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter
|
namespace DiscordChatExporter
|
||||||
{
|
{
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
private static readonly DiscordApiService DiscordApiService = new DiscordApiService();
|
[STAThread]
|
||||||
private static readonly ExportService ExportService = new ExportService();
|
public static void Main()
|
||||||
|
|
||||||
private static Options GetOptions(string[] args)
|
|
||||||
{
|
{
|
||||||
// Parse the arguments
|
var app = new App();
|
||||||
var argsDic = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
app.InitializeComponent();
|
||||||
foreach (string arg in args)
|
|
||||||
|
RuntimeUpdateHandler.Register(app, $"/{Ammy.GetAssemblyName(app)};component/App.g.xaml");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetResourceString(string resourcePath)
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var stream = assembly.GetManifestResourceStream(resourcePath);
|
||||||
|
if (stream == null)
|
||||||
|
throw new MissingManifestResourceException("Could not find resource");
|
||||||
|
|
||||||
|
using (stream)
|
||||||
|
using (var reader = new StreamReader(stream))
|
||||||
{
|
{
|
||||||
var match = Regex.Match(arg, "/(.*?):\"?(.*?)\"?$");
|
return reader.ReadToEnd();
|
||||||
string key = match.Groups[1].Value;
|
|
||||||
string value = match.Groups[2].Value;
|
|
||||||
|
|
||||||
if (key.IsBlank())
|
|
||||||
continue;
|
|
||||||
|
|
||||||
argsDic[key] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract required arguments
|
|
||||||
string token = argsDic.GetOrDefault("token");
|
|
||||||
string channelId = argsDic.GetOrDefault("channelId");
|
|
||||||
|
|
||||||
// Verify arguments
|
|
||||||
if (token.IsBlank() || channelId.IsBlank())
|
|
||||||
throw new ArgumentException("Some or all required command line arguments are missing");
|
|
||||||
|
|
||||||
// Create option set
|
|
||||||
return new Options(token, channelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task MainAsync(string[] args)
|
|
||||||
{
|
|
||||||
// Parse cmd args
|
|
||||||
var options = GetOptions(args);
|
|
||||||
|
|
||||||
// Get messages
|
|
||||||
Console.WriteLine("Getting messages...");
|
|
||||||
var messages = await DiscordApiService.GetMessagesAsync(options.Token, options.ChannelId);
|
|
||||||
var chatLog = new ChatLog(options.ChannelId, messages);
|
|
||||||
|
|
||||||
// Export
|
|
||||||
Console.WriteLine("Exporting messages...");
|
|
||||||
ExportService.Export($"{options.ChannelId}.html", chatLog);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Main(string[] args)
|
|
||||||
{
|
|
||||||
Console.Title = "Discord Chat Exporter";
|
|
||||||
|
|
||||||
MainAsync(args).GetAwaiter().GetResult();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: AssemblyTitle("DiscordChatExporter")]
|
||||||
|
[assembly: AssemblyCompany("Tyrrrz")]
|
||||||
|
[assembly: AssemblyCopyright("Copyright (c) 2017 Alexey Golub")]
|
||||||
|
[assembly: AssemblyVersion("2.3")]
|
||||||
|
[assembly: AssemblyFileVersion("2.3")]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
// Runtime Version:4.0.30319.42000
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Properties
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||||
|
/// </summary>
|
||||||
|
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||||
|
// class via a tool like ResGen or Visual Studio.
|
||||||
|
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||||
|
// with the /str option, or rebuild your VS project.
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
|
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||||
|
internal class Resources
|
||||||
|
{
|
||||||
|
|
||||||
|
private static global::System.Resources.ResourceManager resourceMan;
|
||||||
|
|
||||||
|
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||||
|
|
||||||
|
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||||
|
internal Resources()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the cached ResourceManager instance used by this class.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||||
|
internal static global::System.Resources.ResourceManager ResourceManager
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if ((resourceMan == null))
|
||||||
|
{
|
||||||
|
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DiscordChatExporter.Properties.Resources", typeof(Resources).Assembly);
|
||||||
|
resourceMan = temp;
|
||||||
|
}
|
||||||
|
return resourceMan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overrides the current thread's CurrentUICulture property for all
|
||||||
|
/// resource lookups using this strongly typed resource class.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||||
|
internal static global::System.Globalization.CultureInfo Culture
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return resourceCulture;
|
||||||
|
}
|
||||||
|
set
|
||||||
|
{
|
||||||
|
resourceCulture = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<!--
|
||||||
|
Microsoft ResX Schema
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
|
||||||
|
The primary goals of this format is to allow a simple XML format
|
||||||
|
that is mostly human readable. The generation and parsing of the
|
||||||
|
various data types are done through the TypeConverter classes
|
||||||
|
associated with the data types.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
... ado.net/XML headers & schema ...
|
||||||
|
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||||
|
<resheader name="version">2.0</resheader>
|
||||||
|
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||||
|
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||||
|
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||||
|
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||||
|
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||||
|
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||||
|
</data>
|
||||||
|
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
|
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||||
|
<comment>This is a comment</comment>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
There are any number of "resheader" rows that contain simple
|
||||||
|
name/value pairs.
|
||||||
|
|
||||||
|
Each data row contains a name, and value. The row also contains a
|
||||||
|
type or mimetype. Type corresponds to a .NET class that support
|
||||||
|
text/value conversion through the TypeConverter architecture.
|
||||||
|
Classes that don't support this are serialized and stored with the
|
||||||
|
mimetype set.
|
||||||
|
|
||||||
|
The mimetype is used for serialized objects, and tells the
|
||||||
|
ResXResourceReader how to depersist the object. This is currently not
|
||||||
|
extensible. For a given mimetype the value must be set accordingly:
|
||||||
|
|
||||||
|
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||||
|
that the ResXResourceWriter will generate, however the reader can
|
||||||
|
read any of the formats listed below.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.binary.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Serialization.Formatters.Binary.BinaryFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.soap.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||||
|
value : The object must be serialized into a byte array
|
||||||
|
: using a System.ComponentModel.TypeConverter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
-->
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
body {
|
||||||
|
background-color: #36393E;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0096CF;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
div.pre {
|
||||||
|
background-color: #2F3136;
|
||||||
|
color: rgb(131, 148, 150);
|
||||||
|
font-family: Consolas, Courier New, Courier, Monospace;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.pre {
|
||||||
|
background-color: #2F3136;
|
||||||
|
font-family: Consolas, Courier New, Courier, Monospace;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#info {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#log { max-width: 100%; }
|
||||||
|
|
||||||
|
img.guild-icon {
|
||||||
|
max-height: 64px;
|
||||||
|
max-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.info-right {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.guild-name {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.channel-name {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.misc { margin-top: 2px; }
|
||||||
|
|
||||||
|
div.msg {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
display: flex;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.msg-left {
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.msg-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.msg-right {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.msg-user {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.msg-date {
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
font-size: .75rem;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.msg-edited {
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
font-size: .8rem;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.msg-content {
|
||||||
|
font-size: .9375rem;
|
||||||
|
padding-top: 5px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.msg-attachment {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.msg-attachment {
|
||||||
|
max-height: 500px;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.emoji {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
vertical-align: -.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.mention {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
body {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
color: #737F8D;
|
||||||
|
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #00B0F4;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
div.pre {
|
||||||
|
background-color: #F9F9F9;
|
||||||
|
color: rgb(101, 123, 131);
|
||||||
|
font-family: Consolas, Courier New, Courier, Monospace;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.pre {
|
||||||
|
background-color: #F9F9F9;
|
||||||
|
font-family: Consolas, Courier New, Courier, Monospace;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#info {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#log { max-width: 100%; }
|
||||||
|
|
||||||
|
img.guild-icon {
|
||||||
|
max-height: 64px;
|
||||||
|
max-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.info-right {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.guild-name {
|
||||||
|
color: #2F3136;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.channel-name {
|
||||||
|
color: #2F3136;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.misc { margin-top: 2px; }
|
||||||
|
|
||||||
|
div.msg {
|
||||||
|
border-top: 1px solid #ECEEEF;
|
||||||
|
display: flex;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.msg-left {
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.msg-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.msg-right {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.msg-user {
|
||||||
|
color: #2F3136;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.msg-date {
|
||||||
|
color: #99AAB5;
|
||||||
|
font-size: .75rem;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.msg-edited {
|
||||||
|
color: #99AAB5;
|
||||||
|
font-size: .8rem;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.msg-content {
|
||||||
|
font-size: .9375rem;
|
||||||
|
padding-top: 5px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.msg-attachment {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.msg-attachment {
|
||||||
|
max-height: 500px;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.emoji {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
vertical-align: -.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.mention {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Exceptions;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Services
|
||||||
|
{
|
||||||
|
public partial class DataService : IDataService, IDisposable
|
||||||
|
{
|
||||||
|
private const string ApiRoot = "https://discordapp.com/api/v6";
|
||||||
|
|
||||||
|
private readonly HttpClient _httpClient = new HttpClient();
|
||||||
|
private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>();
|
||||||
|
private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>();
|
||||||
|
|
||||||
|
private async Task<string> GetStringAsync(string url)
|
||||||
|
{
|
||||||
|
using (var response = await _httpClient.GetAsync(url))
|
||||||
|
{
|
||||||
|
// Check status code
|
||||||
|
// We throw our own exception here because default one doesn't have status code
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpErrorStatusCodeException(response.StatusCode);
|
||||||
|
|
||||||
|
// Get content
|
||||||
|
return await response.Content.ReadAsStringAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId)
|
||||||
|
{
|
||||||
|
// Form request url
|
||||||
|
var url = $"{ApiRoot}/guilds/{guildId}?token={token}";
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
var content = await GetStringAsync(url);
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
var roles = JToken.Parse(content)["roles"].Select(ParseRole).ToArray();
|
||||||
|
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token)
|
||||||
|
{
|
||||||
|
// Form request url
|
||||||
|
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
var content = await GetStringAsync(url);
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
var guilds = JArray.Parse(content).Select(ParseGuild).ToArray();
|
||||||
|
|
||||||
|
// HACK: also get roles for all of them
|
||||||
|
foreach (var guild in guilds)
|
||||||
|
{
|
||||||
|
var roles = await GetGuildRolesAsync(token, guild.Id);
|
||||||
|
foreach (var role in roles)
|
||||||
|
_roleCache[role.Id] = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
return guilds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
|
||||||
|
{
|
||||||
|
// Form request url
|
||||||
|
var url = $"{ApiRoot}/users/@me/channels?token={token}";
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
var content = await GetStringAsync(url);
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
|
||||||
|
{
|
||||||
|
// Form request url
|
||||||
|
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
var content = await GetStringAsync(url);
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
foreach (var channel in channels)
|
||||||
|
_channelCache[channel.Id] = channel;
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
|
||||||
|
DateTime? from, DateTime? to)
|
||||||
|
{
|
||||||
|
var result = new List<Message>();
|
||||||
|
|
||||||
|
// We are going backwards from last message to first
|
||||||
|
// collecting everything between them in batches
|
||||||
|
var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Form request url
|
||||||
|
var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
|
||||||
|
if (beforeId.IsNotBlank())
|
||||||
|
url += $"&before={beforeId}";
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
var content = await GetStringAsync(url);
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
var messages = JArray.Parse(content).Select(j => ParseMessage(j, _roleCache, _channelCache));
|
||||||
|
|
||||||
|
// Add messages to list
|
||||||
|
string currentMessageId = null;
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
// Break when the message is older than from date
|
||||||
|
if (from != null && message.TimeStamp < from)
|
||||||
|
{
|
||||||
|
currentMessageId = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message
|
||||||
|
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 string DateTimeToSnowflake(DateTime dateTime)
|
||||||
|
{
|
||||||
|
const long epoch = 62135596800000;
|
||||||
|
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
|
||||||
|
var value = ((ulong) unixTime - 1420070400000UL) << 22;
|
||||||
|
return value.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 Role ParseRole(JToken token)
|
||||||
|
{
|
||||||
|
var id = token.Value<string>("id");
|
||||||
|
var name = token.Value<string>("name");
|
||||||
|
|
||||||
|
return new Role(id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
IDictionary<string, Role> roles, IDictionary<string, Channel> channels)
|
||||||
|
{
|
||||||
|
// 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");
|
||||||
|
var type = (MessageType) token.Value<int>("type");
|
||||||
|
|
||||||
|
// Workarounds for non-default types
|
||||||
|
if (type == MessageType.RecipientAdd)
|
||||||
|
content = "Added a recipient.";
|
||||||
|
else if (type == MessageType.RecipientRemove)
|
||||||
|
content = "Removed a recipient.";
|
||||||
|
else if (type == MessageType.Call)
|
||||||
|
content = "Started a call.";
|
||||||
|
else if (type == MessageType.ChannelNameChange)
|
||||||
|
content = "Changed the channel name.";
|
||||||
|
else if (type == MessageType.ChannelIconChange)
|
||||||
|
content = "Changed the channel icon.";
|
||||||
|
else if (type == MessageType.ChannelPinnedMessage)
|
||||||
|
content = "Pinned a message.";
|
||||||
|
else if (type == MessageType.GuildMemberJoin)
|
||||||
|
content = "Joined the server.";
|
||||||
|
|
||||||
|
// Get author
|
||||||
|
var author = ParseUser(token["author"]);
|
||||||
|
|
||||||
|
// Get attachment
|
||||||
|
var attachments = new List<Attachment>();
|
||||||
|
foreach (var attachmentJson in token["attachments"].EmptyIfNull())
|
||||||
|
{
|
||||||
|
var attachmentId = attachmentJson.Value<string>("id");
|
||||||
|
var attachmentUrl = attachmentJson.Value<string>("url");
|
||||||
|
var attachmentType = attachmentJson["width"] != null
|
||||||
|
? AttachmentType.Image
|
||||||
|
: AttachmentType.Other;
|
||||||
|
var attachmentFileName = attachmentJson.Value<string>("filename");
|
||||||
|
var attachmentFileSize = attachmentJson.Value<long>("size");
|
||||||
|
|
||||||
|
var attachment = new Attachment(
|
||||||
|
attachmentId, attachmentType, attachmentUrl,
|
||||||
|
attachmentFileName, attachmentFileSize);
|
||||||
|
attachments.Add(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user mentions
|
||||||
|
var mentionedUsers = token["mentions"].Select(ParseUser).ToArray();
|
||||||
|
|
||||||
|
// Get role mentions
|
||||||
|
var mentionedRoles = token["mention_roles"]
|
||||||
|
.Values<string>()
|
||||||
|
.Select(i => roles.GetOrDefault(i) ?? new Role(i, "deleted-role"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Get channel mentions
|
||||||
|
var mentionedChanenls = Regex.Matches(content, "<#(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.Select(i => channels.GetOrDefault(i) ?? new Channel(i, "deleted-channel", ChannelType.GuildTextChat))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new Message(id, author, timeStamp, editedTimeStamp, content, attachments,
|
||||||
|
mentionedUsers, mentionedRoles, mentionedChanenls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DiscordChatExporter.Models;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Services
|
|
||||||
{
|
|
||||||
public class DiscordApiService
|
|
||||||
{
|
|
||||||
private const string ApiRoot = "https://discordapp.com/api";
|
|
||||||
private readonly HttpClient _httpClient = new HttpClient();
|
|
||||||
|
|
||||||
private IEnumerable<Message> ParseMessages(string json)
|
|
||||||
{
|
|
||||||
var messagesJson = JArray.Parse(json);
|
|
||||||
foreach (var messageJson in messagesJson)
|
|
||||||
{
|
|
||||||
// Get basic data
|
|
||||||
string id = messageJson.Value<string>("id");
|
|
||||||
var timeStamp = messageJson.Value<DateTime>("timestamp");
|
|
||||||
var editedTimeStamp = messageJson.Value<DateTime?>("edited_timestamp");
|
|
||||||
string content = messageJson.Value<string>("content");
|
|
||||||
|
|
||||||
// Lazy workaround for calls
|
|
||||||
if (messageJson["call"] != null)
|
|
||||||
content = "Started a call.";
|
|
||||||
|
|
||||||
// Get author
|
|
||||||
var authorJson = messageJson["author"];
|
|
||||||
string authorId = authorJson.Value<string>("id");
|
|
||||||
string authorName = authorJson.Value<string>("username");
|
|
||||||
string authorAvatarHash = authorJson.Value<string>("avatar");
|
|
||||||
|
|
||||||
// Get attachment
|
|
||||||
var attachmentsJson = messageJson["attachments"];
|
|
||||||
var attachments = new List<Attachment>();
|
|
||||||
foreach (var attachmentJson in attachmentsJson)
|
|
||||||
{
|
|
||||||
string attachmentId = attachmentJson.Value<string>("id");
|
|
||||||
string attachmentUrl = attachmentJson.Value<string>("url");
|
|
||||||
string attachmentFileName = attachmentJson.Value<string>("filename");
|
|
||||||
bool attachmentIsImage = attachmentJson["width"] != null;
|
|
||||||
|
|
||||||
var attachment = new Attachment(attachmentId, attachmentUrl, attachmentFileName, attachmentIsImage);
|
|
||||||
attachments.Add(attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
var author = new User(authorId, authorName, authorAvatarHash);
|
|
||||||
var message = new Message(id, timeStamp, editedTimeStamp, author, content, attachments);
|
|
||||||
|
|
||||||
yield return message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IEnumerable<Message>> GetMessagesAsync(string token, string channelId)
|
|
||||||
{
|
|
||||||
var result = new List<Message>();
|
|
||||||
|
|
||||||
// We are going backwards from last message to first
|
|
||||||
// ...collecting everything between them in batches
|
|
||||||
string beforeId = null;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
// Form request url
|
|
||||||
string url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
|
|
||||||
if (beforeId.IsNotBlank())
|
|
||||||
url += $"&before={beforeId}";
|
|
||||||
|
|
||||||
// Get response
|
|
||||||
string response = await _httpClient.GetStringAsync(url);
|
|
||||||
|
|
||||||
// Parse
|
|
||||||
var messages = ParseMessages(response);
|
|
||||||
|
|
||||||
// Add messages to list
|
|
||||||
string currentMessageId = null;
|
|
||||||
foreach (var message in messages)
|
|
||||||
{
|
|
||||||
result.Add(message);
|
|
||||||
currentMessageId = message.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no messages - break
|
|
||||||
if (currentMessageId == null) break;
|
|
||||||
|
|
||||||
// Otherwise offset the next request
|
|
||||||
beforeId = currentMessageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages appear newest first, we need to reverse
|
|
||||||
result.Reverse();
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +1,317 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
using System.Linq;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Models;
|
using DiscordChatExporter.Models;
|
||||||
using HtmlAgilityPack;
|
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Services
|
namespace DiscordChatExporter.Services
|
||||||
{
|
{
|
||||||
public class ExportService
|
public partial class ExportService : IExportService
|
||||||
{
|
{
|
||||||
private HtmlDocument GetTemplate()
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
|
public ExportService(ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
const string templateName = "DiscordChatExporter.Services.ExportTemplate.html";
|
_settingsService = settingsService;
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
|
||||||
using (var stream = assembly.GetManifestResourceStream(templateName))
|
|
||||||
{
|
|
||||||
var doc = new HtmlDocument();
|
|
||||||
doc.Load(stream);
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
private async Task ExportAsTextAsync(string filePath, ChannelChatLog log)
|
||||||
{
|
{
|
||||||
var result = new List<MessageGroup>();
|
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
|
||||||
|
|
||||||
// Group adjacent messages by timestamp and author
|
|
||||||
var groupBuffer = new List<Message>();
|
|
||||||
foreach (var message in messages)
|
|
||||||
{
|
{
|
||||||
var groupFirst = groupBuffer.FirstOrDefault();
|
// Generation info
|
||||||
|
await writer.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter");
|
||||||
|
await writer.WriteLineAsync();
|
||||||
|
|
||||||
// Group break condition
|
// Guild and channel info
|
||||||
bool breakCondition =
|
await writer.WriteLineAsync('='.Repeat(48));
|
||||||
groupFirst != null &&
|
await writer.WriteLineAsync($"Guild: {log.Guild}");
|
||||||
(
|
await writer.WriteLineAsync($"Channel: {log.Channel}");
|
||||||
message.Author.Id != groupFirst.Author.Id ||
|
await writer.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}");
|
||||||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
|
await writer.WriteLineAsync('='.Repeat(48));
|
||||||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour
|
await writer.WriteLineAsync();
|
||||||
);
|
|
||||||
|
|
||||||
// If condition is true - flush buffer
|
// Chat log
|
||||||
if (breakCondition)
|
foreach (var group in log.MessageGroups)
|
||||||
{
|
{
|
||||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
|
var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat);
|
||||||
result.Add(group);
|
await writer.WriteLineAsync($"{group.Author} [{timeStampFormatted}]");
|
||||||
groupBuffer.Clear();
|
|
||||||
|
// Messages
|
||||||
|
foreach (var message in group.Messages)
|
||||||
|
{
|
||||||
|
// Content
|
||||||
|
if (message.Content.IsNotBlank())
|
||||||
|
{
|
||||||
|
var contentFormatted = FormatMessageContentText(message);
|
||||||
|
await writer.WriteLineAsync(contentFormatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
foreach (var attachment in message.Attachments)
|
||||||
|
{
|
||||||
|
await writer.WriteLineAsync(attachment.Url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add message to buffer
|
|
||||||
groupBuffer.Add(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add what's remaining in buffer
|
|
||||||
if (groupBuffer.Any())
|
|
||||||
{
|
|
||||||
var groupFirst = groupBuffer.First();
|
|
||||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
|
|
||||||
result.Add(group);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatMessageContent(string content)
|
private async Task ExportAsHtmlAsync(string filePath, ChannelChatLog log, string css)
|
||||||
{
|
{
|
||||||
// Encode HTML
|
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
|
||||||
content = HtmlDocument.HtmlEncode(content);
|
{
|
||||||
|
// Generation info
|
||||||
|
await writer.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
|
||||||
|
|
||||||
// Links from URLs
|
// Html start
|
||||||
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
|
await writer.WriteLineAsync("<!DOCTYPE html>");
|
||||||
"<a href=\"$1\">$1</a>");
|
await writer.WriteLineAsync("<html lang=\"en\">");
|
||||||
|
|
||||||
// Preformatted multiline
|
// HEAD
|
||||||
content = Regex.Replace(content, "```([^`]*?)```",
|
await writer.WriteLineAsync("<head>");
|
||||||
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
|
await writer.WriteLineAsync($"<title>{log.Guild} - {log.Channel}</title>");
|
||||||
|
await writer.WriteLineAsync("<meta charset=\"utf-8\" />");
|
||||||
|
await writer.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
|
||||||
|
await writer.WriteLineAsync($"<style>{css}</style>");
|
||||||
|
await writer.WriteLineAsync("</head>");
|
||||||
|
|
||||||
// Preformatted inline
|
// Body start
|
||||||
content = Regex.Replace(content, "`([^`]*?)`",
|
await writer.WriteLineAsync("<body>");
|
||||||
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
|
|
||||||
|
|
||||||
// Bold
|
// Guild and channel info
|
||||||
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
|
await writer.WriteLineAsync("<div id=\"info\">");
|
||||||
|
await writer.WriteLineAsync("<div class=\"info-left\">");
|
||||||
|
await writer.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
|
||||||
|
await writer.WriteLineAsync("</div>"); // info-left
|
||||||
|
await writer.WriteLineAsync("<div class=\"info-right\">");
|
||||||
|
await writer.WriteLineAsync($"<div class=\"guild-name\">{log.Guild}</div>");
|
||||||
|
await writer.WriteLineAsync($"<div class=\"channel-name\">{log.Channel}</div>");
|
||||||
|
await writer.WriteLineAsync($"<div class=\"misc\">{log.TotalMessageCount:N0} messages</div>");
|
||||||
|
await writer.WriteLineAsync("</div>"); // info-right
|
||||||
|
await writer.WriteLineAsync("</div>"); // info
|
||||||
|
|
||||||
// Italic
|
// Chat log
|
||||||
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
|
await writer.WriteLineAsync("<div id=\"log\">");
|
||||||
|
foreach (var group in log.MessageGroups)
|
||||||
|
{
|
||||||
|
await writer.WriteLineAsync("<div class=\"msg\">");
|
||||||
|
await writer.WriteLineAsync("<div class=\"msg-left\">");
|
||||||
|
await writer.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
|
||||||
|
await writer.WriteLineAsync("</div>");
|
||||||
|
|
||||||
// Underline
|
await writer.WriteLineAsync("<div class=\"msg-right\">");
|
||||||
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
|
await writer.WriteAsync($"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author)}\">");
|
||||||
|
await writer.WriteAsync(HtmlEncode(group.Author.Name));
|
||||||
|
await writer.WriteLineAsync("</span>");
|
||||||
|
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
|
||||||
|
await writer.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
|
||||||
|
|
||||||
// Strike through
|
// Messages
|
||||||
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
|
foreach (var message in group.Messages)
|
||||||
|
{
|
||||||
|
// Content
|
||||||
|
if (message.Content.IsNotBlank())
|
||||||
|
{
|
||||||
|
await writer.WriteLineAsync("<div class=\"msg-content\">");
|
||||||
|
var contentFormatted = FormatMessageContentHtml(message);
|
||||||
|
await writer.WriteAsync(contentFormatted);
|
||||||
|
|
||||||
|
// Edited timestamp
|
||||||
|
if (message.EditedTimeStamp != null)
|
||||||
|
{
|
||||||
|
var editedTimeStampFormatted =
|
||||||
|
HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
|
||||||
|
await writer.WriteAsync(
|
||||||
|
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.WriteLineAsync("</div>"); // msg-content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
foreach (var attachment in message.Attachments)
|
||||||
|
{
|
||||||
|
if (attachment.Type == AttachmentType.Image)
|
||||||
|
{
|
||||||
|
await writer.WriteLineAsync("<div class=\"msg-attachment\">");
|
||||||
|
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
|
||||||
|
await writer.WriteLineAsync(
|
||||||
|
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
|
||||||
|
await writer.WriteLineAsync("</a>");
|
||||||
|
await writer.WriteLineAsync("</div>");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await writer.WriteLineAsync("<div class=\"msg-attachment\">");
|
||||||
|
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
|
||||||
|
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
|
||||||
|
await writer.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
|
||||||
|
await writer.WriteLineAsync("</a>");
|
||||||
|
await writer.WriteLineAsync("</div>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writer.WriteLineAsync("</div>"); // msg-right
|
||||||
|
await writer.WriteLineAsync("</div>"); // msg
|
||||||
|
}
|
||||||
|
await writer.WriteLineAsync("</div>"); // log
|
||||||
|
|
||||||
|
await writer.WriteLineAsync("</body>");
|
||||||
|
await writer.WriteLineAsync("</html>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log)
|
||||||
|
{
|
||||||
|
if (format == ExportFormat.PlainText)
|
||||||
|
{
|
||||||
|
return ExportAsTextAsync(filePath, log);
|
||||||
|
}
|
||||||
|
if (format == ExportFormat.HtmlDark)
|
||||||
|
{
|
||||||
|
var css = Program.GetResourceString("DiscordChatExporter.Resources.ExportService.DarkTheme.css");
|
||||||
|
return ExportAsHtmlAsync(filePath, log, css);
|
||||||
|
}
|
||||||
|
if (format == ExportFormat.HtmlLight)
|
||||||
|
{
|
||||||
|
var css = Program.GetResourceString("DiscordChatExporter.Resources.ExportService.LightTheme.css");
|
||||||
|
return ExportAsHtmlAsync(filePath, log, css);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ExportService
|
||||||
|
{
|
||||||
|
private static string HtmlEncode(string str)
|
||||||
|
{
|
||||||
|
return WebUtility.HtmlEncode(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HtmlEncode(object obj)
|
||||||
|
{
|
||||||
|
return WebUtility.HtmlEncode(obj.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatFileSize(long fileSize)
|
||||||
|
{
|
||||||
|
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
|
||||||
|
double size = fileSize;
|
||||||
|
var unit = 0;
|
||||||
|
|
||||||
|
while (size >= 1024)
|
||||||
|
{
|
||||||
|
size /= 1024;
|
||||||
|
++unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{size:0.#} {units[unit]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatMessageContentText(Message message)
|
||||||
|
{
|
||||||
|
var content = message.Content;
|
||||||
|
|
||||||
// New lines
|
// New lines
|
||||||
content = content.Replace("\n", "</br>");
|
content = content.Replace("\n", Environment.NewLine);
|
||||||
|
|
||||||
|
// User mentions (<@id>)
|
||||||
|
foreach (var mentionedUser in message.MentionedUsers)
|
||||||
|
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
|
||||||
|
|
||||||
|
// Role mentions (<@&id>)
|
||||||
|
foreach (var mentionedRole in message.MentionedRoles)
|
||||||
|
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
|
||||||
|
|
||||||
|
// Channel mentions (<#id>)
|
||||||
|
foreach (var mentionedChannel in message.MentionedChannels)
|
||||||
|
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
|
||||||
|
|
||||||
|
// Custom emojis (<:name:id>)
|
||||||
|
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Export(string filePath, ChatLog chatLog)
|
private static string FormatMessageContentHtml(Message message)
|
||||||
{
|
{
|
||||||
var doc = GetTemplate();
|
var content = message.Content;
|
||||||
|
|
||||||
// Info
|
// Encode HTML
|
||||||
var infoHtml = doc.GetElementbyId("info");
|
content = HtmlEncode(content);
|
||||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
|
|
||||||
string participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
|
|
||||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Participants: <b>{participants}</b></div>"));
|
|
||||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Messages: <b>{chatLog.Messages.Count:N0}</b></div>"));
|
|
||||||
|
|
||||||
// Log
|
// Pre multiline (```text```)
|
||||||
var logHtml = doc.GetElementbyId("log");
|
content = Regex.Replace(content, "```+(?:[^`]*?\\n)?([^`]+)\\n?```+", "<div class=\"pre\">$1</div>");
|
||||||
var messageGroups = GroupMessages(chatLog.Messages);
|
|
||||||
foreach (var messageGroup in messageGroups)
|
// Pre inline (`text`)
|
||||||
|
content = Regex.Replace(content, "`([^`]+)`", "<span class=\"pre\">$1</span>");
|
||||||
|
|
||||||
|
// Bold (**text**)
|
||||||
|
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
|
||||||
|
|
||||||
|
// Italic (*text*)
|
||||||
|
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
|
||||||
|
|
||||||
|
// Underline (__text__)
|
||||||
|
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
|
||||||
|
|
||||||
|
// Italic (_text_)
|
||||||
|
content = Regex.Replace(content, "_([^_]*?)_", "<i>$1</i>");
|
||||||
|
|
||||||
|
// Strike through (~~text~~)
|
||||||
|
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
|
||||||
|
|
||||||
|
// New lines
|
||||||
|
content = content.Replace("\n", "<br />");
|
||||||
|
|
||||||
|
// URL links
|
||||||
|
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s<>]*)", "<a href=\"$1\">$1</a>");
|
||||||
|
|
||||||
|
// Meta mentions (@everyone)
|
||||||
|
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
|
||||||
|
|
||||||
|
// Meta mentions (@here)
|
||||||
|
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
|
||||||
|
|
||||||
|
// User mentions (<@id>)
|
||||||
|
foreach (var mentionedUser in message.MentionedUsers)
|
||||||
{
|
{
|
||||||
// Container
|
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>",
|
||||||
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
|
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser)}\">" +
|
||||||
|
$"@{HtmlEncode(mentionedUser.Name)}" +
|
||||||
// Avatar
|
"</span>");
|
||||||
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-avatar\">" +
|
|
||||||
$"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />" +
|
|
||||||
"</div>"));
|
|
||||||
|
|
||||||
// Body
|
|
||||||
var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-body\"></div>"));
|
|
||||||
|
|
||||||
// Author
|
|
||||||
string authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
|
|
||||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
|
|
||||||
|
|
||||||
// Date
|
|
||||||
string timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
|
|
||||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
|
|
||||||
|
|
||||||
// Individual messages
|
|
||||||
foreach (var message in messageGroup.Messages)
|
|
||||||
{
|
|
||||||
// Content
|
|
||||||
if (message.Content.IsNotBlank())
|
|
||||||
{
|
|
||||||
string content = FormatMessageContent(message.Content);
|
|
||||||
var contentHtml =
|
|
||||||
messageBodyHtml.AppendChild(
|
|
||||||
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
|
|
||||||
|
|
||||||
// Edited timestamp
|
|
||||||
if (message.EditedTimeStamp != null)
|
|
||||||
{
|
|
||||||
contentHtml.AppendChild(
|
|
||||||
HtmlNode.CreateNode(
|
|
||||||
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp:g}\">(edited)</span>"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
foreach (var attachment in message.Attachments)
|
|
||||||
{
|
|
||||||
if (attachment.IsImage)
|
|
||||||
{
|
|
||||||
messageBodyHtml.AppendChild(
|
|
||||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
|
||||||
$"<a href=\"{attachment.Url}\">" +
|
|
||||||
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
|
|
||||||
"</a>" +
|
|
||||||
"</div>"));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
messageBodyHtml.AppendChild(
|
|
||||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
|
||||||
$"<a href=\"{attachment.Url}\">" +
|
|
||||||
$"Attachment: {attachment.FileName}" +
|
|
||||||
"</a>" +
|
|
||||||
"</div>"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.Save(filePath);
|
// Role mentions (<@&id>)
|
||||||
|
foreach (var mentionedRole in message.MentionedRoles)
|
||||||
|
{
|
||||||
|
content = content.Replace($"<@&{mentionedRole.Id}>",
|
||||||
|
"<span class=\"mention\">" +
|
||||||
|
$"@{HtmlEncode(mentionedRole.Name)}" +
|
||||||
|
"</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel mentions (<#id>)
|
||||||
|
foreach (var mentionedChannel in message.MentionedChannels)
|
||||||
|
{
|
||||||
|
content = content.Replace($"<#{mentionedChannel.Id}>",
|
||||||
|
"<span class=\"mention\">" +
|
||||||
|
$"#{HtmlEncode(mentionedChannel.Name)}" +
|
||||||
|
"</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom emojis (<:name:id>)
|
||||||
|
content = Regex.Replace(content, "<(:.*?:)(\\d*)>",
|
||||||
|
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
|
||||||
|
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Discord Chat Log</title>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
color: #37bcf7;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.pre, span.pre {
|
|
||||||
font-family: Consolas, Courier New, Courier, Monospace;
|
|
||||||
|
|
||||||
padding-right: 2px;
|
|
||||||
padding-left: 2px;
|
|
||||||
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
div#info {
|
|
||||||
max-width: 100%;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
color: #939799;
|
|
||||||
}
|
|
||||||
|
|
||||||
div#log {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
margin-right: 10px;
|
|
||||||
margin-left: 10px;
|
|
||||||
padding-top: 15px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
|
|
||||||
border-top: 1px solid #eceeef;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.msg-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg-body {
|
|
||||||
margin-left: 15px;
|
|
||||||
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.msg-user {
|
|
||||||
font-size: 1.15em;
|
|
||||||
|
|
||||||
color: #2f3136;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.msg-date {
|
|
||||||
font-size: .8em;
|
|
||||||
font-weight: 200;
|
|
||||||
|
|
||||||
margin-left: 5px;
|
|
||||||
|
|
||||||
color: #b7bcbf;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.msg-edited {
|
|
||||||
font-size: .8em;
|
|
||||||
font-weight: 200;
|
|
||||||
|
|
||||||
margin-left: 5px;
|
|
||||||
|
|
||||||
color: #b7bcbf;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg-content {
|
|
||||||
padding-top: 5px;
|
|
||||||
|
|
||||||
color: #939799;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg-attachment {
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.msg-attachment {
|
|
||||||
max-width: 50%;
|
|
||||||
max-height: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="info"></div>
|
|
||||||
<div id="log"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Services
|
||||||
|
{
|
||||||
|
public interface IDataService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Guild>> GetGuildsAsync(string token);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
|
||||||
|
DateTime? from, DateTime? to);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Services
|
||||||
|
{
|
||||||
|
public interface IExportService
|
||||||
|
{
|
||||||
|
Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Services
|
||||||
|
{
|
||||||
|
public interface IMessageGroupService
|
||||||
|
{
|
||||||
|
IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using DiscordChatExporter.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Services
|
||||||
|
{
|
||||||
|
public interface ISettingsService
|
||||||
|
{
|
||||||
|
string DateFormat { get; set; }
|
||||||
|
int MessageGroupLimit { get; set; }
|
||||||
|
|
||||||
|
string LastToken { get; set; }
|
||||||
|
ExportFormat LastExportFormat { get; set; }
|
||||||
|
|
||||||
|
void Load();
|
||||||
|
void Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Services
|
||||||
|
{
|
||||||
|
public class MessageGroupService : IMessageGroupService
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
|
public MessageGroupService(ISettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
|
||||||
|
{
|
||||||
|
var result = new List<MessageGroup>();
|
||||||
|
|
||||||
|
// Group adjacent messages by timestamp and author
|
||||||
|
var groupBuffer = new List<Message>();
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
var groupFirst = groupBuffer.FirstOrDefault();
|
||||||
|
|
||||||
|
// Group break condition
|
||||||
|
var breakCondition =
|
||||||
|
groupFirst != null &&
|
||||||
|
(
|
||||||
|
message.Author.Id != groupFirst.Author.Id ||
|
||||||
|
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
|
||||||
|
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour ||
|
||||||
|
groupBuffer.Count >= _settingsService.MessageGroupLimit
|
||||||
|
);
|
||||||
|
|
||||||
|
// If condition is true - flush buffer
|
||||||
|
if (breakCondition)
|
||||||
|
{
|
||||||
|
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
|
||||||
|
result.Add(group);
|
||||||
|
groupBuffer.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message to buffer
|
||||||
|
groupBuffer.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add what's remaining in buffer
|
||||||
|
if (groupBuffer.Any())
|
||||||
|
{
|
||||||
|
var groupFirst = groupBuffer.First();
|
||||||
|
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
|
||||||
|
result.Add(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using DiscordChatExporter.Models;
|
||||||
|
using Tyrrrz.Settings;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Services
|
||||||
|
{
|
||||||
|
public class SettingsService : SettingsManager, ISettingsService
|
||||||
|
{
|
||||||
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
public int MessageGroupLimit { get; set; } = 20;
|
||||||
|
|
||||||
|
public string LastToken { get; set; }
|
||||||
|
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
|
public SettingsService()
|
||||||
|
{
|
||||||
|
Configuration.StorageSpace = StorageSpace.Instance;
|
||||||
|
Configuration.SubDirectoryPath = "";
|
||||||
|
Configuration.FileName = "Settings.dat";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using DiscordChatExporter.Messages;
|
||||||
|
using GalaSoft.MvvmLight;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public class ErrorViewModel : ViewModelBase, IErrorViewModel
|
||||||
|
{
|
||||||
|
public string Message { get; private set; }
|
||||||
|
|
||||||
|
public ErrorViewModel()
|
||||||
|
{
|
||||||
|
// Messages
|
||||||
|
MessengerInstance.Register<ShowErrorMessage>(this, m =>
|
||||||
|
{
|
||||||
|
Message = m.Message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using DiscordChatExporter.Messages;
|
||||||
|
using GalaSoft.MvvmLight;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public class ExportDoneViewModel : ViewModelBase, IExportDoneViewModel
|
||||||
|
{
|
||||||
|
private string _filePath;
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
public RelayCommand OpenCommand { get; }
|
||||||
|
|
||||||
|
public ExportDoneViewModel()
|
||||||
|
{
|
||||||
|
// Commands
|
||||||
|
OpenCommand = new RelayCommand(Open);
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
MessengerInstance.Register<ShowExportDoneMessage>(this, m =>
|
||||||
|
{
|
||||||
|
_filePath = m.FilePath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Open()
|
||||||
|
{
|
||||||
|
Process.Start(_filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using DiscordChatExporter.Messages;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
using DiscordChatExporter.Services;
|
||||||
|
using GalaSoft.MvvmLight;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
|
private string _filePath;
|
||||||
|
private ExportFormat _format;
|
||||||
|
private DateTime? _from;
|
||||||
|
private DateTime? _to;
|
||||||
|
|
||||||
|
public Guild Guild { get; private set; }
|
||||||
|
|
||||||
|
public Channel Channel { get; private set; }
|
||||||
|
|
||||||
|
public string FilePath
|
||||||
|
{
|
||||||
|
get => _filePath;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Set(ref _filePath, value);
|
||||||
|
ExportCommand.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ExportFormat> AvailableFormats { get; }
|
||||||
|
|
||||||
|
public ExportFormat SelectedFormat
|
||||||
|
{
|
||||||
|
get => _format;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Set(ref _format, value);
|
||||||
|
|
||||||
|
// Replace extension in path
|
||||||
|
var newExt = value.GetFileExtension();
|
||||||
|
if (FilePath != null && !FilePath.EndsWith(newExt))
|
||||||
|
FilePath = FilePath.SubstringUntilLast(".") + "." + newExt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime? From
|
||||||
|
{
|
||||||
|
get => _from;
|
||||||
|
set => Set(ref _from, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime? To
|
||||||
|
{
|
||||||
|
get => _to;
|
||||||
|
set => Set(ref _to, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
public RelayCommand ExportCommand { get; }
|
||||||
|
|
||||||
|
public ExportSetupViewModel(ISettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
MessengerInstance.Register<ShowExportSetupMessage>(this, m =>
|
||||||
|
{
|
||||||
|
Guild = m.Guild;
|
||||||
|
Channel = m.Channel;
|
||||||
|
SelectedFormat = _settingsService.LastExportFormat;
|
||||||
|
FilePath = $"{Guild} - {Channel}.{SelectedFormat.GetFileExtension()}"
|
||||||
|
.Replace(Path.GetInvalidFileNameChars(), '_');
|
||||||
|
From = null;
|
||||||
|
To = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Export()
|
||||||
|
{
|
||||||
|
// Save format
|
||||||
|
_settingsService.LastExportFormat = SelectedFormat;
|
||||||
|
|
||||||
|
// Start export
|
||||||
|
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public interface IErrorViewModel
|
||||||
|
{
|
||||||
|
string Message { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public interface IExportDoneViewModel
|
||||||
|
{
|
||||||
|
RelayCommand OpenCommand { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public interface IExportSetupViewModel
|
||||||
|
{
|
||||||
|
Guild Guild { get; }
|
||||||
|
Channel Channel { get; }
|
||||||
|
string FilePath { get; set; }
|
||||||
|
IReadOnlyList<ExportFormat> AvailableFormats { get; }
|
||||||
|
ExportFormat SelectedFormat { get; set; }
|
||||||
|
DateTime? From { get; set; }
|
||||||
|
DateTime? To { get; set; }
|
||||||
|
|
||||||
|
RelayCommand ExportCommand { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public interface IMainViewModel
|
||||||
|
{
|
||||||
|
bool IsBusy { get; }
|
||||||
|
bool IsDataAvailable { get; }
|
||||||
|
|
||||||
|
string Token { get; set; }
|
||||||
|
|
||||||
|
IReadOnlyList<Guild> AvailableGuilds { get; }
|
||||||
|
Guild SelectedGuild { get; set; }
|
||||||
|
IReadOnlyList<Channel> AvailableChannels { get; }
|
||||||
|
|
||||||
|
RelayCommand PullDataCommand { get; }
|
||||||
|
RelayCommand ShowSettingsCommand { get; }
|
||||||
|
RelayCommand ShowAboutCommand { get; }
|
||||||
|
RelayCommand<Channel> ShowExportSetupCommand { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public interface ISettingsViewModel
|
||||||
|
{
|
||||||
|
string DateFormat { get; set; }
|
||||||
|
int MessageGroupLimit { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using DiscordChatExporter.Exceptions;
|
||||||
|
using DiscordChatExporter.Messages;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
using DiscordChatExporter.Services;
|
||||||
|
using GalaSoft.MvvmLight;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public class MainViewModel : ViewModelBase, IMainViewModel
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IDataService _dataService;
|
||||||
|
private readonly IMessageGroupService _messageGroupService;
|
||||||
|
private readonly IExportService _exportService;
|
||||||
|
|
||||||
|
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
|
||||||
|
|
||||||
|
private bool _isBusy;
|
||||||
|
private string _token;
|
||||||
|
private IReadOnlyList<Guild> _availableGuilds;
|
||||||
|
private Guild _selectedGuild;
|
||||||
|
private IReadOnlyList<Channel> _availableChannels;
|
||||||
|
|
||||||
|
public bool IsBusy
|
||||||
|
{
|
||||||
|
get => _isBusy;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
Set(ref _isBusy, value);
|
||||||
|
PullDataCommand.RaiseCanExecuteChanged();
|
||||||
|
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
|
||||||
|
|
||||||
|
public string Token
|
||||||
|
{
|
||||||
|
get => _token;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
// Remove invalid chars
|
||||||
|
value = value?.Trim('"');
|
||||||
|
|
||||||
|
Set(ref _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];
|
||||||
|
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<Channel> AvailableChannels
|
||||||
|
{
|
||||||
|
get => _availableChannels;
|
||||||
|
private set => Set(ref _availableChannels, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelayCommand PullDataCommand { get; }
|
||||||
|
public RelayCommand ShowSettingsCommand { get; }
|
||||||
|
public RelayCommand ShowAboutCommand { get; }
|
||||||
|
public RelayCommand<Channel> ShowExportSetupCommand { get; }
|
||||||
|
|
||||||
|
public MainViewModel(ISettingsService settingsService, IDataService dataService,
|
||||||
|
IMessageGroupService messageGroupService, IExportService exportService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_dataService = dataService;
|
||||||
|
_messageGroupService = messageGroupService;
|
||||||
|
_exportService = exportService;
|
||||||
|
|
||||||
|
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
|
||||||
|
ShowSettingsCommand = new RelayCommand(ShowSettings);
|
||||||
|
ShowAboutCommand = new RelayCommand(ShowAbout);
|
||||||
|
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
MessengerInstance.Register<StartExportMessage>(this, m =>
|
||||||
|
{
|
||||||
|
Export(m.Channel, m.FilePath, m.Format, m.From, m.To);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
_token = _settingsService.LastToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void PullData()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
// Copy token so it doesn't get mutated
|
||||||
|
var token = Token;
|
||||||
|
|
||||||
|
// Save token
|
||||||
|
_settingsService.LastToken = token;
|
||||||
|
|
||||||
|
// Clear existing
|
||||||
|
_guildChannelsMap.Clear();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
const string message = "Unauthorized to perform request. Make sure token is valid.";
|
||||||
|
MessengerInstance.Send(new ShowErrorMessage(message));
|
||||||
|
}
|
||||||
|
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
const string message = "Forbidden to perform request. The account may be locked by 2FA.";
|
||||||
|
MessengerInstance.Send(new ShowErrorMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
|
||||||
|
SelectedGuild = AvailableGuilds.FirstOrDefault();
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowSettings()
|
||||||
|
{
|
||||||
|
MessengerInstance.Send(new ShowSettingsMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowAbout()
|
||||||
|
{
|
||||||
|
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowExportSetup(Channel channel)
|
||||||
|
{
|
||||||
|
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Export(Channel channel, string filePath, ExportFormat format, DateTime? from, DateTime? to)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
// Get last used token
|
||||||
|
var token = _settingsService.LastToken;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get messages
|
||||||
|
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to);
|
||||||
|
|
||||||
|
// Group them
|
||||||
|
var messageGroups = _messageGroupService.GroupMessages(messages);
|
||||||
|
|
||||||
|
// Create log
|
||||||
|
var log = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
|
||||||
|
|
||||||
|
// Export
|
||||||
|
await _exportService.ExportAsync(format, filePath, log);
|
||||||
|
|
||||||
|
// Notify completion
|
||||||
|
MessengerInstance.Send(new ShowExportDoneMessage(filePath));
|
||||||
|
}
|
||||||
|
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
const string message = "Forbidden to view messages in that channel.";
|
||||||
|
MessengerInstance.Send(new ShowErrorMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using DiscordChatExporter.Services;
|
||||||
|
using GalaSoft.MvvmLight;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.ViewModels
|
||||||
|
{
|
||||||
|
public class SettingsViewModel : ViewModelBase, ISettingsViewModel
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
|
public string DateFormat
|
||||||
|
{
|
||||||
|
get => _settingsService.DateFormat;
|
||||||
|
set => _settingsService.DateFormat = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int MessageGroupLimit
|
||||||
|
{
|
||||||
|
get => _settingsService.MessageGroupLimit;
|
||||||
|
set => _settingsService.MessageGroupLimit = value.ClampMin(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingsViewModel(ISettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MaterialDesignThemes.Wpf
|
||||||
|
|
||||||
|
UserControl "DiscordChatExporter.Views.ErrorDialog" {
|
||||||
|
DataContext: bind ErrorViewModel from $resource Container
|
||||||
|
Width: 250
|
||||||
|
|
||||||
|
StackPanel {
|
||||||
|
// Message
|
||||||
|
TextBlock {
|
||||||
|
Margin: 16
|
||||||
|
FontSize: 16
|
||||||
|
TextWrapping: WrapWithOverflow
|
||||||
|
Text: bind Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK
|
||||||
|
Button {
|
||||||
|
Margin: 8
|
||||||
|
Command: DialogHost.CloseDialogCommand
|
||||||
|
Content: "OK"
|
||||||
|
HorizontalAlignment: Right
|
||||||
|
Style: resource dyn "MaterialDesignFlatButton"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace DiscordChatExporter.Views
|
||||||
|
{
|
||||||
|
public partial class ErrorDialog
|
||||||
|
{
|
||||||
|
public ErrorDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using MaterialDesignThemes.Wpf
|
||||||
|
|
||||||
|
UserControl "DiscordChatExporter.Views.ExportDoneDialog" {
|
||||||
|
DataContext: bind ExportDoneViewModel from $resource Container
|
||||||
|
Width: 250
|
||||||
|
|
||||||
|
StackPanel {
|
||||||
|
// Message
|
||||||
|
TextBlock {
|
||||||
|
Margin: 16
|
||||||
|
FontSize: 16
|
||||||
|
TextWrapping: WrapWithOverflow
|
||||||
|
Text: "Finished exporting chat log"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
@StackPanelHorizontal {
|
||||||
|
HorizontalAlignment: Right
|
||||||
|
|
||||||
|
// Open
|
||||||
|
Button "OpenButton" {
|
||||||
|
Margin: 8
|
||||||
|
Click: OpenButton_Click
|
||||||
|
Command: bind OpenCommand
|
||||||
|
Content: "OPEN IT"
|
||||||
|
Style: resource dyn "MaterialDesignFlatButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss
|
||||||
|
Button {
|
||||||
|
Margin: 8
|
||||||
|
Command: DialogHost.CloseDialogCommand
|
||||||
|
Content: "DISMISS"
|
||||||
|
Style: resource dyn "MaterialDesignFlatButton"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using MaterialDesignThemes.Wpf;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Views
|
||||||
|
{
|
||||||
|
public partial class ExportDoneDialog
|
||||||
|
{
|
||||||
|
public ExportDoneDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenButton_Click(object sender, RoutedEventArgs args)
|
||||||
|
{
|
||||||
|
DialogHost.CloseDialogCommand.Execute(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using DiscordChatExporter.Models
|
||||||
|
using MaterialDesignThemes.Wpf
|
||||||
|
|
||||||
|
UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
|
||||||
|
DataContext: bind ExportSetupViewModel from $resource Container
|
||||||
|
Width: 325
|
||||||
|
|
||||||
|
StackPanel {
|
||||||
|
// File path
|
||||||
|
TextBox {
|
||||||
|
Margin: [16, 16, 16, 8]
|
||||||
|
HintAssist.Hint: "Output file"
|
||||||
|
HintAssist.IsFloating: true
|
||||||
|
IsReadOnly: true
|
||||||
|
Text: bind FilePath
|
||||||
|
set [ UpdateSourceTrigger: PropertyChanged ]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format
|
||||||
|
ComboBox {
|
||||||
|
Margin: [16, 8, 16, 8]
|
||||||
|
HintAssist.Hint: "Export format"
|
||||||
|
HintAssist.IsFloating: true
|
||||||
|
IsReadOnly: true
|
||||||
|
ItemsSource: bind AvailableFormats
|
||||||
|
SelectedItem: bind SelectedFormat
|
||||||
|
|
||||||
|
ItemTemplate: DataTemplate {
|
||||||
|
TextBlock {
|
||||||
|
Text: bind
|
||||||
|
convert (ExportFormat f) => Extensions.GetDisplayName(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
Grid {
|
||||||
|
#TwoColumns("*", "*")
|
||||||
|
|
||||||
|
DatePicker {
|
||||||
|
#Cell(0, 0)
|
||||||
|
Margin: [16, 20, 8, 8]
|
||||||
|
HintAssist.Hint: "From (optional)"
|
||||||
|
HintAssist.IsFloating: true
|
||||||
|
SelectedDate: bind From
|
||||||
|
}
|
||||||
|
|
||||||
|
DatePicker {
|
||||||
|
#Cell(0, 1)
|
||||||
|
Margin: [8, 20, 16, 8]
|
||||||
|
HintAssist.Hint: "To (optional)"
|
||||||
|
HintAssist.IsFloating: true
|
||||||
|
SelectedDate: bind To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
@StackPanelHorizontal {
|
||||||
|
HorizontalAlignment: Right
|
||||||
|
|
||||||
|
// Browse
|
||||||
|
Button "BrowseButton" {
|
||||||
|
Margin: 8
|
||||||
|
Click: BrowseButton_Click
|
||||||
|
Content: "BROWSE"
|
||||||
|
Style: resource dyn "MaterialDesignFlatButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export
|
||||||
|
Button "ExportButton" {
|
||||||
|
Margin: 8
|
||||||
|
Click: ExportButton_Click
|
||||||
|
Command: bind ExportCommand
|
||||||
|
Content: "EXPORT"
|
||||||
|
IsDefault: true
|
||||||
|
Style: resource dyn "MaterialDesignFlatButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
Button {
|
||||||
|
Margin: 8
|
||||||
|
Command: DialogHost.CloseDialogCommand
|
||||||
|
Content: "CANCEL"
|
||||||
|
Style: resource dyn "MaterialDesignFlatButton"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using DiscordChatExporter.Models;
|
||||||
|
using DiscordChatExporter.ViewModels;
|
||||||
|
using MaterialDesignThemes.Wpf;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Views
|
||||||
|
{
|
||||||
|
public partial class ExportSetupDialog
|
||||||
|
{
|
||||||
|
private IExportSetupViewModel ViewModel => (IExportSetupViewModel) DataContext;
|
||||||
|
|
||||||
|
public ExportSetupDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BrowseButton_Click(object sender, RoutedEventArgs args)
|
||||||
|
{
|
||||||
|
// Get file extension of the selected format
|
||||||
|
var ext = ViewModel.SelectedFormat.GetFileExtension();
|
||||||
|
|
||||||
|
// Open dialog
|
||||||
|
var sfd = new SaveFileDialog
|
||||||
|
{
|
||||||
|
FileName = ViewModel.FilePath,
|
||||||
|
Filter = $"{ext.ToUpperInvariant()} Files|*.{ext}|All Files|*.*",
|
||||||
|
AddExtension = true,
|
||||||
|
Title = "Select output file"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign new file path if dialog was successful
|
||||||
|
if (sfd.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
ViewModel.FilePath = sfd.FileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ExportButton_Click(object sender, RoutedEventArgs args)
|
||||||
|
{
|
||||||
|
DialogHost.CloseDialogCommand.Execute(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using MaterialDesignThemes.Wpf
|
||||||
|
using MaterialDesignThemes.Wpf.Transitions
|
||||||
|
|
||||||
|
Window "DiscordChatExporter.Views.MainWindow" {
|
||||||
|
Title: "DiscordChatExporter"
|
||||||
|
Width: 600
|
||||||
|
Height: 550
|
||||||
|
Background: resource dyn "MaterialDesignPaper"
|
||||||
|
DataContext: bind MainViewModel from $resource Container
|
||||||
|
FocusManager.FocusedElement: bind from "TokenTextBox"
|
||||||
|
FontFamily: resource dyn "MaterialDesignFont"
|
||||||
|
SnapsToDevicePixels: true
|
||||||
|
TextElement.FontSize: 13
|
||||||
|
TextElement.FontWeight: Regular
|
||||||
|
TextElement.Foreground: resource dyn "SecondaryTextBrush"
|
||||||
|
TextOptions.TextFormattingMode: Ideal
|
||||||
|
TextOptions.TextRenderingMode: Auto
|
||||||
|
UseLayoutRounding: true
|
||||||
|
WindowStartupLocation: CenterScreen
|
||||||
|
|
||||||
|
DialogHost {
|
||||||
|
DockPanel {
|
||||||
|
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 {
|
||||||
|
#Cell(0, 0)
|
||||||
|
Margin: [6, 6, 0, 6]
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
#TwoColumns("*", "Auto")
|
||||||
|
|
||||||
|
// Token
|
||||||
|
TextBox "TokenTextBox" {
|
||||||
|
#Cell(0, 0)
|
||||||
|
Margin: 6
|
||||||
|
BorderThickness: 0
|
||||||
|
HintAssist.Hint: "Token"
|
||||||
|
FontSize: 16
|
||||||
|
Text: bind Token
|
||||||
|
set [ UpdateSourceTrigger: PropertyChanged ]
|
||||||
|
TextFieldAssist.DecorationVisibility: Hidden
|
||||||
|
TextFieldAssist.TextBoxViewMargin: [0, 0, 2, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
Button {
|
||||||
|
#Cell(0, 1)
|
||||||
|
Margin: [0, 6, 6, 6]
|
||||||
|
Padding: 4
|
||||||
|
Command: bind PullDataCommand
|
||||||
|
IsDefault: true
|
||||||
|
Style: resource dyn "MaterialDesignFlatButton"
|
||||||
|
|
||||||
|
PackIcon {
|
||||||
|
Width: 24
|
||||||
|
Height: 24
|
||||||
|
Kind: PackIconKind.ArrowRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popup menu
|
||||||
|
PopupBox {
|
||||||
|
#Cell(0, 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: SlideInFromLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
Border {
|
||||||
|
Margin: -8
|
||||||
|
Background: Transparent
|
||||||
|
Cursor: CursorType.Hand
|
||||||
|
|
||||||
|
Image {
|
||||||
|
Margin: [12, 4, 12, 4]
|
||||||
|
Width: 48
|
||||||
|
Height: 48
|
||||||
|
Source: bind IconUrl
|
||||||
|
ToolTip: bind Name
|
||||||
|
OpacityMask: RadialGradientBrush {
|
||||||
|
GradientStops: [
|
||||||
|
GradientStop { Color: "#FF000000", Offset: 0 }
|
||||||
|
GradientStop { Color: "#FF000000", Offset: 0.96 }
|
||||||
|
GradientStop { Color: "#00000000", Offset: 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.ShowExportSetupCommand from $ancestor<ItemsControl>
|
||||||
|
CommandParameter: bind
|
||||||
|
MouseAction: LeftClick
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
PackIcon {
|
||||||
|
Margin: [16, 7, 0, 6]
|
||||||
|
Kind: PackIconKind.Pound
|
||||||
|
VerticalAlignment: Center
|
||||||
|
}
|
||||||
|
TextBlock {
|
||||||
|
Margin: [3, 8, 8, 8]
|
||||||
|
FontSize: 14
|
||||||
|
Text: bind Name
|
||||||
|
VerticalAlignment: Center
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content placeholder
|
||||||
|
StackPanel {
|
||||||
|
Margin: [32, 32, 8, 8]
|
||||||
|
Visibility: bind IsDataAvailable
|
||||||
|
convert (bool b) => b ? Visibility.Hidden : Visibility.Visible
|
||||||
|
|
||||||
|
TextBlock {
|
||||||
|
FontSize: 18
|
||||||
|
Text: "DiscordChatExporter needs your authorization token to work."
|
||||||
|
}
|
||||||
|
|
||||||
|
TextBlock {
|
||||||
|
Margin: [0, 8, 0, 0]
|
||||||
|
FontSize: 16
|
||||||
|
Text: "To obtain it, follow these steps:"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextBlock {
|
||||||
|
Margin: [8, 0, 0, 0]
|
||||||
|
FontSize: 14
|
||||||
|
|
||||||
|
Run {
|
||||||
|
Text: "1. Open the Discord app"
|
||||||
|
}
|
||||||
|
LineBreak { }
|
||||||
|
Run {
|
||||||
|
Text: "2. Log in if you haven't"
|
||||||
|
}
|
||||||
|
LineBreak { }
|
||||||
|
Run {
|
||||||
|
Text: "3. Press"
|
||||||
|
}
|
||||||
|
Run {
|
||||||
|
Text: "Ctrl+Shift+I"
|
||||||
|
Foreground: resource dyn "PrimaryTextBrush"
|
||||||
|
}
|
||||||
|
LineBreak { }
|
||||||
|
Run {
|
||||||
|
Text: "4. Navigate to"
|
||||||
|
}
|
||||||
|
Run {
|
||||||
|
Text: "Application"
|
||||||
|
Foreground: resource dyn "PrimaryTextBrush"
|
||||||
|
}
|
||||||
|
Run { Text: "tab" }
|
||||||
|
LineBreak { }
|
||||||
|
Run {
|
||||||
|
Text: "5. Expand"
|
||||||
|
}
|
||||||
|
Run {
|
||||||
|
Text: "Storage > Local Storage > https://discordapp.com"
|
||||||
|
Foreground: resource dyn "PrimaryTextBrush"
|
||||||
|
}
|
||||||
|
LineBreak { }
|
||||||
|
Run {
|
||||||
|
Text: "6. Find"
|
||||||
|
}
|
||||||
|
Run {
|
||||||
|
Text: ""token""
|
||||||
|
Foreground: resource dyn "PrimaryTextBrush"
|
||||||
|
}
|
||||||
|
Run {
|
||||||
|
Text: "under key and copy the value"
|
||||||
|
}
|
||||||
|
LineBreak { }
|
||||||
|
Run {
|
||||||
|
Text: "7. Paste the value in the textbox above"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using DiscordChatExporter.Messages;
|
||||||
|
using GalaSoft.MvvmLight.Messaging;
|
||||||
|
using MaterialDesignThemes.Wpf;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Views
|
||||||
|
{
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
Messenger.Default.Register<ShowErrorMessage>(this,
|
||||||
|
m => DialogHost.Show(new ErrorDialog()).Forget());
|
||||||
|
Messenger.Default.Register<ShowExportDoneMessage>(this,
|
||||||
|
m => DialogHost.Show(new ExportDoneDialog()).Forget());
|
||||||
|
Messenger.Default.Register<ShowExportSetupMessage>(this,
|
||||||
|
m => DialogHost.Show(new ExportSetupDialog()).Forget());
|
||||||
|
Messenger.Default.Register<ShowSettingsMessage>(this,
|
||||||
|
m => DialogHost.Show(new SettingsDialog()).Forget());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using MaterialDesignThemes.Wpf
|
||||||
|
|
||||||
|
UserControl "DiscordChatExporter.Views.SettingsDialog" {
|
||||||
|
DataContext: bind SettingsViewModel from $resource Container
|
||||||
|
Width: 250
|
||||||
|
|
||||||
|
StackPanel {
|
||||||
|
// Date format
|
||||||
|
TextBox {
|
||||||
|
Margin: [16, 16, 16, 8]
|
||||||
|
HintAssist.Hint: "Date format"
|
||||||
|
HintAssist.IsFloating: true
|
||||||
|
Text: bind DateFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group limit
|
||||||
|
TextBox {
|
||||||
|
Margin: [16, 8, 16, 8]
|
||||||
|
HintAssist.Hint: "Message group limit"
|
||||||
|
HintAssist.IsFloating: true
|
||||||
|
Text: bind MessageGroupLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
Button {
|
||||||
|
Margin: 8
|
||||||
|
Command: DialogHost.CloseDialogCommand
|
||||||
|
Content: "SAVE"
|
||||||
|
HorizontalAlignment: Right
|
||||||
|
Style: resource dyn "MaterialDesignFlatButton"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace DiscordChatExporter.Views
|
||||||
|
{
|
||||||
|
public partial class SettingsDialog
|
||||||
|
{
|
||||||
|
public SettingsDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
mixin TwoColumns (one = "*", two = "*") for Grid {
|
||||||
|
combine ColumnDefinitions: [
|
||||||
|
ColumnDefinition { Width: $one }
|
||||||
|
ColumnDefinition { Width: $two }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin ThreeColumns (one = none, two = none, three = none) for Grid {
|
||||||
|
#TwoColumns($one, $two)
|
||||||
|
combine ColumnDefinitions: ColumnDefinition { Width: $three }
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FourColumns (one = none, two = none, three = none, four = none) for Grid {
|
||||||
|
#ThreeColumns($one, $two, $three)
|
||||||
|
combine ColumnDefinitions: ColumnDefinition { Width: $four }
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FiveColumns (one = none, two = none, three = none, four = none, five = none) for Grid {
|
||||||
|
#FourColumns($one, $two, $three, $four)
|
||||||
|
combine ColumnDefinitions: ColumnDefinition { Width: $five }
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin TwoRows (one = none, two = none) for Grid
|
||||||
|
{
|
||||||
|
combine RowDefinitions: [
|
||||||
|
RowDefinition { Height: $one }
|
||||||
|
RowDefinition { Height: $two }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin ThreeRows (one = none, two = none, three = none) for Grid
|
||||||
|
{
|
||||||
|
#TwoRows($one, $two)
|
||||||
|
combine RowDefinitions: RowDefinition { Height: $three }
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FourRows (one = none, two = none, three = none, four = none) for Grid
|
||||||
|
{
|
||||||
|
#ThreeRows($one, $two, $three)
|
||||||
|
combine RowDefinitions: RowDefinition { Height: $four }
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FiveRows (one = none, two = none, three = none, four = none, five = none) for Grid
|
||||||
|
{
|
||||||
|
#FourRows($one, $two, $three, $four)
|
||||||
|
combine RowDefinitions: RowDefinition { Height: $five }
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin Cell (row = none, column = none, rowSpan = none, columnSpan = none) for FrameworkElement {
|
||||||
|
Grid.Row: $row
|
||||||
|
Grid.Column: $column
|
||||||
|
Grid.RowSpan: $rowSpan
|
||||||
|
Grid.ColumnSpan: $columnSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
alias ImageCached(source) {
|
||||||
|
Image {
|
||||||
|
Source: BitmapImage {
|
||||||
|
UriCachePolicy: "Revalidate"
|
||||||
|
UriSource: $source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin Setter(property, value, targetName=none) for Style {
|
||||||
|
Setter { Property: $property, Value: $value, TargetName: $targetName }
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
mixin AddSetter(property, value, targetName=none) for Style {
|
||||||
|
combine Setters: #Setter($property, $value, $targetName) {}
|
||||||
|
}*/
|
||||||
|
|
||||||
|
alias DataTrigger(binding, bindingValue) {
|
||||||
|
DataTrigger { Binding: $binding, Value: $bindingValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
alias Trigger(property, value) {
|
||||||
|
Trigger { Property: $property, Value: $value }
|
||||||
|
}
|
||||||
|
|
||||||
|
alias EventTrigger(event, sourceName=none) {
|
||||||
|
EventTrigger { RoutedEvent: $event, SourceName: $sourceName }
|
||||||
|
}
|
||||||
|
|
||||||
|
alias DataTrigger_SetProperty(binding, bindingValue, property, propertyValue) {
|
||||||
|
@DataTrigger ($binding, $bindingValue) {
|
||||||
|
#Setter($property, $propertyValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alias Trigger_SetProperty(triggerProperty, triggerValue, property, propertyValue) {
|
||||||
|
@Trigger ($triggerProperty, $triggerValue) {
|
||||||
|
#Setter($property, $propertyValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alias EventTrigger_SetProperty(event, property, propertyValue) {
|
||||||
|
@EventTrigger ($event) {
|
||||||
|
#Setter($property, $propertyValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alias VisibleIf_DataTrigger(binding, valueForVisible) {
|
||||||
|
@DataTrigger_SetProperty($binding, $valueForVisible, "Visibility", "Visible") {}
|
||||||
|
}
|
||||||
|
|
||||||
|
alias CollapsedIf_DataTrigger(binding, valueForCollapsed) {
|
||||||
|
@DataTrigger_SetProperty($binding, $valueForCollapsed, "Visibility", "Collapsed") {}
|
||||||
|
}
|
||||||
|
|
||||||
|
alias StackPanelHorizontal() {
|
||||||
|
StackPanel {
|
||||||
|
Orientation: Horizontal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alias GridItemsControl() {
|
||||||
|
ItemsControl {
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility: Disabled,
|
||||||
|
|
||||||
|
ItemsPanel: ItemsPanelTemplate {
|
||||||
|
WrapPanel {
|
||||||
|
IsItemsHost: true
|
||||||
|
Orientation: Horizontal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////
|
||||||
|
// Animations //
|
||||||
|
////////////////
|
||||||
|
|
||||||
|
alias DoubleAnimation(property, frm = "0", to = "1", duration = "0:0:1", targetName=none, beginTime=none) {
|
||||||
|
DoubleAnimation {
|
||||||
|
Storyboard.TargetProperty: $property
|
||||||
|
Storyboard.TargetName: $targetName
|
||||||
|
From: $frm
|
||||||
|
To: $to
|
||||||
|
Duration: $duration
|
||||||
|
BeginTime: $beginTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alias DoubleAnimationStoryboard (property, frm = "0", to = "1", duration = "0:0:1", targetName=none) {
|
||||||
|
BeginStoryboard {
|
||||||
|
Storyboard {
|
||||||
|
@DoubleAnimation($property, $frm, $to, $duration, $targetName) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin DoubleAnimation_PropertyTrigger(triggerProperty, triggerValue, animationProperty, frm, to, duration) for Style {
|
||||||
|
combine Triggers: @Trigger ($triggerProperty, $triggerValue) {
|
||||||
|
EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin DoubleAnimation_PropertyTrigger_Toggle(triggerProperty, triggerValue, animationProperty, frm, to, duration) for Style {
|
||||||
|
combine Triggers: @Trigger ($triggerProperty, $triggerValue) {
|
||||||
|
EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
|
||||||
|
ExitActions: @DoubleAnimationStoryboard($animationProperty, $to, $frm, $duration) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin DoubleAnimation_EventTrigger(triggerEvent, animationProperty, frm, to, duration) for Style {
|
||||||
|
combine Triggers: EventTrigger {
|
||||||
|
RoutedEvent: $triggerEvent
|
||||||
|
@DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin DoubleAnimation_DataTrigger(binding, value, animationProperty, frm, to, duration) for Style {
|
||||||
|
combine Triggers: DataTrigger {
|
||||||
|
Binding: $binding
|
||||||
|
Value: $value
|
||||||
|
EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FadeIn_OnProperty(property, value, frm = "0", to = "1", duration = "0:0:1") for Style {
|
||||||
|
#DoubleAnimation_PropertyTrigger($property, $value, "Opacity", $frm, $to, $duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FadeOut_OnProperty(property, value, frm = "1", to = "0", duration = "0:0:1") for Style {
|
||||||
|
#DoubleAnimation_PropertyTrigger($property, $value, "Opacity", $frm, $to, $duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FadeIn_OnEvent(event, frm = "0", to = "1", duration = "0:0:1") for Style {
|
||||||
|
#DoubleAnimation_EventTrigger($event, "Opacity", $frm, $to, $duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FadeOut_OnEvent(event, frm = "1", to = "0", duration = "0:0:1") for Style {
|
||||||
|
#DoubleAnimation_EventTrigger($event, "Opacity", $frm, $to, $duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FadeIn_OnData(binding, value, from_ = "0", to = "1", duration = "0:0:1") for Style {
|
||||||
|
#DoubleAnimation_DataTrigger($binding, $value, "Opacity", $from_, $to, $duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin FadeOut_OnData(binding, value, from_ = "1", to = "0", duration = "0:0:1") for Style {
|
||||||
|
#DoubleAnimation_DataTrigger($binding, $value, "Opacity", $from_, $to, $duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin Property_OnBinding(binding, bindingValue, property, propertyValue, initialValue) for Style {
|
||||||
|
#Setter("Visibility", $initialValue)
|
||||||
|
combine Triggers: [
|
||||||
|
@DataTrigger_SetProperty($binding, $bindingValue, $property, $propertyValue) {}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin Visibility_OnBinding(binding, bindingValue, visibilityValue="Visible", initialValue="Collapsed") for Style {
|
||||||
|
#Property_OnBinding($binding, $bindingValue, "Visibility", $visibilityValue, $initialValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin Fade_OnBinding(binding, bindingValue) for Style {
|
||||||
|
#Setter("Visibility", "Visible")
|
||||||
|
#Setter("Opacity", "0")
|
||||||
|
|
||||||
|
combine Triggers: [
|
||||||
|
@DataTrigger($binding, $bindingValue) {
|
||||||
|
EnterActions: [
|
||||||
|
@DoubleAnimationStoryboard("Opacity", 0, 1, "0:0:0.5") {}
|
||||||
|
]
|
||||||
|
ExitActions: [
|
||||||
|
@DoubleAnimationStoryboard("Opacity", 1, 0, "0:0:0.5") {}
|
||||||
|
]
|
||||||
|
#Setter("Opacity", 1)
|
||||||
|
}
|
||||||
|
@Trigger("Opacity", 0) {
|
||||||
|
#Setter("Visibility", "Hidden")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin MergeDictionary (source) for ResourceDictionary {
|
||||||
|
combine MergedDictionaries: ResourceDictionary { Source: $source }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<packages>
|
||||||
|
<package id="Ammy" version="1.2.87" targetFramework="net461" />
|
||||||
|
<package id="Ammy.WPF" version="1.2.87" targetFramework="net461" />
|
||||||
|
<package id="CommonServiceLocator" version="1.3" targetFramework="net461" />
|
||||||
|
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
|
||||||
|
<package id="MaterialDesignThemes" version="2.3.1.953" targetFramework="net461" />
|
||||||
|
<package id="MvvmLightLibs" version="5.3.0.0" targetFramework="net461" />
|
||||||
|
<package id="Newtonsoft.Json" version="10.0.3" targetFramework="net461" />
|
||||||
|
<package id="Tyrrrz.Extensions" version="1.4.1" targetFramework="net461" />
|
||||||
|
<package id="Tyrrrz.Settings" version="1.3.0" targetFramework="net461" />
|
||||||
|
</packages>
|
||||||
@@ -1,49 +1,40 @@
|
|||||||
# DiscordChatExporter
|
# DiscordChatExporter
|
||||||
|
|
||||||
Command line executable that can export [Discord](https://discordapp.com) channel chat logs to a pretty HTML file.
|
[](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
|
||||||
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
|
|
||||||
|
DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. The tool also lets you select from/to dates to limit the exported messages. There are options to configure the output, such as date format, color theme, message grouping limit, etc.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
|
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Produces output styled similar to the Discord's light theme
|
- Intuitive GUI that displays available guilds and channels
|
||||||
- Displays user avatars
|
- Date ranges to limit messages
|
||||||
- Groups messages by author and time
|
- Groups messages by author and time
|
||||||
- Handles Discord markdown characters
|
- Export to a plain text file
|
||||||
- Converts URLs to links
|
- Export to an HTML file
|
||||||
- Inlines attached images with a link to the full version
|
- Dark and light themes
|
||||||
- Inserts download links for other types of attached files
|
- User avatars
|
||||||
- Marks edited messages with a timestamp
|
- Inline image attachments
|
||||||
|
- Full markdown support
|
||||||
## Usage
|
- Automatic links
|
||||||
|
- Styled similarly to the app
|
||||||
The program expects an access token and channel ID as parameters.
|
|
||||||
|
|
||||||
`DiscordChatExporter.exe /token:REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 /channelId:459360869055190534`
|
|
||||||
|
|
||||||
#### Getting access token:
|
|
||||||
|
|
||||||
- Open Discord desktop or web client
|
|
||||||
- Press `Ctrl+Shift+I`
|
|
||||||
- Navigate to `Storage > LocalStorage > discordapp.com`
|
|
||||||
- Find the value for `token` and extract it
|
|
||||||
|
|
||||||
#### Getting channel ID:
|
|
||||||
|
|
||||||
- Open Discord desktop or web client
|
|
||||||
- Navigate to any DM or server channel
|
|
||||||
- Extract the current URL:
|
|
||||||
- If using desktop client, press `Ctrl+Shift+I`, type `window.location.href` in console and extract the result
|
|
||||||
- If using web client, just take the current URL from the address bar
|
|
||||||
- Pull the ID from the URL:
|
|
||||||
- If it's a DM channel, the format looks like this: `https://discordapp.com/channels/@me/CHANNEL_ID`
|
|
||||||
- If it's a server channel, the format looks like this:
|
|
||||||
`https://discordapp.com/channels/WHATEVER/CHANNEL_ID`
|
|
||||||
|
|
||||||
## Libraries used
|
## Libraries used
|
||||||
|
|
||||||
- [HtmlAgilityPack](https://github.com/zzzprojects/html-agility-pack)
|
- [AmmyUI](https://github.com/AmmyUI/AmmyUI)
|
||||||
- [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json)
|
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
|
||||||
|
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
|
||||||
|
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
|
||||||
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
|
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
|
||||||
|
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)
|
||||||
|
|||||||
Reference in New Issue
Block a user