mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b518fb2f59 | |||
| 94165dcd8d | |||
| d6507598fa | |||
| a0359b1e43 | |||
| aa53cecd4e | |||
| 4144911208 | |||
| d28e81f8dc | |||
| 761cb032d7 | |||
| 47f0561c71 | |||
| 23116b776b | |||
| ba27a9f909 | |||
| 52684c264f | |||
| bcad5b4ed1 | |||
| 95a4217ab3 | |||
| 4e8fb80ac5 | |||
| 7ae560dde0 | |||
| 97a36028b9 | |||
| f0d99676a3 | |||
| 8e38adae7e | |||
| d95bc37592 | |||
| 3f6354053c | |||
| a9bab60ba6 | |||
| 614bd8590d | |||
| bd9dc6455f | |||
| 0faa427970 | |||
| dd9da4831e | |||
| 0d816cee5d | |||
| f63ea41188 | |||
| 3572a21aad | |||
| 37ee0b8be3 | |||
| ed146bac22 | |||
| b9c449709f | |||
| 25207c5093 | |||
| 4fbec95208 | |||
| 0a48da4c56 | |||
| 3f5f62585c | |||
| 49f39c7097 | |||
| e3eac10fb8 | |||
| 23f9331e4e | |||
| 69088b83eb | |||
| 7ecf90d1cb | |||
| f0cd2dce1b | |||
| e8436faf66 | |||
| 61dce7c1a8 | |||
| 6fee160bc7 | |||
| 8678043f0d | |||
| e4f0b8193f | |||
| df811d0b1a | |||
| 08da5d685b | |||
| c2cebe99e3 | |||
| 481991bd00 | |||
| d958f613a3 | |||
| 3b7da21c24 | |||
| 1d0a13c613 | |||
| 67036f59ce | |||
| 517542730a | |||
| 19dc2334e2 | |||
| 4e1f783145 | |||
| ff6ef480ab | |||
| e1e8c73613 | |||
| e0284e281c | |||
| 435510baf9 | |||
| cc2583f96b | |||
| de763f8aaa | |||
| 7b67cbc385 | |||
| 7ffb799136 | |||
| 5a84fb25e6 | |||
| 9988300942 | |||
| 4540134a98 | |||
| 656e5a5b0d | |||
| 63e3ba8891 | |||
| d7572d9fd3 | |||
| 63c835df88 | |||
| 7bfd645e8e | |||
| 4e5d3cb4ed | |||
| cff976e2e4 | |||
| 0c69fd6e45 | |||
| 909a58b641 | |||
| 2db4ad46cc | |||
| ffa1023c50 | |||
| f7ef3bbb44 | |||
| bb2b04d2e5 | |||
| 1f36fb608a | |||
| b6e93e5770 | |||
| 8515efe11b | |||
| 7da82f9ef4 | |||
| 6ccba43807 | |||
| 2ce9e2c77e | |||
| a6a54d688f | |||
| e0d2f9c1ba | |||
| 01f8cf0e32 | |||
| ebab80fda1 | |||
| c432a75f62 | |||
| be4989ea34 | |||
| f8dae72411 | |||
| ab46361489 | |||
| 4a986fe5df | |||
| e3d2afac87 | |||
| 0cfcaca6b2 | |||
| 107c9de265 | |||
| 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 |
@@ -0,0 +1,63 @@
|
|||||||
|
### v2.8 (03-Nov-2018)
|
||||||
|
|
||||||
|
- Added support for partitioning which lets you split the output into multiple files by setting the partition message limit (`-p` parameter for CLI).
|
||||||
|
- Exported file will no longer open automatically on completion.
|
||||||
|
- Reduced amount of memory used during exporting.
|
||||||
|
- Disabled message grouping in PlainText export.
|
||||||
|
- Improved encoding of newlines in CSV export.
|
||||||
|
- Improved error messages in the GUI app during crashes.
|
||||||
|
|
||||||
|
### v2.7 (14-Sep-2018)
|
||||||
|
|
||||||
|
- Updated instructions on how to obtain the user token.
|
||||||
|
- Expanded CLI with new commands: `channels` (get a list of channels in a guild), `dm` (get a list of DM channels), `guilds` (get a list of guilds), on top of `export` (export chatlog).
|
||||||
|
- Improved help text and error messages in CLI.
|
||||||
|
- In CLI, the file name will be automatically generated if the provided output file path is a directory.
|
||||||
|
|
||||||
|
### v2.6 (25-Jul-2018)
|
||||||
|
|
||||||
|
- Added support for bot tokens as an alternative to user tokens. For GUI, use the button in the top-left to switch between user and bot token. For CLI, pass the `--bot` switch to indicate that the given token is a bot token.
|
||||||
|
- Added support for animated avatars.
|
||||||
|
|
||||||
|
### v2.5.1 (01-Jul-2018)
|
||||||
|
|
||||||
|
- Fixed a bug that would prevent timestamps from rendering properly in CSV and PlainText exports.
|
||||||
|
- Fixed incorrect layout in CSV export.
|
||||||
|
- Improved readability of PlainText export.
|
||||||
|
|
||||||
|
### v2.5 (28-Jun-2018)
|
||||||
|
|
||||||
|
- Added support for embeds and webhooks (HTML only).
|
||||||
|
- Implemented progress reporting when downloading messages.
|
||||||
|
- Added support for reactions (HTML only).
|
||||||
|
- Selected date range is now shown in the export (HTML and PlainText).
|
||||||
|
- Added placeholders for guild icons to display while the icons are loading.
|
||||||
|
- Added support for jumbo emojis (custom emojis only).
|
||||||
|
- Added basic retry policy for 429 error responses.
|
||||||
|
- Added validation for from/to dates in the UI which prevents choosing invalid date ranges.
|
||||||
|
- Added an error message when trying to export a deleted channel.
|
||||||
|
|
||||||
|
### v2.4.1 (15-Apr-2018)
|
||||||
|
|
||||||
|
- Added CSV export format.
|
||||||
|
- Channels are now ordered by name in the UI.
|
||||||
|
- Exported file is now always opened upon completion.
|
||||||
|
|
||||||
|
### v2.4 (08-Apr-2018)
|
||||||
|
|
||||||
|
- Added channel topic to output.
|
||||||
|
- Added a command line interface alternative to the current graphical user interface.
|
||||||
|
- Implemented application auto-update (can be disabled in settings).
|
||||||
|
- Improved some GUI elements and replaced some dialogs with notifications.
|
||||||
|
- Fixed a lot of issues with the markdown processor.
|
||||||
|
- Fixed DM group chats that have a custom name being shown as blank in the list.
|
||||||
|
|
||||||
|
### v2.3 (27-Oct-2017)
|
||||||
|
|
||||||
|
- Improved message date filtering, it's now marginally faster.
|
||||||
|
- Fixed underscores not recognized as italics in markdown.
|
||||||
|
- Added support for custom emojis.
|
||||||
|
- Added support for user and role mentions.
|
||||||
|
- Added support for channel mentions.
|
||||||
|
- Fixed text in pre blocks not being wrapped correctly.
|
||||||
|
- Added workaround for non-default message types.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
|
||||||
|
|
||||||
|
# GUI
|
||||||
|
$files = @()
|
||||||
|
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
|
||||||
|
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
$files = @()
|
||||||
|
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
||||||
|
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using CommonServiceLocator;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using GalaSoft.MvvmLight.Ioc;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli
|
||||||
|
{
|
||||||
|
public class Container
|
||||||
|
{
|
||||||
|
public Container()
|
||||||
|
{
|
||||||
|
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
|
||||||
|
SimpleIoc.Default.Reset();
|
||||||
|
|
||||||
|
// Services
|
||||||
|
SimpleIoc.Default.Register<IDataService, DataService>();
|
||||||
|
SimpleIoc.Default.Register<IExportService, ExportService>();
|
||||||
|
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
|
||||||
|
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Resolve<T>(string key = null)
|
||||||
|
{
|
||||||
|
return ServiceLocator.Current.GetInstance<T>(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net461</TargetFramework>
|
||||||
|
<Version>2.8</Version>
|
||||||
|
<Company>Tyrrrz</Company>
|
||||||
|
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
|
||||||
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommandLineParser" Version="2.2.1" />
|
||||||
|
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
|
||||||
|
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
|
||||||
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using CommandLine;
|
||||||
|
using DiscordChatExporter.Cli.Verbs;
|
||||||
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli
|
||||||
|
{
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
private static void PrintTokenHelp()
|
||||||
|
{
|
||||||
|
Console.WriteLine("# To get user token:");
|
||||||
|
Console.WriteLine(" 1. Open Discord app");
|
||||||
|
Console.WriteLine(" 2. Log in if you haven't");
|
||||||
|
Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||||
|
Console.WriteLine(" 4. Press Ctrl+R to trigger reload");
|
||||||
|
Console.WriteLine(" 5. Navigate to the Application tab");
|
||||||
|
Console.WriteLine(" 6. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
|
||||||
|
Console.WriteLine(" 7. Find \"token\" under key and copy the value");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("# To get bot token:");
|
||||||
|
Console.WriteLine(" 1. Go to Discord developer portal");
|
||||||
|
Console.WriteLine(" 2. Log in if you haven't");
|
||||||
|
Console.WriteLine(" 3. Open your application's settings");
|
||||||
|
Console.WriteLine(" 4. Navigate to the Bot section on the left");
|
||||||
|
Console.WriteLine(" 5. Under Token click Copy");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("# To get guild or channel ID:");
|
||||||
|
Console.WriteLine(" 1. Open Discord app");
|
||||||
|
Console.WriteLine(" 2. Log in if you haven't");
|
||||||
|
Console.WriteLine(" 3. Open Settings");
|
||||||
|
Console.WriteLine(" 4. Go to Appearance section");
|
||||||
|
Console.WriteLine(" 5. Enable Developer Mode");
|
||||||
|
Console.WriteLine(" 6. Right click on the desired guild or channel and click Copy ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
// Get all verb types
|
||||||
|
var verbTypes = new[]
|
||||||
|
{
|
||||||
|
typeof(ExportChatOptions),
|
||||||
|
typeof(GetChannelsOptions),
|
||||||
|
typeof(GetDirectMessageChannelsOptions),
|
||||||
|
typeof(GetGuildsOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
|
||||||
|
|
||||||
|
// Execute commands
|
||||||
|
parsedArgs.WithParsed<ExportChatOptions>(o => new ExportChatVerb(o).Execute());
|
||||||
|
parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
|
||||||
|
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
|
||||||
|
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
|
||||||
|
|
||||||
|
// Show token help if help requested or no verb specified
|
||||||
|
parsedArgs.WithNotParsed(errs =>
|
||||||
|
{
|
||||||
|
var err = errs.First();
|
||||||
|
|
||||||
|
if (err.Tag == ErrorType.NoVerbSelectedError)
|
||||||
|
PrintTokenHelp();
|
||||||
|
|
||||||
|
if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
|
||||||
|
PrintTokenHelp();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
{
|
||||||
|
public class ExportChatVerb : Verb<ExportChatOptions>
|
||||||
|
{
|
||||||
|
public ExportChatVerb(ExportChatOptions options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task ExecuteAsync()
|
||||||
|
{
|
||||||
|
// Get services
|
||||||
|
var container = new Container();
|
||||||
|
var settingsService = container.Resolve<ISettingsService>();
|
||||||
|
var dataService = container.Resolve<IDataService>();
|
||||||
|
var exportService = container.Resolve<IExportService>();
|
||||||
|
|
||||||
|
// Configure settings
|
||||||
|
if (Options.DateFormat.IsNotBlank())
|
||||||
|
settingsService.DateFormat = Options.DateFormat;
|
||||||
|
if (Options.MessageGroupLimit > 0)
|
||||||
|
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
|
||||||
|
|
||||||
|
// Get chat log
|
||||||
|
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
|
||||||
|
Options.After, Options.Before);
|
||||||
|
|
||||||
|
// Generate file path if not set
|
||||||
|
var filePath = Options.FilePath;
|
||||||
|
if (filePath == null || filePath.EndsWith("/") || filePath.EndsWith("\\"))
|
||||||
|
{
|
||||||
|
filePath += $"{chatLog.Guild.Name} - {chatLog.Channel.Name}.{Options.ExportFormat.GetFileExtension()}"
|
||||||
|
.Replace(Path.GetInvalidFileNameChars(), '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export
|
||||||
|
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
|
||||||
|
|
||||||
|
// Print result
|
||||||
|
Console.WriteLine($"Exported chat to [{filePath}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
{
|
||||||
|
public class GetChannelsVerb : Verb<GetChannelsOptions>
|
||||||
|
{
|
||||||
|
public GetChannelsVerb(GetChannelsOptions options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task ExecuteAsync()
|
||||||
|
{
|
||||||
|
// Get data service
|
||||||
|
var container = new Container();
|
||||||
|
var dataService = container.Resolve<IDataService>();
|
||||||
|
|
||||||
|
// Get channels
|
||||||
|
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
|
||||||
|
|
||||||
|
// Print result
|
||||||
|
foreach (var channel in channels.Where(c => c.Type.IsEither(ChannelType.GuildTextChat))
|
||||||
|
.OrderBy(c => c.Name))
|
||||||
|
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
{
|
||||||
|
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
|
||||||
|
{
|
||||||
|
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task ExecuteAsync()
|
||||||
|
{
|
||||||
|
// Get data service
|
||||||
|
var container = new Container();
|
||||||
|
var dataService = container.Resolve<IDataService>();
|
||||||
|
|
||||||
|
// Get channels
|
||||||
|
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
|
||||||
|
|
||||||
|
// Print result
|
||||||
|
foreach (var channel in channels.OrderBy(c => c.Name))
|
||||||
|
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
{
|
||||||
|
public class GetGuildsVerb : Verb<GetGuildsOptions>
|
||||||
|
{
|
||||||
|
public GetGuildsVerb(GetGuildsOptions options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task ExecuteAsync()
|
||||||
|
{
|
||||||
|
// Get data service
|
||||||
|
var container = new Container();
|
||||||
|
var dataService = container.Resolve<IDataService>();
|
||||||
|
|
||||||
|
// Get guilds
|
||||||
|
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
|
||||||
|
|
||||||
|
// Print result
|
||||||
|
foreach (var guild in guilds.OrderBy(g => g.Name))
|
||||||
|
Console.WriteLine($"{guild.Id} | {guild.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
using CommandLine;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||||
|
{
|
||||||
|
[Verb("export", HelpText = "Export channel chat log to a file.")]
|
||||||
|
public class ExportChatOptions : TokenOptions
|
||||||
|
{
|
||||||
|
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
|
||||||
|
public string ChannelId { get; set; }
|
||||||
|
|
||||||
|
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
|
||||||
|
public ExportFormat ExportFormat { get; set; }
|
||||||
|
|
||||||
|
[Option('o', "output", Default = null, HelpText = "Output file path.")]
|
||||||
|
public string FilePath { get; set; }
|
||||||
|
|
||||||
|
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
|
||||||
|
public DateTime? After { get; set; }
|
||||||
|
|
||||||
|
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
|
||||||
|
public DateTime? Before { get; set; }
|
||||||
|
|
||||||
|
[Option('p', "partition", Default = null, HelpText = "Split output into partitions limited to this number of messages.")]
|
||||||
|
public int? PartitionLimit { get; set; }
|
||||||
|
|
||||||
|
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
|
||||||
|
public string DateFormat { get; set; }
|
||||||
|
|
||||||
|
[Option("grouplimit", Default = 0, HelpText = "Message group limit.")]
|
||||||
|
public int MessageGroupLimit { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using CommandLine;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||||
|
{
|
||||||
|
[Verb("channels", HelpText = "Get the list of channels in the given guild.")]
|
||||||
|
public class GetChannelsOptions : TokenOptions
|
||||||
|
{
|
||||||
|
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
|
||||||
|
public string GuildId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using CommandLine;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||||
|
{
|
||||||
|
[Verb("dm", HelpText = "Get the list of direct message channels.")]
|
||||||
|
public class GetDirectMessageChannelsOptions : TokenOptions
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using CommandLine;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||||
|
{
|
||||||
|
[Verb("guilds", HelpText = "Get the list of accessible guilds.")]
|
||||||
|
public class GetGuildsOptions : TokenOptions
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using CommandLine;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||||
|
{
|
||||||
|
public class TokenOptions
|
||||||
|
{
|
||||||
|
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
|
||||||
|
public string TokenValue { get; set; }
|
||||||
|
|
||||||
|
[Option('b', "bot", Default = false, HelpText = "Whether this authorization token belongs to a bot.")]
|
||||||
|
public bool IsBotToken { get; set; }
|
||||||
|
|
||||||
|
public AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
{
|
||||||
|
public abstract class Verb<TOptions>
|
||||||
|
{
|
||||||
|
protected TOptions Options { get; }
|
||||||
|
|
||||||
|
protected Verb(TOptions options)
|
||||||
|
{
|
||||||
|
Options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract Task ExecuteAsync();
|
||||||
|
|
||||||
|
public virtual void Execute() => ExecuteAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net461</TargetFramework>
|
||||||
|
<Version>2.8</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Resources\ExportTemplates\PlainText.txt" />
|
||||||
|
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark.html" />
|
||||||
|
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight.html" />
|
||||||
|
<EmbeddedResource Include="Resources\ExportTemplates\Html\Core.html" />
|
||||||
|
<EmbeddedResource Include="Resources\ExportTemplates\Html\Shared.css" />
|
||||||
|
<EmbeddedResource Include="Resources\ExportTemplates\Html\DarkTheme.css" />
|
||||||
|
<EmbeddedResource Include="Resources\ExportTemplates\Html\LightTheme.css" />
|
||||||
|
<EmbeddedResource Include="Resources\ExportTemplates\Csv.csv" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="mscorlib" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
||||||
|
<PackageReference Include="Onova" Version="2.1.0" />
|
||||||
|
<PackageReference Include="Polly" Version="6.0.1" />
|
||||||
|
<PackageReference Include="Scriban" Version="1.2.1" />
|
||||||
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||||
|
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Exceptions
|
||||||
|
{
|
||||||
|
public class HttpErrorStatusCodeException : Exception
|
||||||
|
{
|
||||||
|
public HttpStatusCode StatusCode { get; }
|
||||||
|
|
||||||
|
public string ReasonPhrase { get; }
|
||||||
|
|
||||||
|
public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase)
|
||||||
|
: base($"Error HTTP status code: {statusCode} - {reasonPhrase}")
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
ReasonPhrase = reasonPhrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Internal
|
||||||
|
{
|
||||||
|
internal static class Extensions
|
||||||
|
{
|
||||||
|
public static string ToSnowflake(this DateTime dateTime)
|
||||||
|
{
|
||||||
|
const long epoch = 62135596800000;
|
||||||
|
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
|
||||||
|
var value = ((ulong) unixTime - 1420070400000UL) << 22;
|
||||||
|
return value.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Base64Encode(this string str) => str.GetBytes().ToBase64();
|
||||||
|
|
||||||
|
public static string Base64Decode(this string str) => str.FromBase64().GetString();
|
||||||
|
|
||||||
|
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
||||||
|
|
||||||
|
public class Attachment
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Url { get; }
|
||||||
|
|
||||||
|
public int? Width { get; }
|
||||||
|
|
||||||
|
public int? Height { get; }
|
||||||
|
|
||||||
|
public bool IsImage => Width != null;
|
||||||
|
|
||||||
|
public string FileName { get; }
|
||||||
|
|
||||||
|
public long FileSize { get; }
|
||||||
|
|
||||||
|
public Attachment(string id, int? width, int? height, string url, string fileName, long fileSize)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Url = url;
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
|
FileName = fileName;
|
||||||
|
FileSize = fileSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => FileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
public class AuthToken
|
||||||
|
{
|
||||||
|
public AuthTokenType Type { get; }
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public AuthToken(AuthTokenType type, string value)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
public enum AuthTokenType
|
||||||
|
{
|
||||||
|
User,
|
||||||
|
Bot
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#channel-object
|
||||||
|
|
||||||
|
public partial class Channel
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string GuildId { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public string Topic { get; }
|
||||||
|
|
||||||
|
public ChannelType Type { get; }
|
||||||
|
|
||||||
|
public Channel(string id, string guildId, string name, string topic, ChannelType type)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
GuildId = guildId;
|
||||||
|
Name = name;
|
||||||
|
Topic = topic;
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Channel
|
||||||
|
{
|
||||||
|
public static Channel CreateDeletedChannel(string id) =>
|
||||||
|
new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
|
|
||||||
|
public enum ChannelType
|
||||||
|
{
|
||||||
|
GuildTextChat,
|
||||||
|
DirectTextChat,
|
||||||
|
GuildVoiceChat,
|
||||||
|
DirectGroupTextChat,
|
||||||
|
Category
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
public class ChatLog
|
||||||
|
{
|
||||||
|
public Guild Guild { get; }
|
||||||
|
|
||||||
|
public Channel Channel { get; }
|
||||||
|
|
||||||
|
public DateTime? From { get; }
|
||||||
|
|
||||||
|
public DateTime? To { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Message> Messages { get; }
|
||||||
|
|
||||||
|
public Mentionables Mentionables { get; }
|
||||||
|
|
||||||
|
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
|
||||||
|
IReadOnlyList<Message> messages, Mentionables mentionables)
|
||||||
|
{
|
||||||
|
Guild = guild;
|
||||||
|
Channel = channel;
|
||||||
|
From = from;
|
||||||
|
To = to;
|
||||||
|
Messages = messages;
|
||||||
|
Mentionables = mentionables;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#embed-object
|
||||||
|
|
||||||
|
public class Embed
|
||||||
|
{
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
public string Url { get; }
|
||||||
|
|
||||||
|
public DateTime? Timestamp { get; }
|
||||||
|
|
||||||
|
public Color Color { get; }
|
||||||
|
|
||||||
|
public EmbedAuthor Author { get; }
|
||||||
|
|
||||||
|
public string Description { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<EmbedField> Fields { get; }
|
||||||
|
|
||||||
|
public EmbedImage Thumbnail { get; }
|
||||||
|
|
||||||
|
public EmbedImage Image { get; }
|
||||||
|
|
||||||
|
public EmbedFooter Footer { get; }
|
||||||
|
|
||||||
|
public Embed(string title, string url, DateTime? timestamp, Color color, EmbedAuthor author, string description,
|
||||||
|
IReadOnlyList<EmbedField> fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Url = url;
|
||||||
|
Timestamp = timestamp;
|
||||||
|
Color = color;
|
||||||
|
Author = author;
|
||||||
|
Description = description;
|
||||||
|
Fields = fields;
|
||||||
|
Thumbnail = thumbnail;
|
||||||
|
Image = image;
|
||||||
|
Footer = footer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Title;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||||
|
|
||||||
|
public class EmbedAuthor
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public string Url { get; }
|
||||||
|
|
||||||
|
public string IconUrl { get; }
|
||||||
|
|
||||||
|
public EmbedAuthor(string name, string url, string iconUrl)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Url = url;
|
||||||
|
IconUrl = iconUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||||
|
|
||||||
|
public class EmbedField
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public bool IsInline { get; }
|
||||||
|
|
||||||
|
public EmbedField(string name, string value, bool isInline)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Value = value;
|
||||||
|
IsInline = isInline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"{Name} | {Value}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||||
|
|
||||||
|
public class EmbedFooter
|
||||||
|
{
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
|
public string IconUrl { get; }
|
||||||
|
|
||||||
|
public EmbedFooter(string text, string iconUrl)
|
||||||
|
{
|
||||||
|
Text = text;
|
||||||
|
IconUrl = iconUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||||
|
|
||||||
|
public class EmbedImage
|
||||||
|
{
|
||||||
|
public string Url { get; }
|
||||||
|
|
||||||
|
public int? Width { get; }
|
||||||
|
|
||||||
|
public int? Height { get; }
|
||||||
|
|
||||||
|
public EmbedImage(string url, int? width, int? height)
|
||||||
|
{
|
||||||
|
Url = url;
|
||||||
|
Height = height;
|
||||||
|
Width = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
public enum ExportFormat
|
||||||
|
{
|
||||||
|
PlainText,
|
||||||
|
HtmlDark,
|
||||||
|
HtmlLight,
|
||||||
|
Csv
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.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";
|
||||||
|
if (format == ExportFormat.Csv)
|
||||||
|
return "csv";
|
||||||
|
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(format));
|
||||||
|
}
|
||||||
|
|
||||||
|
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)";
|
||||||
|
if (format == ExportFormat.Csv)
|
||||||
|
return "Comma Seperated Values (CSV)";
|
||||||
|
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(format));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ChatLog> SplitIntoPartitions(this ChatLog chatLog, int partitionLimit)
|
||||||
|
{
|
||||||
|
// If chat log has fewer messages than the limit - just return chat log in a list
|
||||||
|
if (chatLog.Messages.Count <= partitionLimit)
|
||||||
|
return new[] {chatLog};
|
||||||
|
|
||||||
|
var result = new List<ChatLog>();
|
||||||
|
|
||||||
|
// Loop through messages
|
||||||
|
var buffer = new List<Message>();
|
||||||
|
foreach (var message in chatLog.Messages)
|
||||||
|
{
|
||||||
|
// Add message to buffer
|
||||||
|
buffer.Add(message);
|
||||||
|
|
||||||
|
// If reached the limit - split and reset buffer
|
||||||
|
if (buffer.Count >= partitionLimit)
|
||||||
|
{
|
||||||
|
// Add to result
|
||||||
|
var chatLogPartition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, buffer,
|
||||||
|
chatLog.Mentionables);
|
||||||
|
result.Add(chatLogPartition);
|
||||||
|
|
||||||
|
// Reset the buffer instead of clearing to avoid mutations on existing references
|
||||||
|
buffer = new List<Message>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add what's remaining in buffer
|
||||||
|
if (buffer.Any())
|
||||||
|
{
|
||||||
|
// Add to result
|
||||||
|
var chatLogPartition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, buffer,
|
||||||
|
chatLog.Mentionables);
|
||||||
|
result.Add(chatLogPartition);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/guild#guild-object
|
||||||
|
|
||||||
|
public partial 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() => Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Guild
|
||||||
|
{
|
||||||
|
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
public class Mentionables
|
||||||
|
{
|
||||||
|
public IReadOnlyList<User> Users { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Channel> Channels { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Role> Roles { get; }
|
||||||
|
|
||||||
|
public Mentionables(IReadOnlyList<User> users, IReadOnlyList<Channel> channels, IReadOnlyList<Role> roles)
|
||||||
|
{
|
||||||
|
Users = users;
|
||||||
|
Channels = channels;
|
||||||
|
Roles = roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User GetUser(string id) =>
|
||||||
|
Users.FirstOrDefault(u => u.Id == id) ?? User.CreateUnknownUser(id);
|
||||||
|
|
||||||
|
public Channel GetChannel(string id) =>
|
||||||
|
Channels.FirstOrDefault(c => c.Id == id) ?? Channel.CreateDeletedChannel(id);
|
||||||
|
|
||||||
|
public Role GetRole(string id) =>
|
||||||
|
Roles.FirstOrDefault(r => r.Id == id) ?? Role.CreateDeletedRole(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#message-object
|
||||||
|
|
||||||
|
public class Message
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string ChannelId { get; }
|
||||||
|
|
||||||
|
public MessageType Type { get; }
|
||||||
|
|
||||||
|
public User Author { get; }
|
||||||
|
|
||||||
|
public DateTime Timestamp { get; }
|
||||||
|
|
||||||
|
public DateTime? EditedTimestamp { get; }
|
||||||
|
|
||||||
|
public string Content { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Attachment> Attachments { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Embed> Embeds { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Reaction> Reactions { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<User> MentionedUsers { get; }
|
||||||
|
|
||||||
|
public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
|
||||||
|
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
|
||||||
|
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
ChannelId = channelId;
|
||||||
|
Type = type;
|
||||||
|
Author = author;
|
||||||
|
Timestamp = timestamp;
|
||||||
|
EditedTimestamp = editedTimestamp;
|
||||||
|
Content = content;
|
||||||
|
Attachments = attachments;
|
||||||
|
Embeds = embeds;
|
||||||
|
Reactions = reactions;
|
||||||
|
MentionedUsers = mentionedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
|
||||||
|
|
||||||
|
public enum MessageType
|
||||||
|
{
|
||||||
|
Default,
|
||||||
|
RecipientAdd,
|
||||||
|
RecipientRemove,
|
||||||
|
Call,
|
||||||
|
ChannelNameChange,
|
||||||
|
ChannelIconChange,
|
||||||
|
ChannelPinnedMessage,
|
||||||
|
GuildMemberJoin
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#reaction-object
|
||||||
|
|
||||||
|
public class Reaction
|
||||||
|
{
|
||||||
|
public int Count { get; }
|
||||||
|
|
||||||
|
public string EmojiId { get; }
|
||||||
|
|
||||||
|
public string EmojiName { get; }
|
||||||
|
|
||||||
|
public Reaction(int count, string emojiId, string emojiName)
|
||||||
|
{
|
||||||
|
Count = count;
|
||||||
|
EmojiId = emojiId;
|
||||||
|
EmojiName = emojiName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||||
|
|
||||||
|
public partial class Role
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public Role(string id, string name)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Role
|
||||||
|
{
|
||||||
|
public static Role CreateDeletedRole(string id) =>
|
||||||
|
new Role(id, "deleted-role");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||||
|
|
||||||
|
public partial class User
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public int Discriminator { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||||
|
|
||||||
|
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
||||||
|
|
||||||
|
public string AvatarHash { get; }
|
||||||
|
|
||||||
|
public bool IsAvatarAnimated =>
|
||||||
|
AvatarHash.IsNotBlank() && AvatarHash.StartsWith("a_", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public string AvatarUrl
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Custom avatar
|
||||||
|
if (AvatarHash.IsNotBlank())
|
||||||
|
{
|
||||||
|
// Animated
|
||||||
|
if (IsAvatarAnimated)
|
||||||
|
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.gif";
|
||||||
|
|
||||||
|
// Non-animated
|
||||||
|
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default avatar
|
||||||
|
return $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public User(string id, int discriminator, string name, string avatarHash)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Discriminator = discriminator;
|
||||||
|
Name = name;
|
||||||
|
AvatarHash = avatarHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class User
|
||||||
|
{
|
||||||
|
public static User CreateUnknownUser(string id) =>
|
||||||
|
new User(id, 0, "Unknown", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Author;Date;Content;Attachments;
|
||||||
|
{{~ for message in Model.Messages -}}
|
||||||
|
{{- }}"{{ message.Author.FullName }}";
|
||||||
|
|
||||||
|
{{- }}"{{ message.Timestamp | FormatDate }}";
|
||||||
|
|
||||||
|
{{- }}"{{ message.Content | FormatContent }}";
|
||||||
|
|
||||||
|
{{- }}"{{ message.Attachments | array.map "Url" | array.join "," }}";
|
||||||
|
{{~ end -}}
|
||||||
|
Can't render this file because it contains an unexpected character in line 3 and column 9.
|
@@ -0,0 +1,216 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<style>
|
||||||
|
{{ StyleSheet }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{~ # Info ~}}
|
||||||
|
<div class="info">
|
||||||
|
<div class="info__guild-icon-container">
|
||||||
|
<img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
|
||||||
|
</div>
|
||||||
|
<div class="info__metadata">
|
||||||
|
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
|
||||||
|
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
|
||||||
|
|
||||||
|
{{~ if Model.Channel.Topic ~}}
|
||||||
|
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
<div class="info__channel-message-count">{{ Model.Messages | array.size | Format "N0" }} messages</div>
|
||||||
|
|
||||||
|
{{~ if Model.From || Model.To ~}}
|
||||||
|
<div class="info__channel-date-range">
|
||||||
|
{{~ if Model.From && Model.To ~}}
|
||||||
|
Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
|
||||||
|
{{~ else if Model.From ~}}
|
||||||
|
After {{ Model.From | FormatDate | html.escape }}
|
||||||
|
{{~ else if Model.To ~}}
|
||||||
|
Before {{ Model.To | FormatDate | html.escape }}
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{~ # Log ~}}
|
||||||
|
<div class="chatlog">
|
||||||
|
{{~ for group in Model.Messages | GroupMessages ~}}
|
||||||
|
<div class="chatlog__message-group">
|
||||||
|
{{~ # Avatar ~}}
|
||||||
|
<div class="chatlog__author-avatar-container">
|
||||||
|
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
|
||||||
|
</div>
|
||||||
|
<div class="chatlog__messages">
|
||||||
|
{{~ # Author name and timestamp ~}}
|
||||||
|
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}">{{ group.Author.Name | html.escape }}</span>
|
||||||
|
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
|
||||||
|
|
||||||
|
{{~ # Messages ~}}
|
||||||
|
{{~ for message in group.Messages ~}}
|
||||||
|
{{~ # Content ~}}
|
||||||
|
{{~ if message.Content ~}}
|
||||||
|
<div class="chatlog__content">
|
||||||
|
{{ message.Content | FormatContent }}
|
||||||
|
|
||||||
|
{{~ # Edited timestamp ~}}
|
||||||
|
{{~ if message.EditedTimestamp ~}}
|
||||||
|
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ # Attachments ~}}
|
||||||
|
{{~ for attachment in message.Attachments ~}}
|
||||||
|
<div class="chatlog__attachment">
|
||||||
|
<a href="{{ attachment.Url }}">
|
||||||
|
{{ # Image }}
|
||||||
|
{{~ if attachment.IsImage ~}}
|
||||||
|
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
|
||||||
|
{{~ # Non-image ~}}
|
||||||
|
{{~ else ~}}
|
||||||
|
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
|
||||||
|
{{~ end ~}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ # Embeds ~}}
|
||||||
|
{{~ for embed in message.Embeds ~}}
|
||||||
|
<div class="chatlog__embed">
|
||||||
|
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color | FormatColor }})"></div>
|
||||||
|
<div class="chatlog__embed-content-container">
|
||||||
|
<div class="chatlog__embed-content">
|
||||||
|
<div class="chatlog__embed-text">
|
||||||
|
{{~ # Author ~}}
|
||||||
|
{{~ if embed.Author ~}}
|
||||||
|
<div class="chatlog__embed-author">
|
||||||
|
{{~ if embed.Author.IconUrl ~}}
|
||||||
|
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ if embed.Author.Name ~}}
|
||||||
|
<span class="chatlog__embed-author-name">
|
||||||
|
{{~ if embed.Author.Url ~}}
|
||||||
|
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
|
||||||
|
{{~ else ~}}
|
||||||
|
{{ embed.Author.Name | html.escape }}
|
||||||
|
{{~ end ~}}
|
||||||
|
</span>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ # Title ~}}
|
||||||
|
{{~ if embed.Title ~}}
|
||||||
|
<div class="chatlog__embed-title">
|
||||||
|
{{~ if embed.Url ~}}
|
||||||
|
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
|
||||||
|
{{~ else ~}}
|
||||||
|
{{ embed.Title | FormatContent }}
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ # Description ~}}
|
||||||
|
{{~ if embed.Description ~}}
|
||||||
|
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ # Fields ~}}
|
||||||
|
{{~ if embed.Fields | array.size > 0 ~}}
|
||||||
|
<div class="chatlog__embed-fields">
|
||||||
|
{{~ for field in embed.Fields ~}}
|
||||||
|
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
||||||
|
{{~ if field.Name ~}}
|
||||||
|
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
{{~ if field.Value ~}}
|
||||||
|
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{~ # Thumbnail ~}}
|
||||||
|
{{~ if embed.Thumbnail ~}}
|
||||||
|
<div class="chatlog__embed-thumbnail-container">
|
||||||
|
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
|
||||||
|
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{~ # Image ~}}
|
||||||
|
{{~ if embed.Image ~}}
|
||||||
|
<div class="chatlog__embed-image-container">
|
||||||
|
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
|
||||||
|
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ # Footer ~}}
|
||||||
|
{{~ if embed.Footer || embed.Timestamp ~}}
|
||||||
|
<div class="chatlog__embed-footer">
|
||||||
|
{{~ if embed.Footer ~}}
|
||||||
|
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
|
||||||
|
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
|
||||||
|
{{~ end ~}}
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
<span class="chatlog__embed-footer-text">
|
||||||
|
{{~ if embed.Footer ~}}
|
||||||
|
{{~ if embed.Footer.Text ~}}
|
||||||
|
{{ embed.Footer.Text | html.escape }}
|
||||||
|
{{ if embed.Timestamp }} • {{ end }}
|
||||||
|
{{~ end ~}}
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ if embed.Timestamp ~}}
|
||||||
|
{{ embed.Timestamp | FormatDate | html.escape }}
|
||||||
|
{{~ end ~}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ # Reactions ~}}
|
||||||
|
{{~ if message.Reactions | array.size > 0 ~}}
|
||||||
|
<div class="chatlog__reactions">
|
||||||
|
{{~ for reaction in message.Reactions ~}}
|
||||||
|
<div class="chatlog__reaction">
|
||||||
|
<span class="chatlog__reaction-emoji">
|
||||||
|
{{~ # Custom emoji ~}}
|
||||||
|
{{~ if reaction.EmojiId ~}}
|
||||||
|
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
|
||||||
|
{{~ # Default emoji ~}}
|
||||||
|
{{~ else ~}}
|
||||||
|
{{ reaction.EmojiName }}
|
||||||
|
{{~ end ~}}
|
||||||
|
</span>
|
||||||
|
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{~ end ~}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/* === GENERAL === */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #36393e;
|
||||||
|
color: #ffffffb3;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0096cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre {
|
||||||
|
background-color: #2f3136;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre--multiline {
|
||||||
|
border-color: #282b30;
|
||||||
|
color: #839496;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
background-color: #738bd71a;
|
||||||
|
color: #7289da;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === INFO === */
|
||||||
|
|
||||||
|
.info__guild-name {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__channel-name {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__channel-topic {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CHATLOG === */
|
||||||
|
|
||||||
|
.chatlog__message-group {
|
||||||
|
border-color: #ffffff0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__author-name {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__timestamp {
|
||||||
|
color: #ffffff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__edited-timestamp {
|
||||||
|
color: #ffffff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-content-container {
|
||||||
|
background-color: #2e30364d;
|
||||||
|
border-color: #2e303699;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-author-name {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-author-name-link {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-title {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-description {
|
||||||
|
color: #ffffff99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-field-name {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-field-value {
|
||||||
|
color: #ffffff99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-footer {
|
||||||
|
color: #ffffff99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reaction {
|
||||||
|
background-color: #ffffff0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reaction-count {
|
||||||
|
color: #ffffff4d;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/* === GENERAL === */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #737f8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #00b0f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre--multiline {
|
||||||
|
border-color: #f3f3f3;
|
||||||
|
color: #657b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
background-color: #f1f3fb;
|
||||||
|
color: #7289da;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === INFO === */
|
||||||
|
|
||||||
|
.info__guild-name {
|
||||||
|
color: #2f3136;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__channel-name {
|
||||||
|
color: #2f3136;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__channel-topic {
|
||||||
|
color: #2f3136;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CHATLOG === */
|
||||||
|
|
||||||
|
.chatlog__message-group {
|
||||||
|
border-color: #eceeef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__author-name {
|
||||||
|
color: #2f3136;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__timestamp {
|
||||||
|
color: #99aab5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__edited-timestamp {
|
||||||
|
color: #99aab5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-content-container {
|
||||||
|
background-color: #f9f9f94d;
|
||||||
|
border-color: #cccccc4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-author-name {
|
||||||
|
color: #4f545c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-author-name-link {
|
||||||
|
color: #4f545c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-title {
|
||||||
|
color: #4f545c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-description {
|
||||||
|
color: #737f8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-field-name {
|
||||||
|
color: #36393e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-field-value {
|
||||||
|
color: #737f8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-footer {
|
||||||
|
color: #4f535b99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reaction {
|
||||||
|
background-color: #4f545c0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reaction-count {
|
||||||
|
color: #99aab5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/* === GENERAL === */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre {
|
||||||
|
font-family: "Consolas", "Courier New", Courier, Monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre--multiline {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre--inline {
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0 1px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji--small {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji--large {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === INFO === */
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 5px 10px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__guild-icon-container {
|
||||||
|
flex: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__guild-icon {
|
||||||
|
max-width: 88px;
|
||||||
|
max-height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__metadata {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__guild-name {
|
||||||
|
font-size: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__channel-name {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__channel-topic {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__channel-message-count {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info__channel-date-range {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CHATLOG === */
|
||||||
|
|
||||||
|
.chatlog {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-group {
|
||||||
|
display: flex;
|
||||||
|
margin: 0 10px;
|
||||||
|
padding: 15px 0;
|
||||||
|
border-top: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__author-avatar-container {
|
||||||
|
flex: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__author-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__messages {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 50%;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__author-name {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__timestamp {
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: .75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__content {
|
||||||
|
padding-top: 5px;
|
||||||
|
font-size: .9375em;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__edited-timestamp {
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__attachment {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__attachment-thumbnail {
|
||||||
|
max-width: 50%;
|
||||||
|
max-height: 500px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed {
|
||||||
|
display: flex;
|
||||||
|
max-width: 520px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-color-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 4px;
|
||||||
|
border-top-left-radius: 3px;
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-content-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-author {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-author-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-author-name {
|
||||||
|
font-size: .875em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: .875em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-description {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-field {
|
||||||
|
flex: 0;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: 506px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-field--inline {
|
||||||
|
flex: 1;
|
||||||
|
flex-basis: auto;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-field-name {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: .875em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-field-value {
|
||||||
|
font-size: .875em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-thumbnail {
|
||||||
|
flex: 0;
|
||||||
|
margin-left: 20px;
|
||||||
|
max-width: 80px;
|
||||||
|
max-height: 80px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-image-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-image {
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 400px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-footer-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-footer-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reactions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reaction {
|
||||||
|
margin: 6px 2px 2px 2px;
|
||||||
|
padding: 2px 6px 2px 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reaction-emoji {
|
||||||
|
margin-left: 3px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reaction-count {
|
||||||
|
font-size: .875em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{
|
||||||
|
$SharedStyleSheet = include "Html.Shared.css"
|
||||||
|
$ThemeStyleSheet = include "Html.DarkTheme.css"
|
||||||
|
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
|
||||||
|
}}
|
||||||
|
|
||||||
|
{{ include "Html.Core.html" }}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{
|
||||||
|
$SharedStyleSheet = include "Html.Shared.css"
|
||||||
|
$ThemeStyleSheet = include "Html.LightTheme.css"
|
||||||
|
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
|
||||||
|
}}
|
||||||
|
|
||||||
|
{{ include "Html.Core.html" }}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{~ # Info ~}}
|
||||||
|
==============================================================
|
||||||
|
Guild: {{ Model.Guild.Name }}
|
||||||
|
Channel: {{ Model.Channel.Name }}
|
||||||
|
Topic: {{ Model.Channel.Topic }}
|
||||||
|
Messages: {{ Model.Messages | array.size | Format "N0" }}
|
||||||
|
Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model.From || Model.To }}->{{ end }}{{ if Model.To }} {{ Model.To | FormatDate }}{{ end }}
|
||||||
|
==============================================================
|
||||||
|
|
||||||
|
{{~ # Log ~}}
|
||||||
|
{{~ for message in Model.Messages ~}}
|
||||||
|
{{~ # Author name and timestamp ~}}
|
||||||
|
{{~ }}[{{ message.Timestamp | FormatDate }}] {{ message.Author.FullName }}
|
||||||
|
{{~ # Content ~}}
|
||||||
|
{{~ message.Content | FormatContent }}
|
||||||
|
{{~ # Attachments ~}}
|
||||||
|
{{~ for attachment in message.Attachments ~}}
|
||||||
|
{{~ attachment.Url }}
|
||||||
|
{{~ end ~}}
|
||||||
|
|
||||||
|
{{~ end ~}}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Linq;
|
||||||
|
using DiscordChatExporter.Core.Internal;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public partial class DataService
|
||||||
|
{
|
||||||
|
private User ParseUser(JToken json)
|
||||||
|
{
|
||||||
|
var id = json["id"].Value<string>();
|
||||||
|
var discriminator = json["discriminator"].Value<int>();
|
||||||
|
var name = json["username"].Value<string>();
|
||||||
|
var avatarHash = json["avatar"].Value<string>();
|
||||||
|
|
||||||
|
return new User(id, discriminator, name, avatarHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guild ParseGuild(JToken json)
|
||||||
|
{
|
||||||
|
var id = json["id"].Value<string>();
|
||||||
|
var name = json["name"].Value<string>();
|
||||||
|
var iconHash = json["icon"].Value<string>();
|
||||||
|
|
||||||
|
return new Guild(id, name, iconHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Channel ParseChannel(JToken json)
|
||||||
|
{
|
||||||
|
// Get basic data
|
||||||
|
var id = json["id"].Value<string>();
|
||||||
|
var type = (ChannelType) json["type"].Value<int>();
|
||||||
|
var topic = json["topic"]?.Value<string>();
|
||||||
|
|
||||||
|
// Try to extract guild ID
|
||||||
|
var guildId = json["guild_id"]?.Value<string>();
|
||||||
|
|
||||||
|
// If the guild ID is blank, it's direct messages
|
||||||
|
if (guildId.IsBlank())
|
||||||
|
guildId = Guild.DirectMessages.Id;
|
||||||
|
|
||||||
|
// Try to extract name
|
||||||
|
var name = json["name"]?.Value<string>();
|
||||||
|
|
||||||
|
// If the name is blank, it's direct messages
|
||||||
|
if (name.IsBlank())
|
||||||
|
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
|
||||||
|
|
||||||
|
return new Channel(id, guildId, name, topic, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Role ParseRole(JToken json)
|
||||||
|
{
|
||||||
|
var id = json["id"].Value<string>();
|
||||||
|
var name = json["name"].Value<string>();
|
||||||
|
|
||||||
|
return new Role(id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Attachment ParseAttachment(JToken json)
|
||||||
|
{
|
||||||
|
var id = json["id"].Value<string>();
|
||||||
|
var url = json["url"].Value<string>();
|
||||||
|
var width = json["width"]?.Value<int>();
|
||||||
|
var height = json["height"]?.Value<int>();
|
||||||
|
var fileName = json["filename"].Value<string>();
|
||||||
|
var fileSize = json["size"].Value<long>();
|
||||||
|
|
||||||
|
return new Attachment(id, width, height, url, fileName, fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmbedAuthor ParseEmbedAuthor(JToken json)
|
||||||
|
{
|
||||||
|
var name = json["name"]?.Value<string>();
|
||||||
|
var url = json["url"]?.Value<string>();
|
||||||
|
var iconUrl = json["icon_url"]?.Value<string>();
|
||||||
|
|
||||||
|
return new EmbedAuthor(name, url, iconUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmbedField ParseEmbedField(JToken json)
|
||||||
|
{
|
||||||
|
var name = json["name"].Value<string>();
|
||||||
|
var value = json["value"].Value<string>();
|
||||||
|
var isInline = json["inline"]?.Value<bool>() ?? false;
|
||||||
|
|
||||||
|
return new EmbedField(name, value, isInline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmbedImage ParseEmbedImage(JToken json)
|
||||||
|
{
|
||||||
|
var url = json["url"]?.Value<string>();
|
||||||
|
var width = json["width"]?.Value<int>();
|
||||||
|
var height = json["height"]?.Value<int>();
|
||||||
|
|
||||||
|
return new EmbedImage(url, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmbedFooter ParseEmbedFooter(JToken json)
|
||||||
|
{
|
||||||
|
var text = json["text"].Value<string>();
|
||||||
|
var iconUrl = json["icon_url"]?.Value<string>();
|
||||||
|
|
||||||
|
return new EmbedFooter(text, iconUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Embed ParseEmbed(JToken json)
|
||||||
|
{
|
||||||
|
// Get basic data
|
||||||
|
var title = json["title"]?.Value<string>();
|
||||||
|
var description = json["description"]?.Value<string>();
|
||||||
|
var url = json["url"]?.Value<string>();
|
||||||
|
var timestamp = json["timestamp"]?.Value<DateTime>();
|
||||||
|
|
||||||
|
// Get color
|
||||||
|
var color = json["color"] != null
|
||||||
|
? Color.FromArgb(json["color"].Value<int>()).ResetAlpha()
|
||||||
|
: Color.FromArgb(79, 84, 92); // default color
|
||||||
|
|
||||||
|
// Get author
|
||||||
|
var author = json["author"] != null ? ParseEmbedAuthor(json["author"]) : null;
|
||||||
|
|
||||||
|
// Get fields
|
||||||
|
var fields = json["fields"].EmptyIfNull().Select(ParseEmbedField).ToArray();
|
||||||
|
|
||||||
|
// Get thumbnail
|
||||||
|
var thumbnail = json["thumbnail"] != null ? ParseEmbedImage(json["thumbnail"]) : null;
|
||||||
|
|
||||||
|
// Get image
|
||||||
|
var image = json["image"] != null ? ParseEmbedImage(json["image"]) : null;
|
||||||
|
|
||||||
|
// Get footer
|
||||||
|
var footer = json["footer"] != null ? ParseEmbedFooter(json["footer"]) : null;
|
||||||
|
|
||||||
|
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Reaction ParseReaction(JToken json)
|
||||||
|
{
|
||||||
|
var count = json["count"].Value<int>();
|
||||||
|
var emojiId = json["emoji"]["id"]?.Value<string>();
|
||||||
|
var emojiName = json["emoji"]["name"].Value<string>();
|
||||||
|
|
||||||
|
return new Reaction(count, emojiId, emojiName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Message ParseMessage(JToken json)
|
||||||
|
{
|
||||||
|
// Get basic data
|
||||||
|
var id = json["id"].Value<string>();
|
||||||
|
var channelId = json["channel_id"].Value<string>();
|
||||||
|
var timestamp = json["timestamp"].Value<DateTime>();
|
||||||
|
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>();
|
||||||
|
var content = json["content"].Value<string>();
|
||||||
|
var type = (MessageType) json["type"].Value<int>();
|
||||||
|
|
||||||
|
// 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(json["author"]);
|
||||||
|
|
||||||
|
// Get attachments
|
||||||
|
var attachments = json["attachments"].EmptyIfNull().Select(ParseAttachment).ToArray();
|
||||||
|
|
||||||
|
// Get embeds
|
||||||
|
var embeds = json["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray();
|
||||||
|
|
||||||
|
// Get reactions
|
||||||
|
var reactions = json["reactions"].EmptyIfNull().Select(ParseReaction).ToArray();
|
||||||
|
|
||||||
|
// Get mentioned users
|
||||||
|
var mentionedUsers = json["mentions"].EmptyIfNull().Select(ParseUser).ToArray();
|
||||||
|
|
||||||
|
return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
|
||||||
|
reactions, mentionedUsers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using DiscordChatExporter.Core.Internal;
|
||||||
|
using Polly;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public partial class DataService : IDataService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient = new HttpClient();
|
||||||
|
|
||||||
|
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
|
||||||
|
params string[] parameters)
|
||||||
|
{
|
||||||
|
// Create request policy
|
||||||
|
var policy = Policy
|
||||||
|
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
|
||||||
|
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
return await policy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
// Create request
|
||||||
|
const string apiRoot = "https://discordapp.com/api/v6";
|
||||||
|
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
|
||||||
|
{
|
||||||
|
// Set authorization header
|
||||||
|
request.Headers.Authorization = token.Type == AuthTokenType.Bot
|
||||||
|
? new AuthenticationHeaderValue("Bot", token.Value)
|
||||||
|
: new AuthenticationHeaderValue(token.Value);
|
||||||
|
|
||||||
|
// Add parameters
|
||||||
|
foreach (var parameter in parameters)
|
||||||
|
{
|
||||||
|
var key = parameter.SubstringUntil("=");
|
||||||
|
var value = parameter.SubstringAfter("=");
|
||||||
|
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
using (var response = await _httpClient.SendAsync(request))
|
||||||
|
{
|
||||||
|
// 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, response.ReasonPhrase);
|
||||||
|
|
||||||
|
// Get content
|
||||||
|
var raw = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
return JToken.Parse(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
|
||||||
|
{
|
||||||
|
// Special case for direct messages pseudo-guild
|
||||||
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
|
return Guild.DirectMessages;
|
||||||
|
|
||||||
|
var response = await GetApiResponseAsync(token, "guilds", guildId);
|
||||||
|
var guild = ParseGuild(response);
|
||||||
|
|
||||||
|
return guild;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
|
||||||
|
{
|
||||||
|
var response = await GetApiResponseAsync(token, "channels", channelId);
|
||||||
|
var channel = ParseChannel(response);
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
|
||||||
|
{
|
||||||
|
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
|
||||||
|
var guilds = response.Select(ParseGuild).ToArray();
|
||||||
|
|
||||||
|
return guilds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
|
||||||
|
{
|
||||||
|
var response = await GetApiResponseAsync(token, "users", "@me/channels");
|
||||||
|
var channels = response.Select(ParseChannel).ToArray();
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
|
||||||
|
{
|
||||||
|
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
|
||||||
|
var channels = response.Select(ParseChannel).ToArray();
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
|
||||||
|
{
|
||||||
|
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
|
||||||
|
var roles = response.Select(ParseRole).ToArray();
|
||||||
|
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||||
|
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||||
|
{
|
||||||
|
var result = new List<Message>();
|
||||||
|
|
||||||
|
// Report indeterminate progress
|
||||||
|
progress?.Report(-1);
|
||||||
|
|
||||||
|
// Get the snowflakes for the selected range
|
||||||
|
var firstId = from != null ? from.Value.ToSnowflake() : "0";
|
||||||
|
var lastId = to != null ? to.Value.ToSnowflake() : DateTime.MaxValue.ToSnowflake();
|
||||||
|
|
||||||
|
// Get the last message
|
||||||
|
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
||||||
|
"limit=1", $"before={lastId}");
|
||||||
|
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
|
||||||
|
|
||||||
|
// If the last message doesn't exist or it's outside of range - return
|
||||||
|
if (lastMessage == null || lastMessage.Timestamp < from)
|
||||||
|
{
|
||||||
|
progress?.Report(1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get other messages
|
||||||
|
var offsetId = firstId;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Get message batch
|
||||||
|
response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
||||||
|
"limit=100", $"after={offsetId}");
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
var messages = response
|
||||||
|
.Select(ParseMessage)
|
||||||
|
.Reverse() // reverse because messages appear newest first
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||||
|
if (!messages.Any())
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Trim messages to range (until last message)
|
||||||
|
var messagesInRange = messages
|
||||||
|
.TakeWhile(m => m.Id != lastMessage.Id && m.Timestamp < lastMessage.Timestamp)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Add to result
|
||||||
|
result.AddRange(messagesInRange);
|
||||||
|
|
||||||
|
// Break if messages were trimmed (which means the last message was encountered)
|
||||||
|
if (messagesInRange.Length != messages.Length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Report progress (based on the time range of parsed messages compared to total)
|
||||||
|
progress?.Report((result.Last().Timestamp - result.First().Timestamp).TotalSeconds /
|
||||||
|
(lastMessage.Timestamp - result.First().Timestamp).TotalSeconds);
|
||||||
|
|
||||||
|
// Move offset
|
||||||
|
offsetId = result.Last().Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last message
|
||||||
|
result.Add(lastMessage);
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
progress?.Report(1);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
||||||
|
IEnumerable<Message> messages)
|
||||||
|
{
|
||||||
|
// Get channels and roles
|
||||||
|
var channels = guildId != Guild.DirectMessages.Id
|
||||||
|
? await GetGuildChannelsAsync(token, guildId)
|
||||||
|
: Array.Empty<Channel>();
|
||||||
|
var roles = guildId != Guild.DirectMessages.Id
|
||||||
|
? await GetGuildRolesAsync(token, guildId)
|
||||||
|
: Array.Empty<Role>();
|
||||||
|
|
||||||
|
// Get users
|
||||||
|
var userMap = new Dictionary<string, User>();
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
// Author
|
||||||
|
userMap[message.Author.Id] = message.Author;
|
||||||
|
|
||||||
|
// Mentioned users
|
||||||
|
foreach (var mentionedUser in message.MentionedUsers)
|
||||||
|
userMap[mentionedUser.Id] = mentionedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = userMap.Values.ToArray();
|
||||||
|
|
||||||
|
return new Mentionables(users, channels, roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
|
||||||
|
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||||
|
{
|
||||||
|
// Get messages
|
||||||
|
var messages = await GetChannelMessagesAsync(token, channel.Id, from, to, progress);
|
||||||
|
|
||||||
|
// Get mentionables
|
||||||
|
var mentionables = await GetMentionablesAsync(token, guild.Id, messages);
|
||||||
|
|
||||||
|
return new ChatLog(guild, channel, from, to, messages, mentionables);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
|
||||||
|
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||||
|
{
|
||||||
|
// Get channel
|
||||||
|
var channel = await GetChannelAsync(token, channelId);
|
||||||
|
|
||||||
|
// Get guild
|
||||||
|
var guild = channel.GuildId == Guild.DirectMessages.Id
|
||||||
|
? Guild.DirectMessages
|
||||||
|
: await GetGuildAsync(token, channel.GuildId);
|
||||||
|
|
||||||
|
// Get the chat log
|
||||||
|
return await GetChatLogAsync(token, guild, channel, from, to, progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public partial class ExportService
|
||||||
|
{
|
||||||
|
private class MessageGroup
|
||||||
|
{
|
||||||
|
public User Author { get; }
|
||||||
|
|
||||||
|
public DateTime Timestamp { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Message> Messages { get; }
|
||||||
|
|
||||||
|
public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
|
||||||
|
{
|
||||||
|
Author = author;
|
||||||
|
Timestamp = timestamp;
|
||||||
|
Messages = messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using Scriban;
|
||||||
|
using Scriban.Parsing;
|
||||||
|
using Scriban.Runtime;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public partial class ExportService
|
||||||
|
{
|
||||||
|
private class TemplateLoader : ITemplateLoader
|
||||||
|
{
|
||||||
|
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Resources.ExportTemplates";
|
||||||
|
|
||||||
|
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName)
|
||||||
|
{
|
||||||
|
return $"{ResourceRootNamespace}.{templateName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetPath(ExportFormat format)
|
||||||
|
{
|
||||||
|
return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
|
||||||
|
{
|
||||||
|
return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Load(ExportFormat format)
|
||||||
|
{
|
||||||
|
return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using DiscordChatExporter.Core.Internal;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using Scriban.Runtime;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public partial class ExportService
|
||||||
|
{
|
||||||
|
private class TemplateModel
|
||||||
|
{
|
||||||
|
private readonly ExportFormat _format;
|
||||||
|
private readonly ChatLog _log;
|
||||||
|
private readonly string _dateFormat;
|
||||||
|
private readonly int _messageGroupLimit;
|
||||||
|
|
||||||
|
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat, int messageGroupLimit)
|
||||||
|
{
|
||||||
|
_format = format;
|
||||||
|
_log = log;
|
||||||
|
_dateFormat = dateFormat;
|
||||||
|
_messageGroupLimit = messageGroupLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
||||||
|
{
|
||||||
|
// Group adjacent messages by timestamp and author
|
||||||
|
var buffer = new List<Message>();
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
var groupFirst = buffer.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 ||
|
||||||
|
buffer.Count >= _messageGroupLimit
|
||||||
|
);
|
||||||
|
|
||||||
|
// If condition is true - flush buffer
|
||||||
|
if (breakCondition)
|
||||||
|
{
|
||||||
|
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, buffer);
|
||||||
|
|
||||||
|
// Reset the buffer instead of clearing to avoid mutations on existing references
|
||||||
|
buffer = new List<Message>();
|
||||||
|
|
||||||
|
yield return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message to buffer
|
||||||
|
buffer.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add what's remaining in buffer
|
||||||
|
if (buffer.Any())
|
||||||
|
{
|
||||||
|
var groupFirst = buffer.First();
|
||||||
|
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, buffer);
|
||||||
|
|
||||||
|
yield return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
|
||||||
|
|
||||||
|
private string Format(IFormattable obj, string format) =>
|
||||||
|
obj.ToString(format, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat);
|
||||||
|
|
||||||
|
private string FormatFileSize(long fileSize)
|
||||||
|
{
|
||||||
|
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
|
||||||
|
double size = fileSize;
|
||||||
|
var unit = 0;
|
||||||
|
|
||||||
|
while (size >= 1024)
|
||||||
|
{
|
||||||
|
size /= 1024;
|
||||||
|
++unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{size:0.#} {units[unit]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatColor(Color color)
|
||||||
|
{
|
||||||
|
return $"{color.R},{color.G},{color.B},{color.A}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatContentPlainText(string content)
|
||||||
|
{
|
||||||
|
// New lines
|
||||||
|
content = content.Replace("\n", Environment.NewLine);
|
||||||
|
|
||||||
|
// User mentions (<@id> and <@!id>)
|
||||||
|
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedUserId in mentionedUserIds)
|
||||||
|
{
|
||||||
|
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
||||||
|
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel mentions (<#id>)
|
||||||
|
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedChannelId in mentionedChannelIds)
|
||||||
|
{
|
||||||
|
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
||||||
|
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role mentions (<@&id>)
|
||||||
|
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedRoleId in mentionedRoleIds)
|
||||||
|
{
|
||||||
|
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
||||||
|
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom emojis (<:name:id>)
|
||||||
|
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatContentHtml(string content, bool allowLinks = false)
|
||||||
|
{
|
||||||
|
// HTML-encode content
|
||||||
|
content = HtmlEncode(content);
|
||||||
|
|
||||||
|
// Encode multiline codeblocks (```text```)
|
||||||
|
content = Regex.Replace(content,
|
||||||
|
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
|
||||||
|
m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM");
|
||||||
|
|
||||||
|
// Encode inline codeblocks (`text`)
|
||||||
|
content = Regex.Replace(content,
|
||||||
|
@"`([^`]+)`",
|
||||||
|
m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI");
|
||||||
|
|
||||||
|
// Encode links
|
||||||
|
if (allowLinks)
|
||||||
|
{
|
||||||
|
content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)",
|
||||||
|
m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode URLs
|
||||||
|
content = Regex.Replace(content,
|
||||||
|
@"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))",
|
||||||
|
m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU");
|
||||||
|
|
||||||
|
// Process bold (**text**)
|
||||||
|
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
|
||||||
|
|
||||||
|
// Process underline (__text__)
|
||||||
|
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
|
||||||
|
|
||||||
|
// Process italic (*text* or _text_)
|
||||||
|
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
|
||||||
|
|
||||||
|
// Process strike through (~~text~~)
|
||||||
|
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
|
||||||
|
|
||||||
|
// Decode and process multiline codeblocks
|
||||||
|
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
|
||||||
|
m => $"<div class=\"pre pre--multiline\">{m.Groups[1].Value.Base64Decode()}</div>");
|
||||||
|
|
||||||
|
// Decode and process inline codeblocks
|
||||||
|
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
|
||||||
|
m => $"<span class=\"pre pre--inline\">{m.Groups[1].Value.Base64Decode()}</span>");
|
||||||
|
|
||||||
|
// Decode and process links
|
||||||
|
if (allowLinks)
|
||||||
|
{
|
||||||
|
content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL",
|
||||||
|
m => $"<a href=\"{m.Groups[2].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode and process URLs
|
||||||
|
content = Regex.Replace(content, "\x1AU(.*?)\x1AU",
|
||||||
|
m => $"<a href=\"{m.Groups[1].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
|
||||||
|
|
||||||
|
// Process new lines
|
||||||
|
content = content.Replace("\n", "<br />");
|
||||||
|
|
||||||
|
// 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> and <@!id>)
|
||||||
|
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedUserId in mentionedUserIds)
|
||||||
|
{
|
||||||
|
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
||||||
|
content = Regex.Replace(content, $"<@!?{mentionedUserId}>",
|
||||||
|
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
|
||||||
|
$"@{HtmlEncode(mentionedUser.Name)}" +
|
||||||
|
"</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel mentions (<#id>)
|
||||||
|
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedChannelId in mentionedChannelIds)
|
||||||
|
{
|
||||||
|
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
||||||
|
content = content.Replace($"<#{mentionedChannelId}>",
|
||||||
|
"<span class=\"mention\">" +
|
||||||
|
$"#{HtmlEncode(mentionedChannel.Name)}" +
|
||||||
|
"</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role mentions (<@&id>)
|
||||||
|
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedRoleId in mentionedRoleIds)
|
||||||
|
{
|
||||||
|
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
||||||
|
content = content.Replace($"<@&{mentionedRoleId}>",
|
||||||
|
"<span class=\"mention\">" +
|
||||||
|
$"@{HtmlEncode(mentionedRole.Name)}" +
|
||||||
|
"</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom emojis (<:name:id>)
|
||||||
|
var isJumboable = Regex.Replace(content, "<(:.*?:)(\\d*)>", "").IsBlank();
|
||||||
|
var emojiClass = isJumboable ? "emoji emoji--large" : "emoji";
|
||||||
|
content = Regex.Replace(content, "<(:.*?:)(\\d*)>",
|
||||||
|
$"<img class=\"{emojiClass}\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatContentCsv(string content)
|
||||||
|
{
|
||||||
|
// Escape quotes
|
||||||
|
content = content.Replace("\"", "\"\"");
|
||||||
|
|
||||||
|
// Escape commas and semicolons
|
||||||
|
if (content.Contains(",") || content.Contains(";"))
|
||||||
|
content = $"\"{content}\"";
|
||||||
|
|
||||||
|
// User mentions (<@id> and <@!id>)
|
||||||
|
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedUserId in mentionedUserIds)
|
||||||
|
{
|
||||||
|
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
||||||
|
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel mentions (<#id>)
|
||||||
|
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedChannelId in mentionedChannelIds)
|
||||||
|
{
|
||||||
|
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
||||||
|
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role mentions (<@&id>)
|
||||||
|
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
||||||
|
.Cast<Match>()
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.ExceptBlank()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var mentionedRoleId in mentionedRoleIds)
|
||||||
|
{
|
||||||
|
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
||||||
|
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom emojis (<:name:id>)
|
||||||
|
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatContent(string content, bool allowLinks = false)
|
||||||
|
{
|
||||||
|
if (_format == ExportFormat.PlainText)
|
||||||
|
return FormatContentPlainText(content);
|
||||||
|
|
||||||
|
if (_format == ExportFormat.HtmlDark)
|
||||||
|
return FormatContentHtml(content, allowLinks);
|
||||||
|
|
||||||
|
if (_format == ExportFormat.HtmlLight)
|
||||||
|
return FormatContentHtml(content, allowLinks);
|
||||||
|
|
||||||
|
if (_format == ExportFormat.Csv)
|
||||||
|
return FormatContentCsv(content);
|
||||||
|
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(_format));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScriptObject GetScriptObject()
|
||||||
|
{
|
||||||
|
// Create instance
|
||||||
|
var scriptObject = new ScriptObject();
|
||||||
|
|
||||||
|
// Import chat log
|
||||||
|
scriptObject.SetValue("Model", _log, true);
|
||||||
|
|
||||||
|
// Import functions
|
||||||
|
scriptObject.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
|
||||||
|
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
|
||||||
|
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
|
||||||
|
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
|
||||||
|
scriptObject.Import(nameof(FormatColor), new Func<Color, string>(FormatColor));
|
||||||
|
scriptObject.Import(nameof(FormatContent), new Func<string, bool, string>(FormatContent));
|
||||||
|
|
||||||
|
return scriptObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using Scriban;
|
||||||
|
using Scriban.Runtime;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public partial class ExportService : IExportService
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
|
public ExportService(ISettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExportChatLogSingle(ChatLog chatLog, string filePath, ExportFormat format)
|
||||||
|
{
|
||||||
|
// Create template loader
|
||||||
|
var loader = new TemplateLoader();
|
||||||
|
|
||||||
|
// Get template
|
||||||
|
var templateCode = loader.Load(format);
|
||||||
|
var template = Template.Parse(templateCode);
|
||||||
|
|
||||||
|
// Create template context
|
||||||
|
var context = new TemplateContext
|
||||||
|
{
|
||||||
|
TemplateLoader = loader,
|
||||||
|
MemberRenamer = m => m.Name,
|
||||||
|
MemberFilter = m => true,
|
||||||
|
LoopLimit = int.MaxValue,
|
||||||
|
StrictVariables = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create template model
|
||||||
|
var templateModel = new TemplateModel(format, chatLog,
|
||||||
|
_settingsService.DateFormat, _settingsService.MessageGroupLimit);
|
||||||
|
|
||||||
|
context.PushGlobal(templateModel.GetScriptObject());
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
var dirPath = Path.GetDirectoryName(filePath);
|
||||||
|
if (dirPath.IsNotBlank())
|
||||||
|
Directory.CreateDirectory(dirPath);
|
||||||
|
|
||||||
|
// Render output
|
||||||
|
using (var output = File.CreateText(filePath))
|
||||||
|
{
|
||||||
|
// Configure output
|
||||||
|
context.PushOutput(new TextWriterOutput(output));
|
||||||
|
|
||||||
|
// Render output
|
||||||
|
context.Evaluate(template.Page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExportChatLogPartitions(IReadOnlyList<ChatLog> partitions, string filePath, ExportFormat format)
|
||||||
|
{
|
||||||
|
// Split file path into components
|
||||||
|
var dirPath = Path.GetDirectoryName(filePath);
|
||||||
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||||||
|
var fileExt = Path.GetExtension(filePath);
|
||||||
|
|
||||||
|
// Export each partition separately
|
||||||
|
var partitionNumber = 1;
|
||||||
|
foreach (var partition in partitions)
|
||||||
|
{
|
||||||
|
// Compose new file name
|
||||||
|
var partitionFilePath = $"{fileNameWithoutExt}-{partitionNumber}{fileExt}";
|
||||||
|
|
||||||
|
// Compose full file path
|
||||||
|
if (dirPath.IsNotBlank())
|
||||||
|
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
|
||||||
|
|
||||||
|
// Export
|
||||||
|
ExportChatLogSingle(partition, partitionFilePath, format);
|
||||||
|
|
||||||
|
// Increment partition number
|
||||||
|
partitionNumber++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
|
||||||
|
int? partitionLimit = null)
|
||||||
|
{
|
||||||
|
// If partitioning is disabled or there are fewer messages in chat log than the limit - process it without partitioning
|
||||||
|
if (partitionLimit == null || chatLog.Messages.Count <= partitionLimit)
|
||||||
|
{
|
||||||
|
ExportChatLogSingle(chatLog, filePath, format);
|
||||||
|
}
|
||||||
|
// Otherwise split into partitions and export separately
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var partitions = chatLog.SplitIntoPartitions(partitionLimit.Value);
|
||||||
|
ExportChatLogPartitions(partitions, filePath, format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public interface IDataService
|
||||||
|
{
|
||||||
|
Task<Guild> GetGuildAsync(AuthToken token, string guildId);
|
||||||
|
|
||||||
|
Task<Channel> GetChannelAsync(AuthToken token, string channelId);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||||
|
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
||||||
|
|
||||||
|
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
||||||
|
IEnumerable<Message> messages);
|
||||||
|
|
||||||
|
Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
|
||||||
|
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
||||||
|
|
||||||
|
Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
|
||||||
|
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public interface IExportService
|
||||||
|
{
|
||||||
|
void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
|
||||||
|
int? partitionLimit = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public interface ISettingsService
|
||||||
|
{
|
||||||
|
bool IsAutoUpdateEnabled { get; set; }
|
||||||
|
|
||||||
|
string DateFormat { get; set; }
|
||||||
|
int MessageGroupLimit { get; set; }
|
||||||
|
|
||||||
|
AuthToken LastToken { get; set; }
|
||||||
|
ExportFormat LastExportFormat { get; set; }
|
||||||
|
int? LastPartitionLimit { get; set; }
|
||||||
|
|
||||||
|
void Load();
|
||||||
|
void Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public interface IUpdateService
|
||||||
|
{
|
||||||
|
bool NeedRestart { get; set; }
|
||||||
|
|
||||||
|
Task<Version> CheckPrepareUpdateAsync();
|
||||||
|
|
||||||
|
void FinalizeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using Tyrrrz.Settings;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public class SettingsService : SettingsManager, ISettingsService
|
||||||
|
{
|
||||||
|
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
public int MessageGroupLimit { get; set; } = 20;
|
||||||
|
|
||||||
|
public AuthToken LastToken { get; set; }
|
||||||
|
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
public int? LastPartitionLimit { get; set; }
|
||||||
|
|
||||||
|
public SettingsService()
|
||||||
|
{
|
||||||
|
Configuration.StorageSpace = StorageSpace.Instance;
|
||||||
|
Configuration.SubDirectoryPath = "";
|
||||||
|
Configuration.FileName = "Settings.dat";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Onova;
|
||||||
|
using Onova.Services;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public class UpdateService : IUpdateService
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IUpdateManager _manager;
|
||||||
|
|
||||||
|
private Version _updateVersion;
|
||||||
|
private bool _updateFinalized;
|
||||||
|
|
||||||
|
public bool NeedRestart { get; set; }
|
||||||
|
|
||||||
|
public UpdateService(ISettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
|
_manager = new UpdateManager(
|
||||||
|
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
||||||
|
new ZipPackageExtractor());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Version> CheckPrepareUpdateAsync()
|
||||||
|
{
|
||||||
|
// If auto-update is disabled - don't check for updates
|
||||||
|
if (!_settingsService.IsAutoUpdateEnabled)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Cleanup leftover files
|
||||||
|
_manager.Cleanup();
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
var check = await _manager.CheckForUpdatesAsync();
|
||||||
|
if (!check.CanUpdate)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Prepare the update
|
||||||
|
if (!_manager.IsUpdatePrepared(check.LastVersion))
|
||||||
|
await _manager.PrepareUpdateAsync(check.LastVersion);
|
||||||
|
|
||||||
|
return _updateVersion = check.LastVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FinalizeUpdate()
|
||||||
|
{
|
||||||
|
// Check if an update is pending
|
||||||
|
if (_updateVersion == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check if the update has already been finalized
|
||||||
|
if (_updateFinalized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Launch the updater
|
||||||
|
_manager.LaunchUpdater(_updateVersion, NeedRestart);
|
||||||
|
_updateFinalized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<Application
|
||||||
|
x:Class="DiscordChatExporter.Gui.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||||
|
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
||||||
|
DispatcherUnhandledException="App_OnDispatcherUnhandledException"
|
||||||
|
StartupUri="Views/MainWindow.xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
|
||||||
|
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
||||||
|
<ResourceDictionary Source="pack://application:,,,/Tyrrrz.WpfExtensions;component/ConvertersDictionary.xaml" />
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
|
||||||
|
<!-- Colors -->
|
||||||
|
<Color x:Key="PrimaryColor">#343838</Color>
|
||||||
|
<Color x:Key="PrimaryLightColor">#5E6262</Color>
|
||||||
|
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
|
||||||
|
<Color x:Key="AccentColor">#F9A825</Color>
|
||||||
|
<Color x:Key="AccentDarkColor">#C17900</Color>
|
||||||
|
<Color x:Key="TextColor">#000000</Color>
|
||||||
|
<Color x:Key="InverseTextColor">#FFFFFF</Color>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||||
|
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
|
||||||
|
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
|
||||||
|
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="PrimaryTextBrush"
|
||||||
|
Opacity="0.87"
|
||||||
|
Color="{DynamicResource TextColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="SecondaryTextBrush"
|
||||||
|
Opacity="0.64"
|
||||||
|
Color="{DynamicResource TextColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="DimTextBrush"
|
||||||
|
Opacity="0.45"
|
||||||
|
Color="{DynamicResource TextColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="PrimaryInverseTextBrush"
|
||||||
|
Opacity="1"
|
||||||
|
Color="{DynamicResource InverseTextColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="SecondaryInverseTextBrush"
|
||||||
|
Opacity="0.7"
|
||||||
|
Color="{DynamicResource InverseTextColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="DimInverseTextBrush"
|
||||||
|
Opacity="0.52"
|
||||||
|
Color="{DynamicResource InverseTextColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="AccentTextBrush"
|
||||||
|
Opacity="1"
|
||||||
|
Color="{DynamicResource AccentColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="AccentDarkTextBrush"
|
||||||
|
Opacity="1"
|
||||||
|
Color="{DynamicResource AccentDarkColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="DividerBrush"
|
||||||
|
Opacity="0.12"
|
||||||
|
Color="{DynamicResource TextColor}" />
|
||||||
|
<SolidColorBrush
|
||||||
|
x:Key="InverseDividerBrush"
|
||||||
|
Opacity="0.12"
|
||||||
|
Color="{DynamicResource InverseTextColor}" />
|
||||||
|
|
||||||
|
<!-- Styles -->
|
||||||
|
<Style TargetType="{x:Type Image}">
|
||||||
|
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
|
||||||
|
<Setter Property="RenderOptions.EdgeMode" Value="Aliased" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
|
||||||
|
<Setter Property="Height" Value="2" />
|
||||||
|
<Setter Property="Maximum" Value="1" />
|
||||||
|
<Setter Property="Minimum" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style
|
||||||
|
x:Key="MaterialDesignFlatActionToggleButton"
|
||||||
|
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
|
||||||
|
TargetType="{x:Type ToggleButton}">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
|
||||||
|
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Converters -->
|
||||||
|
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
|
||||||
|
|
||||||
|
<!-- Container -->
|
||||||
|
<local:Container x:Key="Container" />
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui
|
||||||
|
{
|
||||||
|
public partial class App
|
||||||
|
{
|
||||||
|
private void App_OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs args)
|
||||||
|
{
|
||||||
|
MessageBox.Show(args.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using CommonServiceLocator;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels;
|
||||||
|
using GalaSoft.MvvmLight.Ioc;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui
|
||||||
|
{
|
||||||
|
public class Container
|
||||||
|
{
|
||||||
|
public IExportSetupViewModel ExportSetupViewModel => Resolve<IExportSetupViewModel>();
|
||||||
|
public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
|
||||||
|
public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>();
|
||||||
|
|
||||||
|
public Container()
|
||||||
|
{
|
||||||
|
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
|
||||||
|
SimpleIoc.Default.Reset();
|
||||||
|
|
||||||
|
// Services
|
||||||
|
SimpleIoc.Default.Register<IDataService, DataService>();
|
||||||
|
SimpleIoc.Default.Register<IExportService, ExportService>();
|
||||||
|
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
|
||||||
|
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
|
||||||
|
|
||||||
|
// View models
|
||||||
|
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
|
||||||
|
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
|
||||||
|
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private T Resolve<T>(string key = null)
|
||||||
|
{
|
||||||
|
return ServiceLocator.Current.GetInstance<T>(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Converters
|
||||||
|
{
|
||||||
|
[ValueConversion(typeof(ExportFormat), typeof(string))]
|
||||||
|
public class ExportFormatToStringConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var format = (ExportFormat) value;
|
||||||
|
return format.GetDisplayName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?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>
|
||||||
|
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||||
|
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||||
|
<ProjectGuid>{732A67AF-93DE-49DF-B10F-FD74710B7863}</ProjectGuid>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<RootNamespace>DiscordChatExporter.Gui</RootNamespace>
|
||||||
|
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||||
|
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||||
|
<FileAlignment>512</FileAlignment>
|
||||||
|
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||||
|
<WarningLevel>4</WarningLevel>
|
||||||
|
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||||
|
<NuGetPackageImportStamp>
|
||||||
|
</NuGetPackageImportStamp>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||||
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
|
<DebugSymbols>true</DebugSymbols>
|
||||||
|
<DebugType>full</DebugType>
|
||||||
|
<Optimize>false</Optimize>
|
||||||
|
<OutputPath>bin\Debug\</OutputPath>
|
||||||
|
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||||
|
<ErrorReport>prompt</ErrorReport>
|
||||||
|
<WarningLevel>4</WarningLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||||
|
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||||
|
<DebugType>pdbonly</DebugType>
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
<OutputPath>bin\Release\</OutputPath>
|
||||||
|
<DefineConstants>TRACE</DefineConstants>
|
||||||
|
<ErrorReport>prompt</ErrorReport>
|
||||||
|
<WarningLevel>4</WarningLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup>
|
||||||
|
<StartupObject />
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="System" />
|
||||||
|
<Reference Include="System.Core" />
|
||||||
|
<Reference Include="System.Xaml">
|
||||||
|
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Tyrrrz.Settings, Version=1.3.2.0, Culture=neutral, PublicKeyToken=null" />
|
||||||
|
<Reference Include="WindowsBase" />
|
||||||
|
<Reference Include="PresentationCore" />
|
||||||
|
<Reference Include="PresentationFramework" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="App.xaml.cs">
|
||||||
|
<DependentUpon>App.xaml</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="Converters\ExportFormatToStringConverter.cs" />
|
||||||
|
<Compile Include="Messages\ShowExportSetupMessage.cs" />
|
||||||
|
<Compile Include="Messages\ShowNotificationMessage.cs" />
|
||||||
|
<Compile Include="Messages\ShowSettingsMessage.cs" />
|
||||||
|
<Compile Include="Messages\StartExportMessage.cs" />
|
||||||
|
<Compile Include="ViewModels\ExportSetupViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\ISettingsViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\SettingsViewModel.cs" />
|
||||||
|
<Compile Include="Container.cs" />
|
||||||
|
<Compile Include="ViewModels\IMainViewModel.cs" />
|
||||||
|
<Compile Include="ViewModels\MainViewModel.cs" />
|
||||||
|
<Compile Include="Views\ExportSetupDialog.xaml.cs">
|
||||||
|
<DependentUpon>ExportSetupDialog.xaml</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="Views\MainWindow.xaml.cs">
|
||||||
|
<DependentUpon>MainWindow.xaml</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="Views\SettingsDialog.xaml.cs">
|
||||||
|
<DependentUpon>SettingsDialog.xaml</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
</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.config" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Resource Include="..\favicon.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj">
|
||||||
|
<Project>{707c0cd0-a7e0-4cab-8db9-07a45cb87377}</Project>
|
||||||
|
<Name>DiscordChatExporter.Core</Name>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ApplicationDefinition Include="App.xaml">
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
</ApplicationDefinition>
|
||||||
|
<Page Include="Views\ExportSetupDialog.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
</Page>
|
||||||
|
<Page Include="Views\MainWindow.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
</Page>
|
||||||
|
<Page Include="Views\SettingsDialog.xaml">
|
||||||
|
<SubType>Designer</SubType>
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
</Page>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommonServiceLocator">
|
||||||
|
<Version>2.0.3</Version>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="MaterialDesignColors">
|
||||||
|
<Version>1.1.3</Version>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="MaterialDesignThemes">
|
||||||
|
<Version>2.4.0.1044</Version>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="MvvmLightLibs">
|
||||||
|
<Version>5.4.1</Version>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Tyrrrz.Extensions">
|
||||||
|
<Version>1.5.1</Version>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Tyrrrz.WpfExtensions">
|
||||||
|
<Version>1.0.5</Version>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.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,25 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Messages
|
||||||
|
{
|
||||||
|
public class ShowNotificationMessage
|
||||||
|
{
|
||||||
|
public string Message { get; }
|
||||||
|
|
||||||
|
public string CallbackCaption { get; }
|
||||||
|
|
||||||
|
public Action Callback { get; }
|
||||||
|
|
||||||
|
public ShowNotificationMessage(string message)
|
||||||
|
{
|
||||||
|
Message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShowNotificationMessage(string message, string callbackCaption, Action callback)
|
||||||
|
: this(message)
|
||||||
|
{
|
||||||
|
CallbackCaption = callbackCaption;
|
||||||
|
Callback = callback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.Messages
|
||||||
|
{
|
||||||
|
public class ShowSettingsMessage
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.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 int? PartitionLimit { get; }
|
||||||
|
|
||||||
|
public StartExportMessage(Channel channel, string filePath, ExportFormat format,
|
||||||
|
DateTime? from, DateTime? to, int? partitionLimit)
|
||||||
|
{
|
||||||
|
Channel = channel;
|
||||||
|
FilePath = filePath;
|
||||||
|
Format = format;
|
||||||
|
From = from;
|
||||||
|
To = to;
|
||||||
|
PartitionLimit = partitionLimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
[assembly: AssemblyTitle("DiscordChatExporter")]
|
||||||
|
[assembly: AssemblyCompany("Tyrrrz")]
|
||||||
|
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
|
||||||
|
[assembly: AssemblyVersion("2.8")]
|
||||||
|
[assembly: AssemblyFileVersion("2.8")]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <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.Gui.Properties {
|
||||||
|
using System;
|
||||||
|
|
||||||
|
|
||||||
|
/// <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", "15.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 (object.ReferenceEquals(resourceMan, null)) {
|
||||||
|
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DiscordChatExporter.Gui.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,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Gui.Messages;
|
||||||
|
using GalaSoft.MvvmLight;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.ViewModels
|
||||||
|
{
|
||||||
|
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
|
private string _filePath;
|
||||||
|
private ExportFormat _format;
|
||||||
|
private DateTime? _from;
|
||||||
|
private DateTime? _to;
|
||||||
|
private int? _partitionLimit;
|
||||||
|
|
||||||
|
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 =>
|
||||||
|
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
|
||||||
|
|
||||||
|
public ExportFormat SelectedFormat
|
||||||
|
{
|
||||||
|
get => _format;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Set(ref _format, value);
|
||||||
|
|
||||||
|
// Replace extension in path
|
||||||
|
var ext = value.GetFileExtension();
|
||||||
|
if (FilePath != null)
|
||||||
|
FilePath = Path.ChangeExtension(FilePath, ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime? From
|
||||||
|
{
|
||||||
|
get => _from;
|
||||||
|
set => Set(ref _from, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTime? To
|
||||||
|
{
|
||||||
|
get => _to;
|
||||||
|
set => Set(ref _to, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? PartitionLimit
|
||||||
|
{
|
||||||
|
get => _partitionLimit;
|
||||||
|
set => Set(ref _partitionLimit, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
public RelayCommand ExportCommand { get; }
|
||||||
|
|
||||||
|
public ExportSetupViewModel(ISettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
MessengerInstance.Register<ShowExportSetupMessage>(this, m =>
|
||||||
|
{
|
||||||
|
Guild = m.Guild;
|
||||||
|
Channel = m.Channel;
|
||||||
|
SelectedFormat = _settingsService.LastExportFormat;
|
||||||
|
FilePath = $"{Guild.Name} - {Channel.Name}.{SelectedFormat.GetFileExtension()}"
|
||||||
|
.Replace(Path.GetInvalidFileNameChars(), '_');
|
||||||
|
From = null;
|
||||||
|
To = null;
|
||||||
|
PartitionLimit = _settingsService.LastPartitionLimit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Export()
|
||||||
|
{
|
||||||
|
// Persist preferences
|
||||||
|
_settingsService.LastExportFormat = SelectedFormat;
|
||||||
|
_settingsService.LastPartitionLimit = PartitionLimit;
|
||||||
|
|
||||||
|
// Clamp 'from' and 'to' values
|
||||||
|
if (From > To)
|
||||||
|
From = To;
|
||||||
|
if (To < From)
|
||||||
|
To = From;
|
||||||
|
|
||||||
|
// Start export
|
||||||
|
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To, PartitionLimit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.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; }
|
||||||
|
int? PartitionLimit { get; set; }
|
||||||
|
|
||||||
|
RelayCommand ExportCommand { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.ViewModels
|
||||||
|
{
|
||||||
|
public interface IMainViewModel
|
||||||
|
{
|
||||||
|
bool IsBusy { get; }
|
||||||
|
bool IsDataAvailable { get; }
|
||||||
|
|
||||||
|
bool IsProgressIndeterminate { get; }
|
||||||
|
double Progress { get; }
|
||||||
|
|
||||||
|
bool IsBotToken { get; set; }
|
||||||
|
string TokenValue { get; set; }
|
||||||
|
|
||||||
|
IReadOnlyList<Guild> AvailableGuilds { get; }
|
||||||
|
Guild SelectedGuild { get; set; }
|
||||||
|
IReadOnlyList<Channel> AvailableChannels { get; }
|
||||||
|
|
||||||
|
RelayCommand ViewLoadedCommand { get; }
|
||||||
|
RelayCommand ViewClosedCommand { get; }
|
||||||
|
RelayCommand PullDataCommand { get; }
|
||||||
|
RelayCommand ShowSettingsCommand { get; }
|
||||||
|
RelayCommand ShowAboutCommand { get; }
|
||||||
|
RelayCommand<Channel> ShowExportSetupCommand { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.ViewModels
|
||||||
|
{
|
||||||
|
public interface ISettingsViewModel
|
||||||
|
{
|
||||||
|
bool IsAutoUpdateEnabled { get; set; }
|
||||||
|
|
||||||
|
string DateFormat { get; set; }
|
||||||
|
int MessageGroupLimit { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Windows;
|
||||||
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Gui.Messages;
|
||||||
|
using GalaSoft.MvvmLight;
|
||||||
|
using GalaSoft.MvvmLight.CommandWpf;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.ViewModels
|
||||||
|
{
|
||||||
|
public class MainViewModel : ViewModelBase, IMainViewModel
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IUpdateService _updateService;
|
||||||
|
private readonly IDataService _dataService;
|
||||||
|
private readonly IExportService _exportService;
|
||||||
|
|
||||||
|
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
|
||||||
|
|
||||||
|
private bool _isBusy;
|
||||||
|
private double _progress;
|
||||||
|
private bool _isBotToken;
|
||||||
|
private string _tokenValue;
|
||||||
|
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 bool IsProgressIndeterminate => Progress <= 0;
|
||||||
|
|
||||||
|
public double Progress
|
||||||
|
{
|
||||||
|
get => _progress;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
Set(ref _progress, value);
|
||||||
|
RaisePropertyChanged(() => IsProgressIndeterminate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBotToken
|
||||||
|
{
|
||||||
|
get => _isBotToken;
|
||||||
|
set => Set(ref _isBotToken, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TokenValue
|
||||||
|
{
|
||||||
|
get => _tokenValue;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
// Remove invalid chars
|
||||||
|
value = value?.Trim('"');
|
||||||
|
|
||||||
|
Set(ref _tokenValue, 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] : Array.Empty<Channel>();
|
||||||
|
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<Channel> AvailableChannels
|
||||||
|
{
|
||||||
|
get => _availableChannels;
|
||||||
|
private set => Set(ref _availableChannels, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelayCommand ViewLoadedCommand { get; }
|
||||||
|
public RelayCommand ViewClosedCommand { get; }
|
||||||
|
public RelayCommand PullDataCommand { get; }
|
||||||
|
public RelayCommand ShowSettingsCommand { get; }
|
||||||
|
public RelayCommand ShowAboutCommand { get; }
|
||||||
|
public RelayCommand<Channel> ShowExportSetupCommand { get; }
|
||||||
|
|
||||||
|
public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService,
|
||||||
|
IExportService exportService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_updateService = updateService;
|
||||||
|
_dataService = dataService;
|
||||||
|
_exportService = exportService;
|
||||||
|
|
||||||
|
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
ViewLoadedCommand = new RelayCommand(ViewLoaded);
|
||||||
|
ViewClosedCommand = new RelayCommand(ViewClosed);
|
||||||
|
PullDataCommand = new RelayCommand(PullData, () => TokenValue.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, m.PartitionLimit));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ViewLoaded()
|
||||||
|
{
|
||||||
|
// Load settings
|
||||||
|
_settingsService.Load();
|
||||||
|
|
||||||
|
// Get last token
|
||||||
|
if (_settingsService.LastToken != null)
|
||||||
|
{
|
||||||
|
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
|
||||||
|
TokenValue = _settingsService.LastToken.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check and prepare update
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
|
||||||
|
if (updateVersion != null)
|
||||||
|
{
|
||||||
|
MessengerInstance.Send(new ShowNotificationMessage(
|
||||||
|
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
|
||||||
|
"INSTALL NOW", () =>
|
||||||
|
{
|
||||||
|
_updateService.NeedRestart = true;
|
||||||
|
Application.Current.Shutdown();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
MessengerInstance.Send(new ShowNotificationMessage("Failed to perform application auto-update"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ViewClosed()
|
||||||
|
{
|
||||||
|
// Save settings
|
||||||
|
_settingsService.Save();
|
||||||
|
|
||||||
|
// Finalize updates if available
|
||||||
|
_updateService.FinalizeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void PullData()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
// Create token
|
||||||
|
var token = new AuthToken(
|
||||||
|
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
|
||||||
|
TokenValue);
|
||||||
|
|
||||||
|
// Save token
|
||||||
|
_settingsService.LastToken = token;
|
||||||
|
|
||||||
|
// Clear existing
|
||||||
|
_guildChannelsMap.Clear();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get DM channels
|
||||||
|
{
|
||||||
|
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
|
||||||
|
var guild = Guild.DirectMessages;
|
||||||
|
_guildChannelsMap[guild] = channels.OrderBy(c => c.Name).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get guild channels
|
||||||
|
{
|
||||||
|
var guilds = await _dataService.GetUserGuildsAsync(token);
|
||||||
|
foreach (var guild in guilds)
|
||||||
|
{
|
||||||
|
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
|
||||||
|
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
MessengerInstance.Send(new ShowNotificationMessage("Unauthorized – make sure the token is valid"));
|
||||||
|
}
|
||||||
|
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
MessengerInstance.Send(new ShowNotificationMessage("Forbidden – account may be locked by 2FA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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, int? partitionLimit)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
// Get last used token
|
||||||
|
var token = _settingsService.LastToken;
|
||||||
|
|
||||||
|
// Get guild
|
||||||
|
var guild = SelectedGuild;
|
||||||
|
|
||||||
|
// Create progress handler
|
||||||
|
var progressHandler = new Progress<double>(p => Progress = p);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get chat log
|
||||||
|
var chatLog = await _dataService.GetChatLogAsync(token, guild, channel, from, to, progressHandler);
|
||||||
|
|
||||||
|
// Export
|
||||||
|
_exportService.ExportChatLog(chatLog, filePath, format, partitionLimit);
|
||||||
|
|
||||||
|
// Notify completion
|
||||||
|
MessengerInstance.Send(new ShowNotificationMessage("Export complete"));
|
||||||
|
}
|
||||||
|
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
|
||||||
|
}
|
||||||
|
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
MessengerInstance.Send(new ShowNotificationMessage("This channel doesn't exist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Progress = 0;
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using GalaSoft.MvvmLight;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.ViewModels
|
||||||
|
{
|
||||||
|
public class SettingsViewModel : ViewModelBase, ISettingsViewModel
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
|
public bool IsAutoUpdateEnabled
|
||||||
|
{
|
||||||
|
get => _settingsService.IsAutoUpdateEnabled;
|
||||||
|
set => _settingsService.IsAutoUpdateEnabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DateFormat
|
||||||
|
{
|
||||||
|
get => _settingsService.DateFormat;
|
||||||
|
set => _settingsService.DateFormat = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int MessageGroupLimit
|
||||||
|
{
|
||||||
|
get => _settingsService.MessageGroupLimit;
|
||||||
|
set => _settingsService.MessageGroupLimit = value.ClampMin(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingsViewModel(ISettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="DiscordChatExporter.Gui.Views.ExportSetupDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
|
Width="325"
|
||||||
|
DataContext="{Binding ExportSetupViewModel, Source={StaticResource Container}}">
|
||||||
|
<StackPanel>
|
||||||
|
<!-- File path -->
|
||||||
|
<TextBox
|
||||||
|
Margin="16,16,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Output file"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
IsReadOnly="True"
|
||||||
|
Text="{Binding FilePath, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
|
||||||
|
<!-- Format -->
|
||||||
|
<ComboBox
|
||||||
|
Margin="16,8,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Export format"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
IsReadOnly="True"
|
||||||
|
ItemsSource="{Binding AvailableFormats}"
|
||||||
|
SelectedItem="{Binding SelectedFormat}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Converter={StaticResource ExportFormatToStringConverter}}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<!-- Date limits -->
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<DatePicker
|
||||||
|
x:Name="FromDatePicker"
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="16,20,8,8"
|
||||||
|
materialDesign:HintAssist.Hint="From (optional)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
DisplayDateEnd="{Binding SelectedDate, ElementName=ToDatePicker}"
|
||||||
|
SelectedDate="{Binding From}" />
|
||||||
|
<DatePicker
|
||||||
|
x:Name="ToDatePicker"
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="8,20,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="To (optional)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
DisplayDateStart="{Binding SelectedDate, ElementName=FromDatePicker}"
|
||||||
|
SelectedDate="{Binding To}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Partitioning -->
|
||||||
|
<TextBox
|
||||||
|
Margin="16,8,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Messages per partition (optional)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
Text="{Binding PartitionLimit, TargetNullValue=''}" />
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
||||||
|
<Button
|
||||||
|
x:Name="BrowseButton"
|
||||||
|
Margin="8"
|
||||||
|
Click="BrowseButton_Click"
|
||||||
|
Content="BROWSE"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||||
|
<Button
|
||||||
|
x:Name="ExportButton"
|
||||||
|
Margin="8"
|
||||||
|
Click="ExportButton_Click"
|
||||||
|
Command="{Binding ExportCommand}"
|
||||||
|
Content="EXPORT"
|
||||||
|
IsDefault="True"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||||
|
<Button
|
||||||
|
Margin="8"
|
||||||
|
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
|
||||||
|
Content="CANCEL"
|
||||||
|
IsCancel="True"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels;
|
||||||
|
using MaterialDesignThemes.Wpf;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.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,285 @@
|
|||||||
|
<Window
|
||||||
|
x:Class="DiscordChatExporter.Gui.Views.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
|
Title="DiscordChatExporter"
|
||||||
|
Width="600"
|
||||||
|
Height="550"
|
||||||
|
Background="{DynamicResource MaterialDesignPaper}"
|
||||||
|
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
|
||||||
|
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
|
||||||
|
FontFamily="{DynamicResource MaterialDesignFont}"
|
||||||
|
Icon="/DiscordChatExporter;component/favicon.ico"
|
||||||
|
SnapsToDevicePixels="True"
|
||||||
|
TextElement.FontSize="13"
|
||||||
|
TextElement.FontWeight="Regular"
|
||||||
|
TextElement.Foreground="{DynamicResource SecondaryTextBrush}"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
|
TextOptions.TextRenderingMode="Auto"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
WindowStartupLocation="CenterScreen">
|
||||||
|
<i:Interaction.Triggers>
|
||||||
|
<i:EventTrigger EventName="Loaded">
|
||||||
|
<i:InvokeCommandAction Command="{Binding ViewLoadedCommand}" />
|
||||||
|
</i:EventTrigger>
|
||||||
|
<i:EventTrigger EventName="Closed">
|
||||||
|
<i:InvokeCommandAction Command="{Binding ViewClosedCommand}" />
|
||||||
|
</i:EventTrigger>
|
||||||
|
</i:Interaction.Triggers>
|
||||||
|
<materialDesign:DialogHost SnackbarMessageQueue="{Binding ElementName=Snackbar}">
|
||||||
|
<DockPanel>
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<Border
|
||||||
|
Background="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
DockPanel.Dock="Top"
|
||||||
|
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
|
||||||
|
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<materialDesign:Card
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="6,6,0,6">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Token type -->
|
||||||
|
<ToggleButton
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="6"
|
||||||
|
IsChecked="{Binding IsBotToken}"
|
||||||
|
Style="{StaticResource MaterialDesignFlatActionToggleButton}"
|
||||||
|
ToolTip="Switch between user token and bot token">
|
||||||
|
<ToggleButton.Content>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="Account" />
|
||||||
|
</ToggleButton.Content>
|
||||||
|
<materialDesign:ToggleButtonAssist.OnContent>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="Robot" />
|
||||||
|
</materialDesign:ToggleButtonAssist.OnContent>
|
||||||
|
</ToggleButton>
|
||||||
|
|
||||||
|
<!-- Token value -->
|
||||||
|
<TextBox
|
||||||
|
x:Name="TokenValueTextBox"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="2,6,6,7"
|
||||||
|
materialDesign:HintAssist.Hint="Token"
|
||||||
|
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||||
|
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
||||||
|
BorderThickness="0"
|
||||||
|
FontSize="16"
|
||||||
|
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
|
||||||
|
<!-- Pull data button -->
|
||||||
|
<Button
|
||||||
|
Grid.Column="2"
|
||||||
|
Margin="0,6,6,6"
|
||||||
|
Padding="4"
|
||||||
|
Command="{Binding PullDataCommand}"
|
||||||
|
IsDefault="True"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}">
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="ArrowRight" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</materialDesign:Card>
|
||||||
|
|
||||||
|
<!-- Menu -->
|
||||||
|
<materialDesign:PopupBox
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="1"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidForegroundBrush}"
|
||||||
|
PlacementMode="LeftAndAlignTopEdges">
|
||||||
|
<StackPanel>
|
||||||
|
<Button Command="{Binding ShowSettingsCommand}" Content="Settings" />
|
||||||
|
<Button Command="{Binding ShowAboutCommand}" Content="About" />
|
||||||
|
</StackPanel>
|
||||||
|
</materialDesign:PopupBox>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<ProgressBar
|
||||||
|
Background="Transparent"
|
||||||
|
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||||
|
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
|
||||||
|
Value="{Binding Progress, Mode=OneWay}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<Grid>
|
||||||
|
<DockPanel
|
||||||
|
Background="{DynamicResource MaterialDesignCardBackground}"
|
||||||
|
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
|
||||||
|
Visibility="{Binding IsDataAvailable, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||||
|
|
||||||
|
<!-- Guilds -->
|
||||||
|
<Border
|
||||||
|
BorderBrush="{DynamicResource DividerBrush}"
|
||||||
|
BorderThickness="0,0,1,0"
|
||||||
|
DockPanel.Dock="Left">
|
||||||
|
<ListBox
|
||||||
|
ItemsSource="{Binding AvailableGuilds}"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
||||||
|
SelectedItem="{Binding SelectedGuild}"
|
||||||
|
VirtualizingStackPanel.IsVirtualizing="False">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<materialDesign:TransitioningContent>
|
||||||
|
<materialDesign:TransitioningContent.OpeningEffect>
|
||||||
|
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
|
||||||
|
</materialDesign:TransitioningContent.OpeningEffect>
|
||||||
|
<Grid
|
||||||
|
Margin="-8"
|
||||||
|
Background="Transparent"
|
||||||
|
Cursor="Hand"
|
||||||
|
ToolTip="{Binding Name}">
|
||||||
|
<!-- Guild icon placeholder -->
|
||||||
|
<Ellipse
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
Margin="12,4,12,4"
|
||||||
|
Fill="{DynamicResource DividerBrush}" />
|
||||||
|
|
||||||
|
<!-- Guild icon -->
|
||||||
|
<Ellipse
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
Margin="12,4,12,4">
|
||||||
|
<Ellipse.Fill>
|
||||||
|
<ImageBrush ImageSource="{Binding IconUrl}" />
|
||||||
|
</Ellipse.Fill>
|
||||||
|
</Ellipse>
|
||||||
|
</Grid>
|
||||||
|
</materialDesign:TransitioningContent>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Channels -->
|
||||||
|
<Border>
|
||||||
|
<ListBox
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
ItemsSource="{Binding AvailableChannels}"
|
||||||
|
VirtualizingStackPanel.IsVirtualizing="False">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<materialDesign:TransitioningContent>
|
||||||
|
<materialDesign:TransitioningContent.OpeningEffect>
|
||||||
|
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
|
||||||
|
</materialDesign:TransitioningContent.OpeningEffect>
|
||||||
|
<StackPanel
|
||||||
|
Margin="-8"
|
||||||
|
Background="Transparent"
|
||||||
|
Cursor="Hand"
|
||||||
|
Orientation="Horizontal">
|
||||||
|
<StackPanel.InputBindings>
|
||||||
|
<MouseBinding
|
||||||
|
Command="{Binding DataContext.ShowExportSetupCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
MouseAction="LeftClick" />
|
||||||
|
</StackPanel.InputBindings>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Margin="16,7,0,6"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Kind="Pound" />
|
||||||
|
<TextBlock
|
||||||
|
Margin="3,8,8,8"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="14"
|
||||||
|
Text="{Binding Name}" />
|
||||||
|
</StackPanel>
|
||||||
|
</materialDesign:TransitioningContent>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Usage instructions -->
|
||||||
|
<Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||||
|
<!-- User token -->
|
||||||
|
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||||
|
<TextBlock FontSize="18" Text="DiscordChatExporter needs your user 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 Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
||||||
|
<Run Text="to show developer tools" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="4. Press" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
|
||||||
|
<Run Text="to trigger reload" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="5. Navigate to the" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
||||||
|
<Run Text="tab" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="6. Select" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
|
||||||
|
<Run Text=">" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discordapp.com" />
|
||||||
|
<Run Text="on the left" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="7. Find" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
|
||||||
|
<Run Text="under key and copy the value" />
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Bot token -->
|
||||||
|
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||||
|
<TextBlock FontSize="18" Text="DiscordChatExporter needs your bot 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. Go to Discord developer portal" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="2. Log in if you haven't" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="3. Open your application's settings" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="4. Navigate to the" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
|
||||||
|
<Run Text="section on the left" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="5. Under" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
|
||||||
|
<Run Text="click" />
|
||||||
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<materialDesign:Snackbar x:Name="Snackbar" />
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</materialDesign:DialogHost>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using DiscordChatExporter.Gui.Messages;
|
||||||
|
using GalaSoft.MvvmLight.Messaging;
|
||||||
|
using MaterialDesignThemes.Wpf;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Views
|
||||||
|
{
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
|
||||||
|
|
||||||
|
Snackbar.MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// Notification messages
|
||||||
|
Messenger.Default.Register<ShowNotificationMessage>(this, m =>
|
||||||
|
{
|
||||||
|
if (m.CallbackCaption != null && m.Callback != null)
|
||||||
|
Snackbar.MessageQueue.Enqueue(m.Message, m.CallbackCaption, m.Callback);
|
||||||
|
else
|
||||||
|
Snackbar.MessageQueue.Enqueue(m.Message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dialog messages
|
||||||
|
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,45 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="DiscordChatExporter.Gui.Views.SettingsDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
|
Width="250"
|
||||||
|
DataContext="{Binding SettingsViewModel, Source={StaticResource Container}}">
|
||||||
|
<StackPanel>
|
||||||
|
<!-- Date format -->
|
||||||
|
<TextBox
|
||||||
|
Margin="16,16,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Date format"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
Text="{Binding DateFormat}" />
|
||||||
|
|
||||||
|
<!-- Message group limit -->
|
||||||
|
<TextBox
|
||||||
|
Margin="16,8,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Message group limit"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
Text="{Binding MessageGroupLimit}" />
|
||||||
|
|
||||||
|
<!-- Auto-updates -->
|
||||||
|
<DockPanel LastChildFill="False">
|
||||||
|
<TextBlock
|
||||||
|
Margin="16,8,16,8"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Auto-updates" />
|
||||||
|
<ToggleButton
|
||||||
|
Margin="16,8,16,8"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Save button -->
|
||||||
|
<Button
|
||||||
|
Margin="8"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
|
||||||
|
Content="SAVE"
|
||||||
|
IsCancel="True"
|
||||||
|
IsDefault="True"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.Views
|
||||||
|
{
|
||||||
|
public partial class SettingsDialog
|
||||||
|
{
|
||||||
|
public SettingsDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<runtime>
|
||||||
|
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity name="CommonServiceLocator" publicKeyToken="489b6accfaf20ef0" culture="neutral" />
|
||||||
|
<bindingRedirect oldVersion="0.0.0.0-2.0.3.0" newVersion="2.0.3.0" />
|
||||||
|
</dependentAssembly>
|
||||||
|
</assemblyBinding>
|
||||||
|
</runtime>
|
||||||
|
</configuration>
|
||||||
+23
-7
@@ -1,28 +1,44 @@
|
|||||||
|
|
||||||
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.27130.2026
|
||||||
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
|
||||||
|
Changelog.md = Changelog.md
|
||||||
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.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core", "DiscordChatExporter.Core\DiscordChatExporter.Core.csproj", "{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}"
|
||||||
|
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
|
||||||
|
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.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
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net45</TargetFramework>
|
|
||||||
<AssemblyVersion>1.0.0</AssemblyVersion>
|
|
||||||
<FileVersion>$(AssemblyVersion)</FileVersion>
|
|
||||||
<Company>Tyrrrz</Company>
|
|
||||||
<Copyright>Copyright (c) 2017 Alexey Golub</Copyright>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="Services\ExportTemplate.html" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.5.1" />
|
|
||||||
<PackageReference Include="Newtonsoft.json" Version="10.0.3" />
|
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.4.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="System.Net.Http" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
namespace DiscordChatExporter.Models
|
|
||||||
{
|
|
||||||
public class Attachment
|
|
||||||
{
|
|
||||||
public string Id { get; }
|
|
||||||
|
|
||||||
public string Url { get; }
|
|
||||||
|
|
||||||
public string FileName { get; }
|
|
||||||
|
|
||||||
public bool IsImage { get; }
|
|
||||||
|
|
||||||
public Attachment(string id, string url, string fileName, bool isImage)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Url = url;
|
|
||||||
FileName = fileName;
|
|
||||||
IsImage = isImage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Models
|
|
||||||
{
|
|
||||||
public class Message
|
|
||||||
{
|
|
||||||
public string Id { get; }
|
|
||||||
|
|
||||||
public DateTime TimeStamp { get; }
|
|
||||||
|
|
||||||
public DateTime? EditedTimeStamp { get; }
|
|
||||||
|
|
||||||
public User Author { get; }
|
|
||||||
|
|
||||||
public string Content { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Attachment> Attachments { get; }
|
|
||||||
|
|
||||||
public Message(string id, DateTime timeStamp, DateTime? editedTimeStamp, User author, string content, IEnumerable<Attachment> attachments)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
TimeStamp = timeStamp;
|
|
||||||
EditedTimeStamp = editedTimeStamp;
|
|
||||||
Author = author;
|
|
||||||
Content = content;
|
|
||||||
Attachments = attachments.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return Content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Models
|
|
||||||
{
|
|
||||||
public class MessageGroup
|
|
||||||
{
|
|
||||||
public User Author { get; }
|
|
||||||
|
|
||||||
public DateTime FirstTimeStamp { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Message> Messages { get; }
|
|
||||||
|
|
||||||
public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable<Message> messages)
|
|
||||||
{
|
|
||||||
Author = author;
|
|
||||||
FirstTimeStamp = firstTimeStamp;
|
|
||||||
Messages = messages.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Models
|
|
||||||
{
|
|
||||||
public class User
|
|
||||||
{
|
|
||||||
public string Id { get; }
|
|
||||||
|
|
||||||
public string Name { get; }
|
|
||||||
|
|
||||||
public string AvatarHash { get; }
|
|
||||||
|
|
||||||
public string AvatarUrl => AvatarHash.IsNotBlank()
|
|
||||||
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size=256"
|
|
||||||
: "https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.png";
|
|
||||||
|
|
||||||
public User(string id, string name, string avatarHash)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Name = name;
|
|
||||||
AvatarHash = avatarHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return Name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DiscordChatExporter.Models;
|
|
||||||
using DiscordChatExporter.Services;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter
|
|
||||||
{
|
|
||||||
public static class Program
|
|
||||||
{
|
|
||||||
private static readonly DiscordApiService DiscordApiService = new DiscordApiService();
|
|
||||||
private static readonly ExportService ExportService = new ExportService();
|
|
||||||
|
|
||||||
private static Options GetOptions(string[] args)
|
|
||||||
{
|
|
||||||
// Parse the arguments
|
|
||||||
var argsDic = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (string arg in args)
|
|
||||||
{
|
|
||||||
var match = Regex.Match(arg, "/(.*?):\"?(.*?)\"?$");
|
|
||||||
string key = match.Groups[1].Value;
|
|
||||||
string value = match.Groups[2].Value;
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using DiscordChatExporter.Models;
|
|
||||||
using HtmlAgilityPack;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Services
|
|
||||||
{
|
|
||||||
public class ExportService
|
|
||||||
{
|
|
||||||
private HtmlDocument GetTemplate()
|
|
||||||
{
|
|
||||||
const string templateName = "DiscordChatExporter.Services.ExportTemplate.html";
|
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
|
||||||
using (var stream = assembly.GetManifestResourceStream(templateName))
|
|
||||||
{
|
|
||||||
var doc = new HtmlDocument();
|
|
||||||
doc.Load(stream);
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<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
|
|
||||||
bool breakCondition =
|
|
||||||
groupFirst != null &&
|
|
||||||
(
|
|
||||||
message.Author.Id != groupFirst.Author.Id ||
|
|
||||||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
|
|
||||||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour
|
|
||||||
);
|
|
||||||
|
|
||||||
// If condition is true - flush buffer
|
|
||||||
if (breakCondition)
|
|
||||||
{
|
|
||||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
|
|
||||||
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);
|
|
||||||
result.Add(group);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatMessageContent(string content)
|
|
||||||
{
|
|
||||||
// Encode HTML
|
|
||||||
content = HtmlDocument.HtmlEncode(content);
|
|
||||||
|
|
||||||
// Links from URLs
|
|
||||||
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
|
|
||||||
"<a href=\"$1\">$1</a>");
|
|
||||||
|
|
||||||
// Preformatted multiline
|
|
||||||
content = Regex.Replace(content, "```([^`]*?)```",
|
|
||||||
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
|
|
||||||
|
|
||||||
// Preformatted inline
|
|
||||||
content = Regex.Replace(content, "`([^`]*?)`",
|
|
||||||
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
|
|
||||||
|
|
||||||
// Bold
|
|
||||||
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
|
|
||||||
|
|
||||||
// Italic
|
|
||||||
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
|
|
||||||
|
|
||||||
// Underline
|
|
||||||
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
|
|
||||||
|
|
||||||
// Strike through
|
|
||||||
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
|
|
||||||
|
|
||||||
// New lines
|
|
||||||
content = content.Replace("\n", "</br>");
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Export(string filePath, ChatLog chatLog)
|
|
||||||
{
|
|
||||||
var doc = GetTemplate();
|
|
||||||
|
|
||||||
// Info
|
|
||||||
var infoHtml = doc.GetElementbyId("info");
|
|
||||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
|
|
||||||
string participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
|
|
||||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Participants: <b>{participants}</b></div>"));
|
|
||||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Messages: <b>{chatLog.Messages.Count:N0}</b></div>"));
|
|
||||||
|
|
||||||
// Log
|
|
||||||
var logHtml = doc.GetElementbyId("log");
|
|
||||||
var messageGroups = GroupMessages(chatLog.Messages);
|
|
||||||
foreach (var messageGroup in messageGroups)
|
|
||||||
{
|
|
||||||
// Container
|
|
||||||
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
|
|
||||||
|
|
||||||
// Avatar
|
|
||||||
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-avatar\">" +
|
|
||||||
$"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />" +
|
|
||||||
"</div>"));
|
|
||||||
|
|
||||||
// Body
|
|
||||||
var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-body\"></div>"));
|
|
||||||
|
|
||||||
// Author
|
|
||||||
string authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
|
|
||||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
|
|
||||||
|
|
||||||
// Date
|
|
||||||
string timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
|
|
||||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
|
|
||||||
|
|
||||||
// Individual messages
|
|
||||||
foreach (var message in messageGroup.Messages)
|
|
||||||
{
|
|
||||||
// Content
|
|
||||||
if (message.Content.IsNotBlank())
|
|
||||||
{
|
|
||||||
string content = FormatMessageContent(message.Content);
|
|
||||||
var contentHtml =
|
|
||||||
messageBodyHtml.AppendChild(
|
|
||||||
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
|
|
||||||
|
|
||||||
// Edited timestamp
|
|
||||||
if (message.EditedTimeStamp != null)
|
|
||||||
{
|
|
||||||
contentHtml.AppendChild(
|
|
||||||
HtmlNode.CreateNode(
|
|
||||||
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp:g}\">(edited)</span>"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
foreach (var attachment in message.Attachments)
|
|
||||||
{
|
|
||||||
if (attachment.IsImage)
|
|
||||||
{
|
|
||||||
messageBodyHtml.AppendChild(
|
|
||||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
|
||||||
$"<a href=\"{attachment.Url}\">" +
|
|
||||||
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
|
|
||||||
"</a>" +
|
|
||||||
"</div>"));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
messageBodyHtml.AppendChild(
|
|
||||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
|
||||||
$"<a href=\"{attachment.Url}\">" +
|
|
||||||
$"Attachment: {attachment.FileName}" +
|
|
||||||
"</a>" +
|
|
||||||
"</div>"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.Save(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Discord Chat Log</title>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
color: #37bcf7;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.pre, span.pre {
|
|
||||||
font-family: Consolas, Courier New, Courier, Monospace;
|
|
||||||
|
|
||||||
padding-right: 2px;
|
|
||||||
padding-left: 2px;
|
|
||||||
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
div#info {
|
|
||||||
max-width: 100%;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
color: #939799;
|
|
||||||
}
|
|
||||||
|
|
||||||
div#log {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
margin-right: 10px;
|
|
||||||
margin-left: 10px;
|
|
||||||
padding-top: 15px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
|
|
||||||
border-top: 1px solid #eceeef;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.msg-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg-body {
|
|
||||||
margin-left: 15px;
|
|
||||||
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.msg-user {
|
|
||||||
font-size: 1.15em;
|
|
||||||
|
|
||||||
color: #2f3136;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.msg-date {
|
|
||||||
font-size: .8em;
|
|
||||||
font-weight: 200;
|
|
||||||
|
|
||||||
margin-left: 5px;
|
|
||||||
|
|
||||||
color: #b7bcbf;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.msg-edited {
|
|
||||||
font-size: .8em;
|
|
||||||
font-weight: 200;
|
|
||||||
|
|
||||||
margin-left: 5px;
|
|
||||||
|
|
||||||
color: #b7bcbf;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg-content {
|
|
||||||
padding-top: 5px;
|
|
||||||
|
|
||||||
color: #939799;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.msg-attachment {
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.msg-attachment {
|
|
||||||
max-width: 50%;
|
|
||||||
max-height: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="info"></div>
|
|
||||||
<div id="log"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM mono:5
|
||||||
|
WORKDIR /root/build
|
||||||
|
COPY DiscordChatExporter.sln favicon.ico ./
|
||||||
|
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||||
|
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||||
|
RUN msbuild ./DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj /t:Restore
|
||||||
|
RUN msbuild ./DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj /p:Configuration=Release
|
||||||
|
|
||||||
|
FROM mono:5
|
||||||
|
COPY --from=0 /root/build/DiscordChatExporter.Cli/bin/Release/net461 /root/bin
|
||||||
|
WORKDIR /a
|
||||||
|
ENTRYPOINT ["mono", "/root/bin/DiscordChatExporter.Cli.exe"]
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2017 Alexey Golub
|
Copyright (c) 2017-2018 Alexey Golub
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,49 +1,39 @@
|
|||||||
# 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, embeds, attachments, mentions, reactions and other features. It works with both user and bot tokens, supports multiple output formats and allows you to trim messages by dates.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
|
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
|
||||||
|
- [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter` (only CLI version)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Produces output styled similar to the Discord's light theme
|
- Graphical and command line interfaces
|
||||||
- Displays user avatars
|
- Supports both user tokens and bot tokens
|
||||||
- Groups messages by author and time
|
- Allows retrieving messages in specified date range
|
||||||
- Handles Discord markdown characters
|
- Multiple export formats: HTML (dark/light), TXT and CSV
|
||||||
- Converts URLs to links
|
- Renders all message features including: markdown, attachments, embeds, emojis, mentions, etc
|
||||||
- Inlines attached images with a link to the full version
|
|
||||||
- Inserts download links for other types of attached files
|
|
||||||
- Marks edited messages with a timestamp
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
The program expects an access token and channel ID as parameters.
|
|
||||||
|
|
||||||
`DiscordChatExporter.exe /token:REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 /channelId:459360869055190534`
|
|
||||||
|
|
||||||
#### Getting access token:
|
|
||||||
|
|
||||||
- Open Discord desktop or web client
|
|
||||||
- Press `Ctrl+Shift+I`
|
|
||||||
- Navigate to `Storage > LocalStorage > discordapp.com`
|
|
||||||
- Find the value for `token` and extract it
|
|
||||||
|
|
||||||
#### Getting channel ID:
|
|
||||||
|
|
||||||
- Open Discord desktop or web client
|
|
||||||
- Navigate to any DM or server channel
|
|
||||||
- Extract the current URL:
|
|
||||||
- If using desktop client, press `Ctrl+Shift+I`, type `window.location.href` in console and extract the result
|
|
||||||
- If using web client, just take the current URL from the address bar
|
|
||||||
- Pull the ID from the URL:
|
|
||||||
- If it's a DM channel, the format looks like this: `https://discordapp.com/channels/@me/CHANNEL_ID`
|
|
||||||
- If it's a server channel, the format looks like this:
|
|
||||||
`https://discordapp.com/channels/WHATEVER/CHANNEL_ID`
|
|
||||||
|
|
||||||
## Libraries used
|
## Libraries used
|
||||||
|
|
||||||
- [HtmlAgilityPack](https://github.com/zzzprojects/html-agility-pack)
|
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
|
||||||
- [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json)
|
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
|
||||||
|
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
|
||||||
|
- [Scriban](https://github.com/lunet-io/scriban)
|
||||||
|
- [Polly](https://github.com/App-vNext/Polly)
|
||||||
|
- [Onova](https://github.com/Tyrrrz/Onova)
|
||||||
|
- [CommandLineParser](https://github.com/commandlineparser/commandline)
|
||||||
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
|
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
|
||||||
|
- [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions)
|
||||||
|
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
version: '{build}'
|
||||||
|
|
||||||
|
image: Visual Studio 2017
|
||||||
|
configuration: Release
|
||||||
|
|
||||||
|
before_build:
|
||||||
|
- ps: nuget restore
|
||||||
|
|
||||||
|
build:
|
||||||
|
verbosity: minimal
|
||||||
|
|
||||||
|
after_build:
|
||||||
|
- ps: Deploy\Prepare.ps1
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
- path: Deploy\bin\DiscordChatExporter.zip
|
||||||
|
name: DiscordChatExporter.zip
|
||||||
|
- path: Deploy\bin\DiscordChatExporter.Cli.zip
|
||||||
|
name: DiscordChatExporter.Cli.zip
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
- provider: GitHub
|
||||||
|
auth_token:
|
||||||
|
secure: sjQHWRw29AMiVMn3MtidtWnAzAf1mJ+mkJ/7h1B9TIAHhkFrqwMK7LtXV+uNJ9AO
|
||||||
|
artifact: DiscordChatExporter.zip,DiscordChatExporter.Cli.zip
|
||||||
|
description: '[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)'
|
||||||
|
on:
|
||||||
|
branch: master
|
||||||
|
appveyor_repo_tag: true
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user