Compare commits

..

36 Commits

Author SHA1 Message Date
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
Alexey Golub 0a48da4c56 Update version 2018-06-28 00:36:26 +03:00
Alexey Golub 3f5f62585c Make emojis larger if they are the only thing in content 2018-06-28 00:29:47 +03:00
Alexey Golub 49f39c7097 Add retry policy for 429 responses 2018-06-28 00:20:31 +03:00
Alexey Golub e3eac10fb8 Bring HTML light theme in line with dark theme 2018-06-27 23:16:28 +03:00
Alexey Golub 23f9331e4e Cleanup 2018-06-27 00:45:29 +03:00
Alexey Golub 69088b83eb Html-encode date ranges in html export 2018-06-26 23:23:18 +03:00
Alexey Golub 7ecf90d1cb Refactor css for 'pre' with modifiers 2018-06-26 01:14:54 +03:00
Alexey Golub f0cd2dce1b Improve reactions 2018-06-26 01:09:23 +03:00
IntriguingTiles e8436faf66 Add support for reactions (#62) 2018-06-26 00:27:45 +03:00
Alexey Golub 61dce7c1a8 Small cleanup 2018-06-25 23:48:40 +03:00
Alexey Golub 6fee160bc7 Show an error when trying to export deleted channel
Closes #51
2018-06-25 23:34:29 +03:00
Alexey Golub 8678043f0d Implement progress reporting when downloading messages (#57) 2018-06-25 23:11:34 +03:00
Oleksii Holub e4f0b8193f Output date range in the export file
Closes #56
2018-06-25 18:44:50 +03:00
Oleksii Holub df811d0b1a Add loading placeholder for guild icons 2018-06-25 17:38:28 +03:00
Oleksii Holub 08da5d685b Prevent choosing invalid from/to dates 2018-06-25 17:11:52 +03:00
Alexey Golub c2cebe99e3 Fix wrong json node being used to parse mentioned users in messages 2018-06-25 02:28:38 +03:00
Alexey Golub 481991bd00 Architecture refactor (#63) 2018-06-25 01:59:15 +03:00
Malcolm Diller d958f613a3 Add support for embeds (#46) 2018-05-06 13:09:25 +03:00
80 changed files with 2512 additions and 1421 deletions
+30
View File
@@ -1,3 +1,33 @@
### 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), `dms` (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, if the provided output file path is a directory, a file name will be generated and appended to it automatically.
### v2.6 (25-Jul-2018)
- Added support for bot tokens as an alternative to user tokens. For GUI, use the button in the top-left to switch between user and bot token. For CLI, pass the `--bot` switch to indicate that the given token is a bot token.
- Added support for animated avatars.
### v2.5.1 (01-Jul-2018)
- Fixed a bug that would prevent timestamps from rendering properly in CSV and PlainText exports.
- Fixed incorrect layout in CSV export.
- Improved readability of PlainText export.
### v2.5 (28-Jun-2018)
- Added support for embeds and webhooks (HTML only).
- Implemented progress reporting when downloading messages.
- Added support for reactions (HTML only).
- Selected date range is now shown in the export (HTML and PlainText).
- Added placeholders for guild icons to display while the icons are loading.
- Added support for jumbo emojis (custom emojis only).
- Added basic retry policy for 429 error responses.
- Added validation for from/to dates in the UI which prevents choosing invalid date ranges.
- Added an error message when trying to export a deleted channel.
### v2.4.1 (15-Apr-2018) ### v2.4.1 (15-Apr-2018)
- Added CSV export format. - Added CSV export format.
+1 -1
View File
@@ -8,4 +8,4 @@ $files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporte
# CLI # CLI
$files = @() $files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config" $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; }
}
}
+4 -14
View File
@@ -1,5 +1,4 @@
using CommonServiceLocator; using CommonServiceLocator;
using DiscordChatExporter.Cli.ViewModels;
using DiscordChatExporter.Core.Services; using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight.Ioc; using GalaSoft.MvvmLight.Ioc;
@@ -7,15 +6,7 @@ namespace DiscordChatExporter.Cli
{ {
public class Container public class Container
{ {
public IMainViewModel MainViewModel => Resolve<IMainViewModel>(); public Container()
public ISettingsService SettingsService => Resolve<ISettingsService>();
private T Resolve<T>(string key = null)
{
return ServiceLocator.Current.GetInstance<T>(key);
}
public void Init()
{ {
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default); ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset(); SimpleIoc.Default.Reset();
@@ -25,13 +16,12 @@ namespace DiscordChatExporter.Cli
SimpleIoc.Default.Register<IExportService, ExportService>(); SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>(); SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>(); SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
// View models
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
} }
public void Cleanup() public T Resolve<T>(string key = null)
{ {
return ServiceLocator.Current.GetInstance<T>(key);
} }
} }
} }
@@ -3,17 +3,17 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework> <TargetFramework>net461</TargetFramework>
<Version>2.4.1</Version> <Version>2.7</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright> <Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.2.1" />
<PackageReference Include="CommonServiceLocator" Version="2.0.3" /> <PackageReference Include="CommonServiceLocator" Version="2.0.3" />
<PackageReference Include="FluentCommandLineParser" Version="1.4.3" />
<PackageReference Include="MvvmLightLibs" Version="5.4.1" /> <PackageReference Include="MvvmLightLibs" Version="5.4.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.0" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+52 -90
View File
@@ -1,111 +1,73 @@
using System; using System;
using System.Reflection; using System.Linq;
using DiscordChatExporter.Core.Models; using CommandLine;
using Fclp; using DiscordChatExporter.Cli.Verbs;
using Tyrrrz.Extensions; using DiscordChatExporter.Cli.Verbs.Options;
namespace DiscordChatExporter.Cli namespace DiscordChatExporter.Cli
{ {
public static class Program public static class Program
{ {
private static readonly Container Container = new Container(); private static void PrintTokenHelp()
private static void ShowHelp()
{ {
var version = Assembly.GetExecutingAssembly().GetName().Version; Console.WriteLine("# To get user token:");
var availableFormats = Enum.GetNames(typeof(ExportFormat)); Console.WriteLine(" 1. Open Discord app");
Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ==="); Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 4. Press Ctrl+R to trigger reload");
Console.WriteLine(" 5. Navigate to the Application tab");
Console.WriteLine(" 6. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
Console.WriteLine(" 7. Find \"token\" under key and copy the value");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("[-t] [--token] Discord authorization token."); Console.WriteLine("# To get bot token:");
Console.WriteLine("[-c] [--channel] Discord channel ID."); Console.WriteLine(" 1. Go to Discord developer portal");
Console.WriteLine("[-f] [--format] Export format. Optional."); Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine("[-o] [--output] Output file path. Optional."); Console.WriteLine(" 3. Open your application's settings");
Console.WriteLine(" [--datefrom] Limit to messages after this date. Optional."); Console.WriteLine(" 4. Navigate to the Bot section on the left");
Console.WriteLine(" [--dateto] Limit to messages before this date. Optional."); Console.WriteLine(" 5. Under Token click Copy");
Console.WriteLine(" [--dateformat] Date format. Optional.");
Console.WriteLine(" [--grouplimit] Message group limit. Optional.");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine($"Available export formats: {availableFormats.JoinToString(", ")}"); Console.WriteLine("# To get guild or channel ID:");
Console.WriteLine(); Console.WriteLine(" 1. Open Discord app");
Console.WriteLine("# To get authorization token:"); Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine(" - Open Discord app"); Console.WriteLine(" 3. Open Settings");
Console.WriteLine(" - Log in if you haven't"); Console.WriteLine(" 4. Go to Appearance section");
Console.WriteLine(" - Press Ctrl+Shift+I"); Console.WriteLine(" 5. Enable Developer Mode");
Console.WriteLine(" - Navigate to Application tab"); Console.WriteLine(" 6. Right click on the desired guild or channel and click Copy ID");
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;
} }
public static void Main(string[] args) public static void Main(string[] args)
{ {
// Init container // Get all verb types
Container.Init(); var verbTypes = new[]
{
typeof(ExportChatOptions),
typeof(GetChannelsOptions),
typeof(GetDirectMessageChannelsOptions),
typeof(GetGuildsOptions),
//typeof(UpdateAppOptions)
};
// Parse options // Parse command line arguments
var options = ParseOptions(args); var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
// Inject some settings // Execute commands
var settings = Container.SettingsService; parsedArgs.WithParsed<ExportChatOptions>(o => new ExportChatVerb(o).Execute());
settings.DateFormat = options.DateFormat; parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
settings.MessageGroupLimit = options.MessageGroupLimit; parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
parsedArgs.WithParsed<UpdateAppOptions>(o => new UpdateAppVerb(o).Execute());
// Export // Show token help if help requested or no verb specified
var vm = Container.MainViewModel; parsedArgs.WithNotParsed(errs =>
vm.ExportAsync( {
options.Token, var err = errs.First();
options.ChannelId,
options.FilePath,
options.ExportFormat,
options.From,
options.To).GetAwaiter().GetResult();
// Cleanup container if (err.Tag == ErrorType.NoVerbSelectedError)
Container.Cleanup(); PrintTokenHelp();
Console.WriteLine("Export complete."); if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
PrintTokenHelp();
});
} }
} }
} }
@@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportChatVerb : Verb<ExportChatOptions>
{
public ExportChatVerb(ExportChatOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var container = new Container();
var settingsService = container.Resolve<ISettingsService>();
var dataService = container.Resolve<IDataService>();
var messageGroupService = container.Resolve<IMessageGroupService>();
var exportService = container.Resolve<IExportService>();
// Configure settings
if (Options.DateFormat.IsNotBlank())
settingsService.DateFormat = Options.DateFormat;
if (Options.MessageGroupLimit > 0)
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
// Get channel and guild
var channel = await dataService.GetChannelAsync(Options.GetToken(), Options.ChannelId);
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await dataService.GetGuildAsync(Options.GetToken(), channel.GuildId);
// Generate file path if not set
var filePath = Options.FilePath;
if (filePath == null || filePath.EndsWith("/") || filePath.EndsWith("\\"))
{
filePath += $"{guild.Name} - {channel.Name}.{Options.ExportFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
}
// TODO: extract this to make it reusable across implementations
// Get messages
var messages =
await dataService.GetChannelMessagesAsync(Options.GetToken(), channel.Id,
Options.After, Options.Before);
// Group messages
var messageGroups = messageGroupService.GroupMessages(messages);
// Get mentionables
var mentionables = await dataService.GetMentionablesAsync(Options.GetToken(), guild.Id, messages);
// Create log
var log = new ChatLog(guild, channel, Options.After, Options.Before, messageGroups, mentionables);
// Export
exportService.Export(Options.ExportFormat, filePath, log);
// Print result
Console.WriteLine($"Exported chat to [{filePath}]");
}
}
}
@@ -0,0 +1,33 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetChannelsVerb : Verb<GetChannelsOptions>
{
public GetChannelsVerb(GetChannelsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var container = new Container();
var dataService = container.Resolve<IDataService>();
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Print result
foreach (var channel in channels.Where(c => c.Type.IsEither(ChannelType.GuildTextChat))
.OrderBy(c => c.Name))
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,30 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
{
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var container = new Container();
var dataService = container.Resolve<IDataService>();
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Print result
foreach (var channel in channels.OrderBy(c => c.Name))
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,30 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetGuildsVerb : Verb<GetGuildsOptions>
{
public GetGuildsVerb(GetGuildsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var container = new Container();
var dataService = container.Resolve<IDataService>();
// Get guilds
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
// Print result
foreach (var guild in guilds.OrderBy(g => g.Name))
Console.WriteLine($"{guild.Id} | {guild.Name}");
}
}
}
@@ -0,0 +1,31 @@
using System;
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("export", HelpText = "Export channel chat log to a file.")]
public class ExportChatOptions : TokenOptions
{
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
public string ChannelId { get; set; }
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
public ExportFormat ExportFormat { get; set; }
[Option('o', "output", Default = null, HelpText = "Output file path.")]
public string FilePath { get; set; }
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
public DateTime? After { get; set; }
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
public DateTime? Before { get; set; }
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
public string DateFormat { get; set; }
[Option("grouplimit", Default = 0, HelpText = "Message group limit.")]
public int MessageGroupLimit { get; set; }
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("channels", HelpText = "Get the list of channels in the given guild.")]
public class GetChannelsOptions : TokenOptions
{
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
public string GuildId { get; set; }
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("dm", HelpText = "Get the list of direct message channels.")]
public class GetDirectMessageChannelsOptions : TokenOptions
{
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("guilds", HelpText = "Get the list of accessible guilds.")]
public class GetGuildsOptions : TokenOptions
{
}
}
@@ -0,0 +1,16 @@
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
public class TokenOptions
{
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
public string TokenValue { get; set; }
[Option('b', "bot", Default = false, HelpText = "Whether this authorization token belongs to a bot.")]
public bool IsBotToken { get; set; }
public AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("update", HelpText = "Updates this application to the latest version.")]
public class UpdateAppOptions
{
}
}
@@ -0,0 +1,38 @@
using System;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class UpdateAppVerb : Verb<UpdateAppOptions>
{
public UpdateAppVerb(UpdateAppOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get update service
var container = new Container();
var updateService = container.Resolve<IUpdateService>();
// TODO: this is configured only for GUI
// Get update version
var updateVersion = await updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
Console.WriteLine($"Updating to version {updateVersion}");
updateService.NeedRestart = false;
updateService.FinalizeUpdate();
}
else
{
Console.WriteLine("There are no application updates available.");
}
}
}
}
+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,53 +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, channelId, from, to);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var log = new ChannelChatLog(guild, channel, messageGroups, messages.Count);
// Export
await _exportService.ExportAsync(format, filePath, log);
}
}
}
@@ -2,23 +2,30 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net461</TargetFramework> <TargetFramework>net461</TargetFramework>
<Version>2.4.1</Version> <Version>2.7</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" /> <EmbeddedResource Include="Resources\ExportTemplates\PlainText.txt" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" /> <EmbeddedResource Include="Resources\ExportTemplates\HtmlDark.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight.html" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\Core.html" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\Shared.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Csv.csv" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Reference Include="System.Net.Http" /> <Reference Include="mscorlib" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="7.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> <PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="Onova" Version="2.1.0" /> <PackageReference Include="Onova" Version="2.1.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.0" /> <PackageReference Include="Polly" Version="6.0.1" />
<PackageReference Include="Scriban" Version="1.2.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" /> <PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
</ItemGroup> </ItemGroup>
@@ -7,9 +7,13 @@ namespace DiscordChatExporter.Core.Exceptions
{ {
public HttpStatusCode StatusCode { get; } public HttpStatusCode StatusCode { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode) public string ReasonPhrase { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase)
: base($"Error HTTP status code: {statusCode} - {reasonPhrase}")
{ {
StatusCode = statusCode; StatusCode = statusCode;
ReasonPhrase = reasonPhrase;
} }
} }
} }
+14 -13
View File
@@ -1,22 +1,23 @@
using System.IO; using System;
using System.Reflection; using System.Drawing;
using System.Resources; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Internal namespace DiscordChatExporter.Core.Internal
{ {
internal static class Extensions internal static class Extensions
{ {
public static string GetManifestResourceString(this Assembly assembly, string resourceName) public static string ToSnowflake(this DateTime dateTime)
{ {
var stream = assembly.GetManifestResourceStream(resourceName); const long epoch = 62135596800000;
if (stream == null) var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
throw new MissingManifestResourceException($"Could not find resource [{resourceName}]."); var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
using (stream)
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
} }
public static string Base64Encode(this string str) => str.GetBytes().ToBase64();
public static string Base64Decode(this string str) => str.FromBase64().GetString();
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
} }
} }
+13 -4
View File
@@ -1,24 +1,33 @@
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#attachment-object
public class Attachment public class Attachment
{ {
public string Id { get; } public string Id { get; }
public AttachmentType Type { get; }
public string Url { get; } public string Url { get; }
public int? Width { get; }
public int? Height { get; }
public bool IsImage => Width != null;
public string FileName { get; } public string FileName { get; }
public long FileSize { get; } public long FileSize { get; }
public Attachment(string id, AttachmentType type, string url, string fileName, long fileSize) public Attachment(string id, int? width, int? height, string url, string fileName, long fileSize)
{ {
Id = id; Id = id;
Type = type;
Url = url; Url = url;
Width = width;
Height = height;
FileName = fileName; FileName = fileName;
FileSize = fileSize; FileSize = fileSize;
} }
public override string ToString() => FileName;
} }
} }
@@ -0,0 +1,15 @@
namespace DiscordChatExporter.Core.Models
{
public class AuthToken
{
public AuthTokenType Type { get; }
public string Value { get; }
public AuthToken(AuthTokenType type, string value)
{
Type = type;
Value = value;
}
}
}
@@ -1,8 +1,8 @@
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
public enum AttachmentType public enum AuthTokenType
{ {
Other, User,
Image Bot
} }
} }
+5 -8
View File
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#channel-object
public partial class Channel public partial class Channel
{ {
public string Id { get; } public string Id { get; }
@@ -21,17 +23,12 @@
Type = type; Type = type;
} }
public override string ToString() public override string ToString() => Name;
{
return Name;
}
} }
public partial class Channel public partial class Channel
{ {
public static Channel CreateDeletedChannel(string id) public static Channel CreateDeletedChannel(string id) =>
{ new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
return new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
}
} }
} }
@@ -1,24 +0,0 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public class ChannelChatLog
{
public Guild Guild { get; }
public Channel Channel { get; }
public IReadOnlyList<MessageGroup> MessageGroups { get; }
public int TotalMessageCount { get; }
public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList<MessageGroup> messageGroups,
int totalMessageCount)
{
Guild = guild;
Channel = channel;
MessageGroups = messageGroups;
TotalMessageCount = totalMessageCount;
}
}
}
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
public enum ChannelType public enum ChannelType
{ {
GuildTextChat, GuildTextChat,
@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Models
{
public class ChatLog
{
public Guild Guild { get; }
public Channel Channel { get; }
public DateTime? From { get; }
public DateTime? To { get; }
public IReadOnlyList<MessageGroup> MessageGroups { get; }
public int TotalMessageCount => MessageGroups.Sum(g => g.Messages.Count);
public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
IReadOnlyList<MessageGroup> messageGroups, Mentionables mentionables)
{
Guild = guild;
Channel = channel;
From = from;
To = to;
MessageGroups = messageGroups;
Mentionables = mentionables;
}
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
}
}
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Drawing;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object
public class Embed
{
public string Title { get; }
public string Url { get; }
public DateTime? Timestamp { get; }
public Color Color { get; }
public EmbedAuthor Author { get; }
public string Description { get; }
public IReadOnlyList<EmbedField> Fields { get; }
public EmbedImage Thumbnail { get; }
public EmbedImage Image { get; }
public EmbedFooter Footer { get; }
public Embed(string title, string url, DateTime? timestamp, Color color, EmbedAuthor author, string description,
IReadOnlyList<EmbedField> fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer)
{
Title = title;
Url = url;
Timestamp = timestamp;
Color = color;
Author = author;
Description = description;
Fields = fields;
Thumbnail = thumbnail;
Image = image;
Footer = footer;
}
public override string ToString() => Title;
}
}
@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
public class EmbedAuthor
{
public string Name { get; }
public string Url { get; }
public string IconUrl { get; }
public EmbedAuthor(string name, string url, string iconUrl)
{
Name = name;
Url = url;
IconUrl = iconUrl;
}
public override string ToString() => Name;
}
}
@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
public class EmbedField
{
public string Name { get; }
public string Value { get; }
public bool IsInline { get; }
public EmbedField(string name, string value, bool isInline)
{
Name = name;
Value = value;
IsInline = isInline;
}
public override string ToString() => $"{Name} | {Value}";
}
}
@@ -0,0 +1,19 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
public class EmbedFooter
{
public string Text { get; }
public string IconUrl { get; }
public EmbedFooter(string text, string iconUrl)
{
Text = text;
IconUrl = iconUrl;
}
public override string ToString() => Text;
}
}
@@ -0,0 +1,20 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
public class EmbedImage
{
public string Url { get; }
public int? Width { get; }
public int? Height { get; }
public EmbedImage(string url, int? width, int? height)
{
Url = url;
Height = height;
Width = width;
}
}
}
+6 -11
View File
@@ -1,8 +1,9 @@
using System.Collections.Generic; using Tyrrrz.Extensions;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
// https://discordapp.com/developers/docs/resources/guild#guild-object
public partial class Guild public partial class Guild
{ {
public string Id { get; } public string Id { get; }
@@ -15,24 +16,18 @@ namespace DiscordChatExporter.Core.Models
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png" ? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png"; : "https://cdn.discordapp.com/embed/avatars/0.png";
public IReadOnlyList<Role> Roles { get; } public Guild(string id, string name, string iconHash)
public Guild(string id, string name, string iconHash, IReadOnlyList<Role> roles)
{ {
Id = id; Id = id;
Name = name; Name = name;
IconHash = iconHash; IconHash = iconHash;
Roles = roles;
} }
public override string ToString() public override string ToString() => Name;
{
return Name;
}
} }
public partial class Guild public partial class Guild
{ {
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null, new Role[0]); public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
} }
} }
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Models
{
public class Mentionables
{
public IReadOnlyList<User> Users { get; }
public IReadOnlyList<Channel> Channels { get; }
public IReadOnlyList<Role> Roles { get; }
public Mentionables(IReadOnlyList<User> users, IReadOnlyList<Channel> channels, IReadOnlyList<Role> roles)
{
Users = users;
Channels = channels;
Roles = roles;
}
public User GetUser(string id) =>
Users.FirstOrDefault(u => u.Id == id) ?? User.CreateUnknownUser(id);
public Channel GetChannel(string id) =>
Channels.FirstOrDefault(c => c.Id == id) ?? Channel.CreateDeletedChannel(id);
public Role GetRole(string id) =>
Roles.FirstOrDefault(r => r.Id == id) ?? Role.CreateDeletedRole(id);
}
}
+16 -19
View File
@@ -3,6 +3,8 @@ using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#message-object
public class Message public class Message
{ {
public string Id { get; } public string Id { get; }
@@ -13,42 +15,37 @@ namespace DiscordChatExporter.Core.Models
public User Author { get; } public User Author { get; }
public DateTime TimeStamp { get; } public DateTime Timestamp { get; }
public DateTime? EditedTimeStamp { get; } public DateTime? EditedTimestamp { get; }
public string Content { get; } public string Content { get; }
public IReadOnlyList<Attachment> Attachments { get; } public IReadOnlyList<Attachment> Attachments { get; }
public IReadOnlyList<Embed> Embeds { get; }
public IReadOnlyList<Reaction> Reactions { get; }
public IReadOnlyList<User> MentionedUsers { get; } public IReadOnlyList<User> MentionedUsers { get; }
public IReadOnlyList<Role> MentionedRoles { get; } public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
public IReadOnlyList<Channel> MentionedChannels { get; } IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
public Message(string id, string channelId, MessageType type,
User author, DateTime timeStamp,
DateTime? editedTimeStamp, string content,
IReadOnlyList<Attachment> attachments, IReadOnlyList<User> mentionedUsers,
IReadOnlyList<Role> mentionedRoles, IReadOnlyList<Channel> mentionedChannels)
{ {
Id = id; Id = id;
ChannelId = channelId; ChannelId = channelId;
Type = type; Type = type;
Author = author; Author = author;
TimeStamp = timeStamp; Timestamp = timestamp;
EditedTimeStamp = editedTimeStamp; EditedTimestamp = editedTimestamp;
Content = content; Content = content;
Attachments = attachments; Attachments = attachments;
Embeds = embeds;
Reactions = reactions;
MentionedUsers = mentionedUsers; MentionedUsers = mentionedUsers;
MentionedRoles = mentionedRoles;
MentionedChannels = mentionedChannels;
} }
public override string ToString() public override string ToString() => Content;
{
return Content;
}
} }
} }
@@ -7,15 +7,17 @@ namespace DiscordChatExporter.Core.Models
{ {
public User Author { get; } public User Author { get; }
public DateTime TimeStamp { get; } public DateTime Timestamp { get; }
public IReadOnlyList<Message> Messages { get; } public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime timeStamp, IReadOnlyList<Message> messages) public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
{ {
Author = author; Author = author;
TimeStamp = timeStamp; Timestamp = timestamp;
Messages = messages; Messages = messages;
} }
public override string ToString() => $"{Author.FullName} | {Timestamp} | {Messages.Count} messages";
} }
} }
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
public enum MessageType public enum MessageType
{ {
Default, Default,
@@ -0,0 +1,20 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#reaction-object
public class Reaction
{
public int Count { get; }
public string EmojiId { get; }
public string EmojiName { get; }
public Reaction(int count, string emojiId, string emojiName)
{
Count = count;
EmojiId = emojiId;
EmojiName = emojiName;
}
}
}
+5 -8
View File
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class Role public partial class Role
{ {
public string Id { get; } public string Id { get; }
@@ -12,17 +14,12 @@
Name = name; Name = name;
} }
public override string ToString() public override string ToString() => Name;
{
return Name;
}
} }
public partial class Role public partial class Role
{ {
public static Role CreateDeletedRole(string id) public static Role CreateDeletedRole(string id) =>
{ new Role(id, "deleted-role");
return new Role(id, "deleted-role");
}
} }
} }
+36 -11
View File
@@ -1,8 +1,11 @@
using Tyrrrz.Extensions; using System;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
public class User // https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class User
{ {
public string Id { get; } public string Id { get; }
@@ -12,13 +15,32 @@ namespace DiscordChatExporter.Core.Models
public string FullName => $"{Name}#{Discriminator:0000}"; public string FullName => $"{Name}#{Discriminator:0000}";
public string AvatarHash { get; }
public string DefaultAvatarHash => $"{Discriminator % 5}"; public string DefaultAvatarHash => $"{Discriminator % 5}";
public string AvatarUrl => AvatarHash.IsNotBlank() public string AvatarHash { get; }
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png"; 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) public User(string id, int discriminator, string name, string avatarHash)
{ {
@@ -28,9 +50,12 @@ namespace DiscordChatExporter.Core.Models
AvatarHash = avatarHash; AvatarHash = avatarHash;
} }
public override string ToString() public override string ToString() => FullName;
{ }
return FullName;
} public partial class User
{
public static User CreateUnknownUser(string id) =>
new User(id, 0, "Unknown", null);
} }
} }
@@ -1,142 +0,0 @@
body {
background-color: #36393E;
color: rgba(255, 255, 255, 0.7);
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
color: #0096CF;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
div.pre {
background-color: #2F3136;
color: rgb(131, 148, 150);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
}
span.pre {
background-color: #2F3136;
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
white-space: pre-wrap;
}
div#info {
display: flex;
margin-bottom: 10px;
margin-left: 5px;
margin-right: 5px;
max-width: 100%;
}
div#log {
max-width: 100%;
}
img.guild-icon {
max-height: 64px;
max-width: 64px;
}
div.info-right {
flex: 1;
margin-left: 10px;
}
div.guild-name {
color: #FFFFFF;
font-size: 1.4em;
}
div.channel-name {
color: #FFFFFF;
font-size: 1.2em;
}
div.channel-topic {
margin-top: 2px;
color: #FFFFFF;
}
div.channel-messagecount {
margin-top: 2px;
}
div.msg {
border-top: 1px solid rgba(255, 255, 255, 0.04);
display: flex;
margin-left: 10px;
margin-right: 10px;
padding-bottom: 15px;
padding-top: 15px;
}
div.msg-left {
height: 40px;
width: 40px;
}
img.msg-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
div.msg-right {
flex: 1;
margin-left: 20px;
min-width: 50%;
}
span.msg-user {
color: #FFFFFF;
font-size: 1em;
}
span.msg-date {
color: rgba(255, 255, 255, 0.2);
font-size: .75em;
margin-left: 5px;
}
span.msg-edited {
color: rgba(255, 255, 255, 0.2);
font-size: .8em;
margin-left: 5px;
}
div.msg-content {
font-size: .9375em;
padding-top: 5px;
word-wrap: break-word;
}
div.msg-attachment {
margin-bottom: 5px;
margin-top: 5px;
}
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -1,142 +0,0 @@
body {
background-color: #FFFFFF;
color: #737F8D;
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
color: #00B0F4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
div.pre {
background-color: #F9F9F9;
color: rgb(101, 123, 131);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
}
span.pre {
background-color: #F9F9F9;
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
white-space: pre-wrap;
}
div#info {
display: flex;
margin-bottom: 10px;
margin-left: 5px;
margin-right: 5px;
max-width: 100%;
}
div#log {
max-width: 100%;
}
img.guild-icon {
max-height: 64px;
max-width: 64px;
}
div.info-right {
flex: 1;
margin-left: 10px;
}
div.guild-name {
color: #2F3136;
font-size: 1.4em;
}
div.channel-name {
color: #2F3136;
font-size: 1.2em;
}
div.channel-topic {
margin-top: 2px;
color: #2F3136;
}
div.channel-messagecount {
margin-top: 2px;
}
div.msg {
border-top: 1px solid #ECEEEF;
display: flex;
margin-left: 10px;
margin-right: 10px;
padding-bottom: 15px;
padding-top: 15px;
}
div.msg-left {
height: 40px;
width: 40px;
}
img.msg-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
div.msg-right {
flex: 1;
margin-left: 20px;
min-width: 50%;
}
span.msg-user {
color: #2F3136;
font-size: 1em;
}
span.msg-date {
color: #99AAB5;
font-size: .75em;
margin-left: 5px;
}
span.msg-edited {
color: #99AAB5;
font-size: .8em;
margin-left: 5px;
}
div.msg-content {
font-size: .9375em;
padding-top: 5px;
word-wrap: break-word;
}
div.msg-attachment {
margin-bottom: 5px;
margin-top: 5px;
}
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -0,0 +1,12 @@
Author;Date;Content;Attachments;
{{~ for group in Model.MessageGroups -}}
{{- for message in group.Messages -}}
{{- message.Author.FullName }};
{{- message.Timestamp | FormatDate }};
{{- message.Content | FormatContent }};
{{- message.Attachments | array.map "Url" | array.join "," }};
{{~ end -}}
{{- end -}}
Can't render this file because it contains an unexpected character in line 10 and column 41.
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<style>
{{ StyleSheet }}
</style>
</head>
<body>
{{~ # Info ~}}
<div class="info">
<div class="info__guild-icon-container">
<img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
</div>
<div class="info__metadata">
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
{{~ if Model.Channel.Topic ~}}
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
{{~ end ~}}
<div class="info__channel-message-count">{{ Model.TotalMessageCount | Format "N0" }} messages</div>
{{~ if Model.From || Model.To ~}}
<div class="info__channel-date-range">
{{~ if Model.From && Model.To ~}}
Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
{{~ else if Model.From ~}}
After {{ Model.From | FormatDate | html.escape }}
{{~ else if Model.To ~}}
Before {{ Model.To | FormatDate | html.escape }}
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
{{~ # Log ~}}
<div class="chatlog">
{{~ for group in Model.MessageGroups ~}}
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}">{{ group.Author.Name | html.escape }}</span>
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
{{ message.Content | FormatContent }}
{{~ # Edited timestamp ~}}
{{~ if message.EditedTimestamp ~}}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment">
<a href="{{ attachment.Url }}">
{{ # Image }}
{{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
{{~ end ~}}
</a>
</div>
{{~ end ~}}
{{~ # Embeds ~}}
{{~ for embed in message.Embeds ~}}
<div class="chatlog__embed">
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color | FormatColor }})"></div>
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
{{~ # Author ~}}
{{~ if embed.Author ~}}
<div class="chatlog__embed-author">
{{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
{{~ end ~}}
{{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name">
{{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{~ else ~}}
{{ embed.Author.Name | html.escape }}
{{~ end ~}}
</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Title ~}}
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
{{~ else ~}}
{{ embed.Title | FormatContent }}
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
{{~ end ~}}
{{~ # Fields ~}}
{{~ if embed.Fields | array.size > 0 ~}}
<div class="chatlog__embed-fields">
{{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}}
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ # Thumbnail ~}}
{{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
</a>
</div>
{{~ end ~}}
</div>
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
</a>
</div>
{{~ end ~}}
{{~ # Footer ~}}
{{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
{{~ end ~}}
{{~ end ~}}
<span class="chatlog__embed-footer-text">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }}
{{~ end ~}}
{{~ end ~}}
{{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | html.escape }}
{{~ end ~}}
</span>
</div>
{{~ end ~}}
</div>
</div>
{{~ end ~}}
{{~ # Reactions ~}}
{{~ if message.Reactions | array.size > 0 ~}}
<div class="chatlog__reactions">
{{~ for reaction in message.Reactions ~}}
<div class="chatlog__reaction">
<span class="chatlog__reaction-emoji">
{{~ # Custom emoji ~}}
{{~ if reaction.EmojiId ~}}
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
{{~ # Default emoji ~}}
{{~ else ~}}
{{ reaction.EmojiName }}
{{~ end ~}}
</span>
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ end ~}}
</div>
</div>
{{~ end ~}}
</div>
</body>
</html>
@@ -0,0 +1,97 @@
/* === GENERAL === */
body {
background-color: #36393e;
color: #ffffffb3;
}
a {
color: #0096cf;
}
.pre {
background-color: #2f3136;
}
.pre--multiline {
border-color: #282b30;
color: #839496;
}
.mention {
background-color: #738bd71a;
color: #7289da;
}
/* === INFO === */
.info__guild-name {
color: #ffffff;
}
.info__channel-name {
color: #ffffff;
}
.info__channel-topic {
color: #ffffff;
}
/* === CHATLOG === */
.chatlog__message-group {
border-color: #ffffff0a;
}
.chatlog__author-name {
color: #ffffff;
}
.chatlog__timestamp {
color: #ffffff33;
}
.chatlog__edited-timestamp {
color: #ffffff33;
}
.chatlog__embed-content-container {
background-color: #2e30364d;
border-color: #2e303699;
}
.chatlog__embed-author-name {
color: #ffffff;
}
.chatlog__embed-author-name-link {
color: #ffffff;
}
.chatlog__embed-title {
color: #ffffff;
}
.chatlog__embed-description {
color: #ffffff99;
}
.chatlog__embed-field-name {
color: #ffffff;
}
.chatlog__embed-field-value {
color: #ffffff99;
}
.chatlog__embed-footer {
color: #ffffff99;
}
.chatlog__reaction {
background-color: #ffffff0a;
}
.chatlog__reaction-count {
color: #ffffff4d;
}
@@ -0,0 +1,97 @@
/* === GENERAL === */
body {
background-color: #ffffff;
color: #737f8d;
}
a {
color: #00b0f4;
}
.pre {
background-color: #f9f9f9;
}
.pre--multiline {
border-color: #f3f3f3;
color: #657b83;
}
.mention {
background-color: #f1f3fb;
color: #7289da;
}
/* === INFO === */
.info__guild-name {
color: #2f3136;
}
.info__channel-name {
color: #2f3136;
}
.info__channel-topic {
color: #2f3136;
}
/* === CHATLOG === */
.chatlog__message-group {
border-color: #eceeef;
}
.chatlog__author-name {
color: #2f3136;
}
.chatlog__timestamp {
color: #99aab5;
}
.chatlog__edited-timestamp {
color: #99aab5;
}
.chatlog__embed-content-container {
background-color: #f9f9f94d;
border-color: #cccccc4d;
}
.chatlog__embed-author-name {
color: #4f545c;
}
.chatlog__embed-author-name-link {
color: #4f545c;
}
.chatlog__embed-title {
color: #4f545c;
}
.chatlog__embed-description {
color: #737f8d;
}
.chatlog__embed-field-name {
color: #36393e;
}
.chatlog__embed-field-value {
color: #737f8d;
}
.chatlog__embed-footer {
color: #4f535b99;
}
.chatlog__reaction {
background-color: #4f545c0f;
}
.chatlog__reaction-count {
color: #99aab5;
}
@@ -0,0 +1,303 @@
/* === GENERAL === */
body {
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
object-fit: contain;
}
.pre {
font-family: "Consolas", "Courier New", Courier, Monospace;
white-space: pre-wrap;
}
.pre--multiline {
margin-top: 4px;
padding: 8px;
border: 2px solid;
border-radius: 5px;
}
.pre--inline {
padding: 2px;
border-radius: 3px;
}
.emoji {
width: 24px;
height: 24px;
margin: 0 1px;
vertical-align: middle;
}
.emoji--small {
width: 16px;
height: 16px;
}
.emoji--large {
width: 32px;
height: 32px;
}
.mention {
font-weight: 600;
}
/* === INFO === */
.info {
display: flex;
max-width: 100%;
margin: 0 5px 10px 5px;
}
.info__guild-icon-container {
flex: 0;
}
.info__guild-icon {
max-width: 88px;
max-height: 88px;
}
.info__metadata {
flex: 1;
margin-left: 10px;
}
.info__guild-name {
font-size: 1.4em;
}
.info__channel-name {
font-size: 1.2em;
}
.info__channel-topic {
margin-top: 2px;
}
.info__channel-message-count {
margin-top: 2px;
}
.info__channel-date-range {
margin-top: 2px;
}
/* === CHATLOG === */
.chatlog {
max-width: 100%;
}
.chatlog__message-group {
display: flex;
margin: 0 10px;
padding: 15px 0;
border-top: 1px solid;
}
.chatlog__author-avatar-container {
flex: 0;
width: 40px;
height: 40px;
}
.chatlog__author-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
.chatlog__messages {
flex: 1;
min-width: 50%;
margin-left: 20px;
}
.chatlog__author-name {
font-size: 1em;
}
.chatlog__timestamp {
margin-left: 5px;
font-size: .75em;
}
.chatlog__content {
padding-top: 5px;
font-size: .9375em;
word-wrap: break-word;
}
.chatlog__edited-timestamp {
margin-left: 5px;
font-size: .8em;
}
.chatlog__attachment {
margin: 5px 0;
}
.chatlog__attachment-thumbnail {
max-width: 50%;
max-height: 500px;
border-radius: 3px;
}
.chatlog__embed {
display: flex;
max-width: 520px;
margin-top: 5px;
}
.chatlog__embed-color-pill {
flex-shrink: 0;
width: 4px;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.chatlog__embed-content-container {
display: flex;
flex-direction: column;
padding: 8px 10px;
border: 1px solid;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
.chatlog__embed-content {
width: 100%;
display: flex;
}
.chatlog__embed-text {
flex: 1;
}
.chatlog__embed-author {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.chatlog__embed-author-icon {
width: 20px;
height: 20px;
margin-right: 9px;
border-radius: 50%;
}
.chatlog__embed-author-name {
font-size: .875em;
font-weight: 600;
}
.chatlog__embed-title {
margin-bottom: 4px;
font-size: .875em;
font-weight: 600;
}
.chatlog__embed-description {
font-weight: 500;
font-size: 14px;
}
.chatlog__embed-fields {
display: flex;
flex-wrap: wrap;
}
.chatlog__embed-field {
flex: 0;
min-width: 100%;
max-width: 506px;
padding-top: 10px;
}
.chatlog__embed-field--inline {
flex: 1;
flex-basis: auto;
min-width: 150px;
}
.chatlog__embed-field-name {
margin-bottom: 4px;
font-size: .875em;
font-weight: 600;
}
.chatlog__embed-field-value {
font-size: .875em;
font-weight: 500;
}
.chatlog__embed-thumbnail {
flex: 0;
margin-left: 20px;
max-width: 80px;
max-height: 80px;
border-radius: 3px;
}
.chatlog__embed-image-container {
margin-top: 10px;
}
.chatlog__embed-image {
max-width: 500px;
max-height: 400px;
border-radius: 3px;
}
.chatlog__embed-footer {
margin-top: 10px;
}
.chatlog__embed-footer-icon {
margin-right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
}
.chatlog__embed-footer-text {
font-weight: 600;
font-size: .75em;
}
.chatlog__reactions {
display: flex;
}
.chatlog__reaction {
margin: 6px 2px 2px 2px;
padding: 2px 6px 2px 2px;
border-radius: 3px;
}
.chatlog__reaction-emoji {
margin-left: 3px;
vertical-align: middle;
}
.chatlog__reaction-count {
font-size: .875em;
vertical-align: middle;
}
@@ -0,0 +1,7 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.DarkTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -0,0 +1,7 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.LightTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -0,0 +1,25 @@
{{~ # Info ~}}
==============================================================
Guild: {{ Model.Guild.Name }}
Channel: {{ Model.Channel.Name }}
Topic: {{ Model.Channel.Topic }}
Messages: {{ Model.TotalMessageCount | Format "N0" }}
Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model.From || Model.To }}->{{ end }}{{ if Model.To }} {{ Model.To | FormatDate }}{{ end }}
==============================================================
{{~ # Log ~}}
{{~ for group in Model.MessageGroups ~}}
{{~ # Author name and timestamp ~}}
{{~ }}[{{ group.Timestamp | FormatDate }}] {{ group.Author.FullName }}
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ message.Content | FormatContent }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ end ~}}
{{~ end ~}}
@@ -0,0 +1,196 @@
using System;
using System.Drawing;
using System.Linq;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService
{
private User ParseUser(JToken json)
{
var id = json["id"].Value<string>();
var discriminator = json["discriminator"].Value<int>();
var name = json["username"].Value<string>();
var avatarHash = json["avatar"].Value<string>();
return new User(id, discriminator, name, avatarHash);
}
private Guild ParseGuild(JToken json)
{
var id = json["id"].Value<string>();
var name = json["name"].Value<string>();
var iconHash = json["icon"].Value<string>();
return new Guild(id, name, iconHash);
}
private Channel ParseChannel(JToken json)
{
// Get basic data
var id = json["id"].Value<string>();
var type = (ChannelType) json["type"].Value<int>();
var topic = json["topic"]?.Value<string>();
// Try to extract guild ID
var guildId = json["guild_id"]?.Value<string>();
// If the guild ID is blank, it's direct messages
if (guildId.IsBlank())
guildId = Guild.DirectMessages.Id;
// Try to extract name
var name = json["name"]?.Value<string>();
// If the name is blank, it's direct messages
if (name.IsBlank())
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
return new Channel(id, guildId, name, topic, type);
}
private Role ParseRole(JToken json)
{
var id = json["id"].Value<string>();
var name = json["name"].Value<string>();
return new Role(id, name);
}
private Attachment ParseAttachment(JToken json)
{
var id = json["id"].Value<string>();
var url = json["url"].Value<string>();
var width = json["width"]?.Value<int>();
var height = json["height"]?.Value<int>();
var fileName = json["filename"].Value<string>();
var fileSize = json["size"].Value<long>();
return new Attachment(id, width, height, url, fileName, fileSize);
}
private EmbedAuthor ParseEmbedAuthor(JToken json)
{
var name = json["name"]?.Value<string>();
var url = json["url"]?.Value<string>();
var iconUrl = json["icon_url"]?.Value<string>();
return new EmbedAuthor(name, url, iconUrl);
}
private EmbedField ParseEmbedField(JToken json)
{
var name = json["name"].Value<string>();
var value = json["value"].Value<string>();
var isInline = json["inline"]?.Value<bool>() ?? false;
return new EmbedField(name, value, isInline);
}
private EmbedImage ParseEmbedImage(JToken json)
{
var url = json["url"]?.Value<string>();
var width = json["width"]?.Value<int>();
var height = json["height"]?.Value<int>();
return new EmbedImage(url, width, height);
}
private EmbedFooter ParseEmbedFooter(JToken json)
{
var text = json["text"].Value<string>();
var iconUrl = json["icon_url"]?.Value<string>();
return new EmbedFooter(text, iconUrl);
}
private Embed ParseEmbed(JToken json)
{
// Get basic data
var title = json["title"]?.Value<string>();
var description = json["description"]?.Value<string>();
var url = json["url"]?.Value<string>();
var timestamp = json["timestamp"]?.Value<DateTime>();
// Get color
var color = json["color"] != null
? Color.FromArgb(json["color"].Value<int>()).ResetAlpha()
: Color.FromArgb(79, 84, 92); // default color
// Get author
var author = json["author"] != null ? ParseEmbedAuthor(json["author"]) : null;
// Get fields
var fields = json["fields"].EmptyIfNull().Select(ParseEmbedField).ToArray();
// Get thumbnail
var thumbnail = json["thumbnail"] != null ? ParseEmbedImage(json["thumbnail"]) : null;
// Get image
var image = json["image"] != null ? ParseEmbedImage(json["image"]) : null;
// Get footer
var footer = json["footer"] != null ? ParseEmbedFooter(json["footer"]) : null;
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
}
private Reaction ParseReaction(JToken json)
{
var count = json["count"].Value<int>();
var emojiId = json["emoji"]["id"]?.Value<string>();
var emojiName = json["emoji"]["name"].Value<string>();
return new Reaction(count, emojiId, emojiName);
}
private Message ParseMessage(JToken json)
{
// Get basic data
var id = json["id"].Value<string>();
var channelId = json["channel_id"].Value<string>();
var timestamp = json["timestamp"].Value<DateTime>();
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>();
var content = json["content"].Value<string>();
var type = (MessageType) json["type"].Value<int>();
// Workarounds for non-default types
if (type == MessageType.RecipientAdd)
content = "Added a recipient.";
else if (type == MessageType.RecipientRemove)
content = "Removed a recipient.";
else if (type == MessageType.Call)
content = "Started a call.";
else if (type == MessageType.ChannelNameChange)
content = "Changed the channel name.";
else if (type == MessageType.ChannelIconChange)
content = "Changed the channel icon.";
else if (type == MessageType.ChannelPinnedMessage)
content = "Pinned a message.";
else if (type == MessageType.GuildMemberJoin)
content = "Joined the server.";
// Get author
var author = ParseUser(json["author"]);
// Get attachments
var attachments = json["attachments"].EmptyIfNull().Select(ParseAttachment).ToArray();
// Get embeds
var embeds = json["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray();
// Get reactions
var reactions = json["reactions"].EmptyIfNull().Select(ParseReaction).ToArray();
// Get mentioned users
var mentionedUsers = json["mentions"].EmptyIfNull().Select(ParseUser).ToArray();
return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
reactions, mentionedUsers);
}
}
}
+154 -262
View File
@@ -2,325 +2,217 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text.RegularExpressions; using System.Net.Http.Headers;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using DiscordChatExporter.Core.Internal;
using Polly;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
public partial class DataService : IDataService, IDisposable public partial class DataService : IDataService, IDisposable
{ {
private const string ApiRoot = "https://discordapp.com/api/v6";
private readonly HttpClient _httpClient = new HttpClient(); private readonly HttpClient _httpClient = new HttpClient();
private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>();
private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>();
private User ParseUser(JToken token) private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
params string[] parameters)
{ {
var id = token["id"].Value<string>(); // Create request policy
var discriminator = token["discriminator"].Value<int>(); var policy = Policy
var name = token["username"].Value<string>(); .Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
var avatarHash = token["avatar"].Value<string>(); .WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
return new User(id, discriminator, name, avatarHash); // Send request
} return await policy.ExecuteAsync(async () =>
private Role ParseRole(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
return new Role(id, name);
}
private Guild ParseGuild(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
var iconHash = token["icon"].Value<string>();
var roles = token["roles"].Select(ParseRole).ToArray();
return new Guild(id, name, iconHash, roles);
}
private Channel ParseChannel(JToken token)
{
// Get basic data
var id = token["id"].Value<string>();
var guildId = token["guild_id"]?.Value<string>();
var type = (ChannelType) token["type"].Value<int>();
var topic = token["topic"]?.Value<string>();
// Extract name based on type
string name;
if (type.IsEither(ChannelType.DirectTextChat, ChannelType.DirectGroupTextChat))
{ {
guildId = Guild.DirectMessages.Id; // Create request
const string apiRoot = "https://discordapp.com/api/v6";
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
{
// Set authorization header
request.Headers.Authorization = token.Type == AuthTokenType.Bot
? new AuthenticationHeaderValue("Bot", token.Value)
: new AuthenticationHeaderValue(token.Value);
// Try to get name if it's set // Add parameters
name = token["name"]?.Value<string>(); foreach (var parameter in parameters)
{
var key = parameter.SubstringUntil("=");
var value = parameter.SubstringAfter("=");
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
}
// Otherwise use recipients as the name // Get response
if (name.IsBlank()) using (var response = await _httpClient.SendAsync(request))
name = token["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", "); {
} // Check status code
else // We throw our own exception here because default one doesn't have status code
{ if (!response.IsSuccessStatusCode)
name = token["name"].Value<string>(); throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase);
}
return new Channel(id, guildId, name, topic, type); // Get content
var raw = await response.Content.ReadAsStringAsync();
// Parse
return JToken.Parse(raw);
}
}
});
} }
private Message ParseMessage(JToken token) public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
{ {
// Get basic data var response = await GetApiResponseAsync(token, "guilds", guildId);
var id = token["id"].Value<string>(); var guild = ParseGuild(response);
var channelId = token["channel_id"].Value<string>();
var timeStamp = token["timestamp"].Value<DateTime>();
var editedTimeStamp = token["edited_timestamp"]?.Value<DateTime?>();
var content = token["content"].Value<string>();
var type = (MessageType) token["type"].Value<int>();
// Workarounds for non-default types
if (type == MessageType.RecipientAdd)
content = "Added a recipient.";
else if (type == MessageType.RecipientRemove)
content = "Removed a recipient.";
else if (type == MessageType.Call)
content = "Started a call.";
else if (type == MessageType.ChannelNameChange)
content = "Changed the channel name.";
else if (type == MessageType.ChannelIconChange)
content = "Changed the channel icon.";
else if (type == MessageType.ChannelPinnedMessage)
content = "Pinned a message.";
else if (type == MessageType.GuildMemberJoin)
content = "Joined the server.";
// Get author
var author = ParseUser(token["author"]);
// Get attachment
var attachments = new List<Attachment>();
foreach (var attachmentJson in token["attachments"].EmptyIfNull())
{
var attachmentId = attachmentJson["id"].Value<string>();
var attachmentUrl = attachmentJson["url"].Value<string>();
var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image
: AttachmentType.Other;
var attachmentFileName = attachmentJson["filename"].Value<string>();
var attachmentFileSize = attachmentJson["size"].Value<long>();
var attachment = new Attachment(
attachmentId, attachmentType, attachmentUrl,
attachmentFileName, attachmentFileSize);
attachments.Add(attachment);
}
// Get user mentions
var mentionedUsers = token["mentions"].Select(ParseUser).ToArray();
// Get role mentions
var mentionedRoles = token["mention_roles"]
.Values<string>()
.Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(id))
.ToArray();
// Get channel mentions
var mentionedChannels = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(id))
.ToArray();
return new Message(id, channelId, type, author, timeStamp, editedTimeStamp, content, attachments,
mentionedUsers, mentionedRoles, mentionedChannels);
}
private async Task<string> GetStringAsync(string url)
{
using (var response = await _httpClient.GetAsync(url))
{
// Check status code
// We throw our own exception here because default one doesn't have status code
if (!response.IsSuccessStatusCode)
throw new HttpErrorStatusCodeException(response.StatusCode);
// Get content
return await response.Content.ReadAsStringAsync();
}
}
public async Task<Guild> GetGuildAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var guild = ParseGuild(JToken.Parse(content));
// Add roles to cache
foreach (var role in guild.Roles)
_roleCache[role.Id] = role;
return guild; return guild;
} }
public async Task<Channel> GetChannelAsync(string token, string channelId) public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
{ {
// Form request url var response = await GetApiResponseAsync(token, "channels", channelId);
var url = $"{ApiRoot}/channels/{channelId}?token={token}"; var channel = ParseChannel(response);
// Get response
var content = await GetStringAsync(url);
// Parse
var channel = ParseChannel(JToken.Parse(content));
// Add channel to cache
_channelCache[channel.Id] = channel;
return channel; return channel;
} }
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId) public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
{ {
// Form request url var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}"; var guilds = response.Select(ParseGuild).ToArray();
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
// Add channels to cache
foreach (var channel in channels)
_channelCache[channel.Id] = channel;
return channels;
}
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
// Get response
var content = await GetStringAsync(url);
// Parse IDs
var guildIds = JArray.Parse(content).Select(t => t["id"].Value<string>());
// Get full guild infos
var guilds = new List<Guild>();
foreach (var guildId in guildIds)
{
var guild = await GetGuildAsync(token, guildId);
guilds.Add(guild);
}
return guilds; return guilds;
} }
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token) public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
{ {
// Form request url var response = await GetApiResponseAsync(token, "users", "@me/channels");
var url = $"{ApiRoot}/users/@me/channels?token={token}"; var channels = response.Select(ParseChannel).ToArray();
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels; return channels;
} }
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId, public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
DateTime? from, DateTime? to) {
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
var channels = response.Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
{
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
var roles = response.Select(ParseRole).ToArray();
return roles;
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{ {
var result = new List<Message>(); var result = new List<Message>();
// We are going backwards from last message to first // Report indeterminate progress
// collecting everything between them in batches progress?.Report(-1);
var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
while (true) // Get the snowflakes for the selected range
var firstId = from != null ? from.Value.ToSnowflake() : "0";
var lastId = to != null ? to.Value.ToSnowflake() : DateTime.MaxValue.ToSnowflake();
// Get the last message
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=1", $"before={lastId}");
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
// If the last message doesn't exist or it's outside of range - return
if (lastMessage == null || lastMessage.Timestamp < from)
{ {
// Form request url progress?.Report(1);
var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100"; return result;
if (beforeId.IsNotBlank())
url += $"&before={beforeId}";
// Get response
var content = await GetStringAsync(url);
// Parse
var messages = JArray.Parse(content).Select(ParseMessage);
// Add messages to list
string currentMessageId = null;
foreach (var message in messages)
{
// Break when the message is older than from date
if (from != null && message.TimeStamp < from)
{
currentMessageId = null;
break;
}
// Add message
result.Add(message);
currentMessageId = message.Id;
}
// If no messages - break
if (currentMessageId == null)
break;
// Otherwise offset the next request
beforeId = currentMessageId;
} }
// Messages appear newest first, we need to reverse // Get other messages
result.Reverse(); var offsetId = firstId;
while (true)
{
// Get message batch
response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=100", $"after={offsetId}");
// Parse
var messages = response
.Select(ParseMessage)
.Reverse() // reverse because messages appear newest first
.ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
break;
// Trim messages to range (until last message)
var messagesInRange = messages
.TakeWhile(m => m.Id != lastMessage.Id && m.Timestamp < lastMessage.Timestamp)
.ToArray();
// Add to result
result.AddRange(messagesInRange);
// Break if messages were trimmed (which means the last message was encountered)
if (messagesInRange.Length != messages.Length)
break;
// Report progress (based on the time range of parsed messages compared to total)
progress?.Report((result.Last().Timestamp - result.First().Timestamp).TotalSeconds /
(lastMessage.Timestamp - result.First().Timestamp).TotalSeconds);
// Move offset
offsetId = result.Last().Id;
}
// Add last message
result.Add(lastMessage);
// Report progress
progress?.Report(1);
return result; return result;
} }
protected virtual void Dispose(bool disposing) public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages)
{ {
if (disposing) // Get channels and roles
var channels = guildId != Guild.DirectMessages.Id
? await GetGuildChannelsAsync(token, guildId)
: Array.Empty<Channel>();
var roles = guildId != Guild.DirectMessages.Id
? await GetGuildRolesAsync(token, guildId)
: Array.Empty<Role>();
// Get users
var userMap = new Dictionary<string, User>();
foreach (var message in messages)
{ {
_httpClient.Dispose(); // Author
userMap[message.Author.Id] = message.Author;
// Mentioned users
foreach (var mentionedUser in message.MentionedUsers)
userMap[mentionedUser.Id] = mentionedUser;
} }
var users = userMap.Values.ToArray();
return new Mentionables(users, channels, roles);
} }
public void Dispose() public void Dispose()
{ {
Dispose(true); _httpClient.Dispose();
GC.SuppressFinalize(this);
}
}
public partial class DataService
{
private static string DateTimeToSnowflake(DateTime dateTime)
{
const long epoch = 62135596800000;
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
} }
} }
} }
@@ -1,76 +0,0 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CsvHelper;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentCsv(Message message)
{
var content = message.Content;
// New lines
content = content.Replace("\n", ", ");
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private async Task ExportAsCsvAsync(ChannelChatLog log, TextWriter output)
{
using (var writer = new CsvWriter(output))
{
// Headers
writer.WriteField("Author");
writer.WriteField("Date");
writer.WriteField("Content");
writer.WriteField("Attachments");
await writer.NextRecordAsync();
// Chat log
foreach (var group in log.MessageGroups)
{
// Messages
foreach (var msg in group.Messages)
{
// Author
writer.WriteField(msg.Author.FullName);
// Date
var timeStampFormatted = msg.TimeStamp.ToString(_settingsService.DateFormat);
writer.WriteField(timeStampFormatted);
// Content
var contentFormatted = msg.Content.IsNotBlank() ? FormatMessageContentCsv(msg) : null;
writer.WriteField(contentFormatted);
// Attachments
var attachmentsFormatted = msg.Attachments.Select(a => a.Url).JoinToString(",");
writer.WriteField(attachmentsFormatted);
await writer.NextRecordAsync();
}
}
}
}
}
}
@@ -1,208 +0,0 @@
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentHtml(Message message)
{
// A lot of these regexes were inspired by or taken from MarkdownSharp
var content = message.Content;
// HTML-encode content
content = HtmlEncode(content);
// Encode multiline codeblocks (```text```)
content = Regex.Replace(content,
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
m => $"\x1AM{Base64Encode(m.Groups[1].Value)}\x1AM");
// Encode inline codeblocks (`text`)
content = Regex.Replace(content,
@"`([^`]+)`",
m => $"\x1AI{Base64Encode(m.Groups[1].Value)}\x1AI");
// Encode URLs
content = Regex.Replace(content,
@"((https?|ftp)://[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\]\(\);]*[-a-zA-Z0-9+&@#/%=~_|\[\])])(?=$|\W)",
m => $"\x1AL{Base64Encode(m.Groups[1].Value)}\x1AL");
// Process bold (**text**)
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
// Process underline (__text__)
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
// Process italic (*text* or _text_)
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
// Process strike through (~~text~~)
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
// Decode and process multiline codeblocks
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
m => $"<div class=\"pre\">{Base64Decode(m.Groups[1].Value)}</div>");
// Decode and process inline codeblocks
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre\">{Base64Decode(m.Groups[1].Value)}</span>");
// Decode and process URLs
content = Regex.Replace(content, "\x1AL(.*?)\x1AL",
m => $"<a href=\"{Base64Decode(m.Groups[1].Value)}\">{Base64Decode(m.Groups[1].Value)}</a>");
// New lines
content = content.Replace("\n", "<br />");
// Meta mentions (@everyone)
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
{
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
{
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
{
content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css)
{
// Generation info
await output.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Html start
await output.WriteLineAsync("<!DOCTYPE html>");
await output.WriteLineAsync("<html lang=\"en\">");
// HEAD
await output.WriteLineAsync("<head>");
await output.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
await output.WriteLineAsync("<meta charset=\"utf-8\" />");
await output.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await output.WriteLineAsync($"<style>{css}</style>");
await output.WriteLineAsync("</head>");
// Body start
await output.WriteLineAsync("<body>");
// Guild and channel info
await output.WriteLineAsync("<div id=\"info\">");
await output.WriteLineAsync("<div class=\"info-left\">");
await output.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await output.WriteLineAsync("</div>"); // info-left
await output.WriteLineAsync("<div class=\"info-right\">");
await output.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-topic\">{log.Channel.Topic}</div>");
await output.WriteLineAsync(
$"<div class=\"channel-messagecount\">{log.TotalMessageCount:N0} messages</div>");
await output.WriteLineAsync("</div>"); // info-right
await output.WriteLineAsync("</div>"); // info
// Chat log
await output.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{
await output.WriteLineAsync("<div class=\"msg\">");
await output.WriteLineAsync("<div class=\"msg-left\">");
await output.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
await output.WriteLineAsync("</div>");
await output.WriteLineAsync("<div class=\"msg-right\">");
await output.WriteAsync(
$"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author.FullName)}\">");
await output.WriteAsync(HtmlEncode(group.Author.Name));
await output.WriteLineAsync("</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
await output.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
await output.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContentHtml(message);
await output.WriteAsync(contentFormatted);
// Edited timestamp
if (message.EditedTimeStamp != null)
{
var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
await output.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
await output.WriteLineAsync("</div>"); // msg-content
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.Type == AttachmentType.Image)
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await output.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
else
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
await output.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
}
}
await output.WriteLineAsync("</div>"); // msg-right
await output.WriteLineAsync("</div>"); // msg
}
await output.WriteLineAsync("</div>"); // log
await output.WriteLineAsync("</body>");
await output.WriteLineAsync("</html>");
}
}
}
@@ -1,79 +0,0 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentPlainText(Message message)
{
var content = message.Content;
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private async Task ExportAsPlainTextAsync(ChannelChatLog log, TextWriter output)
{
// Generation info
await output.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter");
await output.WriteLineAsync();
// Guild and channel info
await output.WriteLineAsync('='.Repeat(48));
await output.WriteLineAsync($"Guild: {log.Guild.Name}");
await output.WriteLineAsync($"Channel: {log.Channel.Name}");
await output.WriteLineAsync($"Topic: {log.Channel.Topic}");
await output.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}");
await output.WriteLineAsync('='.Repeat(48));
await output.WriteLineAsync();
// Chat log
foreach (var group in log.MessageGroups)
{
var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat);
await output.WriteLineAsync($"{group.Author.FullName} [{timeStampFormatted}]");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var contentFormatted = FormatMessageContentPlainText(message);
await output.WriteLineAsync(contentFormatted);
}
// Attachments
foreach (var attachment in message.Attachments)
{
await output.WriteLineAsync(attachment.Url);
}
}
await output.WriteLineAsync();
}
}
}
}
@@ -0,0 +1,37 @@
using System.Reflection;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private class TemplateLoader : ITemplateLoader
{
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Resources.ExportTemplates";
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName)
{
return $"{ResourceRootNamespace}.{templateName}";
}
public string GetPath(ExportFormat format)
{
return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}";
}
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
{
return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath);
}
public string Load(ExportFormat format)
{
return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format));
}
}
}
}
@@ -0,0 +1,323 @@
using System;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private class TemplateModel
{
private readonly ExportFormat _format;
private readonly ChatLog _log;
private readonly string _dateFormat;
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat)
{
_format = format;
_log = log;
_dateFormat = dateFormat;
}
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
private string Format(IFormattable obj, string format) =>
obj.ToString(format, CultureInfo.InvariantCulture);
private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat);
private string FormatFileSize(long fileSize)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
var unit = 0;
while (size >= 1024)
{
size /= 1024;
++unit;
}
return $"{size:0.#} {units[unit]}";
}
private string FormatColor(Color color)
{
return $"{color.R},{color.G},{color.B},{color.A}";
}
private string FormatContentPlainText(string content)
{
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private string FormatContentHtml(string content, bool allowLinks = false)
{
// HTML-encode content
content = HtmlEncode(content);
// Encode multiline codeblocks (```text```)
content = Regex.Replace(content,
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM");
// Encode inline codeblocks (`text`)
content = Regex.Replace(content,
@"`([^`]+)`",
m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI");
// Encode links
if (allowLinks)
{
content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)",
m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL");
}
// Encode URLs
content = Regex.Replace(content,
@"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))",
m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU");
// Process bold (**text**)
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
// Process underline (__text__)
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
// Process italic (*text* or _text_)
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
// Process strike through (~~text~~)
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
// Decode and process multiline codeblocks
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
m => $"<div class=\"pre pre--multiline\">{m.Groups[1].Value.Base64Decode()}</div>");
// Decode and process inline codeblocks
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre pre--inline\">{m.Groups[1].Value.Base64Decode()}</span>");
// Decode and process links
if (allowLinks)
{
content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL",
m => $"<a href=\"{m.Groups[2].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
}
// Decode and process URLs
content = Regex.Replace(content, "\x1AU(.*?)\x1AU",
m => $"<a href=\"{m.Groups[1].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
// Process new lines
content = content.Replace("\n", "<br />");
// Meta mentions (@everyone)
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "&lt;@!?(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"&lt;@!?{mentionedUserId}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "&lt;#(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"&lt;#{mentionedChannelId}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "&lt;@&amp;(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"&lt;@&amp;{mentionedRoleId}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
var isJumboable = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;", "").IsBlank();
var emojiClass = isJumboable ? "emoji emoji--large" : "emoji";
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
$"<img class=\"{emojiClass}\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
private string FormatContentCsv(string content)
{
// New lines
content = content.Replace("\n", ", ");
// Escape quotes
content = content.Replace("\"", "\"\"");
// Escape commas and semicolons
if (content.Contains(",") || content.Contains(";"))
content = $"\"{content}\"";
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private string FormatContent(string content, bool allowLinks = false)
{
if (_format == ExportFormat.PlainText)
return FormatContentPlainText(content);
if (_format == ExportFormat.HtmlDark)
return FormatContentHtml(content, allowLinks);
if (_format == ExportFormat.HtmlLight)
return FormatContentHtml(content, allowLinks);
if (_format == ExportFormat.Csv)
return FormatContentCsv(content);
throw new ArgumentOutOfRangeException(nameof(_format));
}
public ScriptObject GetScriptObject()
{
// Create instance
var scriptObject = new ScriptObject();
// Import chat log
scriptObject.SetValue("Model", _log, true);
// Import functions
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
scriptObject.Import(nameof(FormatColor), new Func<Color, string>(FormatColor));
scriptObject.Import(nameof(FormatContent), new Func<string, bool, string>(FormatContent));
return scriptObject;
}
}
}
}
@@ -1,10 +1,7 @@
using System; using System.IO;
using System.IO;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
@@ -18,69 +15,43 @@ namespace DiscordChatExporter.Core.Services
_settingsService = settingsService; _settingsService = settingsService;
} }
public async Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log) public void Export(ExportFormat format, string filePath, ChatLog log)
{ {
// Create template loader
var loader = new TemplateLoader();
// Get template
var templateCode = loader.Load(format);
var template = Template.Parse(templateCode);
// Create template context
var context = new TemplateContext
{
TemplateLoader = loader,
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
// Create template model
var templateModel = new TemplateModel(format, log, _settingsService.DateFormat);
context.PushGlobal(templateModel.GetScriptObject());
// Create directory
var dirPath = Path.GetDirectoryName(filePath);
if (dirPath.IsNotBlank())
Directory.CreateDirectory(dirPath);
// Render output
using (var output = File.CreateText(filePath)) using (var output = File.CreateText(filePath))
{ {
if (format == ExportFormat.PlainText) // Configure output
{ context.PushOutput(new TextWriterOutput(output));
await ExportAsPlainTextAsync(log, output);
}
else if (format == ExportFormat.HtmlDark) // Render template
{ template.Render(context);
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css");
await ExportAsHtmlAsync(log, output, css);
}
else if (format == ExportFormat.HtmlLight)
{
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css");
await ExportAsHtmlAsync(log, output, css);
}
else if (format == ExportFormat.Csv)
{
await ExportAsCsvAsync(log, output);
}
else throw new ArgumentOutOfRangeException(nameof(format));
} }
} }
} }
public partial class ExportService
{
private static string Base64Encode(string str)
{
return str.GetBytes().ToBase64();
}
private static string Base64Decode(string str)
{
return str.FromBase64().GetString();
}
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static string FormatFileSize(long fileSize)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
var unit = 0;
while (size >= 1024)
{
size /= 1024;
++unit;
}
return $"{size:0.#} {units[unit]}";
}
}
} }
@@ -7,17 +7,22 @@ namespace DiscordChatExporter.Core.Services
{ {
public interface IDataService public interface IDataService
{ {
Task<Guild> GetGuildAsync(string token, string guildId); Task<Guild> GetGuildAsync(AuthToken token, string guildId);
Task<Channel> GetChannelAsync(string token, string channelId); Task<Channel> GetChannelAsync(AuthToken token, string channelId);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId); Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token); Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token); Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId, Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
DateTime? from, DateTime? to);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages);
} }
} }
@@ -1,10 +1,9 @@
using System.Threading.Tasks; using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
public interface IExportService public interface IExportService
{ {
Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log); void Export(ExportFormat format, string filePath, ChatLog log);
} }
} }
@@ -5,6 +5,6 @@ namespace DiscordChatExporter.Core.Services
{ {
public interface IMessageGroupService public interface IMessageGroupService
{ {
IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages); IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages);
} }
} }
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Services
string DateFormat { get; set; } string DateFormat { get; set; }
int MessageGroupLimit { get; set; } int MessageGroupLimit { get; set; }
string LastToken { get; set; } AuthToken LastToken { get; set; }
ExportFormat LastExportFormat { get; set; } ExportFormat LastExportFormat { get; set; }
void Load(); void Load();
@@ -13,7 +13,7 @@ namespace DiscordChatExporter.Core.Services
_settingsService = settingsService; _settingsService = settingsService;
} }
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages) public IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{ {
var result = new List<MessageGroup>(); var result = new List<MessageGroup>();
@@ -28,15 +28,15 @@ namespace DiscordChatExporter.Core.Services
groupFirst != null && groupFirst != null &&
( (
message.Author.Id != groupFirst.Author.Id || message.Author.Id != groupFirst.Author.Id ||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 || (message.Timestamp - groupFirst.Timestamp).TotalHours > 1 ||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour || message.Timestamp.Hour != groupFirst.Timestamp.Hour ||
groupBuffer.Count >= _settingsService.MessageGroupLimit groupBuffer.Count >= _settingsService.MessageGroupLimit
); );
// If condition is true - flush buffer // If condition is true - flush buffer
if (breakCondition) if (breakCondition)
{ {
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray()); var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
result.Add(group); result.Add(group);
groupBuffer.Clear(); groupBuffer.Clear();
} }
@@ -49,7 +49,7 @@ namespace DiscordChatExporter.Core.Services
if (groupBuffer.Any()) if (groupBuffer.Any())
{ {
var groupFirst = groupBuffer.First(); var groupFirst = groupBuffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray()); var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
result.Add(group); result.Add(group);
} }
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Core.Services
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt"; public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20; public int MessageGroupLimit { get; set; } = 20;
public string LastToken { get; set; } public AuthToken LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public SettingsService() public SettingsService()
+15 -2
View File
@@ -4,8 +4,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:local="clr-namespace:DiscordChatExporter.Gui" xmlns:local="clr-namespace:DiscordChatExporter.Gui"
Exit="App_Exit"
Startup="App_Startup"
StartupUri="Views/MainWindow.xaml"> StartupUri="Views/MainWindow.xaml">
<Application.Resources> <Application.Resources>
<ResourceDictionary> <ResourceDictionary>
@@ -96,6 +94,21 @@
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style> </Style>
<Style
x:Key="MaterialDesignFlatActionToggleButton"
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
TargetType="{x:Type ToggleButton}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
</Trigger>
</Style.Triggers>
</Style>
<!-- Converters --> <!-- Converters -->
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" /> <converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
+1 -14
View File
@@ -1,19 +1,6 @@
using System.Windows; namespace DiscordChatExporter.Gui
namespace DiscordChatExporter.Gui
{ {
public partial class App 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();
}
} }
} }
+3 -7
View File
@@ -11,12 +11,7 @@ namespace DiscordChatExporter.Gui
public IMainViewModel MainViewModel => Resolve<IMainViewModel>(); public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>(); public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>();
private T Resolve<T>(string key = null) public Container()
{
return ServiceLocator.Current.GetInstance<T>(key);
}
public void Init()
{ {
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default); ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset(); SimpleIoc.Default.Reset();
@@ -34,8 +29,9 @@ namespace DiscordChatExporter.Gui
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true); SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
} }
public void Cleanup() private T Resolve<T>(string key = null)
{ {
return ServiceLocator.Current.GetInstance<T>(key);
} }
} }
} }
@@ -68,8 +68,8 @@
<Reference Include="System.Xaml"> <Reference Include="System.Xaml">
<RequiredTargetFramework>4.0</RequiredTargetFramework> <RequiredTargetFramework>4.0</RequiredTargetFramework>
</Reference> </Reference>
<Reference Include="Tyrrrz.Extensions, Version=1.5.0.0, Culture=neutral, processorArchitecture=MSIL"> <Reference Include="Tyrrrz.Extensions, Version=1.5.1.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Extensions.1.5.0\lib\net45\Tyrrrz.Extensions.dll</HintPath> <HintPath>..\packages\Tyrrrz.Extensions.1.5.1\lib\net45\Tyrrrz.Extensions.dll</HintPath>
</Reference> </Reference>
<Reference Include="Tyrrrz.Settings, Version=1.3.2.0, Culture=neutral, PublicKeyToken=null" /> <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"> <Reference Include="Tyrrrz.WpfExtensions, Version=1.0.6269.37170, Culture=neutral, processorArchitecture=MSIL">
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")] [assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")] [assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")] [assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
[assembly: AssemblyVersion("2.4.1")] [assembly: AssemblyVersion("2.7")]
[assembly: AssemblyFileVersion("2.4.1")] [assembly: AssemblyFileVersion("2.7")]
@@ -34,7 +34,8 @@ namespace DiscordChatExporter.Gui.ViewModels
} }
} }
public IReadOnlyList<ExportFormat> AvailableFormats { get; } public IReadOnlyList<ExportFormat> AvailableFormats =>
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public ExportFormat SelectedFormat public ExportFormat SelectedFormat
{ {
@@ -44,9 +45,9 @@ namespace DiscordChatExporter.Gui.ViewModels
Set(ref _format, value); Set(ref _format, value);
// Replace extension in path // Replace extension in path
var newExt = value.GetFileExtension(); var ext = value.GetFileExtension();
if (FilePath != null && !FilePath.EndsWith(newExt)) if (FilePath != null)
FilePath = FilePath.SubstringUntilLast(".") + "." + newExt; FilePath = Path.ChangeExtension(FilePath, ext);
} }
} }
@@ -69,9 +70,6 @@ namespace DiscordChatExporter.Gui.ViewModels
{ {
_settingsService = settingsService; _settingsService = settingsService;
// Defaults
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
// Commands // Commands
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank()); ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
@@ -93,6 +91,12 @@ namespace DiscordChatExporter.Gui.ViewModels
// Save format // Save format
_settingsService.LastExportFormat = SelectedFormat; _settingsService.LastExportFormat = SelectedFormat;
// Clamp 'from' and 'to' values
if (From > To)
From = To;
if (To < From)
To = From;
// Start export // Start export
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To)); MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
} }
@@ -9,7 +9,11 @@ namespace DiscordChatExporter.Gui.ViewModels
bool IsBusy { get; } bool IsBusy { get; }
bool IsDataAvailable { get; } bool IsDataAvailable { get; }
string Token { get; set; } bool IsProgressIndeterminate { get; }
double Progress { get; }
bool IsBotToken { get; set; }
string TokenValue { get; set; }
IReadOnlyList<Guild> AvailableGuilds { get; } IReadOnlyList<Guild> AvailableGuilds { get; }
Guild SelectedGuild { get; set; } Guild SelectedGuild { get; set; }
@@ -25,7 +25,9 @@ namespace DiscordChatExporter.Gui.ViewModels
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap; private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy; private bool _isBusy;
private string _token; private double _progress;
private bool _isBotToken;
private string _tokenValue;
private IReadOnlyList<Guild> _availableGuilds; private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild; private Guild _selectedGuild;
private IReadOnlyList<Channel> _availableChannels; private IReadOnlyList<Channel> _availableChannels;
@@ -43,15 +45,33 @@ namespace DiscordChatExporter.Gui.ViewModels
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny(); public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
public string Token public bool IsProgressIndeterminate => Progress <= 0;
public double Progress
{ {
get => _token; get => _progress;
private set
{
Set(ref _progress, value);
RaisePropertyChanged(() => IsProgressIndeterminate);
}
}
public bool IsBotToken
{
get => _isBotToken;
set => Set(ref _isBotToken, value);
}
public string TokenValue
{
get => _tokenValue;
set set
{ {
// Remove invalid chars // Remove invalid chars
value = value?.Trim('"'); value = value?.Trim('"');
Set(ref _token, value); Set(ref _tokenValue, value);
PullDataCommand.RaiseCanExecuteChanged(); PullDataCommand.RaiseCanExecuteChanged();
} }
} }
@@ -72,7 +92,7 @@ namespace DiscordChatExporter.Gui.ViewModels
set set
{ {
Set(ref _selectedGuild, value); Set(ref _selectedGuild, value);
AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0]; AvailableChannels = value != null ? _guildChannelsMap[value] : Array.Empty<Channel>();
ShowExportSetupCommand.RaiseCanExecuteChanged(); ShowExportSetupCommand.RaiseCanExecuteChanged();
} }
} }
@@ -104,7 +124,7 @@ namespace DiscordChatExporter.Gui.ViewModels
// Commands // Commands
ViewLoadedCommand = new RelayCommand(ViewLoaded); ViewLoadedCommand = new RelayCommand(ViewLoaded);
ViewClosedCommand = new RelayCommand(ViewClosed); ViewClosedCommand = new RelayCommand(ViewClosed);
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy); PullDataCommand = new RelayCommand(PullData, () => TokenValue.IsNotBlank() && !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings); ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout); ShowAboutCommand = new RelayCommand(ShowAbout);
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy); ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
@@ -119,8 +139,12 @@ namespace DiscordChatExporter.Gui.ViewModels
// Load settings // Load settings
_settingsService.Load(); _settingsService.Load();
// Set last token // Get last token
Token = _settingsService.LastToken; if (_settingsService.LastToken != null)
{
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
TokenValue = _settingsService.LastToken.Value;
}
// Check and prepare update // Check and prepare update
try try
@@ -156,8 +180,10 @@ namespace DiscordChatExporter.Gui.ViewModels
{ {
IsBusy = true; IsBusy = true;
// Copy token so it doesn't get mutated // Create token
var token = Token; var token = new AuthToken(
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
TokenValue);
// Save token // Save token
_settingsService.LastToken = token; _settingsService.LastToken = token;
@@ -222,19 +248,29 @@ namespace DiscordChatExporter.Gui.ViewModels
// Get last used token // Get last used token
var token = _settingsService.LastToken; var token = _settingsService.LastToken;
// Get guild
var guild = SelectedGuild;
// Create progress handler
var progressHandler = new Progress<double>(p => Progress = p);
try try
{ {
// TODO: extract this to make it reusable across implementations
// Get messages // Get messages
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to); var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to, progressHandler);
// Group them // Group messages
var messageGroups = _messageGroupService.GroupMessages(messages); var messageGroups = _messageGroupService.GroupMessages(messages);
// Get mentionables
var mentionables = await _dataService.GetMentionablesAsync(token, guild.Id, messages);
// Create log // Create log
var log = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count); var log = new ChatLog(guild, channel, from, to, messageGroups, mentionables);
// Export // Export
await _exportService.ExportAsync(format, filePath, log); _exportService.Export(format, filePath, log);
// Open // Open
Process.Start(filePath); Process.Start(filePath);
@@ -246,7 +282,12 @@ namespace DiscordChatExporter.Gui.ViewModels
{ {
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel")); 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; IsBusy = false;
} }
} }
@@ -36,18 +36,22 @@
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<DatePicker <DatePicker
x:Name="FromDatePicker"
Grid.Row="0" Grid.Row="0"
Grid.Column="0" Grid.Column="0"
Margin="16,20,8,8" Margin="16,20,8,8"
materialDesign:HintAssist.Hint="From (optional)" materialDesign:HintAssist.Hint="From (optional)"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding SelectedDate, ElementName=ToDatePicker}"
SelectedDate="{Binding From}" /> SelectedDate="{Binding From}" />
<DatePicker <DatePicker
x:Name="ToDatePicker"
Grid.Row="0" Grid.Row="0"
Grid.Column="1" Grid.Column="1"
Margin="8,20,16,8" Margin="8,20,16,8"
materialDesign:HintAssist.Hint="To (optional)" materialDesign:HintAssist.Hint="To (optional)"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding SelectedDate, ElementName=FromDatePicker}"
SelectedDate="{Binding To}" /> SelectedDate="{Binding To}" />
</Grid> </Grid>
+110 -54
View File
@@ -9,7 +9,7 @@
Height="550" Height="550"
Background="{DynamicResource MaterialDesignPaper}" Background="{DynamicResource MaterialDesignPaper}"
DataContext="{Binding MainViewModel, Source={StaticResource Container}}" DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
FocusManager.FocusedElement="{Binding ElementName=TokenTextBox}" FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
FontFamily="{DynamicResource MaterialDesignFont}" FontFamily="{DynamicResource MaterialDesignFont}"
Icon="/DiscordChatExporter;component/favicon.ico" Icon="/DiscordChatExporter;component/favicon.ico"
SnapsToDevicePixels="True" SnapsToDevicePixels="True"
@@ -48,27 +48,47 @@
Margin="6,6,0,6"> Margin="6,6,0,6">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Token --> <!-- Token type -->
<TextBox <ToggleButton
x:Name="TokenTextBox"
Grid.Row="0"
Grid.Column="0" Grid.Column="0"
Margin="6" 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:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden" materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0" materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0" BorderThickness="0"
FontSize="16" FontSize="16"
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" /> Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button --> <!-- Pull data button -->
<Button <Button
Grid.Row="0" Grid.Column="2"
Grid.Column="1"
Margin="0,6,6,6" Margin="0,6,6,6"
Padding="4" Padding="4"
Command="{Binding PullDataCommand}" Command="{Binding PullDataCommand}"
@@ -98,8 +118,9 @@
<!-- Progress bar --> <!-- Progress bar -->
<ProgressBar <ProgressBar
Background="Transparent" Background="Transparent"
IsIndeterminate="True" IsIndeterminate="{Binding IsProgressIndeterminate}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}" /> Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
Value="{Binding Progress, Mode=OneWay}" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -126,27 +147,28 @@
<materialDesign:TransitioningContent.OpeningEffect> <materialDesign:TransitioningContent.OpeningEffect>
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" /> <materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
</materialDesign:TransitioningContent.OpeningEffect> </materialDesign:TransitioningContent.OpeningEffect>
<Border <Grid
Margin="-8" Margin="-8"
Background="Transparent" Background="Transparent"
Cursor="Hand"> Cursor="Hand"
<Image ToolTip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48" Width="48"
Height="48" Height="48"
Margin="12,4,12,4" Margin="12,4,12,4"
Source="{Binding IconUrl}" Fill="{DynamicResource DividerBrush}" />
ToolTip="{Binding Name}">
<Image.OpacityMask> <!-- Guild icon -->
<RadialGradientBrush> <Ellipse
<RadialGradientBrush.GradientStops> Width="48"
<GradientStop Offset="0" Color="#FF000000" /> Height="48"
<GradientStop Offset="0.96" Color="#FF000000" /> Margin="12,4,12,4">
<GradientStop Offset="1" Color="#00000000" /> <Ellipse.Fill>
</RadialGradientBrush.GradientStops> <ImageBrush ImageSource="{Binding IconUrl}" />
</RadialGradientBrush> </Ellipse.Fill>
</Image.OpacityMask> </Ellipse>
</Image> </Grid>
</Border>
</materialDesign:TransitioningContent> </materialDesign:TransitioningContent>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
@@ -194,34 +216,68 @@
</DockPanel> </DockPanel>
<!-- Usage instructions --> <!-- Usage instructions -->
<StackPanel Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}"> <Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your authorization token to work." /> <!-- User token -->
<TextBlock <StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}">
Margin="0,8,0,0" <TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work." />
FontSize="16" <TextBlock
Text="To obtain it, follow these steps:" /> Margin="0,8,0,0"
<TextBlock Margin="8,0,0,0" FontSize="14"> FontSize="16"
<Run Text="1. Open the Discord app" /> Text="To obtain it, follow these steps:" />
<LineBreak /> <TextBlock Margin="8,0,0,0" FontSize="14">
<Run Text="2. Log in if you haven't" /> <Run Text="1. Open the Discord app" />
<LineBreak /> <LineBreak />
<Run Text="3. Press" /> <Run Text="2. Log in if you haven't" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" /> <LineBreak />
<LineBreak /> <Run Text="3. Press" />
<Run Text="4. Navigate to" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" /> <Run Text="to show developer tools" />
<Run Text="tab" /> <LineBreak />
<LineBreak /> <Run Text="4. Press" />
<Run Text="5. Expand" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage &gt; Local Storage &gt; https://discordapp.com" /> <Run Text="to trigger reload" />
<LineBreak /> <LineBreak />
<Run Text="6. Find" /> <Run Text="5. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="&quot;token&quot;" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
<Run Text="under key and copy the value" /> <Run Text="tab" />
<LineBreak /> <LineBreak />
<Run Text="7. Paste the value in the textbox above" /> <Run Text="6. Select" />
</TextBlock> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
</StackPanel> <Run Text="&gt;" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discordapp.com" />
<Run Text="on the left" />
<LineBreak />
<Run Text="7. Find" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
<Run Text="under key and copy the value" />
</TextBlock>
</StackPanel>
<!-- Bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your bot token to work." />
<TextBlock
Margin="0,8,0,0"
FontSize="16"
Text="To obtain it, follow these steps:" />
<TextBlock Margin="8,0,0,0" FontSize="14">
<Run Text="1. Go to Discord developer portal" />
<LineBreak />
<Run Text="2. Log in if you haven't" />
<LineBreak />
<Run Text="3. Open your application's settings" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="5. Under" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
<Run Text="click" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
</TextBlock>
</StackPanel>
</Grid>
<materialDesign:Snackbar x:Name="Snackbar" /> <materialDesign:Snackbar x:Name="Snackbar" />
</Grid> </Grid>
</DockPanel> </DockPanel>
+1 -1
View File
@@ -4,6 +4,6 @@
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" /> <package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" /> <package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.4.1" targetFramework="net461" /> <package id="MvvmLightLibs" version="5.4.1" targetFramework="net461" />
<package id="Tyrrrz.Extensions" version="1.5.0" targetFramework="net461" /> <package id="Tyrrrz.Extensions" version="1.5.1" targetFramework="net461" />
<package id="Tyrrrz.WpfExtensions" version="1.0.5" targetFramework="net461" /> <package id="Tyrrrz.WpfExtensions" version="1.0.5" targetFramework="net461" />
</packages> </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"]
+10 -18
View File
@@ -4,7 +4,7 @@
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![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) [![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.
## Screenshots ## Screenshots
@@ -15,33 +15,25 @@ DiscordChatExporter can be used to export message history from a [Discord](https
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases) - [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter) - [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
- [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter` (only CLI version)
## Features ## Features
- Intuitive GUI that displays available guilds and channels - Graphical and command line interfaces
- CLI as additional alternative to GUI - Supports both user tokens and bot tokens
- Date ranges to limit messages - Allows retrieving messages in specified date range
- Groups messages by author and time - Multiple export formats: HTML (dark/light), TXT and CSV
- Exports to a plain text file - Renders all message features including: markdown, attachments, embeds, emojis, mentions, etc
- Exports to an HTML file
- Dark and light themes
- User avatars
- Inline image attachments
- 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 ## Libraries used
- [GalaSoft.MVVMLight](http://www.mvvmlight.net) - [GalaSoft.MVVMLight](http://www.mvvmlight.net)
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit) - [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json) - [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [CsvHelper](https://github.com/JoshClose/CsvHelper) - [Scriban](https://github.com/lunet-io/scriban)
- [Polly](https://github.com/App-vNext/Polly)
- [Onova](https://github.com/Tyrrrz/Onova) - [Onova](https://github.com/Tyrrrz/Onova)
- [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser) - [CommandLineParser](https://github.com/commandlineparser/commandline)
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions) - [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
- [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions) - [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions)
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings) - [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)