Compare commits

...

71 Commits

Author SHA1 Message Date
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
Alexey Golub b9c449709f Update version 2018-07-01 21:12:06 +03:00
Alexey Golub 25207c5093 Improve the templates a bit 2018-07-01 21:04:03 +03:00
Alexey Golub 4fbec95208 Fix CSV and PlainText outputs not rendering timestamps correctly
Fixes #65
2018-07-01 16:53:33 +03:00
92 changed files with 2480 additions and 1725 deletions
+42
View File
@@ -1,3 +1,45 @@
### 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.
- Fixed incorrect layout in CSV export.
- Improved readability of PlainText export.
### v2.5 (28-Jun-2018)
- Added support for embeds and webhooks (HTML only).
+1 -1
View File
@@ -8,4 +8,4 @@ $files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporte
# CLI
$files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.Cli.zip" -Force
$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</Version>
<Version>2.9</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 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>
+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,54 @@
using System;
using System.IO;
using System.Threading.Tasks;
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;
if (Options.MessageGroupLimit > 0)
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
Options.After, Options.Before);
// Generate file path if not set 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);
// Print result
Console.WriteLine($"Exported chat to [{filePath}]");
}
}
}
@@ -0,0 +1,76 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
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;
if (Options.MessageGroupLimit > 0)
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
// 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
{
// Print current channel name
Console.WriteLine($"Exporting chat from [{channel.Name}]...");
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before);
// 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);
// Print result
Console.WriteLine($"Exported chat to [{filePath}]");
}
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,77 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
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;
if (Options.MessageGroupLimit > 0)
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
// 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
{
// Print current channel name
Console.WriteLine($"Exporting chat from [{channel.Name}]...");
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before);
// 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);
// Print result
Console.WriteLine($"Exported chat to [{filePath}]");
}
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,30 @@
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; }
[Option("grouplimit", Default = 0, HelpText = "Message group limit.")]
public int MessageGroupLimit { get; set; }
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("channels", HelpText = "Get the list of channels in the given guild.")]
public class GetChannelsOptions : TokenOptions
{
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
public string GuildId { get; set; }
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("dm", HelpText = "Get the list of direct message channels.")]
public class GetDirectMessageChannelsOptions : TokenOptions
{
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("guilds", HelpText = "Get the list of accessible guilds.")]
public class GetGuildsOptions : TokenOptions
{
}
}
@@ -0,0 +1,16 @@
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
public 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);
}
}
}
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net461</TargetFramework>
<Version>2.5</Version>
<Version>2.9</Version>
</PropertyGroup>
<ItemGroup>
@@ -21,12 +21,12 @@
</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="Failsafe" Version="1.0.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>
</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();
}
}
}
@@ -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;
}
@@ -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";
}
}
+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,13 +1,10 @@
Author;Date;Content;Attachments;
{{~ for message in Model.Messages -}}
{{- }}"{{ message.Author.FullName }}";
{{- for group in MessageGroups -}}
{{- for message in group.Messages -}}
{{- message.Author.FullName }};
{{- }}"{{ message.Timestamp | FormatDate }}";
{{- message.TimeStamp | FormatDate }};
{{- }}"{{ message.Content | FormatContent }}";
{{- message.Content | FormatContent }};
{{- message.Attachments | array.map "Url" | array.join "," }};
{{~ end -}}
{{- end -}}
{{- }}"{{ message.Attachments | array.map "Url" | array.join "," }}";
{{~ end -}}
Can't render this file because it contains an unexpected character in line 11 and column 41.
@@ -2,208 +2,214 @@
<html lang="en">
<head>
<title>{{ Guild.Name | HtmlEncode }} - {{ Channel.Name | HtmlEncode }}</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 }}</style>
<style>
{{ StyleSheet }}
</style>
</head>
<body>
{{~ # Info }}
{{~ # 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 | HtmlEncode }}</div>
<div class="info__channel-name">{{ Channel.Name | HtmlEncode }}</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 | HtmlEncode }}</div>
{{ end }}
{{~ 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 | HtmlEncode }} and {{ To | FormatDate | HtmlEncode }}
{{ else if From }}
After {{ From | FormatDate | HtmlEncode }}
{{ else if To }}
Before {{ To | FormatDate | HtmlEncode }}
{{ end }}
{{~ 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 }}
{{~ end ~}}
</div>
</div>
{{~ # Log }}
{{~ # Log ~}}
<div class="chatlog">
{{ for group in MessageGroups }}
{{~ for group in Model.Messages | GroupMessages ~}}
<div class="chatlog__message-group">
{{~ # Avatar }}
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp }}
<span class="chatlog__author-name" title="{{ group.Author.FullName | HtmlEncode }}">{{ group.Author.Name | HtmlEncode }}</span>
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | HtmlEncode }}</span>
{{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}">{{ group.Author.Name | html.escape }}</span>
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages }}
{{ for message in group.Messages }}
{{~ # Content }}
{{ if message.Content }}
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
{{ message.Content | FormatContent }}
{{~ # Edited timestamp }}
{{ if message.EditedTimestamp }}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | HtmlEncode }}">(edited)</span>
{{ end }}
{{~ # Edited timestamp ~}}
{{~ if message.EditedTimestamp ~}}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
{{~ end ~}}
</div>
{{ end }}
{{~ end ~}}
{{~ # Attachments }}
{{ for attachment in message.Attachments }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment">
<a href="{{ attachment.Url }}">
{{~ # Image }}
{{ if attachment.IsImage }}
{{ # Image }}
{{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image }}
{{ else }}
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
{{ end }}
{{~ end ~}}
</a>
</div>
{{ end }}
{{~ end ~}}
{{~ # Embeds }}
{{ for embed in message.Embeds }}
{{~ # Embeds ~}}
{{~ for embed in message.Embeds ~}}
<div class="chatlog__embed">
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color | FormatColor }})"></div>
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
{{~ # Author }}
{{ if embed.Author }}
{{~ # Author ~}}
{{~ if embed.Author ~}}
<div class="chatlog__embed-author">
{{ if embed.Author.IconUrl }}
{{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
{{ end }}
{{~ end ~}}
{{ if embed.Author.Name }}
{{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name">
{{ if embed.Author.Url }}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | HtmlEncode }}</a>
{{ else }}
{{ embed.Author.Name | HtmlEncode }}
{{ end }}
{{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{~ else ~}}
{{ embed.Author.Name | html.escape }}
{{~ end ~}}
</span>
{{ end }}
{{~ end ~}}
</div>
{{ end }}
{{~ end ~}}
{{~ # Title }}
{{ if embed.Title }}
{{~ # Title ~}}
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{ if embed.Url }}
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
{{ else }}
{{~ else ~}}
{{ embed.Title | FormatContent }}
{{ end }}
{{~ end ~}}
</div>
{{ end }}
{{~ end ~}}
{{~ # Description }}
{{ if embed.Description }}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
{{ end }}
{{~ end ~}}
{{~ # Fields }}
<div class="chatlog__embed-fields">
{{ for field in embed.Fields }}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{ if field.Name }}
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
{{ end }}
{{ if field.Value }}
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
{{ end }}
</div>
{{ end }}
</div>
{{~ # Fields ~}}
{{~ if embed.Fields | array.size > 0 ~}}
<div class="chatlog__embed-fields">
{{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}}
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ # Thumbnail }}
{{ if embed.Thumbnail }}
{{~ # Thumbnail ~}}
{{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
</a>
</div>
{{ end }}
{{~ end ~}}
</div>
{{~ # Image }}
{{ if embed.Image }}
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
</a>
</div>
{{ end }}
{{~ end ~}}
{{~ # Footer }}
{{ if embed.Footer || embed.Timestamp }}
{{~ # Footer ~}}
{{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer">
{{ if embed.Footer }}
{{ if embed.Footer.Text && embed.Footer.IconUrl }}
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
{{ end }}
{{ end }}
{{~ end ~}}
{{~ end ~}}
<span class="chatlog__embed-footer-text">
{{ if embed.Footer }}
{{ if embed.Footer.Text }}
{{ embed.Footer.Text | HtmlEncode }}
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }}
{{ end }}
{{ end }}
{{~ end ~}}
{{~ end ~}}
{{ if embed.Timestamp }}
{{ embed.Timestamp | FormatDate | HtmlEncode }}
{{ end }}
{{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | html.escape }}
{{~ end ~}}
</span>
</div>
{{ end }}
{{~ end ~}}
</div>
</div>
{{ end }}
{{~ end ~}}
{{~ # Reactions }}
<div class="chatlog__reactions">
{{ for reaction in message.Reactions }}
<div class="chatlog__reaction">
<span class="chatlog__reaction-emoji">
{{~ # Custom emoji }}
{{ if reaction.EmojiId }}
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
{{~ # Default emoji }}
{{ else }}
{{ reaction.EmojiName }}
{{ end }}
</span>
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{ end }}
</div>
{{ end }}
{{~ # Reactions ~}}
{{~ if message.Reactions | array.size > 0 ~}}
<div class="chatlog__reactions">
{{~ for reaction in message.Reactions ~}}
<div class="chatlog__reaction">
<span class="chatlog__reaction-emoji">
{{~ # Custom emoji ~}}
{{~ if reaction.EmojiId ~}}
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
{{~ # Default emoji ~}}
{{~ else ~}}
{{ reaction.EmojiName }}
{{~ end ~}}
</span>
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ end ~}}
</div>
</div>
{{ end }}
{{~ end ~}}
</div>
</body>
@@ -1,18 +1,21 @@
==============================================================
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 }}
{{~ # 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 }}
==============================================================
{{~ for group in MessageGroups ~}}
{{~ group.Author.FullName }} [{{ group.TimeStamp | FormatDate }}]
{{~ for message in group.Messages ~}}
{{~ message.Content | FormatContent }}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ # Log ~}}
{{~ for message in Model.Messages ~}}
{{~ # Author name and timestamp ~}}
{{~ }}[{{ message.Timestamp | FormatDate }}] {{ message.Author.FullName }}
{{~ # Content ~}}
{{~ message.Content | FormatContent }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ end ~}}
@@ -33,6 +33,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 +51,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)
@@ -1,64 +1,84 @@
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)
{
var key = parameter.SubstringUntil("=");
var value = parameter.SubstringAfter("=");
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
}
// Parse
return JToken.Parse(raw);
// 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 +86,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 +94,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 +102,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 +110,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,14 +118,11 @@ 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();
@@ -169,7 +186,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 +214,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;
}
}
}
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.Linq;
@@ -18,18 +19,62 @@ namespace DiscordChatExporter.Core.Services
private readonly ExportFormat _format;
private readonly ChatLog _log;
private readonly string _dateFormat;
private readonly int _messageGroupLimit;
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat)
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat, int messageGroupLimit)
{
_format = format;
_log = log;
_dateFormat = dateFormat;
_messageGroupLimit = messageGroupLimit;
}
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{
// Group adjacent messages by timestamp and author
var buffer = new List<Message>();
foreach (var message in messages)
{
// Get first message of the group
var groupFirstMessage = buffer.FirstOrDefault();
// Group break condition
var breakCondition =
groupFirstMessage != null &&
(
message.Author.Id != groupFirstMessage.Author.Id || // when author changes
(message.Timestamp - groupFirstMessage.Timestamp).TotalHours > 1 || // when difference in timestamps is an hour or more
message.Timestamp.Hour != groupFirstMessage.Timestamp.Hour || // when the timestamp's hour changes
buffer.Count >= _messageGroupLimit // when group is full
);
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(groupFirstMessage.Author, groupFirstMessage.Timestamp, buffer);
// Reset the buffer instead of clearing to avoid mutations on existing references
buffer = new List<Message>();
yield return group;
}
// Add message to buffer
buffer.Add(message);
}
// Add what's remaining in buffer
if (buffer.Any())
{
var groupFirstMessage = buffer.First();
var group = new MessageGroup(groupFirstMessage.Author, groupFirstMessage.Timestamp, buffer);
yield return group;
}
}
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
private string HtmlDecode(string str) => WebUtility.HtmlDecode(str);
private string Format(IFormattable obj, string format) =>
obj.ToString(format, CultureInfo.InvariantCulture);
@@ -231,9 +276,6 @@ namespace DiscordChatExporter.Core.Services
private string FormatContentCsv(string content)
{
// New lines
content = content.Replace("\n", ", ");
// Escape quotes
content = content.Replace("\"", "\"\"");
@@ -309,11 +351,10 @@ namespace DiscordChatExporter.Core.Services
var scriptObject = new ScriptObject();
// Import chat log
scriptObject.Import(_log, TemplateMemberFilter, TemplateMemberRenamer);
scriptObject.SetValue("Model", _log, true);
// Import functions
scriptObject.Import(nameof(HtmlEncode), new Func<string, string>(HtmlEncode));
scriptObject.Import(nameof(HtmlDecode), new Func<string, string>(HtmlDecode));
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));
@@ -1,23 +1,22 @@
using System.IO;
using System.Collections.Generic;
using System.IO;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService : IExportService
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 +29,101 @@ 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, _settingsService.MessageGroupLimit);
context.PushGlobal(templateModel.GetScriptObject());
// Create directory
var dirPath = Path.GetDirectoryName(filePath);
if (dirPath.IsNotBlank())
Directory.CreateDirectory(dirPath);
// Render output
using (var output = File.CreateText(filePath))
{
// Configure output
context.PushOutput(new TextWriterOutput(output));
// Render 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++;
}
}
private IReadOnlyList<ChatLog> SplitIntoPartitions(ChatLog chatLog, int partitionLimit)
{
var result = new List<ChatLog>();
// Loop through all messages with an increment of partition limit
for (var i = 0; i < chatLog.Messages.Count; i += partitionLimit)
{
// Calculate how many messages left in total
var remainingMessageCount = chatLog.Messages.Count - i;
// Decide how many messages are going into this partition
// Each partition will have the same number of messages except the last one that might have fewer (all remaining messages)
var partitionMessageCount = partitionLimit.ClampMax(remainingMessageCount);
// Get messages that belong to this partition
var partitionMessages = new List<Message>();
for (var j = i; j < i + partitionMessageCount; j++)
partitionMessages.Add(chatLog.Messages[j]);
// Create a partition and add to list
var partition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, partitionMessages,
chatLog.Mentionables);
result.Add(partition);
}
return result;
}
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
{
var partitions = SplitIntoPartitions(chatLog, partitionLimit.Value);
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,16 @@ 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;
}
}
}
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
[assembly: AssemblyVersion("2.5")]
[assembly: AssemblyFileVersion("2.5")]
[assembly: AssemblyVersion("2.9")]
[assembly: AssemblyFileVersion("2.9")]
@@ -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);
}
}
}
@@ -1,12 +1,12 @@
using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{
public class SettingsViewModel : ViewModelBase, ISettingsViewModel
public class SettingsViewModel : DialogScreen
{
private readonly ISettingsService _settingsService;
private readonly SettingsService _settingsService;
public bool IsAutoUpdateEnabled
{
@@ -26,7 +26,7 @@ namespace DiscordChatExporter.Gui.ViewModels
set => _settingsService.MessageGroupLimit = value.ClampMin(0);
}
public SettingsViewModel(ISettingsService settingsService)
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; }
}
}
@@ -1,28 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.Gui.ViewModels
{
public interface IMainViewModel
{
bool IsBusy { get; }
bool IsDataAvailable { get; }
bool IsProgressIndeterminate { get; }
double Progress { get; }
string Token { get; set; }
IReadOnlyList<Guild> AvailableGuilds { get; }
Guild SelectedGuild { get; set; }
IReadOnlyList<Channel> AvailableChannels { get; }
RelayCommand ViewLoadedCommand { get; }
RelayCommand ViewClosedCommand { get; }
RelayCommand PullDataCommand { get; }
RelayCommand ShowSettingsCommand { get; }
RelayCommand ShowAboutCommand { get; }
RelayCommand<Channel> ShowExportSetupCommand { get; }
}
}
@@ -1,10 +0,0 @@
namespace DiscordChatExporter.Gui.ViewModels
{
public interface ISettingsViewModel
{
bool IsAutoUpdateEnabled { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
}
}
@@ -1,280 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Windows;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
{
public class MainViewModel : ViewModelBase, IMainViewModel
{
private readonly ISettingsService _settingsService;
private readonly IUpdateService _updateService;
private readonly IDataService _dataService;
private readonly IMessageGroupService _messageGroupService;
private readonly IExportService _exportService;
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy;
private double _progress;
private string _token;
private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild;
private IReadOnlyList<Channel> _availableChannels;
public bool IsBusy
{
get => _isBusy;
private set
{
Set(ref _isBusy, value);
PullDataCommand.RaiseCanExecuteChanged();
ShowExportSetupCommand.RaiseCanExecuteChanged();
}
}
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
public bool IsProgressIndeterminate => Progress <= 0;
public double Progress
{
get => _progress;
private set
{
Set(ref _progress, value);
RaisePropertyChanged(() => IsProgressIndeterminate);
}
}
public string Token
{
get => _token;
set
{
// Remove invalid chars
value = value?.Trim('"');
Set(ref _token, value);
PullDataCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Guild> AvailableGuilds
{
get => _availableGuilds;
private set
{
Set(ref _availableGuilds, value);
RaisePropertyChanged(() => IsDataAvailable);
}
}
public Guild SelectedGuild
{
get => _selectedGuild;
set
{
Set(ref _selectedGuild, value);
AvailableChannels = value != null ? _guildChannelsMap[value] : Array.Empty<Channel>();
ShowExportSetupCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Channel> AvailableChannels
{
get => _availableChannels;
private set => Set(ref _availableChannels, value);
}
public RelayCommand ViewLoadedCommand { get; }
public RelayCommand ViewClosedCommand { get; }
public RelayCommand PullDataCommand { get; }
public RelayCommand ShowSettingsCommand { get; }
public RelayCommand ShowAboutCommand { get; }
public RelayCommand<Channel> ShowExportSetupCommand { get; }
public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService,
IMessageGroupService messageGroupService, IExportService exportService)
{
_settingsService = settingsService;
_updateService = updateService;
_dataService = dataService;
_messageGroupService = messageGroupService;
_exportService = exportService;
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
// Commands
ViewLoadedCommand = new RelayCommand(ViewLoaded);
ViewClosedCommand = new RelayCommand(ViewClosed);
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout);
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
// Messages
MessengerInstance.Register<StartExportMessage>(this,
m => Export(m.Channel, m.FilePath, m.Format, m.From, m.To));
}
private async void ViewLoaded()
{
// Load settings
_settingsService.Load();
// Set last token
Token = _settingsService.LastToken;
// Check and prepare update
try
{
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
MessengerInstance.Send(new ShowNotificationMessage(
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.NeedRestart = true;
Application.Current.Shutdown();
}));
}
}
catch
{
MessengerInstance.Send(new ShowNotificationMessage("Failed to perform application auto-update"));
}
}
private void ViewClosed()
{
// Save settings
_settingsService.Save();
// Finalize updates if available
_updateService.FinalizeUpdate();
}
private async void PullData()
{
IsBusy = true;
// Copy token so it doesn't get mutated
var token = Token;
// Save token
_settingsService.LastToken = token;
// Clear existing
_guildChannelsMap.Clear();
try
{
// Get DM channels
{
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
var guild = Guild.DirectMessages;
_guildChannelsMap[guild] = channels.OrderBy(c => c.Name).ToArray();
}
// Get guild channels
{
var guilds = await _dataService.GetUserGuildsAsync(token);
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat)
.OrderBy(c => c.Name)
.ToArray();
}
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
MessengerInstance.Send(new ShowNotificationMessage("Unauthorized make sure the token is valid"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
MessengerInstance.Send(new ShowNotificationMessage("Forbidden account may be locked by 2FA"));
}
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
SelectedGuild = AvailableGuilds.FirstOrDefault();
IsBusy = false;
}
private void ShowSettings()
{
MessengerInstance.Send(new ShowSettingsMessage());
}
private void ShowAbout()
{
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
}
private void ShowExportSetup(Channel channel)
{
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
}
private async void Export(Channel channel, string filePath, ExportFormat format, DateTime? from, DateTime? to)
{
IsBusy = true;
// Get last used token
var token = _settingsService.LastToken;
// Get guild
var guild = SelectedGuild;
// Create progress handler
var progressHandler = new Progress<double>(p => Progress = p);
try
{
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to, progressHandler);
// 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);
// Open
Process.Start(filePath);
// Notify completion
MessengerInstance.Send(new ShowNotificationMessage("Export complete"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
MessengerInstance.Send(new ShowNotificationMessage("This channel doesn't exist"));
}
Progress = 0;
IsBusy = false;
}
}
}
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Helpers;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress;
using MaterialDesignThemes.Wpf;
using Stylet;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
{
public class RootViewModel : Screen
{
private readonly IViewModelFactory _viewModelFactory;
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
private readonly UpdateService _updateService;
private readonly DataService _dataService;
private readonly ExportService _exportService;
public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
public IProgressManager ProgressManager { get; } = new ProgressManager();
public bool IsBusy { get; private set; }
public bool IsProgressIndeterminate { get; private set; }
public bool IsBotToken { get; set; }
public string TokenValue { get; set; }
public IReadOnlyList<GuildViewModel> AvailableGuilds { get; private set; }
public GuildViewModel SelectedGuild { get; set; }
public IReadOnlyList<ChannelViewModel> SelectedChannels { get; set; }
public RootViewModel(IViewModelFactory viewModelFactory, DialogManager dialogManager,
SettingsService settingsService, UpdateService updateService, DataService dataService,
ExportService exportService)
{
_viewModelFactory = viewModelFactory;
_dialogManager = dialogManager;
_settingsService = settingsService;
_updateService = updateService;
_dataService = dataService;
_exportService = exportService;
// Set title
var version = Assembly.GetExecutingAssembly().GetName().Version.ToString(3);
DisplayName = $"DiscordChatExporter v{version}";
// Update busy state when progress manager changes
ProgressManager.Bind(o => o.IsActive, (sender, args) => IsBusy = ProgressManager.IsActive);
ProgressManager.Bind(o => o.IsActive,
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress <= 0);
ProgressManager.Bind(o => o.Progress,
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress <= 0);
}
protected override async void OnViewLoaded()
{
base.OnViewLoaded();
// Load settings
_settingsService.Load();
// Get last token
if (_settingsService.LastToken != null)
{
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
TokenValue = _settingsService.LastToken.Value;
}
// Check and prepare update
try
{
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
Notifications.Enqueue(
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.FinalizeUpdate(true);
RequestClose();
});
}
}
catch
{
Notifications.Enqueue("Failed to perform application auto-update");
}
}
protected override void OnClose()
{
base.OnClose();
// Save settings
_settingsService.Save();
// Finalize updates if necessary
_updateService.FinalizeUpdate(false);
}
public async void ShowSettings()
{
// Create dialog
var dialog = _viewModelFactory.CreateSettingsViewModel();
// Show dialog
await _dialogManager.ShowDialogAsync(dialog);
}
public bool CanPopulateGuildsAndChannels => !IsBusy && TokenValue.IsNotBlank();
public async void PopulateGuildsAndChannels()
{
// Create progress operation
var operation = ProgressManager.CreateOperation();
try
{
// Sanitize token
TokenValue = TokenValue.Trim('"');
// Create token
var token = new AuthToken(
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
TokenValue);
// Save token
_settingsService.LastToken = token;
// Prepare available guild list
var availableGuilds = new List<GuildViewModel>();
// Get direct messages
{
// Get fake guild
var guild = Guild.DirectMessages;
// Get channels
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
// Create channel view models
var channelViewModels = new List<ChannelViewModel>();
foreach (var channel in channels)
{
// Get fake category
var category = channel.Type == ChannelType.DirectTextChat ? "Private" : "Group";
// Create channel view model
var channelViewModel = _viewModelFactory.CreateChannelViewModel(channel, category);
// Add to list
channelViewModels.Add(channelViewModel);
}
// Create guild view model
var guildViewModel = _viewModelFactory.CreateGuildViewModel(guild,
channelViewModels.OrderBy(c => c.Category)
.ThenBy(c => c.Model.Name)
.ToArray());
// Add to list
availableGuilds.Add(guildViewModel);
}
// Get guilds
var guilds = await _dataService.GetUserGuildsAsync(token);
foreach (var guild in guilds)
{
// Get channels
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
// Get category channels
var categoryChannels = channels.Where(c => c.Type == ChannelType.Category).ToArray();
// Get text channels
var textChannels = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
// Create channel view models
var channelViewModels = new List<ChannelViewModel>();
foreach (var channel in textChannels)
{
// Get category
var category = categoryChannels.FirstOrDefault(c => c.Id == channel.ParentId)?.Name;
// Create channel view model
var channelViewModel = _viewModelFactory.CreateChannelViewModel(channel, category);
// Add to list
channelViewModels.Add(channelViewModel);
}
// Create guild view model
var guildViewModel = _viewModelFactory.CreateGuildViewModel(guild,
channelViewModels.OrderBy(c => c.Category)
.ThenBy(c => c.Model.Name)
.ToArray());
// Add to list
availableGuilds.Add(guildViewModel);
}
// Update available guild list
AvailableGuilds = availableGuilds;
// Pre-select first guild
SelectedGuild = AvailableGuilds.FirstOrDefault();
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
Notifications.Enqueue("Unauthorized make sure the token is valid");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Notifications.Enqueue("Forbidden account may be locked by 2FA");
}
finally
{
// Dispose progress operation
operation.Dispose();
}
}
public bool CanExportChannels => !IsBusy && SelectedChannels.NotNullAndAny();
public async void ExportChannels()
{
// Get last used token
var token = _settingsService.LastToken;
// Create dialog
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
// Show dialog, if canceled - return
if (await _dialogManager.ShowDialogAsync(dialog) != true)
return;
// Create a progress operation for each channel to export
var operations = ProgressManager.CreateOperations(dialog.Channels.Count);
// Export channels
for (var i = 0; i < dialog.Channels.Count; i++)
{
// Get operation and channel
var operation = operations[i];
var channel = dialog.Channels[i];
try
{
// Generate file path if necessary
var filePath = dialog.OutputPath;
if (ExportHelper.IsDirectoryPath(filePath))
{
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(dialog.SelectedFormat, dialog.Guild,
channel, dialog.From, dialog.To);
// Combine paths
filePath = Path.Combine(filePath, fileName);
}
// Get chat log
var chatLog = await _dataService.GetChatLogAsync(token, dialog.Guild, channel,
dialog.From, dialog.To, operation);
// Export
_exportService.ExportChatLog(chatLog, filePath, dialog.SelectedFormat,
dialog.PartitionLimit);
// Notify completion
Notifications.Enqueue($"Channel [{channel.Model.Name}] successfully exported");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Notifications.Enqueue($"You don't have access to channel [{channel.Model.Name}]");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Notifications.Enqueue($"Channel [{channel.Model.Name}] doesn't exist");
}
finally
{
// Dispose progress operation
operation.Dispose();
}
}
}
}
}
@@ -0,0 +1,127 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
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:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
<StackPanel>
<!-- Guild/channel info -->
<Grid Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Guild icon -->
<Ellipse
Grid.Column="0"
Width="32"
Height="32">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding Guild.Model.IconUrl}" />
</Ellipse.Fill>
</Ellipse>
<!-- Placeholder (for multiple channels) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
TextTrimming="CharacterEllipsis"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<Run Text="{Binding Channels.Count, Mode=OneWay}" />
<Run Text="channels selected" />
</TextBlock>
<!-- Category and channel name (for single channel) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
TextTrimming="CharacterEllipsis"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
<Run Text="/" />
<Run
Foreground="{DynamicResource PrimaryTextBrush}"
Text="{Binding Channels[0].Model.Name, Mode=OneWay}"
ToolTip="{Binding Channels[0].Model.Name, Mode=OneWay}" />
</TextBlock>
</Grid>
<!-- Format -->
<ComboBox
Margin="16,8"
materialDesign:HintAssist.Hint="Format"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
ItemsSource="{Binding AvailableFormats}"
SelectedItem="{Binding SelectedFormat}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Date limits -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8"
materialDesign:HintAssist.Hint="From (optional)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding To}"
SelectedDate="{Binding From}"
ToolTip="If this is set, only messages sent after this date will be exported" />
<DatePicker
Grid.Row="0"
Grid.Column="1"
Margin="16,8"
materialDesign:HintAssist.Hint="To (optional)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding From}"
SelectedDate="{Binding To}"
ToolTip="If this is set, only messages sent before this date will be exported" />
</Grid>
<!-- Partitioning -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Messages per partition (optional)"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding PartitionLimit, TargetNullValue=''}"
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" />
<!-- Buttons -->
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button
Margin="8"
Command="{s:Action Confirm}"
Content="EXPORT"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
<Button
Margin="8"
Command="{s:Action Close}"
Content="CANCEL"
IsCancel="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.Views.Dialogs
{
public partial class ExportSetupView
{
public ExportSetupView()
{
InitializeComponent();
}
}
}
@@ -1,21 +1,33 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.SettingsDialog"
x:Class="DiscordChatExporter.Gui.Views.Dialogs.SettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Width="250"
DataContext="{Binding SettingsViewModel, Source={StaticResource Container}}">
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
Width="300"
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
<StackPanel>
<TextBlock
Margin="16"
FontSize="17"
Foreground="{DynamicResource PrimaryTextBrush}"
Text="Settings" />
<!-- Date format -->
<TextBox
Margin="16,16,16,8"
Margin="16,8"
materialDesign:HintAssist.Hint="Date format"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding DateFormat}" />
<!-- Message group limit -->
<TextBox
Margin="16,8,16,8"
Margin="16,8"
materialDesign:HintAssist.Hint="Message group limit"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding MessageGroupLimit}" />
@@ -23,11 +35,11 @@
<!-- Auto-updates -->
<DockPanel LastChildFill="False">
<TextBlock
Margin="16,8,16,8"
Margin="16,8"
DockPanel.Dock="Left"
Text="Auto-updates" />
<ToggleButton
Margin="16,8,16,8"
Margin="16,8"
DockPanel.Dock="Right"
IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel>
@@ -36,7 +48,7 @@
<Button
Margin="8"
HorizontalAlignment="Right"
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
Command="{s:Action Close}"
Content="SAVE"
IsCancel="True"
IsDefault="True"
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.Views.Dialogs
{
public partial class SettingsView
{
public SettingsView()
{
InitializeComponent();
}
}
}
@@ -1,82 +0,0 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.ExportSetupDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Width="325"
DataContext="{Binding ExportSetupViewModel, Source={StaticResource Container}}">
<StackPanel>
<!-- File path -->
<TextBox
Margin="16,16,16,8"
materialDesign:HintAssist.Hint="Output file"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
Text="{Binding FilePath, UpdateSourceTrigger=PropertyChanged}" />
<!-- Format -->
<ComboBox
Margin="16,8,16,8"
materialDesign:HintAssist.Hint="Export format"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
ItemsSource="{Binding AvailableFormats}"
SelectedItem="{Binding SelectedFormat}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource ExportFormatToStringConverter}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Date limits -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DatePicker
x:Name="FromDatePicker"
Grid.Row="0"
Grid.Column="0"
Margin="16,20,8,8"
materialDesign:HintAssist.Hint="From (optional)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding SelectedDate, ElementName=ToDatePicker}"
SelectedDate="{Binding From}" />
<DatePicker
x:Name="ToDatePicker"
Grid.Row="0"
Grid.Column="1"
Margin="8,20,16,8"
materialDesign:HintAssist.Hint="To (optional)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding SelectedDate, ElementName=FromDatePicker}"
SelectedDate="{Binding To}" />
</Grid>
<!-- Buttons -->
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button
x:Name="BrowseButton"
Margin="8"
Click="BrowseButton_Click"
Content="BROWSE"
Style="{DynamicResource MaterialDesignFlatButton}" />
<Button
x:Name="ExportButton"
Margin="8"
Click="ExportButton_Click"
Command="{Binding ExportCommand}"
Content="EXPORT"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
<Button
Margin="8"
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
Content="CANCEL"
IsCancel="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -1,44 +0,0 @@
using System.Windows;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Gui.ViewModels;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
namespace DiscordChatExporter.Gui.Views
{
public partial class ExportSetupDialog
{
private IExportSetupViewModel ViewModel => (IExportSetupViewModel)DataContext;
public ExportSetupDialog()
{
InitializeComponent();
}
public void BrowseButton_Click(object sender, RoutedEventArgs args)
{
// Get file extension of the selected format
var ext = ViewModel.SelectedFormat.GetFileExtension();
// Open dialog
var sfd = new SaveFileDialog
{
FileName = ViewModel.FilePath,
Filter = $"{ext.ToUpperInvariant()} Files|*.{ext}|All Files|*.*",
AddExtension = true,
Title = "Select output file"
};
// Assign new file path if dialog was successful
if (sfd.ShowDialog() == true)
{
ViewModel.FilePath = sfd.FileName;
}
}
public void ExportButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
}
}
@@ -1,231 +0,0 @@
<Window
x:Class="DiscordChatExporter.Gui.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Title="DiscordChatExporter"
Width="600"
Height="550"
Background="{DynamicResource MaterialDesignPaper}"
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
FocusManager.FocusedElement="{Binding ElementName=TokenTextBox}"
FontFamily="{DynamicResource MaterialDesignFont}"
Icon="/DiscordChatExporter;component/favicon.ico"
SnapsToDevicePixels="True"
TextElement.FontSize="13"
TextElement.FontWeight="Regular"
TextElement.Foreground="{DynamicResource SecondaryTextBrush}"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="Auto"
UseLayoutRounding="True"
WindowStartupLocation="CenterScreen">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Loaded">
<i:InvokeCommandAction Command="{Binding ViewLoadedCommand}" />
</i:EventTrigger>
<i:EventTrigger EventName="Closed">
<i:InvokeCommandAction Command="{Binding ViewClosedCommand}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<materialDesign:DialogHost SnackbarMessageQueue="{Binding ElementName=Snackbar}">
<DockPanel>
<!-- Toolbar -->
<Border
Background="{DynamicResource PrimaryHueMidBrush}"
DockPanel.Dock="Top"
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<materialDesign:Card
Grid.Row="0"
Grid.Column="0"
Margin="6,6,0,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token -->
<TextBox
x:Name="TokenTextBox"
Grid.Row="0"
Grid.Column="0"
Margin="6"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0"
FontSize="16"
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button -->
<Button
Grid.Row="0"
Grid.Column="1"
Margin="0,6,6,6"
Padding="4"
Command="{Binding PullDataCommand}"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}">
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="ArrowRight" />
</Button>
</Grid>
</materialDesign:Card>
<!-- Menu -->
<materialDesign:PopupBox
Grid.Row="0"
Grid.Column="1"
Foreground="{DynamicResource PrimaryHueMidForegroundBrush}"
PlacementMode="LeftAndAlignTopEdges">
<StackPanel>
<Button Command="{Binding ShowSettingsCommand}" Content="Settings" />
<Button Command="{Binding ShowAboutCommand}" Content="About" />
</StackPanel>
</materialDesign:PopupBox>
</Grid>
<!-- Progress bar -->
<ProgressBar
Background="Transparent"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding Progress, Mode=OneWay}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
</Border>
<!-- Content -->
<Grid>
<DockPanel
Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
Visibility="{Binding IsDataAvailable, Converter={StaticResource BoolToVisibilityConverter}}">
<!-- Guilds -->
<Border
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,0,1,0"
DockPanel.Dock="Left">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
VirtualizingStackPanel.IsVirtualizing="False">
<ListBox.ItemTemplate>
<DataTemplate>
<materialDesign:TransitioningContent>
<materialDesign:TransitioningContent.OpeningEffect>
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
</materialDesign:TransitioningContent.OpeningEffect>
<Grid
Margin="-8"
Background="Transparent"
Cursor="Hand"
ToolTip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Fill="{DynamicResource DividerBrush}" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Grid>
</materialDesign:TransitioningContent>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels -->
<Border>
<ListBox
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding AvailableChannels}"
VirtualizingStackPanel.IsVirtualizing="False">
<ListBox.ItemTemplate>
<DataTemplate>
<materialDesign:TransitioningContent>
<materialDesign:TransitioningContent.OpeningEffect>
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
</materialDesign:TransitioningContent.OpeningEffect>
<StackPanel
Margin="-8"
Background="Transparent"
Cursor="Hand"
Orientation="Horizontal">
<StackPanel.InputBindings>
<MouseBinding
Command="{Binding DataContext.ShowExportSetupCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}}"
CommandParameter="{Binding}"
MouseAction="LeftClick" />
</StackPanel.InputBindings>
<materialDesign:PackIcon
Margin="16,7,0,6"
VerticalAlignment="Center"
Kind="Pound" />
<TextBlock
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14"
Text="{Binding Name}" />
</StackPanel>
</materialDesign:TransitioningContent>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</DockPanel>
<!-- Usage instructions -->
<StackPanel Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your authorization token to work." />
<TextBlock
Margin="0,8,0,0"
FontSize="16"
Text="To obtain it, follow these steps:" />
<TextBlock Margin="8,0,0,0" FontSize="14">
<Run Text="1. Open the Discord app" />
<LineBreak />
<Run Text="2. Log in if you haven't" />
<LineBreak />
<Run Text="3. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
<LineBreak />
<Run Text="4. Navigate to" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. Expand" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage &gt; Local Storage &gt; https://discordapp.com" />
<LineBreak />
<Run Text="6. Find" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="&quot;token&quot;" />
<Run Text="under key and copy the value" />
<LineBreak />
<Run Text="7. Paste the value in the textbox above" />
</TextBlock>
</StackPanel>
<materialDesign:Snackbar x:Name="Snackbar" />
</Grid>
</DockPanel>
</materialDesign:DialogHost>
</Window>
@@ -1,35 +0,0 @@
using System;
using System.Reflection;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight.Messaging;
using MaterialDesignThemes.Wpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.Views
{
public partial class MainWindow
{
public MainWindow()
{
InitializeComponent();
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
Snackbar.MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
// Notification messages
Messenger.Default.Register<ShowNotificationMessage>(this, m =>
{
if (m.CallbackCaption != null && m.Callback != null)
Snackbar.MessageQueue.Enqueue(m.Message, m.CallbackCaption, m.Callback);
else
Snackbar.MessageQueue.Enqueue(m.Message);
});
// Dialog messages
Messenger.Default.Register<ShowExportSetupMessage>(this,
m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this,
m => DialogHost.Show(new SettingsDialog()).Forget());
}
}
}
+291
View File
@@ -0,0 +1,291 @@
<Window
x:Class="DiscordChatExporter.Gui.Views.RootView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
Width="600"
Height="550"
MinWidth="325"
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
Background="{DynamicResource MaterialDesignPaper}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
Icon="/DiscordChatExporter;component/favicon.ico"
Style="{DynamicResource MaterialDesignRoot}"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Toolbar -->
<Grid
Grid.Row="0"
Background="{DynamicResource PrimaryHueMidBrush}"
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token and pull data button -->
<materialDesign:Card
Grid.Row="0"
Grid.Column="0"
Margin="12,12,0,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token type -->
<ToggleButton
Grid.Column="0"
Margin="6"
IsChecked="{Binding IsBotToken}"
Style="{StaticResource MaterialDesignFlatActionToggleButton}"
ToolTip="Switch between user token and bot token">
<ToggleButton.Content>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Account" />
</ToggleButton.Content>
<materialDesign:ToggleButtonAssist.OnContent>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Robot" />
</materialDesign:ToggleButtonAssist.OnContent>
</ToggleButton>
<!-- Token value -->
<TextBox
x:Name="TokenValueTextBox"
Grid.Column="1"
Margin="2,6,6,7"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0"
FontSize="16"
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button -->
<Button
Grid.Column="2"
Margin="0,6,6,6"
Padding="4"
Command="{s:Action PopulateGuildsAndChannels}"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}">
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="ArrowRight" />
</Button>
</Grid>
</materialDesign:Card>
<!-- Settings button -->
<Button
Grid.Column="1"
Margin="6"
Padding="4"
Command="{s:Action ShowSettings}"
Style="{DynamicResource MaterialDesignFlatDarkButton}">
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Settings" />
</Button>
</Grid>
<!-- Progress bar -->
<ProgressBar
Grid.Row="1"
Background="{DynamicResource PrimaryHueMidBrush}"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
<!-- Content -->
<Grid Grid.Row="2">
<!-- Placeholder / usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- For user token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<TextBlock FontSize="18" Text="Please provide your user token to authorize" />
<TextBlock Margin="8,8,0,0" FontSize="14">
<Run Text="1. Open Discord" />
<LineBreak />
<Run Text="2. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="3. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. Select" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
<Run Text="&gt;" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discordapp.com" />
<Run Text="on the left" />
<LineBreak />
<Run Text="6. Find" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
<Run Text="under key and copy the value" />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="To authorize using bot token instead, click" />
<InlineUIContainer>
<materialDesign:PackIcon Margin="1,0,0,-3" Kind="Account" />
</InlineUIContainer>
</TextBlock>
</StackPanel>
<!-- For bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<TextBlock FontSize="18" Text="Please provide your bot token to authorize" />
<TextBlock Margin="8,8,0,0" FontSize="14">
<Run Text="1. Open Discord developer portal" />
<LineBreak />
<Run Text="2. Open your application's settings" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="4. Under" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
<Run Text="click" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="To authorize using user token instead, click" />
<InlineUIContainer>
<materialDesign:PackIcon Margin="1,0,0,-1" Kind="Robot" />
</InlineUIContainer>
</TextBlock>
</StackPanel>
</Grid>
<!-- Guilds and channels -->
<Grid Background="{DynamicResource MaterialDesignCardBackground}" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Guilds -->
<Border
Grid.Column="0"
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,0,1,0">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid
Margin="-8"
Background="Transparent"
Cursor="Hand"
ToolTip="{Binding Model.Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Fill="{DynamicResource DividerBrush}" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding Model.IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels -->
<Border Grid.Column="1">
<ListBox
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding SelectedGuild.Channels}"
SelectionMode="Extended">
<i:Interaction.Behaviors>
<behaviors:ChannelViewModelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</i:Interaction.Behaviors>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel
Margin="-8"
Background="Transparent"
Orientation="Horizontal">
<StackPanel.InputBindings>
<MouseBinding Command="{s:Action ExportChannels}" MouseAction="LeftDoubleClick" />
</StackPanel.InputBindings>
<materialDesign:PackIcon
Margin="16,7,0,6"
VerticalAlignment="Center"
Kind="Pound" />
<TextBlock
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14">
<Run Foreground="{DynamicResource SecondaryTextBrush}" Text="{Binding Category, Mode=OneWay}" />
<Run Text="/" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding Model.Name, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</Grid>
<!-- Export button -->
<Button
Margin="32,24"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Command="{s:Action ExportChannels}"
Style="{StaticResource MaterialDesignFloatingActionAccentButton}"
Visibility="{Binding IsEnabled, RelativeSource={RelativeSource Self}, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<materialDesign:PackIcon
Width="32"
Height="32"
Kind="Download" />
</Button>
<!-- Notifications snackbar -->
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
</Grid>
</Grid>
</materialDesign:DialogHost>
</Window>
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.Views
{
public partial class RootView
{
public RootView()
{
InitializeComponent();
}
}
}
@@ -1,10 +0,0 @@
namespace DiscordChatExporter.Gui.Views
{
public partial class SettingsDialog
{
public SettingsDialog()
{
InitializeComponent();
}
}
}
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="CommonServiceLocator" publicKeyToken="489b6accfaf20ef0" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.0.3.0" newVersion="2.0.3.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="CommonServiceLocator" version="2.0.3" targetFramework="net461" />
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.4.1" targetFramework="net461" />
<package id="Tyrrrz.Extensions" version="1.5.1" targetFramework="net461" />
<package id="Tyrrrz.WpfExtensions" version="1.0.5" targetFramework="net461" />
</packages>
+12
View File
@@ -0,0 +1,12 @@
FROM mono:5
WORKDIR /root/build
COPY DiscordChatExporter.sln favicon.ico ./
COPY DiscordChatExporter.Core DiscordChatExporter.Core
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
RUN msbuild ./DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj /t:Restore
RUN msbuild ./DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj /p:Configuration=Release
FROM mono:5
COPY --from=0 /root/build/DiscordChatExporter.Cli/bin/Release/net461 /root/bin
WORKDIR /a
ENTRYPOINT ["mono", "/root/bin/DiscordChatExporter.Cli.exe"]
+21 -29
View File
@@ -4,46 +4,38 @@
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. The tool also lets you select from/to dates to limit the exported messages. There are options to configure the output, such as date format, color theme, message grouping limit, etc.
DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, embeds, attachments, mentions, reactions and other features. It works with both user and bot tokens, supports multiple output formats and allows you to trim messages by dates.
## Download
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
- [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter` (only CLI version)
## Features
- Graphical and command line interfaces
- Supports both user tokens and bot tokens
- Allows retrieving messages in specified date range
- Multiple export formats: HTML (dark/light), TXT and CSV
- Renders all message features including: markdown, attachments, embeds, emojis, mentions, etc
## Screenshots
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/1.png)
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/4.png)
## Download
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
## Features
- Intuitive GUI that displays available guilds and channels
- CLI as additional alternative to GUI
- Date ranges to limit messages
- Groups messages by author and time
- Exports to a plain text file
- Exports to an HTML file
- Dark and light themes
- User avatars
- Inline image attachments
- Embeds and webhooks
- Full markdown support
- Automatic links
- Styled similarly to the app
- Exports to a CSV file
- Renders custom emojis
- Resolves user, role and channel mentions
## Libraries used
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
- [Stylet](https://github.com/canton7/Stylet)
- [PropertyChanged.Fody](https://github.com/Fody/PropertyChanged)
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [Scriban](https://github.com/lunet-io/scriban)
- [Polly](https://github.com/App-vNext/Polly)
- [CommandLineParser](https://github.com/commandlineparser/commandline)
- [Ookii.Dialogs](https://github.com/caioproiete/ookii-dialogs-wpf)
- [Failsafe](https://github.com/Tyrrrz/Failsafe)
- [Gress](https://github.com/Tyrrrz/Gress)
- [Onova](https://github.com/Tyrrrz/Onova)
- [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser)
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
- [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions)
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)
+13
View File
@@ -0,0 +1,13 @@
param([string] $newVersion)
function Replace-TextInFile {
param([string] $filePath, [string] $pattern, [string] $replacement)
$content = [IO.File]::ReadAllText($filePath)
$content = [Text.RegularExpressions.Regex]::Replace($content, $pattern, $replacement)
[IO.File]::WriteAllText($filePath, $content, [Text.Encoding]::UTF8)
}
Replace-TextInFile "$PSScriptRoot\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" '(?<=<Version>)(.*?)(?=</Version>)' $newVersion
Replace-TextInFile "$PSScriptRoot\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" '(?<=<Version>)(.*?)(?=</Version>)' $newVersion
Replace-TextInFile "$PSScriptRoot\DiscordChatExporter.Gui\Properties\AssemblyInfo.cs" '(?<=Assembly.*?Version\(")(.*?)(?="\)\])' $newVersion