Compare commits

...

36 Commits

Author SHA1 Message Date
Oleksii Holub 67325b610a Update version 2022-03-07 03:02:28 +02:00
Oleksii Holub 3186ce90c8 Fix GUI not launching due to .NET bug 2022-03-07 02:46:56 +02:00
Oleksii Holub 168d7c8ec9 Update the war message in CLI 2022-03-07 02:39:05 +02:00
Oleksii Holub daa1b3f6b3 Improve the war message 2022-03-07 01:09:56 +02:00
Oleksii Holub e2780d6666 Update version 2022-03-06 19:41:11 +02:00
Oleksii Holub b996215e36 Add messages about war in Ukraine 2022-03-06 19:24:22 +02:00
Oleksii Holub 08d9af534b Add terms of use 2022-03-05 09:30:41 -08:00
Oleksii Holub bef110a193 Add fuck russia banner to readme 2022-02-23 18:25:23 +02:00
Oleksii Holub 12555e576f Update NuGet packages 2022-02-21 22:47:40 +02:00
Oleksii Holub afb4bc67ae Auto-detect dark mode preference in GUI 2022-02-20 22:29:23 +02:00
Oleksii Holub f80a5fe644 Fix multiline quote rendering in HTML
Fixes #756
2022-02-19 00:49:56 +02:00
Oleksii Holub b7dca87fb9 Add reaction filter
Closes #715
2022-02-19 00:28:38 +02:00
Oleksii Holub 8ecc2940b0 HTML-escape links inside HTML export instead of URL-escaping 2022-02-19 00:26:54 +02:00
Oleksii Holub 407c2bd2ae Don't crash on invalid mentions
Fixes #816
2022-02-19 00:26:53 +02:00
Oleksii Holub 5b563d4f00 Cleanup some code 2022-02-19 00:26:53 +02:00
Tomlacko ecb615ecf1 Fix for extremely long file extensions (#812) 2022-02-17 19:58:47 -08:00
Oleksii Holub 7c212ef893 Update NuGet packages 2022-02-16 14:34:01 +02:00
Oleksii Holub 36b4a45f3a Update Gress 2022-02-16 14:30:26 +02:00
Oleksii Holub 51cc132e5d Display 24-hour time pickers for appropriate cultures in GUI
Related to #808
2022-02-10 17:23:39 +02:00
Oleksii Holub cf83cbd89c Add support for stickers (#802) 2022-02-05 16:26:23 -08:00
Oleksii Holub d51d0d4872 Use snowflakes in more places 2022-02-06 01:15:56 +02:00
Oleksii Holub 2467caac9f Use api v9 2022-02-05 20:19:16 +02:00
Oleksii Holub bd98f4cb6a Refactor StringPart into StringSegment 2022-02-05 04:10:35 +02:00
Oleksii Holub fc7191d74c Fix changelog date for last entry 2022-01-27 18:44:40 +02:00
Oleksii Holub 6c484d1f88 Update version 2022-01-27 18:36:28 +02:00
Oleksii Holub c36b8df8e7 Update NuGet packages 2022-01-27 18:25:21 +02:00
Dhananjay-JSR 0408096746 Update token extraction guide (#791) 2022-01-25 15:11:22 -08:00
Tyrrrz 40d833cd57 Update my name to match correct spelling 2022-01-15 03:27:19 +02:00
Tyrrrz 0de1f47310 More parsing leniency
Closes #767
2022-01-09 22:24:02 +02:00
Tyrrrz e31ff55e33 Handle invalid dates more gracefully
Closes #766
2022-01-05 18:54:23 +02:00
Tyrrrz 81525a1076 Update license 2022-01-05 18:31:16 +02:00
Tyrrrz 65ab003ff5 Relax parsing rules for embed fields
Closes #765
2022-01-04 22:44:03 +02:00
Alexey Golub 2156c6cd0c Automatically detect token kind (#764) 2022-01-03 14:52:16 -08:00
Tyrrrz e97151cd19 Update NuGet packages 2022-01-03 00:36:10 +02:00
Tyrrrz 271a478a0e Minor GUI tweaks 2021-12-15 16:30:23 +02:00
Tyrrrz b40a88c4d3 Add border to settings view too 2021-12-15 16:13:48 +02:00
65 changed files with 987 additions and 595 deletions
+24 -1
View File
@@ -1,6 +1,29 @@
### v2.33.1 (07-Mar-2022)
- Fixed an issue where the application silently failed to run if the system didn't have .NET Runtime 6.0.2 installed. If you continue seeing this issue, please uninstall all existing .NET runtimes from your computer and then try running the application again.
### v2.33 (06-Mar-2022)
- Added messages informing about war in Ukraine and available ways to help.
- Added support for rendering stickers in HTML and JSON. Lottie-based stickers currently cannot be displayed in HTML exports (see [#803](https://github.com/Tyrrrz/DiscordChatExporter/issues/803)).
- Added a new `reaction:` message filter which can be used to check if someone reacted to a message with a specific emoji. You can either pass the emoji name (e.g. `reaction:LUL`) or its ID (e.g. `reaction:41771983429993937`).
- [GUI] Added auto-detection for dark mode. If your system is configured to prefer dark mode in applications, DiscordChatExporter will use it by default instead of light mode.
- Fixed an issue which caused the export process to crash when downloading media files with extremely long file extensions. (Thanks [@Tomlacko](https://github.com/Tomlacko))
- Fixed an issue which caused the export process to crash on invalid mentions.
- [GUI] Fixed an issue where the time pickers used to specify export ranges always displayed time in 12-hour format, instead of respecting the system locale.
### v2.32 (27-Jan-2022)
- Token kind (user or bot) is now detected automatically. Removed the button to switch token kind in GUI. Option `-b|--bot` in CLI is now deprecated and does nothing.
- Updated user token extraction guide to reflect the fact that devtools are no longer accessible in the desktop version of Discord client. The recommended workaround is to open Discord in browser. (Thanks [@Dhananjay-JSR](https://github.com/Dhananjay-JSR))
- Fixed an issue which caused an error when parsing invalid date timestamps. Such timestamps are now rendered as "Invalid date", similarly to how the Discord client does it.
- Fixed an issue which caused an error when parsing certain embed fields.
- Fixed an issue which caused an error when parsing a mention to a user that has no username.
- [GUI] Minor visual fixes.
### v2.31.1 (15-Dec-2021)
- Fixed an issue which caused an exception when parsing certain custom emoji reactions.
- Fixed an issue which caused an error when parsing certain custom emoji reactions.
- [GUI] Improved user interface.
### v2.31 (06-Dec-2021)
+2 -2
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>2.31.1</Version>
<Version>2.33.1</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
@@ -15,11 +15,11 @@
<ItemGroup>
<PackageReference Include="AngleSharp" Version="0.16.1" />
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.2.0" />
<PackageReference Include="FluentAssertions" Version="6.5.1" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.3.0" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="coverlet.msbuild" Version="3.1.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="coverlet.msbuild" Version="3.1.2" PrivateAssets="all" />
<PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" />
@@ -36,8 +36,7 @@ public class ExportWrapperFixture : IDisposable
{
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { channelId },
ExportFormat = format,
OutputPath = filePath
@@ -22,24 +22,5 @@ internal static class Secrets
throw new InvalidOperationException("Discord token not provided for tests.");
});
private static readonly Lazy<bool> IsDiscordTokenBotLazy = new(() =>
{
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN_BOT");
if (!string.IsNullOrWhiteSpace(fromEnvironment))
return string.Equals(fromEnvironment, "true", StringComparison.OrdinalIgnoreCase);
var secretFilePath = Path.Combine(
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
"DiscordTokenBot.secret"
);
if (File.Exists(secretFilePath))
return true;
return false;
});
public static string DiscordToken => DiscordTokenLazy.Value;
public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value;
}
@@ -27,8 +27,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -74,8 +73,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -120,8 +118,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -25,8 +25,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -54,8 +53,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -83,8 +81,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -112,8 +109,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -0,0 +1,45 @@
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Fixtures;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
{
[Fact]
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.StickerTestCases,
Snowflake.Parse("939670623158943754")
);
var container = message.QuerySelector("[title='rock']");
var sourceUrl = container?.QuerySelector("img")?.GetAttribute("src");
// Assert
container.Should().NotBeNull();
sourceUrl.Should().Be("https://discord.com/stickers/904215665597120572.png");
}
[Fact]
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.StickerTestCases,
Snowflake.Parse("939670526517997590")
);
var container = message.QuerySelector("[title='Yikes']");
var sourceUrl = container?.QuerySelector("div[data-source]")?.GetAttribute("data-source");
// Assert
container.Should().NotBeNull();
sourceUrl.Should().Be("https://discord.com/stickers/816087132447178774.json");
}
}
@@ -0,0 +1,54 @@
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Fixtures;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
{
[Fact]
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.StickerTestCases,
Snowflake.Parse("939670623158943754")
);
var sticker = message
.GetProperty("stickers")
.EnumerateArray()
.Single();
// Assert
sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
sticker.GetProperty("name").GetString().Should().Be("rock");
sticker.GetProperty("format").GetString().Should().Be("PngAnimated");
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/904215665597120572.png");
}
[Fact]
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.StickerTestCases,
Snowflake.Parse("939670526517997590")
);
var sticker = message
.GetProperty("stickers")
.EnumerateArray()
.Single();
// Assert
sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
sticker.GetProperty("name").GetString().Should().Be("Yikes");
sticker.GetProperty("format").GetString().Should().Be("Lottie");
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/816087132447178774.json");
}
}
@@ -25,8 +25,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
@@ -50,8 +49,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
@@ -61,6 +59,6 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Assert
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
.Should()
.HaveCount(2);
.HaveCount(3);
}
}
@@ -25,8 +25,7 @@ public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<T
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
@@ -10,6 +10,8 @@ public static class ChannelIds
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729");
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
@@ -14,6 +14,7 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using Gress;
namespace DiscordChatExporter.Cli.Commands.Base;
@@ -78,8 +79,7 @@ public abstract class ExportCommandBase : TokenCommandBase
{
try
{
await progressContext.StartTaskAsync(
$"{channel.Category.Name} / {channel.Name}",
await progressContext.StartTaskAsync($"{channel.Category.Name} / {channel.Name}",
async progress =>
{
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
@@ -98,7 +98,11 @@ public abstract class ExportCommandBase : TokenCommandBase
DateFormat
);
await Exporter.ExportChannelAsync(request, progress, innerCancellationToken);
await Exporter.ExportChannelAsync(
request,
progress.ToPercentageBased(),
innerCancellationToken
);
}
);
}
@@ -162,4 +166,16 @@ public abstract class ExportCommandBase : TokenCommandBase
await ExecuteAsync(console, channels);
}
public override ValueTask ExecuteAsync(IConsole console)
{
// War in Ukraine message
console.Output.WriteLine("========================================================================");
console.Output.WriteLine("|| Ukraine is at war! Support my country in its fight for freedom ||");
console.Output.WriteLine("|| Learn more: https://tyrrrz.me ||");
console.Output.WriteLine("========================================================================");
console.Output.WriteLine("");
return default;
}
}
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
@@ -9,21 +10,14 @@ namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class TokenCommandBase : ICommand
{
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
public string TokenValue { get; init; } = "";
public string Token { get; init; } = "";
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")]
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "This option doesn't do anything. Kept for backwards compatibility.")]
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
public bool IsBotToken { get; init; }
private AuthToken? _authToken;
private AuthToken AuthToken => _authToken ??= new AuthToken(
IsBotToken
? AuthTokenKind.Bot
: AuthTokenKind.User,
TokenValue
);
private DiscordClient? _discordClient;
protected DiscordClient Discord => _discordClient ??= new DiscordClient(AuthToken);
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
public abstract ValueTask ExecuteAsync(IConsole console);
}
@@ -15,6 +15,8 @@ public class ExportAllCommand : ExportCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
@@ -15,6 +15,9 @@ public class ExportChannelsCommand : ExportCommandBase
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
public override async ValueTask ExecuteAsync(IConsole console) =>
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
await base.ExecuteAsync(console, ChannelIds);
}
}
@@ -13,6 +13,8 @@ public class ExportDirectMessagesCommand : ExportCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels...");
@@ -16,6 +16,8 @@ public class ExportGuildCommand : ExportCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels...");
@@ -15,7 +15,7 @@ public class GuideCommand : ICommand
using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get user token:");
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 1. Open Discord in your web browser and login");
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
console.Output.WriteLine(" 4. Navigate to the Application tab");
@@ -61,7 +61,7 @@ public class GuideCommand : ICommand
// Wiki link
using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("For more information, check out the wiki:");
console.Output.WriteLine("If you have questions or issues, please refer to the wiki:");
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
@@ -6,9 +6,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.0.6" />
<PackageReference Include="CliFx" Version="2.2.2" />
<PackageReference Include="Spectre.Console" Version="0.43.0" />
<PackageReference Include="Gress" Version="1.2.0" />
<PackageReference Include="Gress" Version="2.0.1" />
</ItemGroup>
<ItemGroup>
@@ -1,12 +0,0 @@
using System.Net.Http.Headers;
namespace DiscordChatExporter.Core.Discord;
public record AuthToken(AuthTokenKind Kind, string Value)
{
public AuthenticationHeaderValue GetAuthenticationHeader() => Kind switch
{
AuthTokenKind.Bot => new AuthenticationHeaderValue("Bot", Value),
_ => new AuthenticationHeaderValue(Value)
};
}
@@ -36,7 +36,7 @@ public partial record Attachment
var url = json.GetProperty("url").GetNonWhiteSpaceString();
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
var fileName = json.GetProperty("filename").GetNonWhiteSpaceString();
var fileName = json.GetProperty("filename").GetNonNullString();
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
return new Attachment(id, url, fileName, width, height, fileSize);
@@ -11,8 +11,8 @@ public record EmbedField(
{
public static EmbedField Parse(JsonElement json)
{
var name = json.GetProperty("name").GetNonWhiteSpaceString();
var value = json.GetProperty("value").GetNonWhiteSpaceString();
var name = json.GetProperty("name").GetNonNullString();
var value = json.GetProperty("value").GetNonNullString();
var isInline = json.GetPropertyOrNull("inline")?.GetBooleanOrNull() ?? false;
return new EmbedField(name, value, isInline);
@@ -10,14 +10,14 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/emoji#emoji-object
public partial record Emoji(
// Only present on custom emoji
string? Id,
Snowflake? Id,
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
string Name,
bool IsAnimated,
string ImageUrl)
{
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
public string Code => !string.IsNullOrWhiteSpace(Id)
public string Code => Id is not null
? Name
: EmojiIndex.TryGetCode(Name) ?? Name;
}
@@ -32,18 +32,18 @@ public partial record Emoji
.Select(r => r.Value.ToString("x"))
);
private static string GetImageUrl(string id, bool isAnimated) => isAnimated
private static string GetImageUrl(Snowflake id, bool isAnimated) => isAnimated
? $"https://cdn.discordapp.com/emojis/{id}.gif"
: $"https://cdn.discordapp.com/emojis/{id}.png";
private static string GetImageUrl(string name) =>
$"https://twemoji.maxcdn.com/2/svg/{GetTwemojiName(name)}.svg";
public static string GetImageUrl(string? id, string? name, bool isAnimated)
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{
// Custom emoji
if (!string.IsNullOrWhiteSpace(id))
return GetImageUrl(id, isAnimated);
if (id is not null)
return GetImageUrl(id.Value, isAnimated);
// Standard emoji
if (!string.IsNullOrWhiteSpace(name))
@@ -55,7 +55,7 @@ public partial record Emoji
public static Emoji Parse(JsonElement json)
{
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull();
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
@@ -23,7 +23,7 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
public static Guild Parse(JsonElement json)
{
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonWhiteSpaceString();
var name = json.GetProperty("name").GetNonNullString();
var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull();
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
@@ -21,6 +21,7 @@ public record Message(
string Content,
IReadOnlyList<Attachment> Attachments,
IReadOnlyList<Embed> Embeds,
IReadOnlyList<Sticker> Stickers,
IReadOnlyList<Reaction> Reactions,
IReadOnlyList<User> MentionedUsers,
MessageReference? Reference,
@@ -60,6 +61,10 @@ public record Message(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
Array.Empty<Embed>();
var stickers =
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??
Array.Empty<Sticker>();
var reactions =
json.GetPropertyOrNull("reactions")?.EnumerateArrayOrNull()?.Select(Reaction.Parse).ToArray() ??
Array.Empty<Reaction>();
@@ -79,6 +84,7 @@ public record Message(
content,
attachments,
embeds,
stickers,
reactions,
mentionedUsers,
messageReference,
@@ -12,7 +12,7 @@ public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHas
public static Role Parse(JsonElement json)
{
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonWhiteSpaceString();
var name = json.GetProperty("name").GetNonNullString();
var position = json.GetProperty("position").GetInt32();
var color = json
@@ -0,0 +1,25 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
{
private static string GetSourceUrl(Snowflake id, StickerFormat format)
{
var extension = format == StickerFormat.Lottie ? "json" : "png";
return $"https://discord.com/stickers/{id}.{extension}";
}
public static Sticker Parse(JsonElement json)
{
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonWhiteSpaceString();
var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
var sourceUrl = GetSourceUrl(id, format);
return new Sticker(id, name, format, sourceUrl);
}
}
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Core.Discord.Data;
public enum StickerFormat
{
Png = 1,
PngAnimated = 2,
Lottie = 3
}
@@ -38,7 +38,7 @@ public partial record User
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
var name = json.GetProperty("username").GetNonWhiteSpaceString();
var name = json.GetProperty("username").GetNonNullString();
var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull();
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -11,6 +12,7 @@ using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress;
using JsonExtensions.Http;
using JsonExtensions.Reading;
@@ -18,10 +20,30 @@ namespace DiscordChatExporter.Core.Discord;
public class DiscordClient
{
private readonly AuthToken _token;
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute);
public DiscordClient(AuthToken token) => _token = token;
private TokenKind _tokenKind = TokenKind.Unknown;
public DiscordClient(string token) => _token = token;
private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url,
bool isBot,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = isBot
? new AuthenticationHeaderValue("Bot", _token)
: new AuthenticationHeaderValue(_token);
return await Http.Client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
);
}
private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url,
@@ -29,14 +51,33 @@ public class DiscordClient
{
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthenticationHeader();
if (_tokenKind == TokenKind.User)
return await GetResponseAsync(url, false, innerCancellationToken);
return await Http.Client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
if (_tokenKind == TokenKind.Bot)
return await GetResponseAsync(url, true, innerCancellationToken);
// Try to authenticate as user
var userResponse = await GetResponseAsync(url, false, innerCancellationToken);
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
{
_tokenKind = TokenKind.User;
return userResponse;
}
userResponse.Dispose();
// Otherwise, try to authenticate as bot
var botResponse = await GetResponseAsync(url, true, innerCancellationToken);
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
{
_tokenKind = TokenKind.Bot;
return botResponse;
}
// The token is probably invalid altogether.
// Return the last response anyway, upstream should handle the error.
return botResponse;
}, cancellationToken);
}
@@ -233,7 +274,7 @@ public class DiscordClient
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
IProgress<double>? progress = null,
IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Get the last message in the specified range.
@@ -282,16 +323,13 @@ public class DiscordClient
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
if (totalDuration > TimeSpan.Zero)
{
progress.Report(exportedDuration / totalDuration);
}
// Avoid division by zero if all messages have the exact same timestamp
// (which may be the case if there's only one message in the channel)
else
{
progress.Report(1);
}
progress.Report(Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp
// (which may be the case if there's only one message in the channel)
totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration
: 1
));
}
yield return message;
@@ -1,7 +1,8 @@
namespace DiscordChatExporter.Core.Discord;
public enum AuthTokenKind
public enum TokenKind
{
Unknown,
User,
Bot
}
@@ -5,9 +5,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.0" />
<PackageReference Include="Polly" Version="7.2.2" />
<PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Superpower" Version="3.0.0" />
</ItemGroup>
@@ -8,6 +8,7 @@ using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress;
namespace DiscordChatExporter.Core.Exporting;
@@ -17,11 +18,9 @@ public class ChannelExporter
public ChannelExporter(DiscordClient discord) => _discord = discord;
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
public async ValueTask ExportChannelAsync(
ExportRequest request,
IProgress<double>? progress = null,
IProgress<Percentage>? progress = null,
CancellationToken cancellationToken = default)
{
// Build context
@@ -44,6 +44,12 @@ internal static class FilterGrammar
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
.Named("mentions:<value>");
private static readonly TextParser<MessageFilter> ReactionFilter = Span
.EqualToIgnoreCase("reaction:")
.IgnoreThen(String)
.Select(v => (MessageFilter) new ReactionMessageFilter(v))
.Named("reaction:<value>");
private static readonly TextParser<MessageFilter> HasFilter = Span
.EqualToIgnoreCase("has:")
.IgnoreThen(Parse.OneOf(
@@ -72,6 +78,7 @@ internal static class FilterGrammar
GroupedFilter,
FromFilter,
MentionsFilter,
ReactionFilter,
HasFilter,
ContainsFilter
);
@@ -0,0 +1,18 @@
using System;
using System.Linq;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ReactionMessageFilter : MessageFilter
{
private readonly string _value;
public ReactionMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => message.Reactions.Any(r =>
string.Equals(_value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
);
}
@@ -103,7 +103,15 @@ internal partial class MediaDownloader
// Otherwise, use the original file name but inject the hash in the middle
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
var fileExtension = Path.GetExtension(fileName);
// Probably not a file extension, just a dot in a long file name
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708
if (fileExtension.Length > 41)
{
fileNameWithoutExtension = fileName;
fileExtension = "";
}
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
}
}
}
@@ -1,6 +1,7 @@
@using System
@using System.Linq
@using System.Threading.Tasks
@using DiscordChatExporter.Core.Discord.Data
@using DiscordChatExporter.Core.Exporting.Writers.Html;
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
@@ -411,6 +412,21 @@
}
}
@{/* Stickers */}
@foreach (var sticker in message.Stickers)
{
<div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
{
<img class="chatlog__sticker--media" src="@(await ResolveUrlAsync(sticker.SourceUrl))" alt="Sticker">
}
else if (sticker.Format == StickerFormat.Lottie)
{
<div class="chatlog__sticker--media" data-source="@(await ResolveUrlAsync(sticker.SourceUrl))"></div>
}
</div>
}
@{/* Message reactions */}
@if (message.Reactions.Any())
{
@@ -113,8 +113,7 @@
margin: 0.05em 0;
}
.quote::before {
content: "";
.quote-border {
margin-right: 0.5em;
border: 2px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 3px;
@@ -597,6 +596,16 @@
border-radius: 3px;
}
.chatlog__sticker {
width: 180px;
height: 180px;
}
.chatlog__sticker--media {
max-width: 100%;
max-height: 100%;
}
.chatlog__reactions {
display: flex;
}
@@ -660,7 +669,29 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
document.querySelectorAll('.pre--multiline').forEach(e => hljs.highlightBlock(e));
});
</script>
@{/* Lottie animation support */}
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
const imageDataUrl = e.getAttribute('data-source');
const anim = lottie.loadAnimation({
container: e,
renderer: 'svg',
loop: true,
autoplay: true,
path: imageDataUrl
});
anim.addEventListener('data_failed', () =>
e.innerHTML = '<strong>[Sticker cannot be rendered]</strong>'
);
});
});
</script>
@@ -161,6 +161,21 @@ internal class JsonMessageWriter : MessageWriter
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteStickerAsync(
Sticker sticker,
CancellationToken cancellationToken = default)
{
_writer.WriteStartObject();
_writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString("sourceUrl", await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteReactionAsync(
Reaction reaction,
CancellationToken cancellationToken = default)
@@ -169,7 +184,7 @@ internal class JsonMessageWriter : MessageWriter
// Emoji
_writer.WriteStartObject("emoji");
_writer.WriteString("id", reaction.Emoji.Id);
_writer.WriteString("id", reaction.Emoji.Id.ToString());
_writer.WriteString("name", reaction.Emoji.Name);
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
@@ -276,6 +291,14 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteEndArray();
// Stickers
_writer.WriteStartArray("stickers");
foreach (var sticker in message.Stickers)
await WriteStickerAsync(sticker, cancellationToken);
_writer.WriteEndArray();
// Reactions
_writer.WriteStartArray("reactions");
@@ -3,7 +3,6 @@ using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
@@ -40,7 +39,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
FormattingKind.Strikethrough => ("<s>", "</s>"),
FormattingKind.Spoiler => (
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
FormattingKind.Quote => ("<div class=\"quote\">", "</div>"),
FormattingKind.Quote => (
"<div class=\"quote\"><div class=\"quote-border\"></div><div class=\"quote-content\">", "</div></div>"),
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind))
};
@@ -85,8 +85,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_buffer.Append(
!string.IsNullOrWhiteSpace(linkedMessageId)
? $"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
: $"<a href=\"{Uri.EscapeUriString(link.Url)}\">"
? $"<a href=\"{HtmlEncode(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
: $"<a href=\"{HtmlEncode(link.Url)}\">"
);
var result = base.VisitLink(link);
@@ -108,28 +108,34 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Kind == MentionKind.Meta)
if (mention.Kind == MentionKind.Everyone)
{
_buffer
.Append("<span class=\"mention\">")
.Append("@").Append(HtmlEncode(mention.Id))
.Append("@everyone")
.Append("</span>");
}
else if (mention.Kind == MentionKind.Here)
{
_buffer
.Append("<span class=\"mention\">")
.Append("@here")
.Append("</span>");
}
else if (mention.Kind == MentionKind.User)
{
var member = mentionId?.Pipe(_context.TryGetMember);
var member = mention.TargetId?.Pipe(_context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown";
_buffer
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
.Append("@").Append(HtmlEncode(nick))
.Append('@').Append(HtmlEncode(nick))
.Append("</span>");
}
else if (mention.Kind == MentionKind.Channel)
{
var channel = mentionId?.Pipe(_context.TryGetChannel);
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel";
@@ -140,7 +146,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
}
else if (mention.Kind == MentionKind.Role)
{
var role = mentionId?.Pipe(_context.TryGetRole);
var role = mention.TargetId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
var color = role?.Color;
@@ -150,7 +156,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_buffer
.Append($"<span class=\"mention\" style=\"{style}\">")
.Append("@").Append(HtmlEncode(name))
.Append('@').Append(HtmlEncode(name))
.Append("</span>");
}
@@ -159,12 +165,18 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
{
var dateString = timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value)
: "Invalid date";
// Timestamp tooltips always use full date regardless of the configured format
var longDateString = timestamp.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt");
var longDateString = timestamp.Date is not null
? timestamp.Date.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt")
: "Invalid date";
_buffer
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
.Append(HtmlEncode(_context.FormatDate(timestamp.Value)))
.Append(HtmlEncode(dateString))
.Append("</span>");
return base.VisitUnixTimestamp(timestamp);
@@ -1,5 +1,4 @@
using System.Text;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
@@ -36,21 +35,24 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Kind == MentionKind.Meta)
if (mention.Kind == MentionKind.Everyone)
{
_buffer.Append($"@{mention.Id}");
_buffer.Append("@everyone");
}
else if (mention.Kind == MentionKind.Here)
{
_buffer.Append("@here");
}
else if (mention.Kind == MentionKind.User)
{
var member = mentionId?.Pipe(_context.TryGetMember);
var member = mention.TargetId?.Pipe(_context.TryGetMember);
var name = member?.User.Name ?? "Unknown";
_buffer.Append($"@{name}");
}
else if (mention.Kind == MentionKind.Channel)
{
var channel = mentionId?.Pipe(_context.TryGetChannel);
var channel =mention.TargetId?.Pipe(_context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel";
_buffer.Append($"#{name}");
@@ -61,7 +63,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
}
else if (mention.Kind == MentionKind.Role)
{
var role = mentionId?.Pipe(_context.TryGetRole);
var role = mention.TargetId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
_buffer.Append($"@{name}");
@@ -73,7 +75,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
{
_buffer.Append(
_context.FormatDate(timestamp.Value)
timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value)
: "Invalid date"
);
return base.VisitUnixTimestamp(timestamp);
@@ -98,6 +98,25 @@ internal class PlainTextMessageWriter : MessageWriter
}
}
private async ValueTask WriteStickersAsync(
IReadOnlyList<Sticker> stickers,
CancellationToken cancellationToken = default)
{
if (!stickers.Any())
return;
await _writer.WriteLineAsync("{Stickers}");
foreach (var sticker in stickers)
{
cancellationToken.ThrowIfCancellationRequested();
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
}
await _writer.WriteLineAsync();
}
private async ValueTask WriteReactionsAsync(
IReadOnlyList<Reaction> reactions,
CancellationToken cancellationToken = default)
@@ -156,9 +175,10 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteLineAsync();
// Attachments, embeds, reactions
// Attachments, embeds, reactions, etc.
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
await WriteEmbedsAsync(message.Embeds, cancellationToken);
await WriteStickersAsync(message.Stickers, cancellationToken);
await WriteReactionsAsync(message.Reactions, cancellationToken);
await _writer.WriteLineAsync();
@@ -1,21 +1,22 @@
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Markdown;
internal record EmojiNode(
// Only present on custom emoji
string? Id,
Snowflake? Id,
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
string Name,
bool IsAnimated) : MarkdownNode
{
public bool IsCustomEmoji => Id is not null;
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
public string Code => !string.IsNullOrWhiteSpace(Id)
public string Code => IsCustomEmoji
? Name
: EmojiIndex.TryGetCode(Name) ?? Name;
public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id);
public EmojiNode(string name)
: this(null, name, false)
{
@@ -2,7 +2,8 @@
internal enum MentionKind
{
Meta,
Everyone,
Here,
User,
Channel,
Role
@@ -1,3 +1,6 @@
namespace DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Discord;
internal record MentionNode(string Id, MentionKind Kind) : MarkdownNode;
namespace DiscordChatExporter.Core.Markdown;
// Null ID means it's a meta mention or an invalid mention
internal record MentionNode(Snowflake? TargetId, MentionKind Kind) : MarkdownNode;
@@ -16,7 +16,7 @@ internal class AggregateMatcher<T> : IMatcher<T>
{
}
public ParsedMatch<T>? TryMatch(StringPart stringPart)
public ParsedMatch<T>? TryMatch(StringSegment segment)
{
ParsedMatch<T>? earliestMatch = null;
@@ -24,19 +24,19 @@ internal class AggregateMatcher<T> : IMatcher<T>
foreach (var matcher in _matchers)
{
// Try to match
var match = matcher.TryMatch(stringPart);
var match = matcher.TryMatch(segment);
// If there's no match - continue
if (match is null)
continue;
// If this match is earlier than previous earliest - replace
if (earliestMatch is null || match.StringPart.StartIndex < earliestMatch.StringPart.StartIndex)
if (earliestMatch is null || match.Segment.StartIndex < earliestMatch.Segment.StartIndex)
earliestMatch = match;
// If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that
if (earliestMatch.StringPart.StartIndex == stringPart.StartIndex)
if (earliestMatch.Segment.StartIndex == segment.StartIndex)
break;
}
@@ -5,44 +5,57 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
internal interface IMatcher<T>
{
ParsedMatch<T>? TryMatch(StringPart stringPart);
ParsedMatch<T>? TryMatch(StringSegment segment);
}
internal static class MatcherExtensions
{
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher,
StringPart stringPart, Func<StringPart, T> transformFallback)
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(
this IMatcher<T> matcher,
StringSegment segment,
Func<StringSegment, T> transformFallback)
{
// Loop through segments divided by individual matches
var currentIndex = stringPart.StartIndex;
while (currentIndex < stringPart.EndIndex)
var currentIndex = segment.StartIndex;
while (currentIndex < segment.EndIndex)
{
// Find a match within this segment
var match = matcher.TryMatch(stringPart.Slice(currentIndex, stringPart.EndIndex - currentIndex));
var match = matcher.TryMatch(
segment.Relocate(
currentIndex,
segment.EndIndex - currentIndex
)
);
// If there's no match - break
if (match is null)
break;
// If this match doesn't start immediately at current index - transform and yield fallback first
if (match.StringPart.StartIndex > currentIndex)
// If this match doesn't start immediately at the current position - transform and yield fallback first
if (match.Segment.StartIndex > currentIndex)
{
var fallbackPart = stringPart.Slice(currentIndex, match.StringPart.StartIndex - currentIndex);
yield return new ParsedMatch<T>(fallbackPart, transformFallback(fallbackPart));
var fallbackSegment = segment.Relocate(
currentIndex,
match.Segment.StartIndex - currentIndex
);
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
}
// Yield match
yield return match;
// Shift current index to the end of the match
currentIndex = match.StringPart.StartIndex + match.StringPart.Length;
currentIndex = match.Segment.StartIndex + match.Segment.Length;
}
// If EOL wasn't reached - transform and yield remaining part as fallback
if (currentIndex < stringPart.EndIndex)
// If EOL hasn't been reached - transform and yield remaining part as fallback
if (currentIndex < segment.EndIndex)
{
var fallbackPart = stringPart.Slice(currentIndex);
yield return new ParsedMatch<T>(fallbackPart, transformFallback(fallbackPart));
var fallbackSegment = segment.Relocate(
currentIndex,
segment.EndIndex - currentIndex
);
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Markdown.Parsing;
@@ -23,7 +24,7 @@ internal static partial class MarkdownParser
// Capture any character until the earliest double asterisk not followed by an asterisk
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Bold, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
@@ -31,54 +32,54 @@ internal static partial class MarkdownParser
// Closing asterisk must not be preceded by whitespace
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest triple asterisk not followed by an asterisk
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1]), BoldFormattingNodeMatcher))
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher))
);
// Capture any character except underscore until an underscore
// Closing underscore must not be followed by a word character
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest double underscore not followed by an underscore
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Underline, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest triple underscore not followed by an underscore
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Italic,
Parse(p.Slice(m.Groups[1]), UnderlineFormattingNodeMatcher))
(s, m) => new FormattingNode(FormattingKind.Italic,
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher))
);
// Capture any character until the earliest double tilde
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest double pipe
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Spoiler, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(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<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("^>\\s(.+\n?)", DefaultRegexOptions),
(p, m) => new FormattingNode(FormattingKind.Quote, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
// Repeatedly capture any character until the end of the line
@@ -97,7 +98,7 @@ internal static partial class MarkdownParser
// Opening 'greater than' characters must be followed by whitespace
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Quote, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
/* Code blocks */
@@ -123,31 +124,31 @@ internal static partial class MarkdownParser
// Capture @everyone
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>(
"@everyone",
_ => new MentionNode("everyone", MentionKind.Meta)
_ => new MentionNode(null, MentionKind.Everyone)
);
// Capture @here
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>(
"@here",
_ => new MentionNode("here", MentionKind.Meta)
_ => new MentionNode(null, MentionKind.Here)
);
// Capture <@123456> or <@!123456>
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.User)
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
);
// Capture <#123456>
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<#!?(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.Channel)
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
);
// Capture <@&123456>
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<@&(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.Role)
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
);
/* Emoji */
@@ -177,7 +178,11 @@ internal static partial class MarkdownParser
// Capture <:lul:123456> or <a:lul:123456>
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
(_, m) => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value))
(_, m) => new EmojiNode(
Snowflake.TryParse(m.Groups[3].Value),
m.Groups[2].Value,
!string.IsNullOrWhiteSpace(m.Groups[1].Value)
)
);
/* Links */
@@ -185,7 +190,7 @@ internal static partial class MarkdownParser
// Capture [title](link)
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
(p, m) => new LinkNode(m.Groups[2].Value, Parse(p.Slice(m.Groups[1])))
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
);
// Capture any non-whitespace character after http:// or https://
@@ -207,7 +212,7 @@ internal static partial class MarkdownParser
// This escapes it from matching for formatting
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
@"¯\_(ツ)_/¯",
p => new TextNode(p.ToString())
s => new TextNode(s.ToString())
);
// Capture some specific emoji that don't get rendered
@@ -235,7 +240,7 @@ internal static partial class MarkdownParser
// Capture <t:12345678> or <t:12345678:R>
private static readonly IMatcher<MarkdownNode> UnixTimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<t:(\\d+)(?::\\w)?>", DefaultRegexOptions),
new Regex("<t:(-?\\d+)(?::\\w)?>", DefaultRegexOptions),
(_, m) =>
{
// TODO: support formatting parameters
@@ -244,17 +249,19 @@ internal static partial class MarkdownParser
if (!long.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture,
out var offset))
{
return null;
return new UnixTimestampNode(null);
}
// Bound check
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
if (offset < TimeSpan.MinValue.TotalSeconds || offset > TimeSpan.MaxValue.TotalSeconds)
try
{
return null;
return new UnixTimestampNode(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(offset));
}
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
// https://github.com/Tyrrrz/DiscordChatExporter/issues/766
catch (Exception ex) when (ex is ArgumentOutOfRangeException or OverflowException)
{
return new UnixTimestampNode(null);
}
return new UnixTimestampNode(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(offset));
}
);
@@ -321,24 +328,24 @@ internal static partial class MarkdownParser
UnixTimestampNodeMatcher
);
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart, IMatcher<MarkdownNode> matcher) =>
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment, IMatcher<MarkdownNode> matcher) =>
matcher
.MatchAll(stringPart, p => new TextNode(p.ToString()))
.MatchAll(segment, s => new TextNode(s.ToString()))
.Select(r => r.Value)
.ToArray();
}
internal static partial class MarkdownParser
{
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart) =>
Parse(stringPart, AggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
Parse(segment, AggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringPart stringPart) =>
Parse(stringPart, MinimalAggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
Parse(segment, MinimalAggregateNodeMatcher);
public static IReadOnlyList<MarkdownNode> Parse(string input) =>
Parse(new StringPart(input));
Parse(new StringSegment(input));
public static IReadOnlyList<MarkdownNode> ParseMinimal(string input) =>
ParseMinimal(new StringPart(input));
ParseMinimal(new StringSegment(input));
}
@@ -2,13 +2,13 @@
internal class ParsedMatch<T>
{
public StringPart StringPart { get; }
public StringSegment Segment { get; }
public T Value { get; }
public ParsedMatch(StringPart stringPart, T value)
public ParsedMatch(StringSegment segment, T value)
{
StringPart = stringPart;
Segment = segment;
Value = value;
}
}
@@ -6,17 +6,17 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class RegexMatcher<T> : IMatcher<T>
{
private readonly Regex _regex;
private readonly Func<StringPart, Match, T?> _transform;
private readonly Func<StringSegment, Match, T?> _transform;
public RegexMatcher(Regex regex, Func<StringPart, Match, T?> transform)
public RegexMatcher(Regex regex, Func<StringSegment, Match, T?> transform)
{
_regex = regex;
_transform = transform;
}
public ParsedMatch<T>? TryMatch(StringPart stringPart)
public ParsedMatch<T>? TryMatch(StringSegment segment)
{
var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length);
var match = _regex.Match(segment.Source, segment.StartIndex, segment.Length);
if (!match.Success)
return null;
@@ -25,14 +25,14 @@ internal class RegexMatcher<T> : IMatcher<T>
// 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[..stringPart.EndIndex], stringPart.StartIndex))
if (!_regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex))
return null;
var stringPartMatch = stringPart.Slice(match.Index, match.Length);
var value = _transform(stringPartMatch, match);
var segmentMatch = segment.Relocate(match);
var value = _transform(segmentMatch, match);
return value is not null
? new ParsedMatch<T>(stringPartMatch, value)
? new ParsedMatch<T>(segmentMatch, value)
: null;
}
}
@@ -6,31 +6,31 @@ internal class StringMatcher<T> : IMatcher<T>
{
private readonly string _needle;
private readonly StringComparison _comparison;
private readonly Func<StringPart, T?> _transform;
private readonly Func<StringSegment, T?> _transform;
public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T?> transform)
public StringMatcher(string needle, StringComparison comparison, Func<StringSegment, T?> transform)
{
_needle = needle;
_comparison = comparison;
_transform = transform;
}
public StringMatcher(string needle, Func<StringPart, T> transform)
public StringMatcher(string needle, Func<StringSegment, T> transform)
: this(needle, StringComparison.Ordinal, transform)
{
}
public ParsedMatch<T>? TryMatch(StringPart stringPart)
public ParsedMatch<T>? TryMatch(StringSegment segment)
{
var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison);
var index = segment.Source.IndexOf(_needle, segment.StartIndex, segment.Length, _comparison);
if (index < 0)
return null;
var stringPartMatch = stringPart.Slice(index, _needle.Length);
var value = _transform(stringPartMatch);
var segmentMatch = segment.Relocate(index, _needle.Length);
var value = _transform(segmentMatch);
return value is not null
? new ParsedMatch<T>(stringPartMatch, value)
? new ParsedMatch<T>(segmentMatch, value)
: null;
}
}
@@ -1,21 +0,0 @@
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal readonly record struct StringPart(string Target, int StartIndex, int Length)
{
public int EndIndex => StartIndex + Length;
public StringPart(string target)
: this(target, 0, target.Length)
{
}
public StringPart Slice(int newStartIndex, int newLength) => new(Target, newStartIndex, newLength);
public StringPart Slice(int newStartIndex) => Slice(newStartIndex, EndIndex - newStartIndex);
public StringPart Slice(Capture capture) => Slice(capture.Index, capture.Length);
public override string ToString() => Target.Substring(StartIndex, Length);
}
@@ -0,0 +1,19 @@
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal readonly record struct StringSegment(string Source, int StartIndex, int Length)
{
public int EndIndex => StartIndex + Length;
public StringSegment(string target)
: this(target, 0, target.Length)
{
}
public StringSegment Relocate(int newStartIndex, int newLength) => new(Source, newStartIndex, newLength);
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
public override string ToString() => Source.Substring(StartIndex, Length);
}
@@ -2,4 +2,5 @@
namespace DiscordChatExporter.Core.Markdown;
internal record UnixTimestampNode(DateTimeOffset Value) : MarkdownNode;
// Null date means invalid timestamp
internal record UnixTimestampNode(DateTimeOffset? Date) : MarkdownNode;
@@ -13,20 +13,27 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Gress" Version="1.2.0" />
<PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="MaterialDesignColors" Version="2.0.4" />
<PackageReference Include="MaterialDesignThemes" Version="4.3.0" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.0" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Onova" Version="2.6.2" />
<PackageReference Include="Stylet" Version="1.3.6" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.2.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
<!-- Workaround for https://github.com/dotnet/core/issues/7176 -->
<ItemGroup>
<FrameworkReference
Update="Microsoft.WindowsDesktop.App;Microsoft.WindowsDesktop.App.WPF;Microsoft.WindowsDesktop.App.WindowsForms"
TargetingPackVersion="6.0.0" />
</ItemGroup>
</Project>
@@ -1,14 +1,14 @@
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting;
using Microsoft.Win32;
using Tyrrrz.Settings;
namespace DiscordChatExporter.Gui.Services;
public class SettingsService : SettingsManager
public partial class SettingsService : SettingsManager
{
public bool IsAutoUpdateEnabled { get; set; } = true;
public bool IsDarkModeEnabled { get; set; }
public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault();
public bool IsTokenPersisted { get; set; } = true;
@@ -18,7 +18,7 @@ public class SettingsService : SettingsManager
public bool ShouldReuseMedia { get; set; }
public AuthToken? LastToken { get; set; }
public string? LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
@@ -36,4 +36,21 @@ public class SettingsService : SettingsManager
}
public bool ShouldSerializeLastToken() => IsTokenPersisted;
}
public partial class SettingsService
{
private static bool IsDarkModeEnabledByDefault()
{
try
{
return Registry.CurrentUser.OpenSubKey(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", false
)?.GetValue("AppsUseLightTheme") is 0;
}
catch
{
return false;
}
}
}
@@ -0,0 +1,10 @@
using System.Globalization;
namespace DiscordChatExporter.Gui.Utils;
internal static class Internationalization
{
public static bool Is24Hours =>
string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator) &&
string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator);
}
@@ -13,6 +13,7 @@ using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress;
using Gress.Completable;
using MaterialDesignThemes.Wpf;
using Stylet;
@@ -25,17 +26,19 @@ public class RootViewModel : Screen
private readonly SettingsService _settingsService;
private readonly UpdateService _updateService;
public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
private readonly AutoResetProgressMuxer _progressMuxer;
public IProgressManager ProgressManager { get; } = new ProgressManager();
private DiscordClient? _discord;
public bool IsBusy { get; private set; }
public bool IsProgressIndeterminate { get; private set; }
public ProgressContainer<Percentage> Progress { get; } = new();
public bool IsBotToken { get; set; }
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
public string? TokenValue { get; set; }
public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5));
public string? Token { get; set; }
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
@@ -60,20 +63,12 @@ public class RootViewModel : Screen
_settingsService = settingsService;
_updateService = updateService;
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
DisplayName = $"{App.Name} v{App.VersionString}";
// Update busy state when progress manager changes
ProgressManager.Bind(o => o.IsActive, (_, _) =>
IsBusy = ProgressManager.IsActive
);
ProgressManager.Bind(o => o.IsActive, (_, _) =>
IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress is <= 0 or >= 1
);
ProgressManager.Bind(o => o.Progress, (_, _) =>
IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress is <= 0 or >= 1
);
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
}
private async ValueTask CheckForUpdatesAsync()
@@ -111,8 +106,7 @@ public class RootViewModel : Screen
if (_settingsService.LastToken is not null)
{
IsBotToken = _settingsService.LastToken.Kind == AuthTokenKind.Bot;
TokenValue = _settingsService.LastToken.Value;
Token = _settingsService.LastToken;
}
if (_settingsService.IsDarkModeEnabled)
@@ -124,6 +118,19 @@ public class RootViewModel : Screen
App.SetLightTheme();
}
// War in Ukraine message
Notifications.Enqueue(
"⚠ Ukraine is at war! Support my country in its fight for freedom",
"LEARN MORE", _ =>
{
ProcessEx.StartShellExecute("https://tyrrrz.me");
},
null,
true,
true,
TimeSpan.FromMinutes(1)
);
await CheckForUpdatesAsync();
}
@@ -144,23 +151,19 @@ public class RootViewModel : Screen
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
!IsBusy && !string.IsNullOrWhiteSpace(Token);
public async void PopulateGuildsAndChannels()
{
using var operation = ProgressManager.CreateOperation();
IsBusy = true;
var progress = _progressMuxer.CreateInput();
try
{
var tokenValue = TokenValue?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(tokenValue))
var token = Token?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(token))
return;
var token = new AuthToken(
IsBotToken ? AuthTokenKind.Bot : AuthTokenKind.User,
tokenValue
);
_settingsService.LastToken = token;
var discord = new DiscordClient(token);
@@ -172,6 +175,7 @@ public class RootViewModel : Screen
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
}
_discord = discord;
GuildChannelMap = guildChannelMap;
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
}
@@ -188,37 +192,51 @@ public class RootViewModel : Screen
await _dialogManager.ShowDialogAsync(dialog);
}
finally
{
progress.ReportCompletion();
IsBusy = false;
}
}
public bool CanExportChannels =>
!IsBusy && SelectedGuild is not null && SelectedChannels is not null && SelectedChannels.Any();
!IsBusy &&
_discord is not null &&
SelectedGuild is not null &&
SelectedChannels is not null &&
SelectedChannels.Any();
public async void ExportChannels()
{
IsBusy = true;
try
{
var token = _settingsService.LastToken;
if (token is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
return;
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
if (await _dialogManager.ShowDialogAsync(dialog) != true)
return;
var exporter = new ChannelExporter(token);
var exporter = new ChannelExporter(_discord);
var progresses = Enumerable
.Range(0, dialog.Channels!.Count)
.Select(_ => _progressMuxer.CreateInput())
.ToArray();
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0;
await Parallel.ForEachAsync(
dialog.Channels.Zip(operations),
dialog.Channels.Zip(progresses),
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
},
async (tuple, cancellationToken) =>
{
var (channel, operation) = tuple;
var (channel, progress) = tuple;
try
{
@@ -236,7 +254,7 @@ public class RootViewModel : Screen
_settingsService.DateFormat
);
await exporter.ExportChannelAsync(request, operation, cancellationToken);
await exporter.ExportChannelAsync(request, progress, cancellationToken);
Interlocked.Increment(ref successfulExportCount);
}
@@ -246,7 +264,7 @@ public class RootViewModel : Screen
}
finally
{
operation.Dispose();
progress.ReportCompletion();
}
}
);
@@ -264,5 +282,9 @@ public class RootViewModel : Screen
await _dialogManager.ShowDialogAsync(dialog);
}
finally
{
IsBusy = false;
}
}
}
@@ -1,17 +1,18 @@
<UserControl
Style="{DynamicResource MaterialDesignRoot}"
Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
mc:Ignorable="d"
x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -29,8 +30,8 @@
<!-- Guild icon -->
<Ellipse
Grid.Column="0"
Width="32"
Height="32">
Height="32"
Width="32">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding Guild.IconUrl}" />
</Ellipse.Fill>
@@ -38,12 +39,12 @@
<!-- Placeholder (for multiple channels) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
Grid.Column="1"
Margin="8,0,0,0"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<Run Text="{Binding Channels.Count, Mode=OneWay}" />
<Run Text="channels selected" />
@@ -51,12 +52,12 @@
<!-- Category and channel name (for single channel) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
Grid.Column="1"
Margin="8,0,0,0"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Run Text="{Binding Channels[0].Category.Name, Mode=OneWay}" ToolTip="{Binding Channels[0].Category.Name, Mode=OneWay}" />
<Run Text="/" />
@@ -68,21 +69,21 @@
</Grid>
<Border
Grid.Row="1"
Padding="0,8"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,1">
BorderThickness="0,1"
Grid.Row="1"
Padding="0,8">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Format -->
<ComboBox
Margin="16,8"
materialDesign:HintAssist.Hint="Format"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
ItemsSource="{Binding AvailableFormats}"
Margin="16,8"
SelectedItem="{Binding SelectedFormat}"
Style="{DynamicResource MaterialDesignOutlinedComboBox}">
Style="{DynamicResource MaterialDesignOutlinedComboBox}"
materialDesign:HintAssist.Hint="Format"
materialDesign:HintAssist.IsFloating="True">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
@@ -104,64 +105,66 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8,16,4"
materialDesign:HintAssist.Hint="After (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
Grid.Column="0"
Grid.Row="0"
Margin="16,8,16,4"
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
ToolTip="Only include messages sent after this date" />
ToolTip="Only include messages sent after this date"
materialDesign:HintAssist.Hint="After (date)"
materialDesign:HintAssist.IsFloating="True" />
<DatePicker
Grid.Row="0"
Grid.Column="1"
Margin="16,8,16,4"
materialDesign:HintAssist.Hint="Before (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
Grid.Column="1"
Grid.Row="0"
Margin="16,8,16,4"
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
ToolTip="Only include messages sent before this date" />
ToolTip="Only include messages sent before this date"
materialDesign:HintAssist.Hint="Before (date)"
materialDesign:HintAssist.IsFloating="True" />
<materialDesign:TimePicker
Grid.Row="1"
Grid.Column="0"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="After (time)"
materialDesign:HintAssist.IsFloating="True"
Grid.Row="1"
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
IsEnabled="{Binding IsAfterDateSet}"
Margin="16,4,16,8"
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
ToolTip="Only include messages sent after this time" />
ToolTip="Only include messages sent after this time"
materialDesign:HintAssist.Hint="After (time)"
materialDesign:HintAssist.IsFloating="True" />
<materialDesign:TimePicker
Grid.Row="1"
Grid.Column="1"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="Before (time)"
materialDesign:HintAssist.IsFloating="True"
Grid.Row="1"
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
IsEnabled="{Binding IsBeforeDateSet}"
Margin="16,4,16,8"
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
ToolTip="Only include messages sent before this time" />
ToolTip="Only include messages sent before this time"
materialDesign:HintAssist.Hint="Before (time)"
materialDesign:HintAssist.IsFloating="True" />
</Grid>
<!-- Partitioning -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Partition limit"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding PartitionLimitValue}"
ToolTip="Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')" />
ToolTip="Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')"
materialDesign:HintAssist.Hint="Partition limit"
materialDesign:HintAssist.IsFloating="True" />
<!-- Filtering -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Message filter"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding MessageFilterValue}"
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." />
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')."
materialDesign:HintAssist.Hint="Message filter"
materialDesign:HintAssist.IsFloating="True" />
<!-- Download media -->
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
@@ -172,13 +175,13 @@
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="Download media" />
Text="Download media"
VerticalAlignment="Center" />
<ToggleButton
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
IsChecked="{Binding ShouldDownloadMedia}" />
IsChecked="{Binding ShouldDownloadMedia}"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</StackPanel>
@@ -195,8 +198,8 @@
</Grid.ColumnDefinitions>
<Button
Grid.Column="0"
Command="{s:Action ToggleAdvancedSection}"
Grid.Column="0"
IsDefault="True"
ToolTip="Toggle advanced options">
<Button.Style>
@@ -214,17 +217,17 @@
</Button>
<Button
Grid.Column="2"
Command="{s:Action Confirm}"
Content="EXPORT"
Grid.Column="2"
IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
<Button
Grid.Column="3"
Margin="8,0,0,0"
Command="{s:Action Close}"
Content="CANCEL"
Grid.Column="3"
IsCancel="True"
Margin="8,0,0,0"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
</Grid>
</Grid>
@@ -35,7 +35,7 @@
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock
Margin="16,0,16,16"
Margin="16,8"
Text="{Binding Message}"
TextWrapping="Wrap" />
</ScrollViewer>
@@ -24,126 +24,129 @@
FontWeight="Light"
Text="Settings" />
<ScrollViewer
<Border
Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Auto-updates -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Perform automatic updates on every launch">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Auto-updates" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel>
<!-- Dark mode -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Use darker colors in the UI">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Dark mode" />
<ToggleButton
x:Name="DarkModeToggleButton"
VerticalAlignment="Center"
Checked="DarkModeToggleButton_Checked"
DockPanel.Dock="Right"
IsChecked="{Binding IsDarkModeEnabled}"
Unchecked="DarkModeToggleButton_Unchecked" />
</DockPanel>
<!-- Persist token -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Save last used token in a file so that it can be persisted between sessions">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Save token" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsTokenPersisted}" />
</DockPanel>
<!-- Reuse media -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Reuse already existing media content to skip redundant downloads">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Reuse downloaded media" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding ShouldReuseMedia}" />
</DockPanel>
<!-- Date format -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Format used when writing dates (uses .NET date formatting rules)">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Date format" />
<TextBox
Width="150"
VerticalAlignment="Center"
DockPanel.Dock="Right"
Text="{Binding DateFormat}" />
</DockPanel>
<!-- Parallel limit -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="How many channels can be exported at the same time">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Parallel limit"
TextAlignment="Right" />
<StackPanel
VerticalAlignment="Center"
DockPanel.Dock="Right"
Orientation="Horizontal">
Padding="0,8"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Auto-updates -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Perform automatic updates on every launch">
<TextBlock
Margin="10,0"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding ParallelLimit}" />
<Slider
DockPanel.Dock="Left"
Text="Auto-updates" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel>
<!-- Dark mode -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Use darker colors in the UI">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Dark mode" />
<ToggleButton
x:Name="DarkModeToggleButton"
VerticalAlignment="Center"
Checked="DarkModeToggleButton_Checked"
DockPanel.Dock="Right"
IsChecked="{Binding IsDarkModeEnabled}"
Unchecked="DarkModeToggleButton_Unchecked" />
</DockPanel>
<!-- Persist token -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Save last used token in a file so that it can be persisted between sessions">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Save token" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsTokenPersisted}" />
</DockPanel>
<!-- Reuse media -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Reuse already existing media content to skip redundant downloads">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Reuse downloaded media" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding ShouldReuseMedia}" />
</DockPanel>
<!-- Date format -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Format used when writing dates (uses .NET date formatting rules)">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Date format" />
<TextBox
Width="150"
VerticalAlignment="Center"
Maximum="10"
Minimum="1"
Style="{DynamicResource MaterialDesignThinSlider}"
Value="{Binding ParallelLimit}" />
</StackPanel>
</DockPanel>
</StackPanel>
</ScrollViewer>
DockPanel.Dock="Right"
Text="{Binding DateFormat}" />
</DockPanel>
<!-- Parallel limit -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="How many channels can be exported at the same time">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Parallel limit"
TextAlignment="Right" />
<StackPanel
VerticalAlignment="Center"
DockPanel.Dock="Right"
Orientation="Horizontal">
<TextBlock
Margin="10,0"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding ParallelLimit}" />
<Slider
Width="150"
VerticalAlignment="Center"
Maximum="10"
Minimum="1"
Style="{DynamicResource MaterialDesignThinSlider}"
Value="{Binding ParallelLimit}" />
</StackPanel>
</DockPanel>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Save button -->
<Button
+98 -120
View File
@@ -1,7 +1,16 @@
<Window
Background="{DynamicResource MaterialDesignPaper}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
Height="550"
Icon="/DiscordChatExporter;component/favicon.ico"
MinWidth="325"
Style="{DynamicResource MaterialDesignRoot}"
Width="600"
WindowStartupLocation="CenterScreen"
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
mc:Ignorable="d"
x:Class="DiscordChatExporter.Gui.Views.RootView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
@@ -11,21 +20,12 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
Width="600"
Height="550"
MinWidth="325"
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
Background="{DynamicResource MaterialDesignPaper}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
Icon="/DiscordChatExporter;component/favicon.ico"
Style="{DynamicResource MaterialDesignRoot}"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Window.TaskbarItemInfo>
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding Progress.Current.Fraction}" />
</Window.TaskbarItemInfo>
<Window.Resources>
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
<CollectionViewSource Source="{Binding AvailableChannels, Mode=OneWay}" x:Key="AvailableChannelsViewSource">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Category.Name" />
</CollectionViewSource.GroupDescriptions>
@@ -46,7 +46,7 @@
</Grid.RowDefinitions>
<!-- Toolbar -->
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
<Grid Background="{DynamicResource MaterialDesignDarkBackground}" Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -54,8 +54,8 @@
<!-- Token and pull data button -->
<materialDesign:Card
Grid.Row="0"
Grid.Column="0"
Grid.Row="0"
Margin="12,12,0,12">
<Grid>
<Grid.ColumnDefinitions>
@@ -64,87 +64,76 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token type -->
<ToggleButton
<!-- Token icon -->
<materialDesign:PackIcon
Foreground="{DynamicResource PrimaryHueMidBrush}"
Grid.Column="0"
Margin="6"
IsChecked="{Binding IsBotToken}"
Style="{DynamicResource MaterialDesignFlatActionToggleButton}"
ToolTip="Switch between user token and bot token">
<ToggleButton.Content>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Account" />
</ToggleButton.Content>
<materialDesign:ToggleButtonAssist.OnContent>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Robot" />
</materialDesign:ToggleButtonAssist.OnContent>
</ToggleButton>
Height="24"
Kind="Password"
Margin="8"
VerticalAlignment="Center"
Width="24" />
<!-- Token value -->
<TextBox
x:Name="TokenValueTextBox"
BorderThickness="0"
FontSize="16"
Grid.Column="1"
Margin="2,6,6,7"
Margin="0,6,6,8"
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}"
VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0"
FontSize="16"
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
x:Name="TokenValueTextBox" />
<!-- Pull data button -->
<Button
Command="{s:Action PopulateGuildsAndChannels}"
Grid.Column="2"
IsDefault="True"
Margin="0,6,6,6"
Padding="4"
Command="{s:Action PopulateGuildsAndChannels}"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Pull available guilds and channels (Enter)">
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="ArrowRight" />
Kind="ArrowRight"
Width="24" />
</Button>
</Grid>
</materialDesign:Card>
<!-- Settings button -->
<Button
Command="{s:Action ShowSettings}"
Foreground="{DynamicResource MaterialDesignDarkForeground}"
Grid.Column="1"
Margin="6"
Padding="4"
Command="{s:Action ShowSettings}"
Foreground="{DynamicResource MaterialDesignDarkForeground}"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Settings">
<Button.Resources>
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
<SolidColorBrush Color="#4C4C4C" x:Key="MaterialDesignFlatButtonClick" />
</Button.Resources>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Settings" />
Kind="Settings"
Width="24" />
</Button>
</Grid>
<!-- Progress bar -->
<ProgressBar
Grid.Row="1"
Background="{DynamicResource MaterialDesignDarkBackground}"
Grid.Row="1"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
<!-- Content -->
<Grid
Grid.Row="2"
Background="{DynamicResource MaterialDesignCardBackground}"
Grid.Row="2"
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<Grid.Resources>
<Style TargetType="TextBlock">
@@ -152,12 +141,25 @@
</Style>
</Grid.Resources>
<!-- Placeholder / usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- For user token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<TextBlock FontSize="18" Text="Please provide your user token to authorize" />
<TextBlock Margin="8,8,0,0" FontSize="14">
<Run Text="1. Open Discord" />
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock FontSize="14" Margin="32,16">
<Run FontSize="18" Text="Please provide authentication token to continue" />
<LineBreak />
<LineBreak />
<!-- User token -->
<InlineUIContainer>
<materialDesign:PackIcon
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account"
Margin="1,0,0,-2" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using your personal account" />
<LineBreak />
<Run Text="1. Open Discord in your" />
<Run FontWeight="SemiBold" Text="web browser" />
<Run Text="and login" />
<LineBreak />
<Run Text="2. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
@@ -189,35 +191,20 @@
<Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="key" />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="Automating user accounts is technically against TOS, use at your own risk." />
<LineBreak />
<Run Text="To authorize using bot token instead, click" />
<Run Text="* Automating user accounts is technically against TOS, use at your own risk!" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-3"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
Kind="Robot"
Margin="1,0,0,-2" />
</InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" />
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock>
</StackPanel>
<!-- For bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<TextBlock
FontSize="18"
FontWeight="Light"
Text="Please provide your bot token to authorize" />
<TextBlock
Margin="8,8,0,0"
FontSize="14"
FontWeight="Light">
<Run FontSize="16" Text="Authenticate as a bot" />
<LineBreak />
<Run Text="1. Open Discord developer portal" />
<LineBreak />
<Run Text="2. Open your application's settings" />
@@ -230,22 +217,13 @@
<Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" />
<LineBreak />
<LineBreak />
<Run FontSize="16" Text="If you have questions or issues, please refer to the" />
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="To authorize using user token instead, click" />
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-1"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" />
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- Guilds and channels -->
@@ -257,9 +235,9 @@
<!-- Guilds -->
<Border
Grid.Column="0"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,1,0">
BorderThickness="0,0,1,0"
Grid.Column="0">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
@@ -268,24 +246,24 @@
<ListBox.ItemTemplate>
<DataTemplate>
<Grid
Margin="-8"
Background="Transparent"
Cursor="Hand"
Margin="-8"
ToolTip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Fill="{DynamicResource MaterialDesignDivider}"
Height="48"
Margin="12,4,12,4"
Fill="{DynamicResource MaterialDesignDivider}" />
Width="48" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Stroke="{DynamicResource MaterialDesignDivider}"
StrokeThickness="1">
StrokeThickness="1"
Width="48">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill>
@@ -315,13 +293,13 @@
<Setter.Value>
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
<Expander
Margin="0"
Padding="0"
Background="Transparent"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,0,1"
Header="{Binding Name}"
IsExpanded="False">
IsExpanded="False"
Margin="0"
Padding="0">
<ItemsPresenter />
</Expander>
</ControlTemplate>
@@ -333,7 +311,7 @@
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="-8" Background="Transparent">
<Grid Background="Transparent" Margin="-8">
<Grid.InputBindings>
<MouseBinding Command="{s:Action ExportChannels}" MouseAction="LeftDoubleClick" />
</Grid.InputBindings>
@@ -346,27 +324,27 @@
<!-- Channel icon -->
<materialDesign:PackIcon
Grid.Column="0"
Kind="Pound"
Margin="16,7,0,6"
VerticalAlignment="Center"
Kind="Pound" />
VerticalAlignment="Center" />
<!-- Channel name -->
<TextBlock
FontSize="14"
Grid.Column="1"
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14"
Text="{Binding Name, Mode=OneWay}" />
Text="{Binding Name, Mode=OneWay}"
VerticalAlignment="Center" />
<!-- Is selected checkmark -->
<materialDesign:PackIcon
Grid.Column="2"
Width="24"
Height="24"
Kind="Check"
Margin="8,0"
VerticalAlignment="Center"
Kind="Check"
Visibility="{Binding IsSelected, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}, Converter={x:Static s:BoolToVisibilityConverter.Instance}, Mode=OneWay}" />
Visibility="{Binding IsSelected, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}, Converter={x:Static s:BoolToVisibilityConverter.Instance}, Mode=OneWay}"
Width="24" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -376,16 +354,16 @@
<!-- Export button -->
<Button
Margin="32,24"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Command="{s:Action ExportChannels}"
HorizontalAlignment="Right"
Margin="32,24"
Style="{DynamicResource MaterialDesignFloatingActionAccentButton}"
VerticalAlignment="Bottom"
Visibility="{Binding CanExportChannels, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<materialDesign:PackIcon
Width="32"
Height="32"
Kind="Download" />
Kind="Download"
Width="32" />
</Button>
<!-- Notifications snackbar -->
+1 -1
View File
@@ -1,5 +1,5 @@
DiscordChatExporter
Copyright (C) 2017-2021 Alexey Golub
Copyright (C) 2017-2022 Oleksii Holub
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
+14 -4
View File
@@ -6,15 +6,25 @@
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm)
[![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate)
[![Fuck Russia](https://img.shields.io/badge/fuck-russia-black.svg)](https://twitter.com/Tyrrrz/status/1495972128977571848)
**Project status: active**. [What does it mean?](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
**Project status: active**<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
**DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file.
It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features.
**If you have questions or issues, please refer to the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)**.
❓ **If you have questions or issues, please refer to the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)**
💬 **If you want to chat, join my [Discord server](https://discord.gg/2SUWKFnHSm)**.
## Terms of use
By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements:
- You **condemn Russia and its military aggression against Ukraine**
- You **recognize that Russia is an occupant that unlawfully invaded a sovereign state**
- You **support Ukraine's territorial integrity, including its claims over temporarily occupied territories of Crimea and Donbas**
- You **reject false narratives perpetuated by Russian state propaganda**
To learn more about the war and how you can help, [click here](https://tyrrrz.me). Glory to Ukraine! 🇺🇦
## Download
@@ -115,4 +125,4 @@ To do that, navigate to its directory and use `dotnet run`:
```sh
> cd DiscordChatExporter.Gui
> dotnet run
```
```