Compare commits

...

21 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
71 changed files with 865 additions and 792 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

+13
View File
@@ -1,3 +1,16 @@
### 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) ### v2.14 (15-Jun-2019)
- [TXT] Added support for embeds. - [TXT] Added support for embeds.
@@ -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> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks> <TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
<Version>2.14</Version> <Version>2.15</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright> <Copyright>Copyright (c) Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
@@ -12,7 +12,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.3.0" /> <PackageReference Include="CliFx" Version="0.0.5" />
<PackageReference Include="Stylet" Version="1.2.0" /> <PackageReference Include="Stylet" Version="1.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup> </ItemGroup>
@@ -1,31 +1,33 @@
using System; using System;
using CliFx.Services;
namespace DiscordChatExporter.Cli.Internal namespace DiscordChatExporter.Cli.Internal
{ {
internal class InlineProgress : IProgress<double>, IDisposable internal class InlineProgress : IProgress<double>, IDisposable
{ {
private readonly int _posX; private readonly IConsole _console;
private readonly int _posY;
private string _lastOutput = "";
private bool _isCompleted; private bool _isCompleted;
public InlineProgress() public InlineProgress(IConsole console)
{ {
// If output is not redirected - save initial cursor position _console = console;
if (!Console.IsOutputRedirected) }
{
_posX = Console.CursorLeft; private void ResetCursorPosition()
_posY = Console.CursorTop; {
} foreach (var c in _lastOutput)
_console.Output.Write('\b');
} }
public void Report(double progress) public void Report(double progress)
{ {
// If output is not redirected - reset cursor position and write progress // If output is not redirected - reset cursor position and write progress
if (!Console.IsOutputRedirected) if (!_console.IsOutputRedirected)
{ {
Console.SetCursorPosition(_posX, _posY); ResetCursorPosition();
Console.WriteLine($"{progress:P1}"); _console.Output.Write(_lastOutput = $"{progress:P1}");
} }
} }
@@ -34,14 +36,13 @@ namespace DiscordChatExporter.Cli.Internal
public void Dispose() public void Dispose()
{ {
// If output is not redirected - reset cursor position // If output is not redirected - reset cursor position
if (!Console.IsOutputRedirected) if (!_console.IsOutputRedirected)
Console.SetCursorPosition(_posX, _posY); {
ResetCursorPosition();
}
// Inform about completion // Inform about completion
if (_isCompleted) _console.Output.WriteLine(_isCompleted ? "Completed ✓" : "Failed X");
Console.WriteLine("Completed ✓");
else
Console.WriteLine("Failed X");
} }
} }
} }
+22 -67
View File
@@ -1,80 +1,35 @@
using System; using System.Threading.Tasks;
using System.Linq; using CliFx;
using CommandLine; using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Cli.Verbs; using StyletIoC;
using DiscordChatExporter.Cli.Verbs.Options;
namespace DiscordChatExporter.Cli namespace DiscordChatExporter.Cli
{ {
public static class Program public static class Program
{ {
private static void PrintTokenHelp() private static IContainer BuildContainer()
{ {
Console.WriteLine("# To get user token:"); var builder = new StyletIoCBuilder();
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools"); // Autobind the .Services assembly
Console.WriteLine(" 3. Navigate to the Application tab"); builder.Autobind(typeof(DataService).Assembly);
Console.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
Console.WriteLine(" 5. Press Ctrl+R to reload"); // Bind settings as singleton
Console.WriteLine(" 6. Find \"token\" at the bottom and copy the value"); builder.Bind<SettingsService>().ToSelf().InSingletonScope();
Console.WriteLine();
Console.WriteLine("# To get bot token:"); // Set instance
Console.WriteLine(" 1. Go to Discord developer portal"); return builder.BuildContainer();
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");
} }
public static void Main(string[] args) public static Task<int> Main(string[] args)
{ {
// Get all verb types var container = BuildContainer();
var verbTypes = new[]
{
typeof(ExportChannelOptions),
typeof(ExportDirectMessagesOptions),
typeof(ExportGuildOptions),
typeof(GetChannelsOptions),
typeof(GetDirectMessageChannelsOptions),
typeof(GetGuildsOptions)
};
// Parse command line arguments return new CliApplicationBuilder()
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes); .AddCommandsFromThisAssembly()
.UseCommandFactory(schema => (ICommand) container.Get(schema.Type))
// Execute commands .Build()
parsedArgs.WithParsed<ExportChannelOptions>(o => new ExportChannelVerb(o).Execute()); .RunAsync(args);
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();
});
} }
} }
} }
@@ -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();
}
}
@@ -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; ParsedMatch<T> earliestMatch = null;
@@ -24,19 +24,19 @@ namespace DiscordChatExporter.Core.Markdown.Internal
foreach (var matcher in _matchers) foreach (var matcher in _matchers)
{ {
// Try to match // Try to match
var match = matcher.Match(input, startIndex, length); var match = matcher.Match(stringPart);
// If there's no match - continue // If there's no match - continue
if (match == null) if (match == null)
continue; continue;
// If this match is earlier than previous earliest - replace // 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; earliestMatch = match;
// If the earliest match starts at the very beginning - break, // If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that // because it's impossible to find a match earlier than that
if (earliestMatch.StartIndex == startIndex) if (earliestMatch.StringPart.StartIndex == stringPart.StartIndex)
break; break;
} }
@@ -1,50 +1,54 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Internal namespace DiscordChatExporter.Core.Markdown.Internal
{ {
internal static class Extensions internal static class Extensions
{ {
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input, public static StringPart Shrink(this StringPart stringPart, int newStartIndex, int newLength) =>
int startIndex, int length, Func<string, T> fallbackTransform) new StringPart(stringPart.Target, newStartIndex, newLength);
{
// Get end index for simplicity
var endIndex = startIndex + length;
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 // Loop through segments divided by individual matches
var currentIndex = startIndex; var currentIndex = stringPart.StartIndex;
while (currentIndex < endIndex) while (currentIndex < stringPart.EndIndex)
{ {
// Find a match within this segment // 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 there's no match - break
if (match == null) if (match == null)
break; break;
// If this match doesn't start immediately at current index - transform and yield fallback first // 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); var fallbackPart = stringPart.Shrink(currentIndex, match.StringPart.StartIndex - currentIndex);
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback)); yield return new ParsedMatch<T>(fallbackPart, fallbackTransform(fallbackPart));
} }
// Yield match // Yield match
yield return match; yield return match;
// Shift current index to the end of the 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 EOL wasn't reached - transform and yield remaining part as fallback
if (currentIndex < endIndex) if (currentIndex < stringPart.EndIndex)
{ {
var fallback = input.Substring(currentIndex); var fallbackPart = stringPart.Shrink(currentIndex);
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback)); 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> 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> internal class ParsedMatch<T>
{ {
public int StartIndex { get; } public StringPart StringPart { get; }
public int Length { get; }
public T Value { get; } public T Value { get; }
public ParsedMatch(int startIndex, int length, T value) public ParsedMatch(StringPart stringPart, T value)
{ {
StartIndex = startIndex; StringPart = stringPart;
Length = length;
Value = value; Value = value;
} }
} }
@@ -1,4 +1,8 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Internal namespace DiscordChatExporter.Core.Markdown.Internal
@@ -6,18 +10,35 @@ namespace DiscordChatExporter.Core.Markdown.Internal
internal class RegexMatcher<T> : IMatcher<T> internal class RegexMatcher<T> : IMatcher<T>
{ {
private readonly Regex _regex; 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; _regex = regex;
_transform = transform; _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 string _needle;
private readonly StringComparison _comparison; 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; _needle = needle;
_comparison = comparison; _comparison = comparison;
_transform = transform; _transform = transform;
} }
public StringMatcher(string needle, Func<string, T> transform) public StringMatcher(string needle, Func<StringPart, T> transform)
: this(needle, StringComparison.Ordinal, 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); var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison);
return index >= 0 ? new ParsedMatch<T>(index, _needle.Length, _transform(_needle)) : null;
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 // The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
public static class MarkdownParser public static class MarkdownParser
{ {
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant; private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline;
/* Formatting */ /* Formatting */
// Capture any character until the earliest double asterisk not followed by an asterisk // Capture any character until the earliest double asterisk not followed by an asterisk
private static readonly IMatcher<Node> BoldFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> BoldFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline), 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 // Capture any character until the earliest single asterisk not preceded or followed by an asterisk
// Opening asterisk must not be followed by whitespace // Opening asterisk must not be followed by whitespace
// Closing asterisk must not be preceded by whitespace // Closing asterisk must not be preceded by whitespace
private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline), 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 // Capture any character until the earliest triple asterisk not followed by an asterisk
private static readonly IMatcher<Node> ItalicBoldFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> ItalicBoldFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline), 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 // Capture any character except underscore until an underscore
// Closing underscore must not be followed by a word character // Closing underscore must not be followed by a word character
private static readonly IMatcher<Node> ItalicAltFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> ItalicAltFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline), 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 // Capture any character until the earliest double underscore not followed by an underscore
private static readonly IMatcher<Node> UnderlineFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> UnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline), 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 // Capture any character until the earliest triple underscore not followed by an underscore
private static readonly IMatcher<Node> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline), 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 // Capture any character until the earliest double tilde
private static readonly IMatcher<Node> StrikethroughFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> StrikethroughFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline), 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 // Capture any character until the earliest double pipe
private static readonly IMatcher<Node> SpoilerFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> SpoilerFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline), 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 */ /* Code blocks */
// Capture any character except backtick until a backtick // 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>( private static readonly IMatcher<Node> InlineCodeBlockNodeMatcher = new RegexMatcher<Node>(
new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline), 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 // 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 // Language identifier is one word immediately after opening backticks, followed immediately by newline
// Whitespace surrounding content inside backticks is trimmed // Blank lines at the beginning and end of content are trimmed
private static readonly IMatcher<Node> MultilineCodeBlockNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> MultiLineCodeBlockNodeMatcher = new RegexMatcher<Node>(
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline), 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 */ /* Mentions */
// Capture @everyone // Capture @everyone
private static readonly IMatcher<Node> EveryoneMentionNodeMatcher = new StringMatcher<Node>( private static readonly IMatcher<Node> EveryoneMentionNodeMatcher = new StringMatcher<Node>(
"@everyone", "@everyone",
s => new MentionNode(s, "everyone", MentionType.Meta)); p => new MentionNode("everyone", MentionType.Meta));
// Capture @here // Capture @here
private static readonly IMatcher<Node> HereMentionNodeMatcher = new StringMatcher<Node>( private static readonly IMatcher<Node> HereMentionNodeMatcher = new StringMatcher<Node>(
"@here", "@here",
s => new MentionNode(s, "here", MentionType.Meta)); p => new MentionNode("here", MentionType.Meta));
// Capture <@123456> or <@!123456> // Capture <@123456> or <@!123456>
private static readonly IMatcher<Node> UserMentionNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> UserMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<@!?(\\d+)>", DefaultRegexOptions), 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> // Capture <#123456>
private static readonly IMatcher<Node> ChannelMentionNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> ChannelMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<#(\\d+)>", DefaultRegexOptions), 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> // Capture <@&123456>
private static readonly IMatcher<Node> RoleMentionNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> RoleMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<@&(\\d+)>", DefaultRegexOptions), 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 */ /* Emojis */
@@ -108,29 +120,29 @@ namespace DiscordChatExporter.Core.Markdown
// (this does not match all emojis in Discord but it's reasonably accurate enough) // (this does not match all emojis in Discord but it's reasonably accurate enough)
private static readonly IMatcher<Node> StandardEmojiNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> StandardEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|\\p{So}|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions), 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> // Capture <:lul:123456> or <a:lul:123456>
private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions), 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 */ /* Links */
// Capture [title](link) // Capture [title](link)
private static readonly IMatcher<Node> TitledLinkNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> TitledLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions), 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 // 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>( private static readonly IMatcher<Node> AutoLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions), 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 // Same as auto link but also surrounded by angular brackets
private static readonly IMatcher<Node> HiddenLinkNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> HiddenLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions), new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[1].Value)); m => new LinkNode(m.Groups[1].Value));
/* Text */ /* Text */
@@ -138,25 +150,25 @@ namespace DiscordChatExporter.Core.Markdown
// This escapes it from matching for formatting // This escapes it from matching for formatting
private static readonly IMatcher<Node> ShrugTextNodeMatcher = new StringMatcher<Node>( 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 // Capture some specific emojis that don't get rendered
// This escapes it from matching for emoji // This escapes it from matching for emoji
private static readonly IMatcher<Node> IgnoredEmojiTextNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> IgnoredEmojiTextNodeMatcher = new RegexMatcher<Node>(
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions), 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 // Capture any "symbol/other" character or surrogate pair preceded by a backslash
// This escapes it from matching for emoji // This escapes it from matching for emoji
private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions), 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 // Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
// This escapes it from matching for formatting or other tokens // This escapes it from matching for formatting or other tokens
private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions), 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 // Combine all matchers into one
// Matchers that have similar patterns are ordered from most specific to least specific // Matchers that have similar patterns are ordered from most specific to least specific
@@ -176,9 +188,11 @@ namespace DiscordChatExporter.Core.Markdown
ItalicAltFormattedNodeMatcher, ItalicAltFormattedNodeMatcher,
StrikethroughFormattedNodeMatcher, StrikethroughFormattedNodeMatcher,
SpoilerFormattedNodeMatcher, SpoilerFormattedNodeMatcher,
MultiLineQuoteNodeMatcher,
SingleLineQuoteNodeMatcher,
// Code blocks // Code blocks
MultilineCodeBlockNodeMatcher, MultiLineCodeBlockNodeMatcher,
InlineCodeBlockNodeMatcher, InlineCodeBlockNodeMatcher,
// Mentions // Mentions
@@ -197,9 +211,27 @@ namespace DiscordChatExporter.Core.Markdown
StandardEmojiNodeMatcher, StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher); CustomEmojiNodeMatcher);
private static IReadOnlyList<Node> Parse(string input, IMatcher<Node> matcher) => private static readonly IMatcher<Node> MinimalAggregateNodeMatcher = new AggregateMatcher<Node>(
matcher.MatchAll(input, s => new TextNode(s)).Select(r => r.Value).ToArray(); // 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 bool IsCustomEmoji => !Id.IsNullOrWhiteSpace();
public EmojiNode(string source, string id, string name, bool isAnimated) public EmojiNode(string id, string name, bool isAnimated)
: base(source)
{ {
Id = id; Id = id;
Name = name; Name = name;
IsAnimated = isAnimated; IsAnimated = isAnimated;
} }
public EmojiNode(string source, string name) public EmojiNode(string name)
: this(source, null, name, false) : this(null, name, false)
{ {
} }
@@ -4,16 +4,12 @@ namespace DiscordChatExporter.Core.Markdown.Nodes
{ {
public class FormattedNode : Node public class FormattedNode : Node
{ {
public string Token { get; }
public TextFormatting Formatting { get; } public TextFormatting Formatting { get; }
public IReadOnlyList<Node> Children { get; } public IReadOnlyList<Node> Children { get; }
public FormattedNode(string source, string token, TextFormatting formatting, IReadOnlyList<Node> children) public FormattedNode(TextFormatting formatting, IReadOnlyList<Node> children)
: base(source)
{ {
Token = token;
Formatting = formatting; Formatting = formatting;
Children = children; Children = children;
} }
@@ -4,8 +4,7 @@
{ {
public string Code { get; } public string Code { get; }
public InlineCodeBlockNode(string source, string code) public InlineCodeBlockNode(string code)
: base(source)
{ {
Code = code; Code = code;
} }
@@ -6,14 +6,14 @@
public string Title { get; } public string Title { get; }
public LinkNode(string source, string url, string title) public LinkNode(string url, string title)
: base(source)
{ {
Url = url; Url = url;
Title = title; 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 MentionType Type { get; }
public MentionNode(string source, string id, MentionType type) public MentionNode(string id, MentionType type)
: base(source)
{ {
Id = id; Id = id;
Type = type; Type = type;
@@ -1,13 +1,12 @@
namespace DiscordChatExporter.Core.Markdown.Nodes namespace DiscordChatExporter.Core.Markdown.Nodes
{ {
public class MultilineCodeBlockNode : Node public class MultiLineCodeBlockNode : Node
{ {
public string Language { get; } public string Language { get; }
public string Code { get; } public string Code { get; }
public MultilineCodeBlockNode(string source, string language, string code) public MultiLineCodeBlockNode(string language, string code)
: base(source)
{ {
Language = language; Language = language;
Code = code; Code = code;
@@ -2,11 +2,5 @@
{ {
public abstract class Node public abstract class Node
{ {
public string Source { get; }
protected Node(string source)
{
Source = source;
}
} }
} }
@@ -6,6 +6,7 @@
Italic, Italic,
Underline, Underline,
Strikethrough, Strikethrough,
Spoiler Spoiler,
Quote
} }
} }
@@ -4,16 +4,11 @@
{ {
public string Text { get; } public string Text { get; }
public TextNode(string source, string text) public TextNode(string text)
: base(source)
{ {
Text = text; Text = text;
} }
public TextNode(string text) : this(text, text)
{
}
public override string ToString() => Text; public override string ToString() => Text;
} }
} }
+5 -1
View File
@@ -29,9 +29,12 @@ namespace DiscordChatExporter.Core.Models
public IReadOnlyList<User> MentionedUsers { get; } public IReadOnlyList<User> MentionedUsers { get; }
public bool IsPinned { get; }
public Message(string id, string channelId, MessageType type, User author, DateTimeOffset timestamp, public Message(string id, string channelId, MessageType type, User author, DateTimeOffset timestamp,
DateTimeOffset? editedTimestamp, string content, IReadOnlyList<Attachment> attachments, 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; Id = id;
ChannelId = channelId; ChannelId = channelId;
@@ -44,6 +47,7 @@ namespace DiscordChatExporter.Core.Models
Embeds = embeds; Embeds = embeds;
Reactions = reactions; Reactions = reactions;
MentionedUsers = mentionedUsers; MentionedUsers = mentionedUsers;
IsPinned = isPinned;
} }
public override string ToString() => Content; public override string ToString() => Content;
@@ -27,18 +27,21 @@ namespace DiscordChatExporter.Core.Rendering
private string FormatMarkdown(Node node) private string FormatMarkdown(Node node)
{ {
// Formatted node // Text node
if (node is FormattedNode formattedNode) if (node is TextNode textNode)
{ {
// Recursively get inner text return textNode.Text;
var innerText = FormatMarkdown(formattedNode.Children);
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
} }
// Non-meta mention node // Mention node
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta) if (node is MentionNode mentionNode)
{ {
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return mentionNode.Id;
}
// User mention node // User mention node
if (mentionNode.Type == MentionType.User) if (mentionNode.Type == MentionType.User)
{ {
@@ -61,19 +64,19 @@ namespace DiscordChatExporter.Core.Rendering
} }
} }
// Custom emoji node // Emoji node
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji) if (node is EmojiNode emojiNode)
{ {
return $":{emojiNode.Name}:"; return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
} }
// All other nodes - simply return source // Throw on unexpected nodes
return node.Source; throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
} }
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString(""); 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) private async Task RenderFieldAsync(TextWriter writer, string value)
{ {
@@ -83,6 +86,9 @@ namespace DiscordChatExporter.Core.Rendering
private async Task RenderMessageAsync(TextWriter writer, Message message) private async Task RenderMessageAsync(TextWriter writer, Message message)
{ {
// Author ID
await RenderFieldAsync(writer, message.Author.Id);
// Author // Author
await RenderFieldAsync(writer, message.Author.FullName); await RenderFieldAsync(writer, message.Author.FullName);
@@ -97,7 +103,7 @@ namespace DiscordChatExporter.Core.Rendering
await RenderFieldAsync(writer, formattedAttachments); await RenderFieldAsync(writer, formattedAttachments);
// Reactions // Reactions
var formattedReactions = message.Reactions.Select(r => r.Emoji.Name + $"({r.Count})").JoinToString(","); var formattedReactions = message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(",");
await RenderFieldAsync(writer, formattedReactions); await RenderFieldAsync(writer, formattedReactions);
// Line break // Line break
@@ -107,7 +113,7 @@ namespace DiscordChatExporter.Core.Rendering
public async Task RenderAsync(TextWriter writer) public async Task RenderAsync(TextWriter writer)
{ {
// Headers // Headers
await writer.WriteLineAsync("Author;Date;Content;Attachments;Reactions;"); await writer.WriteLineAsync("AuthorID;Author;Date;Content;Attachments;Reactions;");
// Log // Log
foreach (var message in _chatLog.Messages) foreach (var message in _chatLog.Messages)
@@ -15,7 +15,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Scriban" Version="2.0.1" /> <PackageReference Include="Scriban" Version="2.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes; using DiscordChatExporter.Core.Markdown.Nodes;
@@ -80,6 +81,10 @@ namespace DiscordChatExporter.Core.Rendering
// Spoiler // Spoiler
if (formattedNode.Formatting == TextFormatting.Spoiler) if (formattedNode.Formatting == TextFormatting.Spoiler)
return $"<span class=\"spoiler\">{innerHtml}</span>"; return $"<span class=\"spoiler\">{innerHtml}</span>";
// Quote
if (formattedNode.Formatting == TextFormatting.Quote)
return $"<div class=\"quote\">{innerHtml}</div>";
} }
// Inline code block node // Inline code block node
@@ -89,14 +94,14 @@ namespace DiscordChatExporter.Core.Rendering
} }
// Multi-line code block node // Multi-line code block node
if (node is MultilineCodeBlockNode multilineCodeBlockNode) if (node is MultiLineCodeBlockNode multilineCodeBlockNode)
{ {
// Set language class for syntax highlighting // Set CSS class for syntax highlighting
var languageCssClass = !multilineCodeBlockNode.Language.IsNullOrWhiteSpace() var highlightCssClass = !multilineCodeBlockNode.Language.IsNullOrWhiteSpace()
? "language-" + multilineCodeBlockNode.Language ? $"language-{multilineCodeBlockNode.Language}"
: null; : "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 // Mention node
@@ -145,17 +150,22 @@ namespace DiscordChatExporter.Core.Rendering
// Link node // Link node
if (node is LinkNode linkNode) 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 // Throw on unexpected nodes
return node.Source; throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
} }
private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel) private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel)
{ {
// Emojis are jumbo if all top-level nodes are emoji nodes, disregarding whitespace // Emojis are jumbo if all top-level nodes are emoji nodes or whitespace text nodes
var isJumbo = isTopLevel && nodes.Where(n => !n.Source.IsNullOrWhiteSpace()).All(n => n is EmojiNode); var isJumbo = isTopLevel && nodes.All(n => n is EmojiNode || n is TextNode textNode && textNode.Text.IsNullOrWhiteSpace());
return nodes.Select(n => FormatMarkdown(n, isJumbo)).JoinToString(""); return nodes.Select(n => FormatMarkdown(n, isJumbo)).JoinToString("");
} }
@@ -45,18 +45,21 @@ namespace DiscordChatExporter.Core.Rendering
private string FormatMarkdown(Node node) private string FormatMarkdown(Node node)
{ {
// Formatted node // Text node
if (node is FormattedNode formattedNode) if (node is TextNode textNode)
{ {
// Recursively get inner text return textNode.Text;
var innerText = FormatMarkdown(formattedNode.Children);
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
} }
// Non-meta mention node // Mention node
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta) if (node is MentionNode mentionNode)
{ {
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return mentionNode.Id;
}
// User mention node // User mention node
if (mentionNode.Type == MentionType.User) if (mentionNode.Type == MentionType.User)
{ {
@@ -79,19 +82,34 @@ namespace DiscordChatExporter.Core.Rendering
} }
} }
// Custom emoji node // Emoji node
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji) if (node is EmojiNode emojiNode)
{ {
return $":{emojiNode.Name}:"; return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
} }
// All other nodes - simply return source // Throw on unexpected nodes
return node.Source; throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
} }
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString(""); 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) private async Task RenderAttachmentsAsync(TextWriter writer, IReadOnlyList<Attachment> attachments)
{ {
@@ -179,8 +197,8 @@ namespace DiscordChatExporter.Core.Rendering
private async Task RenderMessageAsync(TextWriter writer, Message message) private async Task RenderMessageAsync(TextWriter writer, Message message)
{ {
// Timestamp and author // Header
await writer.WriteLineAsync($"[{FormatDate(message.Timestamp)}] {message.Author.FullName}"); await RenderMessageHeaderAsync(writer, message);
// Content // Content
await writer.WriteLineAsync(FormatMarkdown(message.Content)); await writer.WriteLineAsync(FormatMarkdown(message.Content));
@@ -13,6 +13,10 @@ a {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
} }
.quote {
border-color: #4f545c;
}
.pre { .pre {
background-color: #2f3136 !important; background-color: #2f3136 !important;
} }
@@ -54,6 +58,14 @@ a {
color: rgba(255, 255, 255, 0.2); 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 { .chatlog__edited-timestamp {
color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.2);
} }
@@ -2,7 +2,8 @@
body { body {
background-color: #ffffff; background-color: #ffffff;
color: #747f8d; color: #23262a;
font-weight: 500;
} }
a { a {
@@ -13,6 +14,10 @@ a {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
.quote {
border-color: #c7ccd1;
}
.pre { .pre {
background-color: #f9f9f9 !important; background-color: #f9f9f9 !important;
} }
@@ -48,15 +53,24 @@ a {
} }
.chatlog__author-name { .chatlog__author-name {
font-weight: 600;
color: #2f3136; color: #2f3136;
} }
.chatlog__timestamp { .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 { .chatlog__edited-timestamp {
color: #99aab5; color: #747f8d;
} }
.chatlog__embed-content-container { .chatlog__embed-content-container {
@@ -89,7 +103,7 @@ a {
} }
.chatlog__embed-footer { .chatlog__embed-footer {
color: rgba(79, 83, 91, 0.4); color: rgba(79, 83, 91, 0.6);
} }
.chatlog__reaction { .chatlog__reaction {
@@ -97,5 +111,5 @@ a {
} }
.chatlog__reaction-count { .chatlog__reaction-count {
color: #99aab5; color: #747f8d;
} }
@@ -32,7 +32,7 @@
body { body {
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 17px;
} }
a { a {
@@ -57,15 +57,22 @@ img {
border-radius: 3px; border-radius: 3px;
} }
.quote {
border-left: 4px solid;
border-radius: 3px;
margin: 8px 0;
padding-left: 10px;
}
.pre { .pre {
font-family: "Consolas", "Courier New", Courier, Monospace; font-family: "Consolas", "Courier New", Courier, monospace;
} }
.pre--multiline { .pre--multiline {
margin-top: 4px; margin-top: 4px;
padding: 8px; padding: 8px;
border: 2px solid; border: 2px solid;
border-radius: 5px; border-radius: 5px;
} }
.pre--inline { .pre--inline {
@@ -100,7 +107,7 @@ img {
.info { .info {
display: flex; display: flex;
max-width: 100%; max-width: 100%;
margin: 0 5px 10px 5px; margin: 0 5px 10px 5px;
} }
.info__guild-icon-container { .info__guild-icon-container {
@@ -179,8 +186,15 @@ img {
font-size: .75em; font-size: .75em;
} }
.chatlog__message {
padding: 2px 5px;
margin-right: -5px;
margin-left: -5px;
background-color: transparent;
transition: background-color 1s ease;
}
.chatlog__content { .chatlog__content {
padding-top: 5px;
font-size: .9375em; font-size: .9375em;
word-wrap: break-word; word-wrap: break-word;
} }
@@ -190,20 +204,17 @@ img {
font-size: .8em; font-size: .8em;
} }
.chatlog__attachment {
margin: 5px 0;
}
.chatlog__attachment-thumbnail { .chatlog__attachment-thumbnail {
margin-top: 5px;
max-width: 50%; max-width: 50%;
max-height: 500px; max-height: 500px;
border-radius: 3px; border-radius: 3px;
} }
.chatlog__embed { .chatlog__embed {
margin-top: 5px;
display: flex; display: flex;
max-width: 520px; max-width: 520px;
margin-top: 5px;
} }
.chatlog__embed-color-pill { .chatlog__embed-color-pill {
@@ -301,7 +312,7 @@ img {
margin-top: 10px; margin-top: 10px;
} }
.chatlog__embed-image { .chatlog__embed-image {
max-width: 500px; max-width: 500px;
max-height: 400px; max-height: 400px;
border-radius: 3px; border-radius: 3px;
@@ -15,6 +15,28 @@
{{ ThemeStyleSheet }} {{ ThemeStyleSheet }}
</style> </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 ~}} {{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css"> <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> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
@@ -27,7 +49,7 @@
</script> </script>
</head> </head>
<body> <body>
{{~ # Info ~}} {{~ # Info ~}}
<div class="info"> <div class="info">
<div class="info__guild-icon-container"> <div class="info__guild-icon-container">
@@ -36,13 +58,13 @@
<div class="info__metadata"> <div class="info__metadata">
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div> <div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div> <div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
{{~ if Model.Channel.Topic ~}} {{~ if Model.Channel.Topic ~}}
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div> <div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
{{~ end ~}} {{~ end ~}}
<div class="info__channel-message-count">{{ Model.Messages | array.size | object.format "N0" }} messages</div> <div class="info__channel-message-count">{{ Model.Messages | array.size | object.format "N0" }} messages</div>
{{~ if Model.After || Model.Before ~}} {{~ if Model.After || Model.Before ~}}
<div class="info__channel-date-range"> <div class="info__channel-date-range">
{{~ if Model.After && Model.Before ~}} {{~ if Model.After && Model.Before ~}}
@@ -64,17 +86,21 @@
{{~ # Avatar ~}} {{~ # Avatar ~}}
<div class="chatlog__author-avatar-container"> <div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" /> <img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
</div> </div>
<div class="chatlog__messages"> <div class="chatlog__messages">
{{~ # Author name and timestamp ~}} {{~ # 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> <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 ~}} {{~ if group.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span> <span class="chatlog__bot-tag">BOT</span>
{{~ end ~}} {{~ end ~}}
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span> <span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}} {{~ # Messages ~}}
{{~ for message in group.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 ~}} {{~ # Content ~}}
{{~ if message.Content ~}} {{~ if message.Content ~}}
<div class="chatlog__content"> <div class="chatlog__content">
@@ -169,9 +195,9 @@
</a> </a>
</div> </div>
{{~ end ~}} {{~ end ~}}
</div> </div>
{{~ # Image ~}} {{~ # Image ~}}
{{~ if embed.Image ~}} {{~ if embed.Image ~}}
<div class="chatlog__embed-image-container"> <div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}"> <a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
@@ -188,7 +214,7 @@
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" /> <img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
{{~ end ~}} {{~ end ~}}
{{~ end ~}} {{~ end ~}}
<span class="chatlog__embed-footer-text"> <span class="chatlog__embed-footer-text">
{{~ if embed.Footer ~}} {{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}} {{~ if embed.Footer.Text ~}}
@@ -218,6 +244,7 @@
{{~ end ~}} {{~ end ~}}
</div> </div>
{{~ end ~}} {{~ end ~}}
</div>
{{~ end ~}} {{~ end ~}}
</div> </div>
</div> </div>
@@ -225,4 +252,4 @@
</div> </div>
</body> </body>
</html> </html>
@@ -201,8 +201,11 @@ namespace DiscordChatExporter.Core.Services
// Get mentioned users // Get mentioned users
var mentionedUsers = json["mentions"].EmptyIfNull().Select(ParseUser).ToArray(); 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, return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
reactions, mentionedUsers); reactions, mentionedUsers, isPinned);
} }
} }
} }
@@ -8,7 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Failsafe" Version="1.1.0" /> <PackageReference Include="Failsafe" Version="1.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="Onova" Version="2.4.3" /> <PackageReference Include="Onova" Version="2.4.5" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" /> <PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
</ItemGroup> </ItemGroup>
+2
View File
@@ -89,6 +89,8 @@
<Setter Property="UseLayoutRounding" Value="True" /> <Setter Property="UseLayoutRounding" Value="True" />
</Style> </Style>
<Style BasedOn="{StaticResource MaterialDesignScrollBarMinimal}" TargetType="{x:Type ScrollBar}" />
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}"> <Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" /> <Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
+2
View File
@@ -24,11 +24,13 @@ namespace DiscordChatExporter.Gui
builder.Bind<IViewModelFactory>().ToAbstractFactory(); builder.Bind<IViewModelFactory>().ToAbstractFactory();
} }
#if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e) protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
{ {
base.OnUnhandledException(e); base.OnUnhandledException(e);
MessageBox.Show(e.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error); MessageBox.Show(e.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error);
} }
#endif
} }
} }
@@ -133,10 +133,10 @@
<Version>1.1.1</Version> <Version>1.1.1</Version>
</PackageReference> </PackageReference>
<PackageReference Include="MaterialDesignColors"> <PackageReference Include="MaterialDesignColors">
<Version>1.1.3</Version> <Version>1.2.0</Version>
</PackageReference> </PackageReference>
<PackageReference Include="MaterialDesignThemes"> <PackageReference Include="MaterialDesignThemes">
<Version>2.5.1</Version> <Version>2.6.0</Version>
</PackageReference> </PackageReference>
<PackageReference Include="Ookii.Dialogs.Wpf"> <PackageReference Include="Ookii.Dialogs.Wpf">
<Version>1.1.0</Version> <Version>1.1.0</Version>
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")] [assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")] [assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) Alexey Golub")] [assembly: AssemblyCopyright("Copyright (c) Alexey Golub")]
[assembly: AssemblyVersion("2.14")] [assembly: AssemblyVersion("2.15")]
[assembly: AssemblyFileVersion("2.14")] [assembly: AssemblyFileVersion("2.15")]
@@ -9,8 +9,6 @@ namespace DiscordChatExporter.Gui.Services
{ {
public class UpdateService : IDisposable public class UpdateService : IDisposable
{ {
private readonly SettingsService _settingsService;
private readonly IUpdateManager _updateManager = new UpdateManager( private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"), new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor()); new ZipPackageExtractor());
@@ -18,36 +16,25 @@ namespace DiscordChatExporter.Gui.Services
private Version _updateVersion; private Version _updateVersion;
private bool _updaterLaunched; 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 try
{ {
// If auto-update is disabled - don't check for updates await _updateManager.PrepareUpdateAsync(_updateVersion = version);
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;
} }
catch (UpdaterAlreadyLaunchedException) catch (UpdaterAlreadyLaunchedException)
{ {
return null; // Ignore race conditions
} }
catch (LockFileNotAcquiredException) catch (LockFileNotAcquiredException)
{ {
return null; // Ignore race conditions
} }
} }
@@ -55,23 +42,20 @@ namespace DiscordChatExporter.Gui.Services
{ {
try try
{ {
// Check if an update is pending if (_updateVersion == null || _updaterLaunched)
if (_updateVersion == null)
return; return;
// Check if the updater has already been launched
if (_updaterLaunched)
return;
// Launch the updater
_updateManager.LaunchUpdater(_updateVersion, needRestart); _updateManager.LaunchUpdater(_updateVersion, needRestart);
_updaterLaunched = true; _updaterLaunched = true;
} }
catch (UpdaterAlreadyLaunchedException) catch (UpdaterAlreadyLaunchedException)
{ {
// Ignore race conditions
} }
catch (LockFileNotAcquiredException) catch (LockFileNotAcquiredException)
{ {
// Ignore race conditions
} }
} }
@@ -22,46 +22,46 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen); var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
// Set up event routing that will close the view when called from viewmodel // 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 // 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;
} }
dialogScreen.Closed += OnScreenClosed; dialogScreen.Closed += OnScreenClosed;
}; }
// Show view // Show view
await DialogHost.Show(view, onDialogOpened); await DialogHost.Show(view, OnDialogOpened);
// Return the result // Return the result
return dialogScreen.DialogResult; return dialogScreen.DialogResult;
} }
public string PromptSaveFilePath(string filter = "All files|*.*", string initialFilePath = "") public string PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
{ {
// Create dialog // Create dialog
var dialog = new SaveFileDialog var dialog = new SaveFileDialog
{ {
Filter = filter, Filter = filter,
AddExtension = true, AddExtension = true,
FileName = initialFilePath, FileName = defaultFilePath,
DefaultExt = Path.GetExtension(initialFilePath) ?? "" DefaultExt = Path.GetExtension(defaultFilePath) ?? ""
}; };
// Show dialog and return result // Show dialog and return result
return dialog.ShowDialog() == true ? dialog.FileName : null; return dialog.ShowDialog() == true ? dialog.FileName : null;
} }
public string PromptDirectoryPath(string initialDirPath = "") public string PromptDirectoryPath(string defaultDirPath = "")
{ {
// Create dialog // Create dialog
var dialog = new VistaFolderBrowserDialog var dialog = new VistaFolderBrowserDialog
{ {
SelectedPath = initialDirPath SelectedPath = defaultDirPath
}; };
// Show dialog and return result // Show dialog and return result
@@ -36,9 +36,5 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
return viewModel; 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.Linq;
using System.Net; using System.Net;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services; using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions; using DiscordChatExporter.Core.Services.Exceptions;
@@ -68,6 +69,39 @@ namespace DiscordChatExporter.Gui.ViewModels
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)); (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() protected override async void OnViewLoaded()
{ {
base.OnViewLoaded(); base.OnViewLoaded();
@@ -83,24 +117,7 @@ namespace DiscordChatExporter.Gui.ViewModels
} }
// Check and prepare update // Check and prepare update
try await HandleAutoUpdateAsync();
{
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
Notifications.Enqueue(
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.FinalizeUpdate(true);
RequestClose();
});
}
}
catch
{
Notifications.Enqueue("Failed to perform application auto-update");
}
} }
protected override void OnClose() protected override void OnClose()
@@ -23,10 +23,14 @@
Margin="16,8" Margin="16,8"
materialDesign:HintAssist.Hint="Date format" materialDesign:HintAssist.Hint="Date format"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
Text="{Binding DateFormat}" /> Text="{Binding DateFormat}"
ToolTip="Format used when rendering dates (refer to .NET date format)" />
<!-- Auto-updates --> <!-- Auto-updates -->
<DockPanel LastChildFill="False"> <DockPanel
LastChildFill="False"
Background="Transparent"
ToolTip="Perform automatic updates on every launch">
<TextBlock <TextBlock
Margin="16,8" Margin="16,8"
DockPanel.Dock="Left" DockPanel.Dock="Left"
+4 -2
View File
@@ -94,7 +94,8 @@
Padding="4" Padding="4"
Command="{s:Action PopulateGuildsAndChannels}" Command="{s:Action PopulateGuildsAndChannels}"
IsDefault="True" IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}"> Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Pull available guilds and channels (Enter)">
<materialDesign:PackIcon <materialDesign:PackIcon
Width="24" Width="24"
Height="24" Height="24"
@@ -109,7 +110,8 @@
Margin="6" Margin="6"
Padding="4" Padding="4"
Command="{s:Action ShowSettings}" Command="{s:Action ShowSettings}"
Style="{DynamicResource MaterialDesignFlatDarkButton}"> Style="{DynamicResource MaterialDesignFlatDarkButton}"
ToolTip="Settings">
<materialDesign:PackIcon <materialDesign:PackIcon
Width="24" Width="24"
Height="24" Height="24"
+3 -3
View File
@@ -24,8 +24,8 @@ DiscordChatExporter can be used to export message history from a [Discord](https
## Screenshots ## Screenshots
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/1.png) ![channel list](.screenshots/list.png)
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/4.png) ![rendered output](.screenshots/output.png)
## Libraries used ## Libraries used
@@ -34,8 +34,8 @@ DiscordChatExporter can be used to export message history from a [Discord](https
- [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)
- [Scriban](https://github.com/lunet-io/scriban) - [Scriban](https://github.com/lunet-io/scriban)
- [CommandLineParser](https://github.com/commandlineparser/commandline)
- [Ookii.Dialogs](https://github.com/caioproiete/ookii-dialogs-wpf) - [Ookii.Dialogs](https://github.com/caioproiete/ookii-dialogs-wpf)
- [CliFx](https://github.com/Tyrrrz/CliFx)
- [Failsafe](https://github.com/Tyrrrz/Failsafe) - [Failsafe](https://github.com/Tyrrrz/Failsafe)
- [Gress](https://github.com/Tyrrrz/Gress) - [Gress](https://github.com/Tyrrrz/Gress)
- [Onova](https://github.com/Tyrrrz/Onova) - [Onova](https://github.com/Tyrrrz/Onova)