Compare commits

..

26 Commits

Author SHA1 Message Date
Alexey Golub 3a0d351143 Update version 2019-09-15 22:36:02 +03:00
Alexey Golub f04719c4bd [TXT] Indicate whether a message is pinned 2019-09-15 21:31:24 +03:00
Alexey Golub d88cd9b228 [HTML] Add support for block quotes
Closes #208
2019-09-15 21:25:04 +03:00
Alexey Golub cd042e5368 Rework markdown parser and improve its performance for non-HTML formats 2019-09-15 21:24:07 +03:00
Alexey Golub 533671c59f [GUI] Allow the app to crash in DEBUG mode 2019-09-15 20:30:10 +03:00
Alexey Golub 29c35f6754 [HTML] Mark code blocks without language so that highlight.js doesn't touch them 2019-09-15 16:18:30 +03:00
Alexey Golub 7b32101517 [HTML] Fix incorrect extraction for linked message ID when used in guild and not in DM 2019-09-15 15:47:34 +03:00
Alexey Golub 9bb88128b0 [HTML] Fix messages not being highlighted when they are pinned 2019-09-15 14:16:24 +03:00
Alexey Golub 12a5091d73 Streamline auto-update process and refactor 2019-09-15 14:11:06 +03:00
Alexey Golub 82af36c90d [HTML] Add smooth highlight transition when navigating to message via link 2019-09-14 23:35:49 +03:00
Alexey Golub a2beb9836b [HTML] Jump to linked Discord messages
Closes #128
2019-09-14 20:28:27 +03:00
Alexey Golub 60f46bad29 [HTML] Update light theme to match the current one in Discord 2019-09-14 19:54:25 +03:00
Alexey Golub 2cdb230b1e [HTML] Add indication for pinned messages
Closes #193
2019-09-14 19:31:24 +03:00
Alexey Golub 9241b7d2d1 [GUI] Add some tooltips 2019-09-14 18:38:55 +03:00
Alexey Golub 40f54d9a98 Update nuget packages 2019-09-14 18:35:42 +03:00
Alexey Golub f2faf823b9 [CSV] Add user ID to output
Closes #197
2019-09-14 18:34:25 +03:00
Alexey Golub 5b9eaa57f5 Move screenshots to repository 2019-09-06 20:27:53 +03:00
Alexey Golub 58ba99e0c6 Migrate DiscordChatExporter.Cli to CliFx 2019-08-20 18:33:27 +03:00
Alexey Golub 117d1b7e4a Create FUNDING.yml 2019-07-27 02:00:54 +03:00
Alexey Golub 835910ee22 Update nuget packages 2019-07-17 13:09:00 +03:00
Malcolm Diller 5630020d88 Add message ids in HTML export (#196) 2019-07-14 21:12:13 +03:00
Alexey Golub 71e6a8de7a Update version 2019-06-15 18:16:17 +03:00
Alexey Golub 592b8b6294 Update nuget packages 2019-06-15 18:09:56 +03:00
Alexey Golub e6edcd27a4 [CSV] Render reactions
Addresses the other part of #168
2019-06-11 22:45:19 +03:00
Alexey Golub 557b5be844 [TXT] Render reactions
Addresses part of #168
2019-06-11 22:39:17 +03:00
Alexey Golub 02fe2a46e8 [TXT] Add support for embeds and change how attachments are rendered
Closes #165
2019-06-10 22:16:37 +03:00
73 changed files with 979 additions and 806 deletions
+4
View File
@@ -0,0 +1,4 @@
github: Tyrrrz
patreon: Tyrrrz
open_collective: Tyrrrz
custom: ['buymeacoffee.com/Tyrrrz']
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+20
View File
@@ -1,3 +1,23 @@
### v2.15 (15-Sep-2019)
- Improved markdown parser and made it even faster for non-HTML formats.
- [HTML] Added support for block quotes.
- [HTML] Links pointing to a Discord message will now navigate to the linked message inside exported chat log if it's there.
- [HTML] Updated light theme to match how it looks in Discord after recent changes.
- [HTML] Added indication for when a message is pinned. Pinned messages now have a tinted background.
- [HTML] Fixed an issue where multiline code blocks sometimes had incorrect formatting applied to them.
- [TXT] Added indication for when a message is pinned. Pinned messages now have `(pinned)` next to timestamp and author.
- [CSV] Added message author's user ID to output.
- [GUI] Streamlined auto-update process a bit.
- [GUI] Added some tooltips.
### v2.14 (15-Jun-2019)
- [TXT] Added support for embeds.
- [TXT] Added support for reactions.
- [CSV] Added support for reactions.
- [TXT] Changed how attachments are rendered.
### v2.13.1 (06-Jun-2019)
- Fixed an issue where the app sometimes crashed when exporting due to `System.InvalidCastException`.
@@ -0,0 +1,28 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Commands
{
[Command("export", Description = "Export a channel.")]
public class ExportChannelCommand : ExportCommandBase
{
[CommandOption("channel", 'c', IsRequired = true, Description= "Channel ID.")]
public string ChannelId { get; set; }
public ExportChannelCommand(SettingsService settingsService, DataService dataService, ExportService exportService)
: base(settingsService, dataService, exportService)
{
}
public override async Task ExecuteAsync(IConsole console)
{
// Get channel
var channel = await DataService.GetChannelAsync(GetToken(), ChannelId);
// Export
await ExportChannelAsync(console, channel);
}
}
}
@@ -0,0 +1,78 @@
using System;
using System.IO;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Commands
{
public abstract class ExportCommandBase : TokenCommandBase
{
protected SettingsService SettingsService { get; }
protected ExportService ExportService { get; }
[CommandOption("format", 'f', Description = "Output file format.")]
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
[CommandOption("output", 'o', Description = "Output file or directory path.")]
public string OutputPath { get; set; }
[CommandOption("after",Description = "Limit to messages sent after this date.")]
public DateTimeOffset? After { get; set; }
[CommandOption("before",Description = "Limit to messages sent before this date.")]
public DateTimeOffset? Before { get; set; }
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
public int? PartitionLimit { get; set; }
[CommandOption("dateformat", Description = "Date format used in output.")]
public string DateFormat { get; set; }
protected ExportCommandBase(SettingsService settingsService, DataService dataService, ExportService exportService)
: base(dataService)
{
SettingsService = settingsService;
ExportService = exportService;
}
protected async Task ExportChannelAsync(IConsole console, Channel channel)
{
// Configure settings
if (!DateFormat.IsNullOrWhiteSpace())
SettingsService.DateFormat = DateFormat;
console.Output.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress(console))
{
// Get chat log
var chatLog = await DataService.GetChatLogAsync(GetToken(), channel, After, Before, progress);
// Generate file path if not set or is a directory
var filePath = OutputPath;
if (filePath.IsNullOrWhiteSpace() || ExportHelper.IsDirectoryPath(filePath))
{
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(ExportFormat, chatLog.Guild,
chatLog.Channel, After, Before);
// Combine paths
filePath = Path.Combine(filePath ?? "", fileName);
}
// Export
await ExportService.ExportChatLogAsync(chatLog, filePath, ExportFormat, PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
}
}
@@ -0,0 +1,45 @@
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
namespace DiscordChatExporter.Cli.Commands
{
[Command("exportdm", Description = "Export all direct message channels.")]
public class ExportDirectMessagesCommand : ExportCommandBase
{
public ExportDirectMessagesCommand(SettingsService settingsService, DataService dataService, ExportService exportService)
: base(settingsService, dataService, exportService)
{
}
public override async Task ExecuteAsync(IConsole console)
{
// Get channels
var channels = await DataService.GetDirectMessageChannelsAsync(GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
await ExportChannelAsync(console, channel);
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
console.Error.WriteLine("You don't have access to this channel.");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
console.Error.WriteLine("This channel doesn't exist.");
}
}
}
}
}
@@ -0,0 +1,49 @@
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
namespace DiscordChatExporter.Cli.Commands
{
[Command("exportguild", Description = "Export all channels within specified guild.")]
public class ExportGuildCommand : ExportCommandBase
{
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
public string GuildId { get; set; }
public ExportGuildCommand(SettingsService settingsService, DataService dataService, ExportService exportService)
: base(settingsService, dataService, exportService)
{
}
public override async Task ExecuteAsync(IConsole console)
{
// Get channels
var channels = await DataService.GetGuildChannelsAsync(GetToken(), GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
await ExportChannelAsync(console, channel);
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
console.Error.WriteLine("You don't have access to this channel.");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
console.Error.WriteLine("This channel doesn't exist.");
}
}
}
}
}
@@ -0,0 +1,34 @@
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Commands
{
[Command("channels", Description = "Get the list of channels in specified guild.")]
public class GetChannelsCommand : TokenCommandBase
{
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
public string GuildId { get; set; }
public GetChannelsCommand(DataService dataService)
: base(dataService)
{
}
public override async Task ExecuteAsync(IConsole console)
{
// Get channels
var channels = await DataService.GetGuildChannelsAsync(GetToken(), GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels)
console.Output.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,30 @@
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Commands
{
[Command("dm", Description = "Get the list of direct message channels.")]
public class GetDirectMessageChannelsCommand : TokenCommandBase
{
public GetDirectMessageChannelsCommand(DataService dataService)
: base(dataService)
{
}
public override async Task ExecuteAsync(IConsole console)
{
// Get channels
var channels = await DataService.GetDirectMessageChannelsAsync(GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels)
console.Output.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,30 @@
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Commands
{
[Command("guilds", Description = "Get the list of accessible guilds.")]
public class GetGuildsCommand : TokenCommandBase
{
public GetGuildsCommand(DataService dataService)
: base(dataService)
{
}
public override async Task ExecuteAsync(IConsole console)
{
// Get guilds
var guilds = await DataService.GetUserGuildsAsync(GetToken());
// Order guilds
guilds = guilds.OrderBy(g => g.Name).ToArray();
// Print result
foreach (var guild in guilds)
console.Output.WriteLine($"{guild.Id} | {guild.Name}");
}
}
}
@@ -0,0 +1,53 @@
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Services;
namespace DiscordChatExporter.Cli.Commands
{
[Command("guide", Description = "Explains how to obtain token, guild or channel ID.")]
public class GuideCommand : ICommand
{
public Task ExecuteAsync(IConsole console)
{
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get user token:"));
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 3. Navigate to the Application tab");
console.Output.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
console.Output.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get bot token:"));
console.Output.WriteLine(" 1. Go to Discord developer portal");
console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy");
console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get guild ID or guild channel ID:"));
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open Settings");
console.Output.WriteLine(" 3. Go to Appearance section");
console.Output.WriteLine(" 4. Enable Developer Mode");
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get direct message channel ID:"));
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open the desired direct message channel");
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 4. Navigate to the Console tab");
console.Output.WriteLine(" 5. Type \"window.location.href\" and press Enter");
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
console.Output.WriteLine();
console.Output.WriteLine("If you still have unanswered questions, check out the wiki:");
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,29 @@
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Services;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Commands
{
public abstract class TokenCommandBase : ICommand
{
protected DataService DataService { get; }
[CommandOption("token", 't', IsRequired = true, Description = "Authorization token.")]
public string TokenValue { get; set; }
[CommandOption("bot", 'b', Description = "Whether this authorization token belongs to a bot.")]
public bool IsBotToken { get; set; }
protected TokenCommandBase(DataService dataService)
{
DataService = dataService;
}
protected AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
public abstract Task ExecuteAsync(IConsole console);
}
}
-24
View File
@@ -1,24 +0,0 @@
using DiscordChatExporter.Core.Services;
using StyletIoC;
namespace DiscordChatExporter.Cli
{
public static class Container
{
public static IContainer Instance { get; }
static Container()
{
var builder = new StyletIoCBuilder();
// Autobind the .Services assembly
builder.Autobind(typeof(DataService).Assembly);
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Set instance
Instance = builder.BuildContainer();
}
}
}
@@ -3,7 +3,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
<Version>2.13.1</Version>
<Version>2.15</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
@@ -12,9 +12,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.3.0" />
<PackageReference Include="Stylet" Version="1.1.22" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.1" />
<PackageReference Include="CliFx" Version="0.0.5" />
<PackageReference Include="Stylet" Version="1.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup>
<ItemGroup>
@@ -1,31 +1,33 @@
using System;
using CliFx.Services;
namespace DiscordChatExporter.Cli.Internal
{
internal class InlineProgress : IProgress<double>, IDisposable
{
private readonly int _posX;
private readonly int _posY;
private readonly IConsole _console;
private string _lastOutput = "";
private bool _isCompleted;
public InlineProgress()
public InlineProgress(IConsole console)
{
// If output is not redirected - save initial cursor position
if (!Console.IsOutputRedirected)
{
_posX = Console.CursorLeft;
_posY = Console.CursorTop;
}
_console = console;
}
private void ResetCursorPosition()
{
foreach (var c in _lastOutput)
_console.Output.Write('\b');
}
public void Report(double progress)
{
// If output is not redirected - reset cursor position and write progress
if (!Console.IsOutputRedirected)
if (!_console.IsOutputRedirected)
{
Console.SetCursorPosition(_posX, _posY);
Console.WriteLine($"{progress:P1}");
ResetCursorPosition();
_console.Output.Write(_lastOutput = $"{progress:P1}");
}
}
@@ -34,14 +36,13 @@ namespace DiscordChatExporter.Cli.Internal
public void Dispose()
{
// If output is not redirected - reset cursor position
if (!Console.IsOutputRedirected)
Console.SetCursorPosition(_posX, _posY);
if (!_console.IsOutputRedirected)
{
ResetCursorPosition();
}
// Inform about completion
if (_isCompleted)
Console.WriteLine("Completed ✓");
else
Console.WriteLine("Failed X");
_console.Output.WriteLine(_isCompleted ? "Completed ✓" : "Failed X");
}
}
}
+22 -67
View File
@@ -1,80 +1,35 @@
using System;
using System.Linq;
using CommandLine;
using DiscordChatExporter.Cli.Verbs;
using DiscordChatExporter.Cli.Verbs.Options;
using System.Threading.Tasks;
using CliFx;
using DiscordChatExporter.Core.Services;
using StyletIoC;
namespace DiscordChatExporter.Cli
{
public static class Program
{
private static void PrintTokenHelp()
private static IContainer BuildContainer()
{
Console.WriteLine("# To get user token:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 3. Navigate to the Application tab");
Console.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
Console.WriteLine(" 5. Press Ctrl+R to reload");
Console.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
Console.WriteLine();
Console.WriteLine("# To get bot token:");
Console.WriteLine(" 1. Go to Discord developer portal");
Console.WriteLine(" 2. Open your application's settings");
Console.WriteLine(" 3. Navigate to the Bot section on the left");
Console.WriteLine(" 4. Under Token click Copy");
Console.WriteLine();
Console.WriteLine("# To get guild ID or guild channel ID:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Open Settings");
Console.WriteLine(" 3. Go to Appearance section");
Console.WriteLine(" 4. Enable Developer Mode");
Console.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
Console.WriteLine();
Console.WriteLine("# To get direct message channel ID:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Open the desired direct message channel");
Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 4. Navigate to the Console tab");
Console.WriteLine(" 5. Type \"window.location.href\" and press Enter");
Console.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
var builder = new StyletIoCBuilder();
// Autobind the .Services assembly
builder.Autobind(typeof(DataService).Assembly);
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Set instance
return builder.BuildContainer();
}
public static void Main(string[] args)
public static Task<int> Main(string[] args)
{
// Get all verb types
var verbTypes = new[]
{
typeof(ExportChannelOptions),
typeof(ExportDirectMessagesOptions),
typeof(ExportGuildOptions),
typeof(GetChannelsOptions),
typeof(GetDirectMessageChannelsOptions),
typeof(GetGuildsOptions)
};
var container = BuildContainer();
// Parse command line arguments
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
// Execute commands
parsedArgs.WithParsed<ExportChannelOptions>(o => new ExportChannelVerb(o).Execute());
parsedArgs.WithParsed<ExportDirectMessagesOptions>(o => new ExportDirectMessagesVerb(o).Execute());
parsedArgs.WithParsed<ExportGuildOptions>(o => new ExportGuildVerb(o).Execute());
parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
// Show token help if help requested or no verb specified
parsedArgs.WithNotParsed(errs =>
{
var err = errs.First();
if (err.Tag == ErrorType.NoVerbSelectedError)
PrintTokenHelp();
if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
PrintTokenHelp();
});
return new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseCommandFactory(schema => (ICommand) container.Get(schema.Type))
.Build()
.RunAsync(args);
}
}
}
@@ -1,58 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportChannelVerb : Verb<ExportChannelOptions>
{
public ExportChannelVerb(ExportChannelOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Track progress
Console.Write($"Exporting channel [{Options.ChannelId}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
Options.After, Options.Before, progress);
// Generate file path if not set or is a directory
var filePath = Options.OutputPath;
if (filePath.IsNullOrWhiteSpace() || ExportHelper.IsDirectoryPath(filePath))
{
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Combine paths
filePath = Path.Combine(filePath ?? "", fileName);
}
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
}
}
@@ -1,78 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportDirectMessagesVerb : Verb<ExportDirectMessagesOptions>
{
public ExportDirectMessagesVerb(ExportDirectMessagesOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
// Track progress
Console.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before, progress);
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Generate file path
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat,
Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Console.Error.WriteLine("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.Error.WriteLine("This channel doesn't exist");
}
}
}
}
}
@@ -1,79 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportGuildVerb : Verb<ExportGuildOptions>
{
public ExportGuildVerb(ExportGuildOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
// Track progress
Console.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before, progress);
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Generate file path
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat,
Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Console.Error.WriteLine("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.Error.WriteLine("This channel doesn't exist");
}
}
}
}
}
@@ -1,33 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetChannelsVerb : Verb<GetChannelsOptions>
{
public GetChannelsVerb(GetChannelsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var dataService = Container.Instance.Get<DataService>();
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels)
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -1,32 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
{
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var dataService = Container.Instance.Get<DataService>();
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels)
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -1,32 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetGuildsVerb : Verb<GetGuildsOptions>
{
public GetGuildsVerb(GetGuildsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var dataService = Container.Instance.Get<DataService>();
// Get guilds
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
// Order guilds
guilds = guilds.OrderBy(g => g.Name).ToArray();
// Print result
foreach (var guild in guilds)
Console.WriteLine($"{guild.Id} | {guild.Name}");
}
}
}
@@ -1,11 +0,0 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("export", HelpText = "Export channel.")]
public class ExportChannelOptions : ExportOptions
{
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
public string ChannelId { get; set; }
}
}
@@ -1,9 +0,0 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("exportdm", HelpText = "Export all direct message channels.")]
public class ExportDirectMessagesOptions : ExportOptions
{
}
}
@@ -1,11 +0,0 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("exportguild", HelpText = "Export all channels within a given guild.")]
public class ExportGuildOptions : ExportOptions
{
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
public string GuildId { get; set; }
}
}
@@ -1,29 +0,0 @@
using System;
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
public abstract class ExportOptions : TokenOptions
{
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
public ExportFormat ExportFormat { get; set; }
[Option('o', "output", Default = null, HelpText = "Output file or directory path.")]
public string OutputPath { get; set; }
// HACK: CommandLineParser doesn't support DateTimeOffset
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
public DateTime? After { get; set; }
// HACK: CommandLineParser doesn't support DateTimeOffset
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
public DateTime? Before { get; set; }
[Option('p', "partition", Default = null, HelpText = "Split output into partitions limited to this number of messages.")]
public int? PartitionLimit { get; set; }
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
public string DateFormat { get; set; }
}
}
@@ -1,11 +0,0 @@
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; }
}
}
@@ -1,9 +0,0 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("dm", HelpText = "Get the list of direct message channels.")]
public class GetDirectMessageChannelsOptions : TokenOptions
{
}
}
@@ -1,9 +0,0 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("guilds", HelpText = "Get the list of accessible guilds.")]
public class GetGuildsOptions : TokenOptions
{
}
}
@@ -1,16 +0,0 @@
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
public abstract class TokenOptions
{
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
public string TokenValue { get; set; }
[Option('b', "bot", Default = false, HelpText = "Whether this authorization token belongs to a bot.")]
public bool IsBotToken { get; set; }
public AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
}
}
-18
View File
@@ -1,18 +0,0 @@
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();
}
}
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup>
</Project>
@@ -16,7 +16,7 @@ namespace DiscordChatExporter.Core.Markdown.Internal
{
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
public ParsedMatch<T> Match(StringPart stringPart)
{
ParsedMatch<T> earliestMatch = null;
@@ -24,19 +24,19 @@ namespace DiscordChatExporter.Core.Markdown.Internal
foreach (var matcher in _matchers)
{
// Try to match
var match = matcher.Match(input, startIndex, length);
var match = matcher.Match(stringPart);
// If there's no match - continue
if (match == null)
continue;
// If this match is earlier than previous earliest - replace
if (earliestMatch == null || match.StartIndex < earliestMatch.StartIndex)
if (earliestMatch == null || match.StringPart.StartIndex < earliestMatch.StringPart.StartIndex)
earliestMatch = match;
// If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that
if (earliestMatch.StartIndex == startIndex)
if (earliestMatch.StringPart.StartIndex == stringPart.StartIndex)
break;
}
@@ -1,50 +1,54 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal static class Extensions
{
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input,
int startIndex, int length, Func<string, T> fallbackTransform)
{
// Get end index for simplicity
var endIndex = startIndex + length;
public static StringPart Shrink(this StringPart stringPart, int newStartIndex, int newLength) =>
new StringPart(stringPart.Target, newStartIndex, newLength);
public static StringPart Shrink(this StringPart stringPart, int newStartIndex) =>
stringPart.Shrink(newStartIndex, stringPart.EndIndex - newStartIndex);
public static StringPart Shrink(this StringPart stringPart, Capture capture) =>
stringPart.Shrink(capture.Index, capture.Length);
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, StringPart stringPart,
Func<StringPart, T> fallbackTransform)
{
// Loop through segments divided by individual matches
var currentIndex = startIndex;
while (currentIndex < endIndex)
var currentIndex = stringPart.StartIndex;
while (currentIndex < stringPart.EndIndex)
{
// Find a match within this segment
var match = matcher.Match(input, currentIndex, endIndex - currentIndex);
var match = matcher.Match(stringPart.Shrink(currentIndex, stringPart.EndIndex - currentIndex));
// If there's no match - break
if (match == null)
break;
// If this match doesn't start immediately at current index - transform and yield fallback first
if (match.StartIndex > currentIndex)
if (match.StringPart.StartIndex > currentIndex)
{
var fallback = input.Substring(currentIndex, match.StartIndex - currentIndex);
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback));
var fallbackPart = stringPart.Shrink(currentIndex, match.StringPart.StartIndex - currentIndex);
yield return new ParsedMatch<T>(fallbackPart, fallbackTransform(fallbackPart));
}
// Yield match
yield return match;
// Shift current index to the end of the match
currentIndex = match.StartIndex + match.Length;
currentIndex = match.StringPart.StartIndex + match.StringPart.Length;
}
// If EOL wasn't reached - transform and yield remaining part as fallback
if (currentIndex < endIndex)
if (currentIndex < stringPart.EndIndex)
{
var fallback = input.Substring(currentIndex);
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback));
var fallbackPart = stringPart.Shrink(currentIndex);
yield return new ParsedMatch<T>(fallbackPart, fallbackTransform(fallbackPart));
}
}
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input,
Func<string, T> fallbackTransform) => matcher.MatchAll(input, 0, input.Length, fallbackTransform);
}
}
@@ -2,6 +2,6 @@
{
internal interface IMatcher<T>
{
ParsedMatch<T> Match(string input, int startIndex, int length);
ParsedMatch<T> Match(StringPart stringPart);
}
}
@@ -2,16 +2,13 @@
{
internal class ParsedMatch<T>
{
public int StartIndex { get; }
public int Length { get; }
public StringPart StringPart { get; }
public T Value { get; }
public ParsedMatch(int startIndex, int length, T value)
public ParsedMatch(StringPart stringPart, T value)
{
StartIndex = startIndex;
Length = length;
StringPart = stringPart;
Value = value;
}
}
@@ -1,4 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Internal
@@ -6,18 +10,35 @@ namespace DiscordChatExporter.Core.Markdown.Internal
internal class RegexMatcher<T> : IMatcher<T>
{
private readonly Regex _regex;
private readonly Func<Match, T> _transform;
private readonly Func<StringPart, Match, T> _transform;
public RegexMatcher(Regex regex, Func<Match, T> transform)
public RegexMatcher(Regex regex, Func<StringPart, Match, T> transform)
{
_regex = regex;
_transform = transform;
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
public RegexMatcher(Regex regex, Func<Match, T> transform)
: this(regex, (p, m) => transform(m))
{
var match = _regex.Match(input, startIndex, length);
return match.Success ? new ParsedMatch<T>(match.Index, match.Length, _transform(match)) : null;
}
public ParsedMatch<T> Match(StringPart stringPart)
{
var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length);
if (!match.Success)
return null;
// Overload regex.Match(string, int, int) doesn't take the whole string into account,
// it effectively functions as a match check on a substring.
// Which is super weird because regex.Match(string, int) takes the whole input in context.
// So in order to properly account for ^/$ regex tokens, we need to make sure that
// the expression also matches on the bigger part of the input.
if (!_regex.IsMatch(stringPart.Target.Substring(0, stringPart.EndIndex), stringPart.StartIndex))
return null;
var stringPartShrunk = stringPart.Shrink(match.Index, match.Length);
return new ParsedMatch<T>(stringPartShrunk, _transform(stringPartShrunk, match));
}
}
}
@@ -6,24 +6,31 @@ namespace DiscordChatExporter.Core.Markdown.Internal
{
private readonly string _needle;
private readonly StringComparison _comparison;
private readonly Func<string, T> _transform;
private readonly Func<StringPart, T> _transform;
public StringMatcher(string needle, StringComparison comparison, Func<string, T> transform)
public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T> transform)
{
_needle = needle;
_comparison = comparison;
_transform = transform;
}
public StringMatcher(string needle, Func<string, T> transform)
public StringMatcher(string needle, Func<StringPart, T> transform)
: this(needle, StringComparison.Ordinal, transform)
{
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
public ParsedMatch<T> Match(StringPart stringPart)
{
var index = input.IndexOf(_needle, startIndex, length, _comparison);
return index >= 0 ? new ParsedMatch<T>(index, _needle.Length, _transform(_needle)) : null;
var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison);
if (index >= 0)
{
var stringPartShrunk = stringPart.Shrink(index, _needle.Length);
return new ParsedMatch<T>(stringPartShrunk, _transform(stringPartShrunk));
}
return null;
}
}
}
@@ -0,0 +1,28 @@
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class StringPart
{
public string Target { get; }
public int StartIndex { get; }
public int Length { get; }
public int EndIndex { get; }
public StringPart(string target, int startIndex, int length)
{
Target = target;
StartIndex = startIndex;
Length = length;
EndIndex = startIndex + length;
}
public StringPart(string target)
: this(target, 0, target.Length)
{
}
public override string ToString() => Target.Substring(StartIndex, Length);
}
}
@@ -10,94 +10,106 @@ namespace DiscordChatExporter.Core.Markdown
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
public static class MarkdownParser
{
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant;
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline;
/* Formatting */
// Capture any character until the earliest double asterisk not followed by an asterisk
private static readonly IMatcher<Node> BoldFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "**", TextFormatting.Bold, Parse(m.Groups[1].Value)));
(p, m) => new FormattedNode(TextFormatting.Bold, Parse(p.Shrink(m.Groups[1]))));
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
// Opening asterisk must not be followed by whitespace
// Closing asterisk must not be preceded by whitespace
private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value)));
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Shrink(m.Groups[1]))));
// Capture any character until the earliest triple asterisk not followed by an asterisk
private static readonly IMatcher<Node> ItalicBoldFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value, BoldFormattedNodeMatcher)));
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Shrink(m.Groups[1]), BoldFormattedNodeMatcher)));
// Capture any character except underscore until an underscore
// Closing underscore must not be followed by a word character
private static readonly IMatcher<Node> ItalicAltFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "_", TextFormatting.Italic, Parse(m.Groups[1].Value)));
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Shrink(m.Groups[1]))));
// Capture any character until the earliest double underscore not followed by an underscore
private static readonly IMatcher<Node> UnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "__", TextFormatting.Underline, Parse(m.Groups[1].Value)));
(p, m) => new FormattedNode(TextFormatting.Underline, Parse(p.Shrink(m.Groups[1]))));
// Capture any character until the earliest triple underscore not followed by an underscore
private static readonly IMatcher<Node> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "_", TextFormatting.Italic, Parse(m.Groups[1].Value, UnderlineFormattedNodeMatcher)));
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Shrink(m.Groups[1]), UnderlineFormattedNodeMatcher)));
// Capture any character until the earliest double tilde
private static readonly IMatcher<Node> StrikethroughFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "~~", TextFormatting.Strikethrough, Parse(m.Groups[1].Value)));
(p, m) => new FormattedNode(TextFormatting.Strikethrough, Parse(p.Shrink(m.Groups[1]))));
// Capture any character until the earliest double pipe
private static readonly IMatcher<Node> SpoilerFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "||", TextFormatting.Spoiler, Parse(m.Groups[1].Value)));
(p, m) => new FormattedNode(TextFormatting.Spoiler, Parse(p.Shrink(m.Groups[1]))));
// Capture any character until the end of the line
// Opening 'greater than' character must be followed by whitespace
private static readonly IMatcher<Node> SingleLineQuoteNodeMatcher = new RegexMatcher<Node>(
new Regex("^>\\s(.+)\r?\n?", DefaultRegexOptions),
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Shrink(m.Groups[1]))));
// Capture any character until the end of the input
// Opening 'greater than' characters must be followed by whitespace
private static readonly IMatcher<Node> MultiLineQuoteNodeMatcher = new RegexMatcher<Node>(
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Shrink(m.Groups[1]))));
/* Code blocks */
// Capture any character except backtick until a backtick
// Whitespace surrounding content inside backticks is trimmed
// Blank lines at the beginning and end of content are trimmed
private static readonly IMatcher<Node> InlineCodeBlockNodeMatcher = new RegexMatcher<Node>(
new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline),
m => new InlineCodeBlockNode(m.Value, m.Groups[1].Value.Trim()));
m => new InlineCodeBlockNode(m.Groups[1].Value.Trim('\r', '\n')));
// Capture language identifier and then any character until the earliest triple backtick
// Languge identifier is one word immediately after opening backticks, followed immediately by newline
// Whitespace surrounding content inside backticks is trimmed
private static readonly IMatcher<Node> MultilineCodeBlockNodeMatcher = new RegexMatcher<Node>(
// Language identifier is one word immediately after opening backticks, followed immediately by newline
// Blank lines at the beginning and end of content are trimmed
private static readonly IMatcher<Node> MultiLineCodeBlockNodeMatcher = new RegexMatcher<Node>(
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
m => new MultilineCodeBlockNode(m.Value, m.Groups[1].Value, m.Groups[2].Value.Trim()));
m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')));
/* Mentions */
// Capture @everyone
private static readonly IMatcher<Node> EveryoneMentionNodeMatcher = new StringMatcher<Node>(
"@everyone",
s => new MentionNode(s, "everyone", MentionType.Meta));
p => new MentionNode("everyone", MentionType.Meta));
// Capture @here
private static readonly IMatcher<Node> HereMentionNodeMatcher = new StringMatcher<Node>(
"@here",
s => new MentionNode(s, "here", MentionType.Meta));
p => new MentionNode("here", MentionType.Meta));
// Capture <@123456> or <@!123456>
private static readonly IMatcher<Node> UserMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.User));
m => new MentionNode(m.Groups[1].Value, MentionType.User));
// Capture <#123456>
private static readonly IMatcher<Node> ChannelMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<#(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Channel));
m => new MentionNode(m.Groups[1].Value, MentionType.Channel));
// Capture <@&123456>
private static readonly IMatcher<Node> RoleMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<@&(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Role));
m => new MentionNode(m.Groups[1].Value, MentionType.Role));
/* Emojis */
@@ -108,29 +120,29 @@ namespace DiscordChatExporter.Core.Markdown
// (this does not match all emojis in Discord but it's reasonably accurate enough)
private static readonly IMatcher<Node> StandardEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|\\p{So}|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
m => new EmojiNode(m.Value, m.Groups[1].Value));
m => new EmojiNode(m.Groups[1].Value));
// Capture <:lul:123456> or <a:lul:123456>
private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, !m.Groups[1].Value.IsNullOrWhiteSpace()));
m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !m.Groups[1].Value.IsNullOrWhiteSpace()));
/* Links */
// Capture [title](link)
private static readonly IMatcher<Node> TitledLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[2].Value, m.Groups[1].Value));
m => new LinkNode(m.Groups[2].Value, m.Groups[1].Value));
// Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace
private static readonly IMatcher<Node> AutoLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[1].Value));
m => new LinkNode(m.Groups[1].Value));
// Same as auto link but also surrounded by angular brackets
private static readonly IMatcher<Node> HiddenLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[1].Value));
m => new LinkNode(m.Groups[1].Value));
/* Text */
@@ -138,25 +150,25 @@ namespace DiscordChatExporter.Core.Markdown
// This escapes it from matching for formatting
private static readonly IMatcher<Node> ShrugTextNodeMatcher = new StringMatcher<Node>(
@"¯\_(ツ)_/¯",
s => new TextNode(s));
p => new TextNode(p.ToString()));
// Capture some specific emojis that don't get rendered
// This escapes it from matching for emoji
private static readonly IMatcher<Node> IgnoredEmojiTextNodeMatcher = new RegexMatcher<Node>(
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
m => new TextNode(m.Groups[1].Value));
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
// This escapes it from matching for emoji
private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
m => new TextNode(m.Groups[1].Value));
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
// This escapes it from matching for formatting or other tokens
private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
m => new TextNode(m.Groups[1].Value));
// Combine all matchers into one
// Matchers that have similar patterns are ordered from most specific to least specific
@@ -176,9 +188,11 @@ namespace DiscordChatExporter.Core.Markdown
ItalicAltFormattedNodeMatcher,
StrikethroughFormattedNodeMatcher,
SpoilerFormattedNodeMatcher,
MultiLineQuoteNodeMatcher,
SingleLineQuoteNodeMatcher,
// Code blocks
MultilineCodeBlockNodeMatcher,
MultiLineCodeBlockNodeMatcher,
InlineCodeBlockNodeMatcher,
// Mentions
@@ -197,9 +211,27 @@ namespace DiscordChatExporter.Core.Markdown
StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher);
private static IReadOnlyList<Node> Parse(string input, IMatcher<Node> matcher) =>
matcher.MatchAll(input, s => new TextNode(s)).Select(r => r.Value).ToArray();
private static readonly IMatcher<Node> MinimalAggregateNodeMatcher = new AggregateMatcher<Node>(
// Mentions
EveryoneMentionNodeMatcher,
HereMentionNodeMatcher,
UserMentionNodeMatcher,
ChannelMentionNodeMatcher,
RoleMentionNodeMatcher,
public static IReadOnlyList<Node> Parse(string input) => Parse(input, AggregateNodeMatcher);
// Emoji
StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher);
private static IReadOnlyList<Node> Parse(StringPart stringPart, IMatcher<Node> matcher) =>
matcher.MatchAll(stringPart, p => new TextNode(p.ToString())).Select(r => r.Value).ToArray();
private static IReadOnlyList<Node> Parse(StringPart stringPart) => Parse(stringPart, AggregateNodeMatcher);
private static IReadOnlyList<Node> ParseMinimal(StringPart stringPart) => Parse(stringPart, MinimalAggregateNodeMatcher);
public static IReadOnlyList<Node> Parse(string input) => Parse(new StringPart(input));
public static IReadOnlyList<Node> ParseMinimal(string input) => ParseMinimal(new StringPart(input));
}
}
@@ -12,16 +12,15 @@ namespace DiscordChatExporter.Core.Markdown.Nodes
public bool IsCustomEmoji => !Id.IsNullOrWhiteSpace();
public EmojiNode(string source, string id, string name, bool isAnimated)
: base(source)
public EmojiNode(string id, string name, bool isAnimated)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
}
public EmojiNode(string source, string name)
: this(source, null, name, false)
public EmojiNode(string name)
: this(null, name, false)
{
}
@@ -4,16 +4,12 @@ namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class FormattedNode : Node
{
public string Token { get; }
public TextFormatting Formatting { get; }
public IReadOnlyList<Node> Children { get; }
public FormattedNode(string source, string token, TextFormatting formatting, IReadOnlyList<Node> children)
: base(source)
public FormattedNode(TextFormatting formatting, IReadOnlyList<Node> children)
{
Token = token;
Formatting = formatting;
Children = children;
}
@@ -4,8 +4,7 @@
{
public string Code { get; }
public InlineCodeBlockNode(string source, string code)
: base(source)
public InlineCodeBlockNode(string code)
{
Code = code;
}
@@ -6,14 +6,14 @@
public string Title { get; }
public LinkNode(string source, string url, string title)
: base(source)
public LinkNode(string url, string title)
{
Url = url;
Title = title;
}
public LinkNode(string source, string url) : this(source, url, url)
public LinkNode(string url)
: this(url, url)
{
}
@@ -6,8 +6,7 @@
public MentionType Type { get; }
public MentionNode(string source, string id, MentionType type)
: base(source)
public MentionNode(string id, MentionType type)
{
Id = id;
Type = type;
@@ -1,13 +1,12 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class MultilineCodeBlockNode : Node
public class MultiLineCodeBlockNode : Node
{
public string Language { get; }
public string Code { get; }
public MultilineCodeBlockNode(string source, string language, string code)
: base(source)
public MultiLineCodeBlockNode(string language, string code)
{
Language = language;
Code = code;
@@ -2,11 +2,5 @@
{
public abstract class Node
{
public string Source { get; }
protected Node(string source)
{
Source = source;
}
}
}
@@ -6,6 +6,7 @@
Italic,
Underline,
Strikethrough,
Spoiler
Spoiler,
Quote
}
}
@@ -4,16 +4,11 @@
{
public string Text { get; }
public TextNode(string source, string text)
: base(source)
public TextNode(string text)
{
Text = text;
}
public TextNode(string text) : this(text, text)
{
}
public override string ToString() => Text;
}
}
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup>
</Project>
+5 -1
View File
@@ -29,9 +29,12 @@ namespace DiscordChatExporter.Core.Models
public IReadOnlyList<User> MentionedUsers { get; }
public bool IsPinned { get; }
public Message(string id, string channelId, MessageType type, User author, DateTimeOffset timestamp,
DateTimeOffset? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers,
bool isPinned)
{
Id = id;
ChannelId = channelId;
@@ -44,6 +47,7 @@ namespace DiscordChatExporter.Core.Models
Embeds = embeds;
Reactions = reactions;
MentionedUsers = mentionedUsers;
IsPinned = isPinned;
}
public override string ToString() => Content;
@@ -27,18 +27,21 @@ namespace DiscordChatExporter.Core.Rendering
private string FormatMarkdown(Node node)
{
// Formatted node
if (node is FormattedNode formattedNode)
// Text node
if (node is TextNode textNode)
{
// Recursively get inner text
var innerText = FormatMarkdown(formattedNode.Children);
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
return textNode.Text;
}
// Non-meta mention node
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
// Mention node
if (node is MentionNode mentionNode)
{
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return mentionNode.Id;
}
// User mention node
if (mentionNode.Type == MentionType.User)
{
@@ -61,19 +64,19 @@ namespace DiscordChatExporter.Core.Rendering
}
}
// Custom emoji node
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
// Emoji node
if (node is EmojiNode emojiNode)
{
return $":{emojiNode.Name}:";
return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
}
// All other nodes - simply return source
return node.Source;
// Throw on unexpected nodes
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
}
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.ParseMinimal(markdown));
private async Task RenderFieldAsync(TextWriter writer, string value)
{
@@ -83,6 +86,9 @@ namespace DiscordChatExporter.Core.Rendering
private async Task RenderMessageAsync(TextWriter writer, Message message)
{
// Author ID
await RenderFieldAsync(writer, message.Author.Id);
// Author
await RenderFieldAsync(writer, message.Author.FullName);
@@ -96,6 +102,10 @@ namespace DiscordChatExporter.Core.Rendering
var formattedAttachments = message.Attachments.Select(a => a.Url).JoinToString(",");
await RenderFieldAsync(writer, formattedAttachments);
// Reactions
var formattedReactions = message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(",");
await RenderFieldAsync(writer, formattedReactions);
// Line break
await writer.WriteLineAsync();
}
@@ -103,7 +113,7 @@ namespace DiscordChatExporter.Core.Rendering
public async Task RenderAsync(TextWriter writer)
{
// Headers
await writer.WriteLineAsync("Author;Date;Content;Attachments;");
await writer.WriteLineAsync("AuthorID;Author;Date;Content;Attachments;Reactions;");
// Log
foreach (var message in _chatLog.Messages)
@@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Scriban" Version="2.0.0" />
<PackageReference Include="Scriban" Version="2.1.0" />
</ItemGroup>
<ItemGroup>
@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
@@ -80,6 +81,10 @@ namespace DiscordChatExporter.Core.Rendering
// Spoiler
if (formattedNode.Formatting == TextFormatting.Spoiler)
return $"<span class=\"spoiler\">{innerHtml}</span>";
// Quote
if (formattedNode.Formatting == TextFormatting.Quote)
return $"<div class=\"quote\">{innerHtml}</div>";
}
// Inline code block node
@@ -89,14 +94,14 @@ namespace DiscordChatExporter.Core.Rendering
}
// Multi-line code block node
if (node is MultilineCodeBlockNode multilineCodeBlockNode)
if (node is MultiLineCodeBlockNode multilineCodeBlockNode)
{
// Set language class for syntax highlighting
var languageCssClass = !multilineCodeBlockNode.Language.IsNullOrWhiteSpace()
? "language-" + multilineCodeBlockNode.Language
: null;
// Set CSS class for syntax highlighting
var highlightCssClass = !multilineCodeBlockNode.Language.IsNullOrWhiteSpace()
? $"language-{multilineCodeBlockNode.Language}"
: "nohighlight";
return $"<div class=\"pre pre--multiline {languageCssClass}\">{HtmlEncode(multilineCodeBlockNode.Code)}</div>";
return $"<div class=\"pre pre--multiline {highlightCssClass}\">{HtmlEncode(multilineCodeBlockNode.Code)}</div>";
}
// Mention node
@@ -145,17 +150,22 @@ namespace DiscordChatExporter.Core.Rendering
// Link node
if (node is LinkNode linkNode)
{
return $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\">{HtmlEncode(linkNode.Title)}</a>";
// Extract message ID if the link points to a Discord message
var linkedMessageId = Regex.Match(linkNode.Url, "^https?://discordapp.com/channels/.*?/(\\d+)/?$").Groups[1].Value;
return linkedMessageId.IsNullOrWhiteSpace()
? $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\">{HtmlEncode(linkNode.Title)}</a>"
: $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">{HtmlEncode(linkNode.Title)}</a>";
}
// All other nodes - simply return source
return node.Source;
// Throw on unexpected nodes
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
}
private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel)
{
// Emojis are jumbo if all top-level nodes are emoji nodes, disregarding whitespace
var isJumbo = isTopLevel && nodes.Where(n => !n.Source.IsNullOrWhiteSpace()).All(n => n is EmojiNode);
// Emojis are jumbo if all top-level nodes are emoji nodes or whitespace text nodes
var isJumbo = isTopLevel && nodes.All(n => n is EmojiNode || n is TextNode textNode && textNode.Text.IsNullOrWhiteSpace());
return nodes.Select(n => FormatMarkdown(n, isJumbo)).JoinToString("");
}
@@ -45,18 +45,21 @@ namespace DiscordChatExporter.Core.Rendering
private string FormatMarkdown(Node node)
{
// Formatted node
if (node is FormattedNode formattedNode)
// Text node
if (node is TextNode textNode)
{
// Recursively get inner text
var innerText = FormatMarkdown(formattedNode.Children);
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
return textNode.Text;
}
// Non-meta mention node
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
// Mention node
if (node is MentionNode mentionNode)
{
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return mentionNode.Id;
}
// User mention node
if (mentionNode.Type == MentionType.User)
{
@@ -79,31 +82,138 @@ namespace DiscordChatExporter.Core.Rendering
}
}
// Custom emoji node
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
// Emoji node
if (node is EmojiNode emojiNode)
{
return $":{emojiNode.Name}:";
return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
}
// All other nodes - simply return source
return node.Source;
// Throw on unexpected nodes
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
}
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.ParseMinimal(markdown));
private async Task RenderMessageHeaderAsync(TextWriter writer, Message message)
{
// Timestamp
await writer.WriteAsync($"[{FormatDate(message.Timestamp)}]");
// Author
await writer.WriteAsync($" {message.Author.FullName}");
// Whether the message is pinned
if (message.IsPinned)
await writer.WriteAsync(" (pinned)");
await writer.WriteLineAsync();
}
private async Task RenderAttachmentsAsync(TextWriter writer, IReadOnlyList<Attachment> attachments)
{
if (attachments.Any())
{
await writer.WriteLineAsync("{Attachments}");
foreach (var attachment in attachments)
await writer.WriteLineAsync(attachment.Url);
await writer.WriteLineAsync();
}
}
private async Task RenderEmbedsAsync(TextWriter writer, IReadOnlyList<Embed> embeds)
{
foreach (var embed in embeds)
{
await writer.WriteLineAsync("{Embed}");
// Author name
if (!(embed.Author?.Name).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Author?.Name);
// URL
if (!embed.Url.IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Url);
// Title
if (!embed.Title.IsNullOrWhiteSpace())
await writer.WriteLineAsync(FormatMarkdown(embed.Title));
// Description
if (!embed.Description.IsNullOrWhiteSpace())
await writer.WriteLineAsync(FormatMarkdown(embed.Description));
// Fields
foreach (var field in embed.Fields)
{
// Name
if (!field.Name.IsNullOrWhiteSpace())
await writer.WriteLineAsync(field.Name);
// Value
if (!field.Value.IsNullOrWhiteSpace())
await writer.WriteLineAsync(field.Value);
}
// Thumbnail URL
if (!(embed.Thumbnail?.Url).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Thumbnail?.Url);
// Image URL
if (!(embed.Image?.Url).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Image?.Url);
// Footer text
if (!(embed.Footer?.Text).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Footer?.Text);
await writer.WriteLineAsync();
}
}
private async Task RenderReactionsAsync(TextWriter writer, IReadOnlyList<Reaction> reactions)
{
if (reactions.Any())
{
await writer.WriteLineAsync("{Reactions}");
foreach (var reaction in reactions)
{
await writer.WriteAsync(reaction.Emoji.Name);
if (reaction.Count > 1)
await writer.WriteAsync($" ({reaction.Count})");
await writer.WriteAsync(" ");
}
await writer.WriteLineAsync();
await writer.WriteLineAsync();
}
}
private async Task RenderMessageAsync(TextWriter writer, Message message)
{
// Timestamp and author
await writer.WriteLineAsync($"[{FormatDate(message.Timestamp)}] {message.Author.FullName}");
// Header
await RenderMessageHeaderAsync(writer, message);
// Content
await writer.WriteLineAsync(FormatMarkdown(message.Content));
// Separator
await writer.WriteLineAsync();
// Attachments
foreach (var attachment in message.Attachments)
await writer.WriteLineAsync(attachment.Url);
await RenderAttachmentsAsync(writer, message.Attachments);
// Embeds
await RenderEmbedsAsync(writer, message.Embeds);
// Reactions
await RenderReactionsAsync(writer, message.Reactions);
}
public async Task RenderAsync(TextWriter writer)
@@ -120,10 +230,7 @@ namespace DiscordChatExporter.Core.Rendering
// Log
foreach (var message in _chatLog.Messages)
{
await RenderMessageAsync(writer, message);
await writer.WriteLineAsync();
}
}
}
}
@@ -13,6 +13,10 @@ a {
background-color: rgba(255, 255, 255, 0.1);
}
.quote {
border-color: #4f545c;
}
.pre {
background-color: #2f3136 !important;
}
@@ -54,6 +58,14 @@ a {
color: rgba(255, 255, 255, 0.2);
}
.chatlog__message--highlighted {
background-color: rgba(114, 137, 218, 0.2) !important;
}
.chatlog__message--pinned {
background-color: rgba(249, 168, 37, 0.05);
}
.chatlog__edited-timestamp {
color: rgba(255, 255, 255, 0.2);
}
@@ -2,7 +2,8 @@
body {
background-color: #ffffff;
color: #747f8d;
color: #23262a;
font-weight: 500;
}
a {
@@ -13,6 +14,10 @@ a {
background-color: rgba(0, 0, 0, 0.1);
}
.quote {
border-color: #c7ccd1;
}
.pre {
background-color: #f9f9f9 !important;
}
@@ -48,15 +53,24 @@ a {
}
.chatlog__author-name {
font-weight: 600;
color: #2f3136;
}
.chatlog__timestamp {
color: #99aab5;
color: #747f8d;
}
.chatlog__message--highlighted {
background-color: rgba(114, 137, 218, 0.2) !important;
}
.chatlog__message--pinned {
background-color: rgba(249, 168, 37, 0.05);
}
.chatlog__edited-timestamp {
color: #99aab5;
color: #747f8d;
}
.chatlog__embed-content-container {
@@ -89,7 +103,7 @@ a {
}
.chatlog__embed-footer {
color: rgba(79, 83, 91, 0.4);
color: rgba(79, 83, 91, 0.6);
}
.chatlog__reaction {
@@ -97,5 +111,5 @@ a {
}
.chatlog__reaction-count {
color: #99aab5;
color: #747f8d;
}
@@ -32,7 +32,7 @@
body {
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
font-size: 17px;
}
a {
@@ -57,15 +57,22 @@ img {
border-radius: 3px;
}
.quote {
border-left: 4px solid;
border-radius: 3px;
margin: 8px 0;
padding-left: 10px;
}
.pre {
font-family: "Consolas", "Courier New", Courier, Monospace;
font-family: "Consolas", "Courier New", Courier, monospace;
}
.pre--multiline {
margin-top: 4px;
padding: 8px;
border: 2px solid;
border-radius: 5px;
border-radius: 5px;
}
.pre--inline {
@@ -100,7 +107,7 @@ img {
.info {
display: flex;
max-width: 100%;
margin: 0 5px 10px 5px;
margin: 0 5px 10px 5px;
}
.info__guild-icon-container {
@@ -179,8 +186,15 @@ img {
font-size: .75em;
}
.chatlog__message {
padding: 2px 5px;
margin-right: -5px;
margin-left: -5px;
background-color: transparent;
transition: background-color 1s ease;
}
.chatlog__content {
padding-top: 5px;
font-size: .9375em;
word-wrap: break-word;
}
@@ -190,20 +204,17 @@ img {
font-size: .8em;
}
.chatlog__attachment {
margin: 5px 0;
}
.chatlog__attachment-thumbnail {
margin-top: 5px;
max-width: 50%;
max-height: 500px;
border-radius: 3px;
}
.chatlog__embed {
margin-top: 5px;
display: flex;
max-width: 520px;
margin-top: 5px;
}
.chatlog__embed-color-pill {
@@ -301,7 +312,7 @@ img {
margin-top: 10px;
}
.chatlog__embed-image {
.chatlog__embed-image {
max-width: 500px;
max-height: 400px;
border-radius: 3px;
@@ -15,6 +15,28 @@
{{ ThemeStyleSheet }}
</style>
{{~ # Local scripts ~}}
<script>
function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id);
if (element !== null && element !== undefined) {
event.preventDefault();
element.classList.add('chatlog__message--highlighted');
window.scrollTo({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
}
</script>
{{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
@@ -27,7 +49,7 @@
</script>
</head>
<body>
{{~ # Info ~}}
<div class="info">
<div class="info__guild-icon-container">
@@ -36,13 +58,13 @@
<div class="info__metadata">
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
{{~ if Model.Channel.Topic ~}}
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
{{~ end ~}}
<div class="info__channel-message-count">{{ Model.Messages | array.size | object.format "N0" }} messages</div>
{{~ if Model.After || Model.Before ~}}
<div class="info__channel-date-range">
{{~ if Model.After && Model.Before ~}}
@@ -64,17 +86,21 @@
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
</div>
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}" data-user-id="{{ group.Author.Id | html.escape }}">{{ group.Author.Name | html.escape }}</span>
{{~ # Bot tag ~}}
{{~ if group.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span>
{{~ end ~}}
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
<div class="chatlog__message {{if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
@@ -169,9 +195,9 @@
</a>
</div>
{{~ end ~}}
</div>
</div>
{{~ # Image ~}}
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
@@ -188,7 +214,7 @@
<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 ~}}
@@ -218,6 +244,7 @@
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
@@ -225,4 +252,4 @@
</div>
</body>
</html>
</html>
@@ -201,8 +201,11 @@ namespace DiscordChatExporter.Core.Services
// Get mentioned users
var mentionedUsers = json["mentions"].EmptyIfNull().Select(ParseUser).ToArray();
// Get whether this message is pinned
var isPinned = json["pinned"].Value<bool>();
return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
reactions, mentionedUsers);
reactions, mentionedUsers, isPinned);
}
}
}
@@ -7,9 +7,9 @@
<ItemGroup>
<PackageReference Include="Failsafe" Version="1.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
<PackageReference Include="Onova" Version="2.4.2" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="Onova" Version="2.4.5" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
</ItemGroup>
+2
View File
@@ -89,6 +89,8 @@
<Setter Property="UseLayoutRounding" Value="True" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignScrollBarMinimal}" TargetType="{x:Type ScrollBar}" />
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
+2
View File
@@ -24,11 +24,13 @@ namespace DiscordChatExporter.Gui
builder.Bind<IViewModelFactory>().ToAbstractFactory();
}
#if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
{
base.OnUnhandledException(e);
MessageBox.Show(e.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error);
}
#endif
}
}
@@ -130,28 +130,28 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Gress">
<Version>1.0.2</Version>
<Version>1.1.1</Version>
</PackageReference>
<PackageReference Include="MaterialDesignColors">
<Version>1.1.3</Version>
<Version>1.2.0</Version>
</PackageReference>
<PackageReference Include="MaterialDesignThemes">
<Version>2.5.1</Version>
<Version>2.6.0</Version>
</PackageReference>
<PackageReference Include="Ookii.Dialogs.Wpf">
<Version>1.0.0</Version>
<Version>1.1.0</Version>
</PackageReference>
<PackageReference Include="PropertyChanged.Fody">
<Version>2.6.1</Version>
</PackageReference>
<PackageReference Include="Stylet">
<Version>1.1.22</Version>
<Version>1.2.0</Version>
</PackageReference>
<PackageReference Include="System.Windows.Interactivity.WPF">
<Version>2.0.20525</Version>
</PackageReference>
<PackageReference Include="Tyrrrz.Extensions">
<Version>1.6.1</Version>
<Version>1.6.2</Version>
</PackageReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) Alexey Golub")]
[assembly: AssemblyVersion("2.13.1")]
[assembly: AssemblyFileVersion("2.13.1")]
[assembly: AssemblyVersion("2.15")]
[assembly: AssemblyFileVersion("2.15")]
@@ -9,8 +9,6 @@ namespace DiscordChatExporter.Gui.Services
{
public class UpdateService : IDisposable
{
private readonly SettingsService _settingsService;
private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
@@ -18,36 +16,25 @@ namespace DiscordChatExporter.Gui.Services
private Version _updateVersion;
private bool _updaterLaunched;
public UpdateService(SettingsService settingsService)
public async Task<Version> CheckForUpdatesAsync()
{
_settingsService = settingsService;
var check = await _updateManager.CheckForUpdatesAsync();
return check.CanUpdate ? check.LastVersion : null;
}
public async Task<Version> CheckPrepareUpdateAsync()
public async Task PrepareUpdateAsync(Version version)
{
try
{
// If auto-update is disabled - don't check for updates
if (!_settingsService.IsAutoUpdateEnabled)
return null;
// Check for updates
var check = await _updateManager.CheckForUpdatesAsync();
if (!check.CanUpdate)
return null;
// Prepare the update
await _updateManager.PrepareUpdateAsync(check.LastVersion);
return _updateVersion = check.LastVersion;
await _updateManager.PrepareUpdateAsync(_updateVersion = version);
}
catch (UpdaterAlreadyLaunchedException)
{
return null;
// Ignore race conditions
}
catch (LockFileNotAcquiredException)
{
return null;
// Ignore race conditions
}
}
@@ -55,23 +42,20 @@ namespace DiscordChatExporter.Gui.Services
{
try
{
// Check if an update is pending
if (_updateVersion == null)
if (_updateVersion == null || _updaterLaunched)
return;
// Check if the updater has already been launched
if (_updaterLaunched)
return;
// Launch the updater
_updateManager.LaunchUpdater(_updateVersion, needRestart);
_updaterLaunched = true;
}
catch (UpdaterAlreadyLaunchedException)
{
// Ignore race conditions
}
catch (LockFileNotAcquiredException)
{
// Ignore race conditions
}
}
@@ -22,46 +22,46 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
// Set up event routing that will close the view when called from viewmodel
DialogOpenedEventHandler onDialogOpened = (sender, e) =>
void OnDialogOpened(object sender, DialogOpenedEventArgs openArgs)
{
// Delegate to close the dialog and unregister event handler
void OnScreenClosed(object o, CloseEventArgs args)
void OnScreenClosed(object o, CloseEventArgs closeArgs)
{
e.Session.Close();
openArgs.Session.Close();
dialogScreen.Closed -= OnScreenClosed;
}
dialogScreen.Closed += OnScreenClosed;
};
}
// Show view
await DialogHost.Show(view, onDialogOpened);
await DialogHost.Show(view, OnDialogOpened);
// Return the result
return dialogScreen.DialogResult;
}
public string PromptSaveFilePath(string filter = "All files|*.*", string initialFilePath = "")
public string PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
{
// Create dialog
var dialog = new SaveFileDialog
{
Filter = filter,
AddExtension = true,
FileName = initialFilePath,
DefaultExt = Path.GetExtension(initialFilePath) ?? ""
FileName = defaultFilePath,
DefaultExt = Path.GetExtension(defaultFilePath) ?? ""
};
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
public string PromptDirectoryPath(string initialDirPath = "")
public string PromptDirectoryPath(string defaultDirPath = "")
{
// Create dialog
var dialog = new VistaFolderBrowserDialog
{
SelectedPath = initialDirPath
SelectedPath = defaultDirPath
};
// Show dialog and return result
@@ -36,9 +36,5 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
return viewModel;
}
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
GuildViewModel guild, ChannelViewModel channel)
=> factory.CreateExportSetupViewModel(guild, new[] { channel });
}
}
@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
@@ -68,6 +69,39 @@ namespace DiscordChatExporter.Gui.ViewModels
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
}
private async Task HandleAutoUpdateAsync()
{
try
{
// Don't check for updates if auto-update is disabled
if (!_settingsService.IsAutoUpdateEnabled)
return;
// Check for updates
var updateVersion = await _updateService.CheckForUpdatesAsync();
if (updateVersion == null)
return;
// Notify user of an update and prepare it
Notifications.Enqueue($"Downloading update to DiscordChatExporter v{updateVersion}...");
await _updateService.PrepareUpdateAsync(updateVersion);
// Prompt user to install update (otherwise install it when application exits)
Notifications.Enqueue(
"Update has been downloaded and will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.FinalizeUpdate(true);
RequestClose();
});
}
catch
{
// Failure to update shouldn't crash the application
Notifications.Enqueue("Failed to perform application update");
}
}
protected override async void OnViewLoaded()
{
base.OnViewLoaded();
@@ -83,24 +117,7 @@ namespace DiscordChatExporter.Gui.ViewModels
}
// Check and prepare update
try
{
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
Notifications.Enqueue(
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.FinalizeUpdate(true);
RequestClose();
});
}
}
catch
{
Notifications.Enqueue("Failed to perform application auto-update");
}
await HandleAutoUpdateAsync();
}
protected override void OnClose()
@@ -23,10 +23,14 @@
Margin="16,8"
materialDesign:HintAssist.Hint="Date format"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding DateFormat}" />
Text="{Binding DateFormat}"
ToolTip="Format used when rendering dates (refer to .NET date format)" />
<!-- Auto-updates -->
<DockPanel LastChildFill="False">
<DockPanel
LastChildFill="False"
Background="Transparent"
ToolTip="Perform automatic updates on every launch">
<TextBlock
Margin="16,8"
DockPanel.Dock="Left"
+4 -2
View File
@@ -94,7 +94,8 @@
Padding="4"
Command="{s:Action PopulateGuildsAndChannels}"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}">
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Pull available guilds and channels (Enter)">
<materialDesign:PackIcon
Width="24"
Height="24"
@@ -109,7 +110,8 @@
Margin="6"
Padding="4"
Command="{s:Action ShowSettings}"
Style="{DynamicResource MaterialDesignFlatDarkButton}">
Style="{DynamicResource MaterialDesignFlatDarkButton}"
ToolTip="Settings">
<materialDesign:PackIcon
Width="24"
Height="24"
+3 -3
View File
@@ -24,8 +24,8 @@ DiscordChatExporter can be used to export message history from a [Discord](https
## Screenshots
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/1.png)
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/4.png)
![channel list](.screenshots/list.png)
![rendered output](.screenshots/output.png)
## Libraries used
@@ -34,8 +34,8 @@ DiscordChatExporter can be used to export message history from a [Discord](https
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [Scriban](https://github.com/lunet-io/scriban)
- [CommandLineParser](https://github.com/commandlineparser/commandline)
- [Ookii.Dialogs](https://github.com/caioproiete/ookii-dialogs-wpf)
- [CliFx](https://github.com/Tyrrrz/CliFx)
- [Failsafe](https://github.com/Tyrrrz/Failsafe)
- [Gress](https://github.com/Tyrrrz/Gress)
- [Onova](https://github.com/Tyrrrz/Onova)