mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 22:54:38 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a0d351143 | |||
| f04719c4bd | |||
| d88cd9b228 | |||
| cd042e5368 | |||
| 533671c59f | |||
| 29c35f6754 | |||
| 7b32101517 | |||
| 9bb88128b0 | |||
| 12a5091d73 | |||
| 82af36c90d | |||
| a2beb9836b | |||
| 60f46bad29 | |||
| 2cdb230b1e | |||
| 9241b7d2d1 | |||
| 40f54d9a98 | |||
| f2faf823b9 | |||
| 5b9eaa57f5 | |||
| 58ba99e0c6 | |||
| 117d1b7e4a | |||
| 835910ee22 | |||
| 5630020d88 |
@@ -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 |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
+2
-3
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ DiscordChatExporter can be used to export message history from a [Discord](https
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user