Compare commits

...

102 Commits

Author SHA1 Message Date
Alexey Golub 6fa61bf75a Update version 2019-03-10 14:45:39 +02:00
Alexey Golub 1976e3ad08 [CLI] Don't write progress if output is redirected
FIxes #155
2019-03-10 14:40:08 +02:00
Alexey Golub adbe544c57 Add some margin at the end of the chatlog in HTML 2019-03-07 22:15:21 +02:00
Alexey Golub 6f4d1c3d77 Refactor message grouping 2019-03-07 22:13:00 +02:00
Alexey Golub 33f71c1612 Remove message group limit setting 2019-03-07 21:14:11 +02:00
Alexey Golub 9b78472e05 Update message grouping algorithm to match Discord's
Fixes #152
2019-03-07 21:10:22 +02:00
Alexey Golub 1f6d1f21e6 Update version 2019-03-06 23:00:56 +02:00
Alexey Golub 918cb4820f Add alt text to emoji images
Closes #151
2019-03-06 22:57:18 +02:00
Alexey Golub 32af1aa05d Fix incorrect regex for capturing standard emojis
Fixes #147
2019-03-06 22:48:24 +02:00
Alexey Golub dda32d8af2 Fix markdown grammar being too greedy in some places
Fixes #150
2019-03-06 22:37:36 +02:00
Alexey Golub d1d82f975b Add license file to releasable artifacts 2019-03-03 22:26:02 +02:00
Alexey Golub b1de9c4eca Update version 2019-03-03 21:48:03 +02:00
Alexey Golub 48ec4d085a Fix bad overflow wrapping 2019-03-03 21:33:27 +02:00
Alexey Golub 668ac70fae Fix some parsing issues 2019-03-03 21:24:37 +02:00
Alexey Golub 0d1ce38489 Jumboable emojis 2019-03-03 21:09:47 +02:00
Alexey Golub 666f2d8d4a Fix emoji and reaction css 2019-03-03 20:47:24 +02:00
Alexey Golub 2dd0742c1c Fix video attachments getting rendered as images
Fixes #120
2019-03-03 20:20:01 +02:00
Alexey Golub 69fe8af87a Update Failsafe 2019-03-03 20:08:47 +02:00
Alexey Golub e493357a9f Add progress reporting to CLI
Closes #143
2019-03-03 19:59:23 +02:00
Alexey Golub 492003c75a Fix incorrect standard emoji rendering in plaintext 2019-03-03 18:41:54 +02:00
Alexey Golub 28175e2110 Parse emojis in markdown and render them using Twemoji
Closes #140
2019-03-03 18:36:12 +02:00
Alexey Golub a564906719 Use ByteSize for file size in attachments 2019-03-03 16:38:41 +02:00
Alexey Golub 019278f6d4 Fix dockerfile 2019-03-03 16:05:09 +02:00
Alexey Golub 5161593e0f Forgot to commit DataService.Parsers 2019-03-03 15:40:26 +02:00
Alexey Golub 847a05a269 Refactor reaction emojis into a separate class, add support for animations and standard emojis via twemoji
Closes #71
2019-03-03 15:24:31 +02:00
Alexey Golub f09f30c7bd Implement a more sophisticated markdown parsing engine (#145) 2019-03-03 14:16:12 +02:00
Alexey Golub 88727a1fe6 Forgot one more place 2019-02-17 19:55:48 +02:00
Alexey Golub bb98049df7 Remove years from copyright 2019-02-17 19:48:54 +02:00
Alexey Golub e519bdcc6f Update license 2019-02-17 19:31:26 +02:00
Oleksii Holub 1356d4483a Add a checkmark that displays whether a channel is selected 2019-02-15 13:01:30 +02:00
Alexey Golub 4a3dc5c5ee Bind export button visibility on the guard property 2019-02-14 22:29:52 +02:00
Alexey Golub e665356890 Show export progress in taskbar 2019-02-14 21:52:36 +02:00
Alexey Golub 7b614ed448 Update version 2019-02-10 17:27:45 +02:00
Alexey Golub 6531462220 Fix getting last message when there's no date range
Fixes #137
2019-02-10 17:26:18 +02:00
Alexey Golub e89f2735a0 Update version 2019-02-09 21:26:15 +02:00
Alexey Golub 01d276a9dc Use UTF8 encoding in UpdateVersion.ps1 2019-02-09 21:20:20 +02:00
Alexey Golub c446d4069a Export single channel by double-clicking in GUI 2019-02-09 21:05:31 +02:00
Alexey Golub ba5718aaf5 Improve GUI a bit 2019-02-09 20:07:30 +02:00
Alexey Golub 20ff4a15eb Refactor output path usage in CLI 2019-02-09 19:41:08 +02:00
Alexey Golub 991dfccf3c Retry on 503 status code 2019-02-09 19:39:21 +02:00
Alexey Golub 524c86b8f2 Add channel ID to default export file name to avoid collisions 2019-02-09 19:20:13 +02:00
Alexey Golub e4b0d60c40 Add multichannel export to GUI
Closes #12
2019-02-09 19:03:34 +02:00
Alexey Golub 65c5df89f4 Use Failsafe 2019-02-08 20:37:53 +02:00
Oleksii Holub 659c499bd0 Small clenaup 2019-02-01 15:33:06 +02:00
Alexey Golub b6fcbac030 Refactor 2019-01-30 23:14:17 +02:00
Oleksii Holub b3e2dd3994 Group channels by category in GUI
Closes #77
2019-01-30 18:16:46 +02:00
Alexey Golub ba5790e312 Remove reference to Tyrrrz.Settings in GUI project 2019-01-21 23:21:35 +02:00
Alexey Golub 2859a740eb Update nuget packages 2019-01-07 18:29:36 +02:00
Oleksii Holub b88a1f8b47 Some refactor 2018-12-21 15:37:14 +02:00
Alexey Golub 78b44aed19 Use embedded style for dialog host 2018-12-19 21:51:03 +02:00
Oleksii Holub 13925c69af Refactor CLI and add 'exportdm' command to export all direct message channels 2018-12-19 17:29:36 +02:00
Oleksii Holub e22e1c03fb Cleanup 2018-12-19 17:15:54 +02:00
TVdata bf5cedb646 Add CLI command to export all available channels in a guild (#123) 2018-12-19 16:53:21 +02:00
Alexey Golub 17b0161ade Make dialogs white again 2018-12-16 00:07:17 +02:00
Alexey Golub fa1e84b518 Refactor UI 2018-12-15 23:53:52 +02:00
Alexey Golub 6d5ed9e9ca Improve UI 2018-12-14 01:00:57 +02:00
Oleksii Holub bdaeea5512 Refactor busy state and progress reporting 2018-12-11 18:03:32 +02:00
Alexey Golub dddb13fcc5 Refactor default file name generation and add date ranges there
Closes #29
2018-12-10 23:16:15 +02:00
Alexey Golub cacbb58683 Improve usage instructions a bit 2018-12-08 23:56:04 +02:00
Alexey Golub ca727e9f23 Improve naming of exported partition files 2018-12-08 23:55:49 +02:00
Oleksii Holub fd15b62adf Improve UI layout and usage instructions 2018-12-07 18:14:27 +02:00
Oleksii Holub 78d01b47b4 Add some tooltips 2018-12-07 17:05:17 +02:00
Oleksii Holub daf38153f4 Improve UI some more 2018-12-05 18:45:13 +02:00
Oleksii Holub ac91c536dd Improve ExportSetupView UI 2018-12-05 18:20:20 +02:00
Alexey Golub 0dbd619244 Remove unsused library from readme 2018-11-29 20:55:20 +02:00
Alexey Golub 0d3510222e Migrate to Stylet and refactor view/view-model framework 2018-11-29 19:18:44 +02:00
Alexey Golub 083bdef419 Move screenshots section below features section in readme 2018-11-17 23:22:25 +02:00
Oleksii Holub d620010e85 Add some safeguarding in case partition limit is less or equal to zero 2018-11-15 10:26:54 +02:00
Oleksii Holub beadc25e61 Cleanup GroupMessages 2018-11-14 18:15:07 +02:00
Oleksii Holub 8bc29c4928 Refactor SplitIntoPartitions 2018-11-14 17:51:03 +02:00
Oleksii Holub 0b14952346 Add a script to update version in all projects 2018-11-05 13:31:42 +02:00
Alexey Golub b518fb2f59 Update version 2018-11-03 23:51:19 +02:00
Alexey Golub 94165dcd8d Don't render the template in memory to avoid out of memory exceptions
Fixes #88
2018-11-03 23:04:26 +02:00
Alexey Golub d6507598fa Fix not being able to reset partition limit in GUI 2018-11-03 23:01:58 +02:00
Alexey Golub a0359b1e43 Split output file into multiple partitions (#116) 2018-11-03 22:53:20 +02:00
Oleksii Holub aa53cecd4e Don't open exported file on completion
Fixes #85
2018-11-02 17:12:17 +02:00
Oleksii Holub 4144911208 Remove autoupdate feature from CLI entirely 2018-11-02 16:24:06 +02:00
Oleksii Holub d28e81f8dc Don't replace new lines in csv
Part of #102
2018-11-01 18:33:29 +02:00
Oleksii Holub 761cb032d7 Don't group messages in PlainText export
Part of #102
2018-11-01 18:27:41 +02:00
Oleksii Holub 47f0561c71 Refactor message grouping from data layer to render layer 2018-11-01 18:16:23 +02:00
Oleksii Holub 23116b776b Show unhandled exceptions to users 2018-11-01 14:43:21 +02:00
Oleksii Holub ba27a9f909 Rename ExportService.Export to ExportChatLog for consistency 2018-10-31 17:54:13 +02:00
Oleksii Holub 52684c264f Change order of parameters in ExportService.Export 2018-10-31 17:35:28 +02:00
Oleksii Holub bcad5b4ed1 Set ChatLog.TotalMessageCount from ChatLogService 2018-10-31 17:34:57 +02:00
Alexey Golub 95a4217ab3 Refactor resolving chat log to a separate service to allow reusability across CLI and GUI 2018-10-30 22:52:28 +02:00
Alexey Golub 4e8fb80ac5 Use PackageReference 2018-10-28 15:25:22 +02:00
Alexey Golub 7ae560dde0 Fix changelog 2018-09-14 00:36:31 +03:00
Alexey Golub 97a36028b9 Update version 2018-09-14 00:34:18 +03:00
Alexey Golub f0d99676a3 Disable UpdateAppOptions in CLI 2018-09-14 00:25:21 +03:00
Alexey Golub 8e38adae7e Update instructions to obtain token
Fixes #76
2018-09-14 00:23:23 +03:00
Alexey Golub d95bc37592 Link to dockerhub from readme 2018-09-13 23:32:09 +03:00
Hyeon Kim 3f6354053c Dockerize the application (#92) 2018-09-13 22:50:15 +03:00
Alexey Golub a9bab60ba6 Update instructions to obtain token 2018-08-14 00:49:32 +03:00
Alexey Golub 614bd8590d Generate file name if given output path CLI argument is a directory
Closes #67
2018-08-13 23:25:06 +03:00
Alexey Golub bd9dc6455f Refactor CLI (#81) 2018-08-13 22:49:13 +03:00
Alexey Golub 0faa427970 Invert if condition in authentication header 2018-08-03 00:13:44 +03:00
Alexey Golub dd9da4831e Change how user token is sent in requests 2018-08-03 00:00:51 +03:00
Oleksii Holub 0d816cee5d Update readme 2018-07-26 18:25:47 +03:00
Oleksii Holub f63ea41188 Update version 2018-07-25 11:53:05 +03:00
Alexey Golub 3572a21aad Allow using bot token instead of user token (#70) 2018-07-24 22:47:45 +03:00
Alexey Golub 37ee0b8be3 Cleanup templates and change how model is referenced 2018-07-03 22:58:46 +03:00
trainboy2019 ed146bac22 Add support for animated avatars (#66) 2018-07-02 21:12:32 +03:00
123 changed files with 3856 additions and 2053 deletions
+63
View File
@@ -1,3 +1,66 @@
### v2.10.2 (10-Mar-2019)
- [HTML] Updated message grouping algorithm to make it the same as in Discord. Removed "message group limit" setting and parameter.
- [HTML] Added small margin at the end of the chatlog so it doesn't look like it was truncated.
- [CLI] Fixed an issue where the app would crash if stdout was redirected. Progress will not be reported in such cases.
### v2.10.1 (06-Mar-2019)
- [HTML] Fixed an issue where multiple emojis on a single line would get rendered as one emoji.
- [HTML] Fixed an issue where an open square bracket character would incorrectly render as an emoji.
- [HTML] Added alt text to emoji images so that they will be rendered using default font when Twemoji images are not available.
### v2.10 (03-Mar-2019)
- [HTML] Reworked the entire markdown parsing engine which fixes numerous rendering inconsistencies (over 15 issues fixed).
- [HTML] Added support for animated emojis.
- [HTML] Standard emojis are now rendered using Twemoji, the same image set that Discord uses.
- [HTML] Fixed an issue where video attachments would render incorrectly.
- [HTML] Minor fixes in styling.
- [GUI] Aggregated export progress is now shown in the taskbar.
- [GUI] Checkmarks are now displayed next to channels that are selected.
- [CLI] Added progress reporting when exporting chat logs.
### v2.9.1 (10-Feb-2019)
- Fixed an issue where some of the last messages in a channel would sometimes not get exported.
### v2.9 (09-Feb-2019)
- Added categories to channel list in GUI. Channels are now also grouped by category.
- Added ability to export multiple channels at once in GUI by selecting them with shift or ctrl keys.
- Added CLI command to export all available channels in a guild (`exportguild`).
- Added CLI command to export all direct message channels (`exportdm`).
- Added retry policy for 503 status code errors to prevent random crashes.
- Changed how channels are exported in GUI. Now you need to select them first and then click a button to export. You can also double click a single channel to export it as before.
- Improved default file name generation by adding date ranges and channel ID to the file name.
- Improved naming of files exported with partitioning.
- Improved usage instructions.
- Improved GUI layout and styling.
- Fixed an issue where dialogs would sometimes appear over other windows.
- Fixed an issue where launching DiscordChatExporter from Windows Start Menu would make it crash.
### 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.
+3 -1
View File
@@ -2,10 +2,12 @@ New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
# GUI
$files = @()
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
$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-Item -Path "$PSScriptRoot\..\License.txt"
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.Cli.zip" -Force
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
-24
View File
@@ -1,24 +0,0 @@
using System;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli
{
public class CliOptions
{
public string Token { get; set; }
public string ChannelId { get; set; }
public ExportFormat ExportFormat { get; set; }
public string FilePath { get; set; }
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public string DateFormat { get; set; }
public int MessageGroupLimit { get; set; }
}
}
+12 -25
View File
@@ -1,37 +1,24 @@
using CommonServiceLocator;
using DiscordChatExporter.Cli.ViewModels;
using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight.Ioc;
using DiscordChatExporter.Core.Services;
using StyletIoC;
namespace DiscordChatExporter.Cli
{
public class Container
public static class Container
{
public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
public ISettingsService SettingsService => Resolve<ISettingsService>();
public static IContainer Instance { get; }
private T Resolve<T>(string key = null)
static Container()
{
return ServiceLocator.Current.GetInstance<T>(key);
}
var builder = new StyletIoCBuilder();
public void Init()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
// Autobind services in the .Core assembly
builder.Autobind(typeof(DataService).Assembly);
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// View models
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
}
public void Cleanup()
{
// Set instance
Instance = builder.BuildContainer();
}
}
}
@@ -3,16 +3,15 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework>
<Version>2.5.1</Version>
<Version>2.10.2</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
<PackageReference Include="FluentCommandLineParser" Version="1.4.3" />
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
<PackageReference Include="CommandLineParser" Version="2.3.0" />
<PackageReference Include="Stylet" Version="1.1.22" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
</ItemGroup>
@@ -0,0 +1,40 @@
using System;
namespace DiscordChatExporter.Cli.Internal
{
internal class InlineProgress : IProgress<double>, IDisposable
{
private readonly int _posX;
private readonly int _posY;
public InlineProgress()
{
// If output is not redirected - save initial cursor position
if (!Console.IsOutputRedirected)
{
_posX = Console.CursorLeft;
_posY = Console.CursorTop;
}
}
public void Report(double progress)
{
// If output is not redirected - reset cursor position and write progress
if (!Console.IsOutputRedirected)
{
Console.SetCursorPosition(_posX, _posY);
Console.WriteLine($"{progress:P1}");
}
}
public void Dispose()
{
// If output is not redirected - reset cursor position
if (!Console.IsOutputRedirected)
Console.SetCursorPosition(_posX, _posY);
// Inform about completion
Console.WriteLine("Completed ✓");
}
}
}
+51 -90
View File
@@ -1,111 +1,72 @@
using System;
using System.Reflection;
using DiscordChatExporter.Core.Models;
using Fclp;
using Tyrrrz.Extensions;
using System.Linq;
using CommandLine;
using DiscordChatExporter.Cli.Verbs;
using DiscordChatExporter.Cli.Verbs.Options;
namespace DiscordChatExporter.Cli
{
public static class Program
{
private static readonly Container Container = new Container();
private static void ShowHelp()
private static void PrintTokenHelp()
{
var version = Assembly.GetExecutingAssembly().GetName().Version;
var availableFormats = Enum.GetNames(typeof(ExportFormat));
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ===");
Console.WriteLine("# To get user token:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 3. Press Ctrl+R to reload");
Console.WriteLine(" 4. Navigate to the Application tab");
Console.WriteLine(" 5. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
Console.WriteLine(" 6. Find \"token\" under key and copy the value");
Console.WriteLine();
Console.WriteLine("[-t] [--token] Discord authorization token.");
Console.WriteLine("[-c] [--channel] Discord channel ID.");
Console.WriteLine("[-f] [--format] Export format. Optional.");
Console.WriteLine("[-o] [--output] Output file path. Optional.");
Console.WriteLine(" [--datefrom] Limit to messages after this date. Optional.");
Console.WriteLine(" [--dateto] Limit to messages before this date. Optional.");
Console.WriteLine(" [--dateformat] Date format. Optional.");
Console.WriteLine(" [--grouplimit] Message group limit. Optional.");
Console.WriteLine("# To get bot token:");
Console.WriteLine(" 1. Go to Discord developer portal");
Console.WriteLine(" 2. Open your application's settings");
Console.WriteLine(" 3. Navigate to the Bot section on the left");
Console.WriteLine(" 4. Under Token click Copy");
Console.WriteLine();
Console.WriteLine($"Available export formats: {availableFormats.JoinToString(", ")}");
Console.WriteLine();
Console.WriteLine("# To get authorization token:");
Console.WriteLine(" - Open Discord app");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Press Ctrl+Shift+I");
Console.WriteLine(" - Navigate to Application tab");
Console.WriteLine(" - Expand Storage > Local Storage > https://discordapp.com");
Console.WriteLine(" - Find \"token\" under key and copy the value");
Console.WriteLine();
Console.WriteLine("# To get channel ID:");
Console.WriteLine(" - Open Discord app");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Go to any channel you want to export");
Console.WriteLine(" - Press Ctrl+Shift+I");
Console.WriteLine(" - Navigate to Console tab");
Console.WriteLine(" - Type \"document.URL\" and press Enter");
Console.WriteLine(" - Copy the long sequence of numbers after last slash");
}
private static CliOptions ParseOptions(string[] args)
{
var argsParser = new FluentCommandLineParser<CliOptions>();
var settings = Container.SettingsService;
argsParser.Setup(o => o.Token).As('t', "token").Required();
argsParser.Setup(o => o.ChannelId).As('c', "channel").Required();
argsParser.Setup(o => o.ExportFormat).As('f', "format").SetDefault(ExportFormat.HtmlDark);
argsParser.Setup(o => o.FilePath).As('o', "output").SetDefault(null);
argsParser.Setup(o => o.From).As("datefrom").SetDefault(null);
argsParser.Setup(o => o.To).As("dateto").SetDefault(null);
argsParser.Setup(o => o.DateFormat).As("dateformat").SetDefault(settings.DateFormat);
argsParser.Setup(o => o.MessageGroupLimit).As("grouplimit").SetDefault(settings.MessageGroupLimit);
var parsed = argsParser.Parse(args);
// Show help if no arguments
if (parsed.EmptyArgs)
{
ShowHelp();
Environment.Exit(0);
}
// Show error if there are any
else if (parsed.HasErrors)
{
Console.Error.Write(parsed.ErrorText);
Environment.Exit(-1);
}
return argsParser.Object;
Console.WriteLine("# To get guild or channel ID:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Open Settings");
Console.WriteLine(" 3. Go to Appearance section");
Console.WriteLine(" 4. Enable Developer Mode");
Console.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
}
public static void Main(string[] args)
{
// Init container
Container.Init();
// Get all verb types
var verbTypes = new[]
{
typeof(ExportChannelOptions),
typeof(ExportDirectMessagesOptions),
typeof(ExportGuildOptions),
typeof(GetChannelsOptions),
typeof(GetDirectMessageChannelsOptions),
typeof(GetGuildsOptions)
};
// Parse options
var options = ParseOptions(args);
// Parse command line arguments
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
// Inject some settings
var settings = Container.SettingsService;
settings.DateFormat = options.DateFormat;
settings.MessageGroupLimit = options.MessageGroupLimit;
// Execute commands
parsedArgs.WithParsed<ExportChannelOptions>(o => new ExportChannelVerb(o).Execute());
parsedArgs.WithParsed<ExportDirectMessagesOptions>(o => new ExportDirectMessagesVerb(o).Execute());
parsedArgs.WithParsed<ExportGuildOptions>(o => new ExportGuildVerb(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());
// Export
var vm = Container.MainViewModel;
vm.ExportAsync(
options.Token,
options.ChannelId,
options.FilePath,
options.ExportFormat,
options.From,
options.To).GetAwaiter().GetResult();
// Show token help if help requested or no verb specified
parsedArgs.WithNotParsed(errs =>
{
var err = errs.First();
// Cleanup container
Container.Cleanup();
if (err.Tag == ErrorType.NoVerbSelectedError)
PrintTokenHelp();
Console.WriteLine("Export complete.");
if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
PrintTokenHelp();
});
}
}
}
@@ -0,0 +1,55 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Helpers;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportChannelVerb : Verb<ExportChannelOptions>
{
public ExportChannelVerb(ExportChannelOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (Options.DateFormat.IsNotBlank())
settingsService.DateFormat = Options.DateFormat;
// Track progress
Console.Write($"Exporting channel [{Options.ChannelId}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
Options.After, Options.Before, progress);
// Generate file path if not set or is a directory
var filePath = Options.OutputPath;
if (filePath.IsBlank() || ExportHelper.IsDirectoryPath(filePath))
{
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Combine paths
filePath = Path.Combine(filePath ?? "", fileName);
}
// Export
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
}
}
}
}
@@ -0,0 +1,74 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Helpers;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportDirectMessagesVerb : Verb<ExportDirectMessagesOptions>
{
public ExportDirectMessagesVerb(ExportDirectMessagesOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (Options.DateFormat.IsNotBlank())
settingsService.DateFormat = Options.DateFormat;
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
// Track progress
Console.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before, progress);
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Generate file path
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
// Export
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Console.Error.WriteLine("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.Error.WriteLine("This channel doesn't exist");
}
}
}
}
}
@@ -0,0 +1,75 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Helpers;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportGuildVerb : Verb<ExportGuildOptions>
{
public ExportGuildVerb(ExportGuildOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (Options.DateFormat.IsNotBlank())
settingsService.DateFormat = Options.DateFormat;
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
// Track progress
Console.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before, progress);
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Generate file path
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
// Export
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Console.Error.WriteLine("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.Error.WriteLine("This channel doesn't exist");
}
}
}
}
}
@@ -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;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetChannelsVerb : Verb<GetChannelsOptions>
{
public GetChannelsVerb(GetChannelsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var dataService = Container.Instance.Get<DataService>();
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels)
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,32 @@
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 dataService = Container.Instance.Get<DataService>();
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels)
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,32 @@
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 dataService = Container.Instance.Get<DataService>();
// Get guilds
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
// Order guilds
guilds = guilds.OrderBy(g => g.Name).ToArray();
// Print result
foreach (var guild in guilds)
Console.WriteLine($"{guild.Id} | {guild.Name}");
}
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("export", HelpText = "Export channel.")]
public class ExportChannelOptions : ExportOptions
{
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
public string ChannelId { get; set; }
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("exportdm", HelpText = "Export all direct message channels.")]
public class ExportDirectMessagesOptions : ExportOptions
{
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("exportguild", HelpText = "Export all channels within a given guild.")]
public class ExportGuildOptions : ExportOptions
{
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
public string GuildId { get; set; }
}
}
@@ -0,0 +1,27 @@
using System;
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
public abstract class ExportOptions : TokenOptions
{
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
public ExportFormat ExportFormat { get; set; }
[Option('o', "output", Default = null, HelpText = "Output file or directory path.")]
public string OutputPath { 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; }
}
}
@@ -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 abstract 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);
}
}
+18
View File
@@ -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();
}
}
@@ -1,12 +0,0 @@
using System;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.ViewModels
{
public interface IMainViewModel
{
Task ExportAsync(string token, string channelId, string filePath, ExportFormat format, DateTime? from,
DateTime? to);
}
}
@@ -1,56 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.ViewModels
{
public class MainViewModel : IMainViewModel
{
private readonly IDataService _dataService;
private readonly IMessageGroupService _messageGroupService;
private readonly IExportService _exportService;
public MainViewModel(IDataService dataService, IMessageGroupService messageGroupService,
IExportService exportService)
{
_dataService = dataService;
_messageGroupService = messageGroupService;
_exportService = exportService;
}
public async Task ExportAsync(string token, string channelId, string filePath, ExportFormat format,
DateTime? from, DateTime? to)
{
// Get channel and guild
var channel = await _dataService.GetChannelAsync(token, channelId);
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await _dataService.GetGuildAsync(token, channel.GuildId);
// Generate file path if not set
if (filePath.IsBlank())
{
filePath = $"{guild.Name} - {channel.Name}.{format.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
}
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to);
// Group messages
var messageGroups = _messageGroupService.GroupMessages(messages);
// Get mentionables
var mentionables = await _dataService.GetMentionablesAsync(token, guild.Id, messages);
// Create log
var log = new ChatLog(guild, channel, from, to, messageGroups, mentionables);
// Export
_exportService.Export(format, filePath, log);
}
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net461</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Sprache" Version="2.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Markdown
{
public class EmojiNode : Node
{
public string Id { get; }
public string Name { get; }
public bool IsAnimated { get; }
public bool IsCustomEmoji => Id.IsNotBlank();
public EmojiNode(string lexeme, string id, string name, bool isAnimated)
: base(lexeme)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
}
public EmojiNode(string lexeme, string name)
: this(lexeme, null, name, false)
{
}
public override string ToString() => $"<Emoji> {Name}";
}
}
@@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown
{
public class FormattedNode : Node
{
public string Token { get; }
public TextFormatting Formatting { get; }
public IReadOnlyList<Node> Children { get; }
public FormattedNode(string lexeme, string token, TextFormatting formatting, IReadOnlyList<Node> children)
: base(lexeme)
{
Token = token;
Formatting = formatting;
Children = children;
}
public override string ToString() => $"<{Formatting}> ({Children.Count} direct children)";
}
}
@@ -0,0 +1,15 @@
namespace DiscordChatExporter.Core.Markdown
{
public class InlineCodeBlockNode : Node
{
public string Code { get; }
public InlineCodeBlockNode(string lexeme, string code)
: base(lexeme)
{
Code = code;
}
public override string ToString() => $"<Code> {Code}";
}
}
@@ -0,0 +1,178 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Sprache;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Markdown.Internal
{
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
internal static class Grammar
{
/* Formatting */
// Capture until the earliest double asterisk not followed by an asterisk
private static readonly Parser<Node> BoldFormattedNode =
Parse.RegexMatch(new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", RegexOptions.Singleline))
.Select(m => new FormattedNode(m.Value, "**", TextFormatting.Bold, BuildTree(m.Groups[1].Value)));
// Capture until the earliest single asterisk not preceded or followed by an asterisk
// Can't have whitespace right after opening or right before closing asterisk
private static readonly Parser<Node> ItalicFormattedNode =
Parse.RegexMatch(new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", RegexOptions.Singleline))
.Select(m => new FormattedNode(m.Value, "*", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
// Can't have underscores inside
// Can't have word characters right after closing underscore
private static readonly Parser<Node> ItalicAltFormattedNode =
Parse.RegexMatch(new Regex("_([^_]+?)_(?!\\w)", RegexOptions.Singleline))
.Select(m => new FormattedNode(m.Value, "_", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
// Treated as a separate entity for simplicity
// Capture until the earliest triple asterisk not preceded or followed by an asterisk
private static readonly Parser<Node> ItalicBoldFormattedNode =
Parse.RegexMatch(new Regex("\\*(\\*\\*(?:.+?)\\*\\*)\\*(?!\\*)", RegexOptions.Singleline))
.Select(m => new FormattedNode(m.Value, "*", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
// Capture until the earliest double underscore not followed by an underscore
private static readonly Parser<Node> UnderlineFormattedNode =
Parse.RegexMatch(new Regex("__(.+?)__(?!_)", RegexOptions.Singleline))
.Select(m => new FormattedNode(m.Value, "__", TextFormatting.Underline, BuildTree(m.Groups[1].Value)));
// Treated as a separate entity for simplicity
// Capture until the earliest triple underscore not preceded or followed by an underscore
private static readonly Parser<Node> ItalicUnderlineFormattedNode =
Parse.RegexMatch(new Regex("_(__(?:.+?)__)_(?!_)", RegexOptions.Singleline))
.Select(m => new FormattedNode(m.Value, "_", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
// Strikethrough is safe
private static readonly Parser<Node> StrikethroughFormattedNode =
Parse.RegexMatch(new Regex("~~(.+?)~~", RegexOptions.Singleline))
.Select(m => new FormattedNode(m.Value, "~~", TextFormatting.Strikethrough, BuildTree(m.Groups[1].Value)));
// Spoiler is safe
private static readonly Parser<Node> SpoilerFormattedNode =
Parse.RegexMatch(new Regex("\\|\\|(.+?)\\|\\|", RegexOptions.Singleline))
.Select(m => new FormattedNode(m.Value, "||", TextFormatting.Spoiler, BuildTree(m.Groups[1].Value)));
// Combinator, order matters
private static readonly Parser<Node> AnyFormattedNode =
ItalicBoldFormattedNode.Or(ItalicUnderlineFormattedNode)
.Or(BoldFormattedNode).Or(ItalicFormattedNode)
.Or(UnderlineFormattedNode).Or(ItalicAltFormattedNode)
.Or(StrikethroughFormattedNode).Or(SpoilerFormattedNode);
/* Code blocks */
// Can't have backticks inside and surrounding whitespace is trimmed
private static readonly Parser<Node> InlineCodeBlockNode =
Parse.RegexMatch(new Regex("`\\s*([^`]+?)\\s*`", RegexOptions.Singleline))
.Select(m => new InlineCodeBlockNode(m.Value, m.Groups[1].Value));
// The first word is a language identifier if it's the only word followed by a newline, the rest is code
private static readonly Parser<Node> MultilineCodeBlockNode =
Parse.RegexMatch(new Regex("```(?:(\\w*?)?(?:\\s*?\\n))?(.+?)```", RegexOptions.Singleline))
.Select(m => new MultilineCodeBlockNode(m.Value, m.Groups[1].Value, m.Groups[2].Value));
// Combinator, order matters
private static readonly Parser<Node> AnyCodeBlockNode = MultilineCodeBlockNode.Or(InlineCodeBlockNode);
/* Mentions */
// @everyone or @here
private static readonly Parser<Node> MetaMentionNode = Parse.RegexMatch("@(everyone|here)")
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Meta));
// <@123456> or <@!123456>
private static readonly Parser<Node> UserMentionNode = Parse.RegexMatch("<@!?(\\d+)>")
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.User));
// <#123456>
private static readonly Parser<Node> ChannelMentionNode = Parse.RegexMatch("<#(\\d+)>")
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Channel));
// <@&123456>
private static readonly Parser<Node> RoleMentionNode = Parse.RegexMatch("<@&(\\d+)>")
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Role));
// Combinator, order matters
private static readonly Parser<Node> AnyMentionNode =
MetaMentionNode.Or(UserMentionNode).Or(ChannelMentionNode).Or(RoleMentionNode);
/* Emojis */
// Matches all standard unicode emojis
private static readonly Parser<Node> StandardEmojiNode = Parse.RegexMatch(
"([\\u2700-\\u27bf]|" +
"(?:\\ud83c[\\udde6-\\uddff]){2}|" +
"[\\ud800-\\udbff][\\udc00-\\udfff]|" +
"[\\u0023-\\u0039]\\u20e3|" +
"\\u3299|\\u3297|\\u303d|\\u3030|\\u24c2|\\ud83c[\\udd70-\\udd71]|\\ud83c[\\udd7e-\\udd7f]|\\ud83c\\udd8e|\\ud83c[\\udd91-\\udd9a]|\\ud83c[\\udde6-\\uddff]|" +
"\\ud83c[\\ude01-\\ude02]|\\ud83c\\ude1a|\\ud83c\\ude2f|\\ud83c[\\ude32-\\ude3a]|\\ud83c[\\ude50-\\ude51]|\\u203c|\\u2049|[\\u25aa-\\u25ab]|" +
"\\u25b6|\\u25c0|[\\u25fb-\\u25fe]|\\u00a9|\\u00ae|\\u2122|\\u2139|\\ud83c\\udc04|[\\u2600-\\u26FF]|\\u2b05|\\u2b06|\\u2b07|\\u2b1b|\\u2b1c|\\u2b50|" +
"\\u2b55|\\u231a|\\u231b|\\u2328|\\u23cf|[\\u23e9-\\u23f3]|[\\u23f8-\\u23fa]|\\ud83c\\udccf|\\u2934|\\u2935|[\\u2190-\\u21ff])")
.Select(m => new EmojiNode(m.Value, m.Groups[1].Value));
// <:lul:123456> or <a:lul:123456>
private static readonly Parser<Node> CustomEmojiNode = Parse.RegexMatch("<(a)?:(.+?):(\\d+)>")
.Select(m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, m.Groups[1].Value.IsNotBlank()));
// Combinator, order matters
private static readonly Parser<Node> AnyEmojiNode = StandardEmojiNode.Or(CustomEmojiNode);
/* Links */
// [title](link)
private static readonly Parser<Node> TitledLinkNode = Parse.RegexMatch("\\[(.+?)\\]\\((.+?)\\)")
.Select(m => new LinkNode(m.Value, m.Groups[2].Value, m.Groups[1].Value));
// Starts with http:// or https://, stops at the last non-whitespace character followed by whitespace or punctuation character
private static readonly Parser<Node> AutoLinkNode = Parse.RegexMatch("(https?://\\S*[^\\.,:;\"\'\\s])")
.Select(m => new LinkNode(m.Value, m.Groups[1].Value));
// Autolink surrounded by angular brackets
private static readonly Parser<Node> HiddenLinkNode = Parse.RegexMatch("<(https?://\\S*[^\\.,:;\"\'\\s])>")
.Select(m => new LinkNode(m.Value, m.Groups[1].Value));
// Combinator, order matters
private static readonly Parser<Node> AnyLinkNode = TitledLinkNode.Or(HiddenLinkNode).Or(AutoLinkNode);
/* Text */
// Shrug is an exception and needs to be exempt from formatting
private static readonly Parser<Node> ShrugTextNode =
Parse.String("¯\\_(ツ)_/¯").Text().Select(s => new TextNode(s));
// Backslash escapes any following unicode surrogate pair
private static readonly Parser<Node> EscapedSurrogateTextNode =
from slash in Parse.Char('\\')
from high in Parse.AnyChar.Where(char.IsHighSurrogate)
from low in Parse.AnyChar
let lexeme = $"{slash}{high}{low}"
let text = $"{high}{low}"
select new TextNode(lexeme, text);
// Backslash escapes any following non-whitespace character except for digits and latin letters
private static readonly Parser<Node> EscapedTextNode =
Parse.RegexMatch("\\\\([^a-zA-Z0-9\\s])").Select(m => new TextNode(m.Value, m.Groups[1].Value));
// Combinator, order matters
private static readonly Parser<Node> AnyTextNode = ShrugTextNode.Or(EscapedSurrogateTextNode).Or(EscapedTextNode);
/* Aggregator and fallback */
// Any node recognized by above patterns
private static readonly Parser<Node> AnyRecognizedNode = AnyFormattedNode.Or(AnyCodeBlockNode)
.Or(AnyMentionNode).Or(AnyEmojiNode).Or(AnyLinkNode).Or(AnyTextNode);
// Any node not recognized by above patterns (treated as plain text)
private static readonly Parser<Node> FallbackNode =
Parse.AnyChar.Except(AnyRecognizedNode).AtLeastOnce().Text().Select(s => new TextNode(s));
// Any node
private static readonly Parser<Node> AnyNode = AnyRecognizedNode.Or(FallbackNode);
// Entry point
public static IReadOnlyList<Node> BuildTree(string input) => AnyNode.Many().Parse(input).ToArray();
}
}
@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Core.Markdown
{
public class LinkNode : Node
{
public string Url { get; }
public string Title { get; }
public LinkNode(string lexeme, string url, string title)
: base(lexeme)
{
Url = url;
Title = title;
}
public LinkNode(string lexeme, string url) : this(lexeme, url, url)
{
}
public override string ToString() => $"<Link> {Title}";
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Markdown.Internal;
namespace DiscordChatExporter.Core.Markdown
{
public static class MarkdownParser
{
public static IReadOnlyList<Node> Parse(string input) => Grammar.BuildTree(input);
}
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Core.Markdown
{
public class MentionNode : Node
{
public string Id { get; }
public MentionType Type { get; }
public MentionNode(string lexeme, string id, MentionType type)
: base(lexeme)
{
Id = id;
Type = type;
}
public override string ToString() => $"<{Type} mention> {Id}";
}
}
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Core.Markdown
{
public enum MentionType
{
Meta,
User,
Channel,
Role
}
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Core.Markdown
{
public class MultilineCodeBlockNode : Node
{
public string Language { get; }
public string Code { get; }
public MultilineCodeBlockNode(string lexeme, string language, string code)
: base(lexeme)
{
Language = language;
Code = code;
}
public override string ToString() => $"<Code [{Language}]> {Code}";
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace DiscordChatExporter.Core.Markdown
{
public abstract class Node
{
public string Lexeme { get; }
protected Node(string lexeme)
{
Lexeme = lexeme;
}
}
}
@@ -0,0 +1,11 @@
namespace DiscordChatExporter.Core.Markdown
{
public enum TextFormatting
{
Bold,
Italic,
Underline,
Strikethrough,
Spoiler
}
}
@@ -0,0 +1,19 @@
namespace DiscordChatExporter.Core.Markdown
{
public class TextNode : Node
{
public string Text { get; }
public TextNode(string lexeme, string text)
: base(lexeme)
{
Text = text;
}
public TextNode(string text) : this(text, text)
{
}
public override string ToString() => Text;
}
}
@@ -2,18 +2,17 @@
<PropertyGroup>
<TargetFramework>net461</TargetFramework>
<Version>2.5.1</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" />
<EmbeddedResource Include="Resources\ExportTemplates\PlainText\Template.txt" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark\Template.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight\Template.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlShared\Main.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlShared\Main.css" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark\Theme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight\Theme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Csv\Template.csv" />
</ItemGroup>
<ItemGroup>
@@ -21,12 +20,17 @@
</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="ByteSize" Version="1.3.0" />
<PackageReference Include="Failsafe" Version="1.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
<PackageReference Include="Onova" Version="2.2.0" />
<PackageReference Include="Scriban" Version="1.2.9" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,59 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Helpers
{
public static class ExportHelper
{
public static bool IsDirectoryPath(string path)
=> path.Last() == Path.DirectorySeparatorChar ||
path.Last() == Path.AltDirectorySeparatorChar ||
Path.GetExtension(path).IsBlank();
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
DateTime? from = null, DateTime? to = null)
{
var result = new StringBuilder();
// Append guild and channel names
result.Append($"{guild.Name} - {channel.Name} [{channel.Id}]");
// Append date range
if (from != null || to != null)
{
result.Append(" (");
// Both 'from' and 'to' are set
if (from != null && to != null)
{
result.Append($"{from:yyyy-MM-dd} to {to:yyyy-MM-dd}");
}
// Only 'from' is set
else if (from != null)
{
result.Append($"after {from:yyyy-MM-dd}");
}
// Only 'to' is set
else
{
result.Append($"before {to:yyyy-MM-dd}");
}
result.Append(")");
}
// Append extension
result.Append($".{format.GetFileExtension()}");
// Replace invalid chars
foreach (var invalidChar in Path.GetInvalidFileNameChars())
result.Replace(invalidChar, '_');
return result.ToString();
}
}
}
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using Tyrrrz.Extensions;
using System.Linq;
using System.Net;
namespace DiscordChatExporter.Core.Internal
{
@@ -14,10 +16,37 @@ namespace DiscordChatExporter.Core.Internal
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);
public static string HtmlEncode(this string value) => WebUtility.HtmlEncode(value);
public static IEnumerable<IReadOnlyList<T>> GroupAdjacentWhile<T>(this IEnumerable<T> source,
Func<IReadOnlyList<T>, T, bool> groupPredicate)
{
// Create buffer
var buffer = new List<T>();
// Enumerate source
foreach (var element in source)
{
// If buffer is not empty and group predicate failed - yield and flush buffer
if (buffer.Any() && !groupPredicate(buffer, element))
{
yield return buffer;
buffer = new List<T>(); // new instance to reset reference
}
// Add element to buffer
buffer.Add(element);
}
// If buffer still has something after the source has been enumerated - yield
if (buffer.Any())
yield return buffer;
}
public static IEnumerable<IReadOnlyList<T>> GroupAdjacentWhile<T>(this IEnumerable<T> source,
Func<IReadOnlyList<T>, bool> groupPredicate)
=> source.GroupAdjacentWhile((buffer, _) => groupPredicate(buffer));
}
}
+12 -5
View File
@@ -1,4 +1,7 @@
namespace DiscordChatExporter.Core.Models
using System;
using ByteSizeLib;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#attachment-object
@@ -12,13 +15,17 @@
public int? Height { get; }
public bool IsImage => Width != null;
public string FileName { get; }
public long FileSize { get; }
public bool IsImage => FileName.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
FileName.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
FileName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
FileName.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ||
FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase);
public Attachment(string id, int? width, int? height, string url, string fileName, long fileSize)
public ByteSize FileSize { get; }
public Attachment(string id, int? width, int? height, string url, string fileName, ByteSize fileSize)
{
Id = id;
Url = url;
@@ -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
}
}
+6 -3
View File
@@ -6,6 +6,8 @@
{
public string Id { get; }
public string ParentId { get; }
public string GuildId { get; }
public string Name { get; }
@@ -13,10 +15,11 @@
public string Topic { get; }
public ChannelType Type { get; }
public Channel(string id, string guildId, string name, string topic, ChannelType type)
public Channel(string id, string parentId, string guildId, string name, string topic, ChannelType type)
{
Id = id;
ParentId = parentId;
GuildId = guildId;
Name = name;
Topic = topic;
@@ -29,6 +32,6 @@
public partial class Channel
{
public static Channel CreateDeletedChannel(string id) =>
new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
new Channel(id, null, null, "deleted-channel", null, ChannelType.GuildTextChat);
}
}
+3 -6
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Models
{
@@ -14,20 +13,18 @@ namespace DiscordChatExporter.Core.Models
public DateTime? To { get; }
public IReadOnlyList<MessageGroup> MessageGroups { get; }
public int TotalMessageCount => MessageGroups.Sum(g => g.Messages.Count);
public IReadOnlyList<Message> Messages { get; }
public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
IReadOnlyList<MessageGroup> messageGroups, Mentionables mentionables)
IReadOnlyList<Message> messages, Mentionables mentionables)
{
Guild = guild;
Channel = channel;
From = from;
To = to;
MessageGroups = messageGroups;
Messages = messages;
Mentionables = mentionables;
}
+56
View File
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
public partial class Emoji
{
public string Id { get; }
public string Name { get; }
public bool IsAnimated { get; }
public string ImageUrl
{
get
{
// Custom emoji
if (Id.IsNotBlank())
{
// Animated
if (IsAnimated)
return $"https://cdn.discordapp.com/emojis/{Id}.gif";
// Non-animated
return $"https://cdn.discordapp.com/emojis/{Id}.png";
}
// Standard unicode emoji (via twemoji)
return $"https://twemoji.maxcdn.com/2/72x72/{GetTwemojiName(Name)}.png";
}
}
public Emoji(string id, string name, bool isAnimated)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
}
}
public partial class Emoji
{
private static IEnumerable<int> GetCodePoints(string emoji)
{
for (var i = 0; i < emoji.Length; i += char.IsHighSurrogate(emoji[i]) ? 2 : 1)
yield return char.ConvertToUtf32(emoji, i);
}
private static string GetTwemojiName(string emoji)
=> GetCodePoints(emoji).Select(i => i.ToString("x")).JoinToString("-");
}
}
@@ -1,23 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public 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;
}
public override string ToString() => $"{Author.FullName} | {Timestamp} | {Messages.Count} messages";
}
}
+3 -6
View File
@@ -6,15 +6,12 @@
{
public int Count { get; }
public string EmojiId { get; }
public Emoji Emoji { get; }
public string EmojiName { get; }
public Reaction(int count, string emojiId, string emojiName)
public Reaction(int count, Emoji emoji)
{
Count = count;
EmojiId = emojiId;
EmojiName = emojiName;
Emoji = emoji;
}
}
}
+26 -6
View File
@@ -1,4 +1,5 @@
using Tyrrrz.Extensions;
using System;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
@@ -14,13 +15,32 @@ namespace DiscordChatExporter.Core.Models
public string FullName => $"{Name}#{Discriminator:0000}";
public string AvatarHash { get; }
public string DefaultAvatarHash => $"{Discriminator % 5}";
public string AvatarUrl => AvatarHash.IsNotBlank()
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
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)
{
@@ -1,12 +0,0 @@
Author;Date;Content;Attachments;
{{~ for group in MessageGroups -}}
{{- for message in group.Messages -}}
{{- message.Author.FullName }};
{{- message.Timestamp | FormatDate }};
{{- message.Content | FormatContent }};
{{- message.Attachments | array.map "Url" | array.join "," }};
{{~ end -}}
{{- end -}}
Can't render this file because it contains an unexpected character in line 10 and column 41.
@@ -0,0 +1,10 @@
Author;Date;Content;Attachments;
{{~ for message in Model.Messages -}}
{{- }}"{{ message.Author.FullName }}";
{{- }}"{{ message.Timestamp | FormatDate }}";
{{- }}"{{ message.Content | FormatMarkdown | string.replace "\"" "\"\"" }}";
{{- }}"{{ 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.
@@ -1,7 +0,0 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.DarkTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -0,0 +1,2 @@
{{~ ThemeStyleSheet = include "HtmlDark.Theme.css" ~}}
{{~ include "HtmlShared.Main.html" ~}}
@@ -2,13 +2,17 @@
body {
background-color: #36393e;
color: #ffffffb3;
color: #dcddde;
}
a {
color: #0096cf;
}
.spoiler {
background-color: rgba(255, 255, 255, 0.1);
}
.pre {
background-color: #2f3136;
}
@@ -19,7 +23,6 @@ a {
}
.mention {
background-color: #738bd71a;
color: #7289da;
}
@@ -40,7 +43,7 @@ a {
/* === CHATLOG === */
.chatlog__message-group {
border-color: #ffffff0a;
border-color: rgba(255, 255, 255, 0.1);
}
.chatlog__author-name {
@@ -48,16 +51,16 @@ a {
}
.chatlog__timestamp {
color: #ffffff33;
color: rgba(255, 255, 255, 0.2);
}
.chatlog__edited-timestamp {
color: #ffffff33;
color: rgba(255, 255, 255, 0.2);
}
.chatlog__embed-content-container {
background-color: #2e30364d;
border-color: #2e303699;
background-color: rgba(46, 48, 54, 0.3);
border-color: rgba(46, 48, 54, 0.6);
}
.chatlog__embed-author-name {
@@ -73,7 +76,7 @@ a {
}
.chatlog__embed-description {
color: #ffffff99;
color: rgba(255, 255, 255, 0.6);
}
.chatlog__embed-field-name {
@@ -81,17 +84,17 @@ a {
}
.chatlog__embed-field-value {
color: #ffffff99;
color: rgba(255, 255, 255, 0.6);
}
.chatlog__embed-footer {
color: #ffffff99;
color: rgba(255, 255, 255, 0.6);
}
.chatlog__reaction {
background-color: #ffffff0a;
background-color: rgba(255, 255, 255, 0.05);
}
.chatlog__reaction-count {
color: #ffffff4d;
color: rgba(255, 255, 255, 0.3);
}
@@ -1,7 +0,0 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.LightTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -0,0 +1,2 @@
{{~ ThemeStyleSheet = include "HtmlLight.Theme.css" ~}}
{{~ include "HtmlShared.Main.html" ~}}
@@ -2,13 +2,17 @@
body {
background-color: #ffffff;
color: #737f8d;
color: #747f8d;
}
a {
color: #00b0f4;
}
.spoiler {
background-color: rgba(0, 0, 0, 0.1);
}
.pre {
background-color: #f9f9f9;
}
@@ -56,8 +60,8 @@ a {
}
.chatlog__embed-content-container {
background-color: #f9f9f94d;
border-color: #cccccc4d;
background-color: rgba(249, 249, 249, 0.3);
border-color: rgba(204, 204, 204, 0.3);
}
.chatlog__embed-author-name {
@@ -85,11 +89,11 @@ a {
}
.chatlog__embed-footer {
color: #4f535b99;
color: rgba(79, 83, 91, 0.4);
}
.chatlog__reaction {
background-color: #4f545c0f;
background-color: rgba(79, 84, 92, 0.06);
}
.chatlog__reaction-count {
@@ -17,9 +17,18 @@ img {
object-fit: contain;
}
.markdown {
white-space: pre-wrap;
line-height: 1.3;
overflow-wrap: break-word;
}
.spoiler {
border-radius: 3px;
}
.pre {
font-family: "Consolas", "Courier New", Courier, Monospace;
white-space: pre-wrap;
}
.pre--multiline {
@@ -34,25 +43,25 @@ img {
border-radius: 3px;
}
.mention {
font-weight: 500;
}
.emoji {
width: 24px;
height: 24px;
width: 1.45em;
height: 1.45em;
margin: 0 1px;
vertical-align: middle;
vertical-align: -0.4em;
}
.emoji--small {
width: 16px;
height: 16px;
width: 1rem;
height: 1rem;
}
.emoji--large {
width: 32px;
height: 32px;
}
.mention {
font-weight: 600;
width: 2rem;
height: 2rem;
}
/* === INFO === */
@@ -101,6 +110,7 @@ img {
.chatlog {
max-width: 100%;
margin-bottom: 24px;
}
.chatlog__message-group {
@@ -130,6 +140,7 @@ img {
.chatlog__author-name {
font-size: 1em;
font-weight: 500;
}
.chatlog__timestamp {
@@ -144,7 +155,7 @@ img {
}
.chatlog__edited-timestamp {
margin-left: 5px;
margin-left: 3px;
font-size: .8em;
}
@@ -283,21 +294,19 @@ img {
}
.chatlog__reactions {
display: flex;
display: flex;
}
.chatlog__reaction {
display: flex;
align-items: center;
margin: 6px 2px 2px 2px;
padding: 2px 6px 2px 2px;
padding: 3px 6px;
border-radius: 3px;
}
.chatlog__reaction-emoji {
margin-left: 3px;
vertical-align: middle;
}
.chatlog__reaction-count {
min-width: 9px;
margin-left: 6px;
font-size: .875em;
vertical-align: middle;
}
@@ -2,11 +2,14 @@
<html lang="en">
<head>
<title>{{ Guild.Name | html.escape }} - {{ Channel.Name | html.escape }}</title>
<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 }}
{{ include "HtmlShared.Main.css" }}
</style>
<style>
{{ ThemeStyleSheet }}
</style>
</head>
<body>
@@ -14,26 +17,26 @@
{{~ # Info ~}}
<div class="info">
<div class="info__guild-icon-container">
<img class="info__guild-icon" src="{{ Guild.IconUrl }}" />
<img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
</div>
<div class="info__metadata">
<div class="info__guild-name">{{ Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Channel.Name | html.escape }}</div>
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
{{~ if Channel.Topic ~}}
<div class="info__channel-topic">{{ Channel.Topic | 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">{{ TotalMessageCount | Format "N0" }} messages</div>
<div class="info__channel-message-count">{{ Model.Messages | array.size | Format "N0" }} messages</div>
{{~ if From || To ~}}
{{~ if Model.From || Model.To ~}}
<div class="info__channel-date-range">
{{~ if From && To ~}}
Between {{ From | FormatDate | html.escape }} and {{ To | FormatDate | html.escape }}
{{~ else if From ~}}
After {{ From | FormatDate | html.escape }}
{{~ else if To ~}}
Before {{ To | FormatDate | html.escape }}
{{~ 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 ~}}
@@ -42,7 +45,7 @@
{{~ # Log ~}}
<div class="chatlog">
{{~ for group in MessageGroups ~}}
{{~ for group in Model.Messages | GroupMessages ~}}
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
@@ -58,7 +61,7 @@
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
{{ message.Content | FormatContent }}
<span class="markdown">{{ message.Content | FormatMarkdown }}</span>
{{~ # Edited timestamp ~}}
{{~ if message.EditedTimestamp ~}}
@@ -76,7 +79,7 @@
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
{{~ end ~}}
</a>
</div>
@@ -85,7 +88,7 @@
{{~ # 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-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
@@ -112,16 +115,16 @@
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a>
{{~ else ~}}
{{ embed.Title | FormatContent }}
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ # Fields ~}}
@@ -130,10 +133,10 @@
{{~ 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>
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div>
{{~ end ~}}
</div>
{{~ end ~}}
@@ -192,15 +195,7 @@
<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>
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}" />
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
@@ -1,25 +0,0 @@
{{~ # Info ~}}
==============================================================
Guild: {{ Guild.Name }}
Channel: {{ Channel.Name }}
Topic: {{ Channel.Topic }}
Messages: {{ TotalMessageCount | Format "N0" }}
Range: {{ if From }}{{ From | FormatDate }} {{ end }}{{ if From || To }}->{{ end }}{{ if To }} {{ To | FormatDate }}{{ end }}
==============================================================
{{~ # Log ~}}
{{~ for group in MessageGroups ~}}
{{~ # Author name and timestamp ~}}
{{~ }}[{{ group.Timestamp | FormatDate }}] {{ group.Author.FullName }}
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ message.Content | FormatContent }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ end ~}}
{{~ end ~}}
@@ -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 | FormatMarkdown }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ end ~}}
@@ -1,6 +1,7 @@
using System;
using System.Drawing;
using System.Linq;
using ByteSizeLib;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq;
@@ -33,6 +34,7 @@ namespace DiscordChatExporter.Core.Services
{
// Get basic data
var id = json["id"].Value<string>();
var parentId = json["parent_id"]?.Value<string>();
var type = (ChannelType) json["type"].Value<int>();
var topic = json["topic"]?.Value<string>();
@@ -50,7 +52,7 @@ namespace DiscordChatExporter.Core.Services
if (name.IsBlank())
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
return new Channel(id, guildId, name, topic, type);
return new Channel(id, parentId, guildId, name, topic, type);
}
private Role ParseRole(JToken json)
@@ -68,7 +70,9 @@ namespace DiscordChatExporter.Core.Services
var width = json["width"]?.Value<int>();
var height = json["height"]?.Value<int>();
var fileName = json["filename"].Value<string>();
var fileSize = json["size"].Value<long>();
var fileSizeBytes = json["size"].Value<long>();
var fileSize = ByteSize.FromBytes(fileSizeBytes);
return new Attachment(id, width, height, url, fileName, fileSize);
}
@@ -139,13 +143,21 @@ namespace DiscordChatExporter.Core.Services
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
}
private Emoji ParseEmoji(JToken json)
{
var id = json["id"]?.Value<string>();
var name = json["name"]?.Value<string>();
var isAnimated = json["animated"]?.Value<bool>() ?? false;
return new Emoji(id, name, isAnimated);
}
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>();
var emoji = ParseEmoji(json["emoji"]);
return new Reaction(count, emojiId, emojiName);
return new Reaction(count, emoji);
}
private Message ParseMessage(JToken json)
@@ -1,64 +1,89 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
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 Failsafe;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService : IDataService, IDisposable
public partial class DataService : IDisposable
{
private readonly HttpClient _httpClient = new HttpClient();
private async Task<JToken> GetApiResponseAsync(string token, string resource, string endpoint,
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
params string[] parameters)
{
// Format URL
const string apiRoot = "https://discordapp.com/api/v6";
var url = $"{apiRoot}/{resource}/{endpoint}?token={token}";
// Add parameters
foreach (var parameter in parameters)
url += $"&{parameter}";
// Create request policy
var policy = Policy
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
// Create retry policy
var retry = Retry.Create()
.Catch<HttpErrorStatusCodeException>(false, e => e.StatusCode == HttpStatusCode.ServiceUnavailable)
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode == 429)
.WithMaxTryCount(10)
.WithDelay(TimeSpan.FromSeconds(0.4));
// Send request
return await policy.ExecuteAsync(async () =>
return await retry.ExecuteAsync(async () =>
{
using (var response = await _httpClient.GetAsync(url))
// Create request
const string apiRoot = "https://discordapp.com/api/v6";
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
{
// 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);
// Set authorization header
request.Headers.Authorization = token.Type == AuthTokenType.Bot
? new AuthenticationHeaderValue("Bot", token.Value)
: new AuthenticationHeaderValue(token.Value);
// Get content
var raw = await response.Content.ReadAsStringAsync();
// Add parameters
foreach (var parameter in parameters.ExceptBlank())
{
var key = parameter.SubstringUntil("=");
var value = parameter.SubstringAfter("=");
// Parse
return JToken.Parse(raw);
// Skip empty values
if (value.IsBlank())
continue;
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(string token, string guildId)
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(string token, string channelId)
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
{
var response = await GetApiResponseAsync(token, "channels", channelId);
var channel = ParseChannel(response);
@@ -66,7 +91,7 @@ namespace DiscordChatExporter.Core.Services
return channel;
}
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token)
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
{
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
var guilds = response.Select(ParseGuild).ToArray();
@@ -74,7 +99,7 @@ namespace DiscordChatExporter.Core.Services
return guilds;
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
{
var response = await GetApiResponseAsync(token, "users", "@me/channels");
var channels = response.Select(ParseChannel).ToArray();
@@ -82,7 +107,7 @@ namespace DiscordChatExporter.Core.Services
return channels;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
{
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
var channels = response.Select(ParseChannel).ToArray();
@@ -90,7 +115,7 @@ namespace DiscordChatExporter.Core.Services
return channels;
}
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId)
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
{
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
var roles = response.Select(ParseRole).ToArray();
@@ -98,21 +123,14 @@ namespace DiscordChatExporter.Core.Services
return roles;
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
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}");
"limit=1", $"before={to?.ToSnowflake()}");
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
// If the last message doesn't exist or it's outside of range - return
@@ -123,7 +141,7 @@ namespace DiscordChatExporter.Core.Services
}
// Get other messages
var offsetId = firstId;
var offsetId = from?.ToSnowflake() ?? "0";
while (true)
{
// Get message batch
@@ -169,7 +187,7 @@ namespace DiscordChatExporter.Core.Services
return result;
}
public async Task<Mentionables> GetMentionablesAsync(string token, string guildId,
public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages)
{
// Get channels and roles
@@ -197,6 +215,40 @@ namespace DiscordChatExporter.Core.Services
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, Channel channel,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
// 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 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 the chat log
return await GetChatLogAsync(token, 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;
}
}
}
}
@@ -20,7 +20,7 @@ namespace DiscordChatExporter.Core.Services
public string GetPath(ExportFormat format)
{
return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}";
return $"{ResourceRootNamespace}.{format}.Template.{format.GetFileExtension()}";
}
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
@@ -1,10 +1,10 @@
using System;
using System.Drawing;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Text;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Models;
using Scriban.Runtime;
using Tyrrrz.Extensions;
@@ -26,279 +26,179 @@ namespace DiscordChatExporter.Core.Services
_dateFormat = dateFormat;
}
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
=> messages.GroupAdjacentWhile((buffer, message) =>
{
// Break group if the author changed
if (buffer.Last().Author.Id != message.Author.Id)
return false;
private string Format(IFormattable obj, string format) =>
obj.ToString(format, CultureInfo.InvariantCulture);
// Break group if last message was more than 7 minutes ago
if ((message.Timestamp - buffer.Last().Timestamp).TotalMinutes > 7)
return false;
return true;
}).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g));
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)
private string FormatMarkdownPlainText(IReadOnlyList<Node> nodes)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
var unit = 0;
var buffer = new StringBuilder();
while (size >= 1024)
foreach (var node in nodes)
{
size /= 1024;
++unit;
if (node is FormattedNode formattedNode)
{
var innerText = FormatMarkdownPlainText(formattedNode.Children);
buffer.Append($"{formattedNode.Token}{innerText}{formattedNode.Token}");
}
else if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
{
if (mentionNode.Type == MentionType.User)
{
var user = _log.Mentionables.GetUser(mentionNode.Id);
buffer.Append($"@{user.Name}");
}
else if (mentionNode.Type == MentionType.Channel)
{
var channel = _log.Mentionables.GetChannel(mentionNode.Id);
buffer.Append($"#{channel.Name}");
}
else if (mentionNode.Type == MentionType.Role)
{
var role = _log.Mentionables.GetRole(mentionNode.Id);
buffer.Append($"@{role.Name}");
}
}
else if (node is EmojiNode emojiNode)
{
buffer.Append(emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : node.Lexeme);
}
else
{
buffer.Append(node.Lexeme);
}
}
return $"{size:0.#} {units[unit]}";
return buffer.ToString();
}
private string FormatColor(Color color)
private string FormatMarkdownPlainText(string input)
=> FormatMarkdownPlainText(MarkdownParser.Parse(input));
private string FormatMarkdownHtml(IReadOnlyList<Node> nodes, int depth = 0)
{
return $"{color.R},{color.G},{color.B},{color.A}";
var buffer = new StringBuilder();
foreach (var node in nodes)
{
if (node is TextNode textNode)
{
buffer.Append(textNode.Text.HtmlEncode());
}
else if (node is FormattedNode formattedNode)
{
var innerHtml = FormatMarkdownHtml(formattedNode.Children, depth + 1);
if (formattedNode.Formatting == TextFormatting.Bold)
buffer.Append($"<strong>{innerHtml}</strong>");
else if (formattedNode.Formatting == TextFormatting.Italic)
buffer.Append($"<em>{innerHtml}</em>");
else if (formattedNode.Formatting == TextFormatting.Underline)
buffer.Append($"<u>{innerHtml}</u>");
else if (formattedNode.Formatting == TextFormatting.Strikethrough)
buffer.Append($"<s>{innerHtml}</s>");
else if (formattedNode.Formatting == TextFormatting.Spoiler)
buffer.Append($"<span class=\"spoiler\">{innerHtml}</span>");
}
else if (node is InlineCodeBlockNode inlineCodeBlockNode)
{
buffer.Append($"<span class=\"pre pre--inline\">{inlineCodeBlockNode.Code.HtmlEncode()}</span>");
}
else if (node is MultilineCodeBlockNode multilineCodeBlockNode)
{
// Set language class for syntax highlighting
var languageCssClass = multilineCodeBlockNode.Language.IsNotBlank()
? "language-" + multilineCodeBlockNode.Language
: null;
buffer.Append(
$"<div class=\"pre pre--multiline {languageCssClass}\">{multilineCodeBlockNode.Code.HtmlEncode()}</div>");
}
else if (node is MentionNode mentionNode)
{
if (mentionNode.Type == MentionType.Meta)
{
buffer.Append($"<span class=\"mention\">@{mentionNode.Id.HtmlEncode()}</span>");
}
else if (mentionNode.Type == MentionType.User)
{
var user = _log.Mentionables.GetUser(mentionNode.Id);
buffer.Append($"<span class=\"mention\" title=\"{user.FullName}\">@{user.Name.HtmlEncode()}</span>");
}
else if (mentionNode.Type == MentionType.Channel)
{
var channel = _log.Mentionables.GetChannel(mentionNode.Id);
buffer.Append($"<span class=\"mention\">#{channel.Name.HtmlEncode()}</span>");
}
else if (mentionNode.Type == MentionType.Role)
{
var role = _log.Mentionables.GetRole(mentionNode.Id);
buffer.Append($"<span class=\"mention\">@{role.Name.HtmlEncode()}</span>");
}
}
else if (node is EmojiNode emojiNode)
{
// Get emoji image URL
var emojiImageUrl = new Emoji(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated).ImageUrl;
// Emoji can be jumboable if it's the only top-level node
var jumboableCssClass = depth == 0 && nodes.Count == 1
? "emoji--large"
: null;
buffer.Append($"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />");
}
else if (node is LinkNode linkNode)
{
var escapedUrl = Uri.EscapeUriString(linkNode.Url);
buffer.Append($"<a href=\"{escapedUrl}\">{linkNode.Title.HtmlEncode()}</a>");
}
}
return buffer.ToString();
}
private string FormatContentPlainText(string content)
private string FormatMarkdownHtml(string input)
=> FormatMarkdownHtml(MarkdownParser.Parse(input));
private string FormatMarkdown(string input)
{
// 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, "&lt;@!?(\\d+)&gt;")
.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, $"&lt;@!?{mentionedUserId}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "&lt;#(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"&lt;#{mentionedChannelId}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "&lt;@&amp;(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"&lt;@&amp;{mentionedRoleId}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
var isJumboable = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;", "").IsBlank();
var emojiClass = isJumboable ? "emoji emoji--large" : "emoji";
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
$"<img class=\"{emojiClass}\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
private string FormatContentCsv(string content)
{
// New lines
content = content.Replace("\n", ", ");
// 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));
return _format == ExportFormat.HtmlDark || _format == ExportFormat.HtmlLight
? FormatMarkdownHtml(input)
: FormatMarkdownPlainText(input);
}
public ScriptObject GetScriptObject()
@@ -306,15 +206,14 @@ namespace DiscordChatExporter.Core.Services
// Create instance
var scriptObject = new ScriptObject();
// Import chat log
scriptObject.Import(_log, TemplateMemberFilter, TemplateMemberRenamer);
// Import model
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));
scriptObject.Import(nameof(FormatMarkdown), new Func<string, string>(FormatMarkdown));
return scriptObject;
}
@@ -1,23 +1,24 @@
using System.IO;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService : IExportService
public partial class ExportService
{
private static readonly MemberRenamerDelegate TemplateMemberRenamer = m => m.Name;
private static readonly MemberFilterDelegate TemplateMemberFilter = m => true;
private readonly SettingsService _settingsService;
private readonly ISettingsService _settingsService;
public ExportService(ISettingsService settingsService)
public ExportService(SettingsService settingsService)
{
_settingsService = settingsService;
}
public void Export(ExportFormat format, string filePath, ChatLog log)
private void ExportChatLogSingle(ChatLog chatLog, string filePath, ExportFormat format)
{
// Create template loader
var loader = new TemplateLoader();
@@ -30,22 +31,76 @@ namespace DiscordChatExporter.Core.Services
var context = new TemplateContext
{
TemplateLoader = loader,
MemberRenamer = TemplateMemberRenamer,
MemberFilter = TemplateMemberFilter
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
// Create template model
var templateModel = new TemplateModel(format, log, _settingsService.DateFormat);
var templateModel = new TemplateModel(format, chatLog, _settingsService.DateFormat);
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 template
template.Render(context);
// Render output
context.Evaluate(template.Page);
}
}
private void ExportChatLogPartitioned(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} of {partitions.Count}]{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 || partitionLimit <= 0 || chatLog.Messages.Count <= partitionLimit)
{
ExportChatLogSingle(chatLog, filePath, format);
}
// Otherwise split into partitions and export separately
else
{
// Create partitions by grouping up to X adjacent messages into separate chat logs
var partitions = chatLog.Messages.GroupAdjacentWhile(g => g.Count < partitionLimit.Value)
.Select(g => new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, g, chatLog.Mentionables))
.ToArray();
ExportChatLogPartitioned(partitions, filePath, format);
}
}
}
@@ -1,28 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IDataService
{
Task<Guild> GetGuildAsync(string token, string guildId);
Task<Channel> GetChannelAsync(string token, string channelId);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<Mentionables> GetMentionablesAsync(string token, string guildId,
IEnumerable<Message> messages);
}
}
@@ -1,9 +0,0 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IExportService
{
void Export(ExportFormat format, string filePath, ChatLog log);
}
}
@@ -1,10 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IMessageGroupService
{
IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages);
}
}
@@ -1,18 +0,0 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface ISettingsService
{
bool IsAutoUpdateEnabled { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
string LastToken { get; set; }
ExportFormat LastExportFormat { get; set; }
void Load();
void Save();
}
}
@@ -1,14 +0,0 @@
using System;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Services
{
public interface IUpdateService
{
bool NeedRestart { get; set; }
Task<Version> CheckPrepareUpdateAsync();
void FinalizeUpdate();
}
}
@@ -1,59 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public class MessageGroupService : IMessageGroupService
{
private readonly ISettingsService _settingsService;
public MessageGroupService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public IReadOnlyList<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
var breakCondition =
groupFirst != null &&
(
message.Author.Id != groupFirst.Author.Id ||
(message.Timestamp - groupFirst.Timestamp).TotalHours > 1 ||
message.Timestamp.Hour != groupFirst.Timestamp.Hour ||
groupBuffer.Count >= _settingsService.MessageGroupLimit
);
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
result.Add(group);
groupBuffer.Clear();
}
// Add message to buffer
groupBuffer.Add(message);
}
// Add what's remaining in buffer
if (groupBuffer.Any())
{
var groupFirst = groupBuffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
result.Add(group);
}
return result;
}
}
}
@@ -3,15 +3,15 @@ using Tyrrrz.Settings;
namespace DiscordChatExporter.Core.Services
{
public class SettingsService : SettingsManager, ISettingsService
public class SettingsService : SettingsManager
{
public bool IsAutoUpdateEnabled { get; set; } = true;
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20;
public string LastToken { get; set; }
public AuthToken LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public int? LastPartitionLimit { get; set; }
public SettingsService()
{
@@ -5,23 +5,20 @@ using Onova.Services;
namespace DiscordChatExporter.Core.Services
{
public class UpdateService : IUpdateService
public class UpdateService
{
private readonly ISettingsService _settingsService;
private readonly IUpdateManager _manager;
private readonly SettingsService _settingsService;
private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
private Version _updateVersion;
private bool _updateFinalized;
private bool _updaterLaunched;
public bool NeedRestart { get; set; }
public UpdateService(ISettingsService settingsService)
public UpdateService(SettingsService settingsService)
{
_settingsService = settingsService;
_manager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
}
public async Task<Version> CheckPrepareUpdateAsync()
@@ -31,33 +28,33 @@ namespace DiscordChatExporter.Core.Services
return null;
// Cleanup leftover files
_manager.Cleanup();
_updateManager.Cleanup();
// Check for updates
var check = await _manager.CheckForUpdatesAsync();
var check = await _updateManager.CheckForUpdatesAsync();
if (!check.CanUpdate)
return null;
// Prepare the update
if (!_manager.IsUpdatePrepared(check.LastVersion))
await _manager.PrepareUpdateAsync(check.LastVersion);
if (!_updateManager.IsUpdatePrepared(check.LastVersion))
await _updateManager.PrepareUpdateAsync(check.LastVersion);
return _updateVersion = check.LastVersion;
}
public void FinalizeUpdate()
public void FinalizeUpdate(bool needRestart)
{
// Check if an update is pending
if (_updateVersion == null)
return;
// Check if the update has already been finalized
if (_updateFinalized)
// Check if the updater has already been launched
if (_updaterLaunched)
return;
// Launch the updater
_manager.LaunchUpdater(_updateVersion, NeedRestart);
_updateFinalized = true;
_updateManager.LaunchUpdater(_updateVersion, needRestart);
_updaterLaunched = true;
}
}
}
+46 -13
View File
@@ -2,17 +2,19 @@
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"
Exit="App_Exit"
Startup="App_Startup"
StartupUri="Views/MainWindow.xaml">
xmlns:s="https://github.com/canton7/Stylet">
<Application.Resources>
<ResourceDictionary>
<s:ApplicationLoader>
<!-- Bootstrapper -->
<s:ApplicationLoader.Bootstrapper>
<local:Bootstrapper />
</s:ApplicationLoader.Bootstrapper>
<!-- Merged dictionaries -->
<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 -->
@@ -75,9 +77,16 @@
Color="{DynamicResource InverseTextColor}" />
<!-- Styles -->
<Style TargetType="{x:Type Image}">
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
<Setter Property="RenderOptions.EdgeMode" Value="Aliased" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="TextElement.FontSize" Value="13" />
<Setter Property="TextElement.FontWeight" Value="Regular" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource SecondaryTextBrush}" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
<Setter Property="UseLayoutRounding" Value="True" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
@@ -96,11 +105,35 @@
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<!-- Converters -->
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<!-- Container -->
<local:Container x:Key="Container" />
</ResourceDictionary>
<Style
x:Key="MaterialDesignFlatDarkButton"
BasedOn="{StaticResource MaterialDesignFlatButton}"
TargetType="{x:Type Button}">
<Style.Resources>
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
</Style.Resources>
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidForegroundBrush}" />
</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>
</s:ApplicationLoader>
</Application.Resources>
</Application>
+2 -15
View File
@@ -1,19 +1,6 @@
using System.Windows;
namespace DiscordChatExporter.Gui
namespace DiscordChatExporter.Gui
{
public partial class App
{
private Container Container => (Container) Resources["Container"];
private void App_Startup(object sender, StartupEventArgs e)
{
Container.Init();
}
private void App_Exit(object sender, ExitEventArgs e)
{
Container.Cleanup();
}
{
}
}
@@ -0,0 +1,8 @@
using DiscordChatExporter.Gui.ViewModels.Components;
namespace DiscordChatExporter.Gui.Behaviors
{
public class ChannelViewModelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<ChannelViewModel>
{
}
}
@@ -0,0 +1,92 @@
using System.Collections;
using System.Collections.Specialized;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interactivity;
namespace DiscordChatExporter.Gui.Behaviors
{
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
{
public static readonly DependencyProperty SelectedItemsProperty =
DependencyProperty.Register(nameof(SelectedItems), typeof(IList),
typeof(MultiSelectionListBoxBehavior<T>),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedItemsChanged));
private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
var behavior = (MultiSelectionListBoxBehavior<T>) sender;
if (behavior._modelHandled) return;
if (behavior.AssociatedObject == null)
return;
behavior._modelHandled = true;
behavior.SelectItems();
behavior._modelHandled = false;
}
private bool _viewHandled;
private bool _modelHandled;
public IList SelectedItems
{
get => (IList) GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, value);
}
// Propagate selected items from model to view
private void SelectItems()
{
_viewHandled = true;
AssociatedObject.SelectedItems.Clear();
if (SelectedItems != null)
{
foreach (var item in SelectedItems)
AssociatedObject.SelectedItems.Add(item);
}
_viewHandled = false;
}
// Propagate selected items from view to model
private void OnListBoxSelectionChanged(object sender, SelectionChangedEventArgs args)
{
if (_viewHandled) return;
if (AssociatedObject.Items.SourceCollection == null) return;
SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray();
}
// Re-select items when the set of items changes
private void OnListBoxItemsChanged(object sender, NotifyCollectionChangedEventArgs args)
{
if (_viewHandled) return;
if (AssociatedObject.Items.SourceCollection == null) return;
SelectItems();
}
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.SelectionChanged += OnListBoxSelectionChanged;
((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged += OnListBoxItemsChanged;
}
/// <inheritdoc />
protected override void OnDetaching()
{
base.OnDetaching();
if (AssociatedObject != null)
{
AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged;
((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged -= OnListBoxItemsChanged;
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Windows;
using System.Windows.Threading;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Stylet;
using StyletIoC;
namespace DiscordChatExporter.Gui
{
public class Bootstrapper : Bootstrapper<RootViewModel>
{
protected override void ConfigureIoC(IStyletIoCBuilder builder)
{
base.ConfigureIoC(builder);
// Autobind services in the .Core assembly
builder.Autobind(typeof(DataService).Assembly);
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Bind view model factory
builder.Bind<IViewModelFactory>().ToAbstractFactory();
}
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
{
base.OnUnhandledException(e);
MessageBox.Show(e.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
-41
View File
@@ -1,41 +0,0 @@
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>();
private T Resolve<T>(string key = null)
{
return ServiceLocator.Current.GetInstance<T>(key);
}
public void Init()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
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);
}
public void Cleanup()
{
}
}
}
@@ -8,10 +8,12 @@ namespace DiscordChatExporter.Gui.Converters
[ValueConversion(typeof(ExportFormat), typeof(string))]
public class ExportFormatToStringConverter : IValueConverter
{
public static ExportFormatToStringConverter Instance { get; } = new ExportFormatToStringConverter();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var format = (ExportFormat) value;
return format.GetDisplayName();
var format = value as ExportFormat?;
return format?.GetDisplayName();
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
@@ -42,39 +42,11 @@
<StartupObject />
</PropertyGroup>
<ItemGroup>
<Reference Include="CommonServiceLocator, Version=2.0.3.0, Culture=neutral, PublicKeyToken=489b6accfaf20ef0, processorArchitecture=MSIL">
<HintPath>..\packages\CommonServiceLocator.2.0.3\lib\net45\CommonServiceLocator.dll</HintPath>
</Reference>
<Reference Include="GalaSoft.MvvmLight, Version=5.4.1.0, Culture=neutral, PublicKeyToken=e7570ab207bcb616, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.dll</HintPath>
</Reference>
<Reference Include="GalaSoft.MvvmLight.Extras, Version=5.4.1.0, Culture=neutral, PublicKeyToken=669f0b5e8f868abf, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Extras.dll</HintPath>
</Reference>
<Reference Include="GalaSoft.MvvmLight.Platform, Version=5.4.1.0, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
</Reference>
<Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath>
</Reference>
<Reference Include="MaterialDesignThemes.Wpf, Version=2.4.0.1044, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignThemes.2.4.0.1044\lib\net45\MaterialDesignThemes.Wpf.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Windows.Interactivity, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\System.Windows.Interactivity.dll</HintPath>
</Reference>
<Reference Include="System.Xaml">
<RequiredTargetFramework>4.0</RequiredTargetFramework>
</Reference>
<Reference Include="Tyrrrz.Extensions, Version=1.5.1.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Extensions.1.5.1\lib\net45\Tyrrrz.Extensions.dll</HintPath>
</Reference>
<Reference Include="Tyrrrz.Settings, Version=1.3.2.0, Culture=neutral, PublicKeyToken=null" />
<Reference Include="Tyrrrz.WpfExtensions, Version=1.0.6269.37170, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.WpfExtensions.1.0.5\lib\net45\Tyrrrz.WpfExtensions.dll</HintPath>
</Reference>
<Reference Include="WindowsBase" />
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
@@ -83,26 +55,27 @@
<Compile Include="App.xaml.cs">
<DependentUpon>App.xaml</DependentUpon>
</Compile>
<Compile Include="Behaviors\ChannelViewModelMultiSelectionListBoxBehavior.cs" />
<Compile Include="Behaviors\MultiSelectionListBoxBehavior.cs" />
<Compile Include="Bootstrapper.cs" />
<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 Include="ViewModels\Components\ChannelViewModel.cs" />
<Compile Include="ViewModels\Components\GuildViewModel.cs" />
<Compile Include="ViewModels\Dialogs\ExportSetupViewModel.cs" />
<Compile Include="ViewModels\Framework\DialogManager.cs" />
<Compile Include="ViewModels\Framework\DialogScreen.cs" />
<Compile Include="ViewModels\Framework\Extensions.cs" />
<Compile Include="ViewModels\Framework\IViewModelFactory.cs" />
<Compile Include="ViewModels\Dialogs\SettingsViewModel.cs" />
<Compile Include="ViewModels\RootViewModel.cs" />
<Compile Include="Views\Dialogs\ExportSetupView.xaml.cs">
<DependentUpon>ExportSetupView.xaml</DependentUpon>
</Compile>
<Compile Include="Views\MainWindow.xaml.cs">
<DependentUpon>MainWindow.xaml</DependentUpon>
<Compile Include="Views\RootView.xaml.cs">
<DependentUpon>RootView.xaml</DependentUpon>
</Compile>
<Compile Include="Views\SettingsDialog.xaml.cs">
<DependentUpon>SettingsDialog.xaml</DependentUpon>
<Compile Include="Views\Dialogs\SettingsView.xaml.cs">
<DependentUpon>SettingsView.xaml</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
@@ -118,10 +91,6 @@
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="app.config" />
<None Include="packages.config">
<SubType>Designer</SubType>
</None>
</ItemGroup>
<ItemGroup>
<Resource Include="..\favicon.ico" />
@@ -137,19 +106,45 @@
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</ApplicationDefinition>
<Page Include="Views\ExportSetupDialog.xaml">
<Page Include="Views\Dialogs\ExportSetupView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\MainWindow.xaml">
<Page Include="Views\RootView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\SettingsDialog.xaml">
<Page Include="Views\Dialogs\SettingsView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Gress">
<Version>1.0.2</Version>
</PackageReference>
<PackageReference Include="MaterialDesignColors">
<Version>1.1.3</Version>
</PackageReference>
<PackageReference Include="MaterialDesignThemes">
<Version>2.5.0.1205</Version>
</PackageReference>
<PackageReference Include="Ookii.Dialogs.Wpf">
<Version>1.0.0</Version>
</PackageReference>
<PackageReference Include="PropertyChanged.Fody">
<Version>2.6.0</Version>
</PackageReference>
<PackageReference Include="Stylet">
<Version>1.1.22</Version>
</PackageReference>
<PackageReference Include="System.Windows.Interactivity.WPF">
<Version>2.0.20525</Version>
</PackageReference>
<PackageReference Include="Tyrrrz.Extensions">
<Version>1.5.1</Version>
</PackageReference>
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<PropertyChanged />
</Weavers>
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuild. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="PropertyChanged" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:attribute name="InjectOnPropertyNameChanged" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="EventInvokerNames" type="xs:string">
<xs:annotation>
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="CheckForEquality" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="CheckForEqualityUsingBaseEquals" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should use the Equals method resolved from the base class.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UseStaticEqualsFromBase" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if equality checks should use the static Equals method resolved from the base class.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification on the target assembly after all weavers have been finished.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>
@@ -1,17 +0,0 @@
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;
}
}
}
@@ -1,25 +0,0 @@
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;
}
}
}
@@ -1,6 +0,0 @@
namespace DiscordChatExporter.Gui.Messages
{
public class ShowSettingsMessage
{
}
}
@@ -1,28 +0,0 @@
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 StartExportMessage(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to)
{
Channel = channel;
FilePath = filePath;
Format = format;
From = from;
To = to;
}
}
}
@@ -2,6 +2,6 @@
[assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
[assembly: AssemblyVersion("2.5.1")]
[assembly: AssemblyFileVersion("2.5.1")]
[assembly: AssemblyCopyright("Copyright (c) Alexey Golub")]
[assembly: AssemblyVersion("2.10.2")]
[assembly: AssemblyFileVersion("2.10.2")]
@@ -0,0 +1,17 @@
using DiscordChatExporter.Core.Models;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Components
{
public partial class ChannelViewModel : PropertyChangedBase
{
public Channel Model { get; set; }
public string Category { get; set; }
}
public partial class ChannelViewModel
{
public static implicit operator Channel(ChannelViewModel viewModel) => viewModel.Model;
}
}
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Components
{
public partial class GuildViewModel : PropertyChangedBase
{
public Guild Model { get; set; }
public IReadOnlyList<ChannelViewModel> Channels { get; set; }
}
public partial class GuildViewModel
{
public static implicit operator Guild(GuildViewModel viewModel) => viewModel.Model;
}
}
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Core.Helpers;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{
public class ExportSetupViewModel : DialogScreen
{
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
public GuildViewModel Guild { get; set; }
public IReadOnlyList<ChannelViewModel> Channels { get; set; }
public bool IsSingleChannel => Channels.Count == 1;
public string OutputPath { get; set; }
public IReadOnlyList<ExportFormat> AvailableFormats =>
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public ExportFormat SelectedFormat { get; set; } = ExportFormat.HtmlDark;
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public int? PartitionLimit { get; set; }
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
{
_dialogManager = dialogManager;
_settingsService = settingsService;
}
protected override void OnViewLoaded()
{
base.OnViewLoaded();
// Persist preferences
SelectedFormat = _settingsService.LastExportFormat;
PartitionLimit = _settingsService.LastPartitionLimit;
}
public void Confirm()
{
// Persist preferences
_settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimit = PartitionLimit;
// Clamp 'from' and 'to' values
if (From > To)
From = To;
if (To < From)
To = From;
// If single channel - prompt file path
if (IsSingleChannel)
{
// Get single channel
var channel = Channels.Single();
// Generate default file name
var defaultFileName = ExportHelper.GetDefaultExportFileName(SelectedFormat, Guild, channel, From, To);
// Generate filter
var ext = SelectedFormat.GetFileExtension();
var filter = $"{ext.ToUpperInvariant()} files|*.{ext}";
// Prompt user
OutputPath = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
}
// If multiple channels - prompt dir path
else
{
// Prompt user
OutputPath = _dialogManager.PromptDirectoryPath();
}
// If canceled - return
if (OutputPath.IsBlank())
return;
// Close dialog
Close(true);
}
}
}
@@ -0,0 +1,27 @@
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels.Framework;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{
public class SettingsViewModel : DialogScreen
{
private readonly SettingsService _settingsService;
public bool IsAutoUpdateEnabled
{
get => _settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value;
}
public string DateFormat
{
get => _settingsService.DateFormat;
set => _settingsService.DateFormat = value;
}
public SettingsViewModel(SettingsService settingsService)
{
_settingsService = settingsService;
}
}
}
@@ -1,106 +0,0 @@
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;
public Guild Guild { get; private set; }
public Channel Channel { get; private set; }
public string FilePath
{
get => _filePath;
set
{
Set(ref _filePath, value);
ExportCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<ExportFormat> AvailableFormats { get; }
public ExportFormat SelectedFormat
{
get => _format;
set
{
Set(ref _format, value);
// Replace extension in path
var 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);
}
// Commands
public RelayCommand ExportCommand { get; }
public ExportSetupViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
// Defaults
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
// Commands
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
// Messages
MessengerInstance.Register<ShowExportSetupMessage>(this, m =>
{
Guild = m.Guild;
Channel = m.Channel;
SelectedFormat = _settingsService.LastExportFormat;
FilePath = $"{Guild.Name} - {Channel.Name}.{SelectedFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
From = null;
To = null;
});
}
private void Export()
{
// Save format
_settingsService.LastExportFormat = SelectedFormat;
// 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));
}
}
}
@@ -0,0 +1,71 @@
using System.IO;
using System.Threading.Tasks;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
using Ookii.Dialogs.Wpf;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework
{
public class DialogManager
{
private readonly IViewManager _viewManager;
public DialogManager(IViewManager viewManager)
{
_viewManager = viewManager;
}
public async Task<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
{
// Get the view that renders this viewmodel
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
// Set up event routing that will close the view when called from viewmodel
DialogOpenedEventHandler onDialogOpened = (sender, e) =>
{
// Delegate to close the dialog and unregister event handler
void OnScreenClosed(object o, CloseEventArgs args)
{
e.Session.Close();
dialogScreen.Closed -= OnScreenClosed;
}
dialogScreen.Closed += OnScreenClosed;
};
// Show view
await DialogHost.Show(view, onDialogOpened);
// Return the result
return dialogScreen.DialogResult;
}
public string PromptSaveFilePath(string filter = "All files|*.*", string initialFilePath = "")
{
// Create dialog
var dialog = new SaveFileDialog
{
Filter = filter,
AddExtension = true,
FileName = initialFilePath,
DefaultExt = Path.GetExtension(initialFilePath) ?? ""
};
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
public string PromptDirectoryPath(string initialDirPath = "")
{
// Create dialog
var dialog = new VistaFolderBrowserDialog
{
SelectedPath = initialDirPath
};
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
}
}
}
@@ -0,0 +1,26 @@
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework
{
public abstract class DialogScreen<T> : Screen
{
public T DialogResult { get; private set; }
public void Close(T dialogResult = default(T))
{
// Set the result
DialogResult = dialogResult;
// If there is a parent - ask them to close this dialog
if (Parent != null)
RequestClose(Equals(dialogResult, default(T)));
// Otherwise close ourselves
else
((IScreenState) this).Close();
}
}
public abstract class DialogScreen : DialogScreen<bool?>
{
}
}
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.ViewModels.Framework
{
public static class Extensions
{
public static ChannelViewModel CreateChannelViewModel(this IViewModelFactory factory, Channel model,
string category = null)
{
var viewModel = factory.CreateChannelViewModel();
viewModel.Model = model;
viewModel.Category = category;
return viewModel;
}
public static GuildViewModel CreateGuildViewModel(this IViewModelFactory factory, Guild model,
IReadOnlyList<ChannelViewModel> channels)
{
var viewModel = factory.CreateGuildViewModel();
viewModel.Model = model;
viewModel.Channels = channels;
return viewModel;
}
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
GuildViewModel guild, IReadOnlyList<ChannelViewModel> channels)
{
var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
GuildViewModel guild, ChannelViewModel channel)
=> factory.CreateExportSetupViewModel(guild, new[] {channel});
}
}
@@ -0,0 +1,17 @@
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.ViewModels.Framework
{
// Used to instantiate new view models while making use of dependency injection
public interface IViewModelFactory
{
ChannelViewModel CreateChannelViewModel();
GuildViewModel CreateGuildViewModel();
ExportSetupViewModel CreateExportSetupViewModel();
SettingsViewModel CreateSettingsViewModel();
}
}
@@ -1,20 +0,0 @@
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; }
RelayCommand ExportCommand { get; }
}
}

Some files were not shown because too many files have changed in this diff Show More