mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-08-16 09:09:30 +02:00
Compare commits
5 Commits
e6562de1f5
...
4c06eb1cba
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c06eb1cba | |||
| c4bfb3424e | |||
| 522caba420 | |||
| ad1170b42e | |||
| d2c0e2169b |
@@ -1,7 +1,12 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
@@ -44,4 +49,41 @@ public class HtmlContentSpecs
|
||||
"Yeet"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order()
|
||||
{
|
||||
// Arrange
|
||||
using var file = TempFile.Create();
|
||||
|
||||
// Act
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = file.Path,
|
||||
Locale = "en-US",
|
||||
IsUtcNormalizationEnabled = true,
|
||||
IsReverseMessageOrder = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
var document = Html.Parse(await File.ReadAllTextAsync(file.Path));
|
||||
var messages = document.QuerySelectorAll("[data-message-id]").ToArray();
|
||||
|
||||
// Assert
|
||||
messages
|
||||
.Select(e => e.GetAttribute("data-message-id"))
|
||||
.Should()
|
||||
.Equal(
|
||||
"885169254029213696",
|
||||
"868505973294268457",
|
||||
"868505969821364245",
|
||||
"868505966528835604",
|
||||
"868490009366396958",
|
||||
"866732113319428096",
|
||||
"866710679758045195",
|
||||
"866674314627121232"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -43,4 +49,43 @@ public class JsonContentSpecs
|
||||
"Yeet"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order()
|
||||
{
|
||||
// Arrange
|
||||
using var file = TempFile.Create();
|
||||
|
||||
// Act
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Locale = "en-US",
|
||||
IsUtcNormalizationEnabled = true,
|
||||
IsReverseMessageOrder = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
var messages = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.ToArray();
|
||||
|
||||
// Assert
|
||||
messages
|
||||
.Select(j => j.GetProperty("id").GetString())
|
||||
.Should()
|
||||
.Equal(
|
||||
"885169254029213696",
|
||||
"868505973294268457",
|
||||
"868505969821364245",
|
||||
"868505966528835604",
|
||||
"868490009366396958",
|
||||
"866732113319428096",
|
||||
"866710679758045195",
|
||||
"866674314627121232"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public int ParallelLimit { get; init; } = 1;
|
||||
|
||||
[CommandOption(
|
||||
"reverse",
|
||||
Description = "Export messages in reverse chronological order (newest first)."
|
||||
)]
|
||||
public bool IsReverseMessageOrder { get; init; }
|
||||
|
||||
[CommandOption(
|
||||
"markdown",
|
||||
Description = "Process markdown, mentions, and other special tokens."
|
||||
@@ -267,6 +273,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
Before,
|
||||
PartitionLimit,
|
||||
MessageFilter,
|
||||
IsReverseMessageOrder,
|
||||
ShouldFormatMarkdown,
|
||||
ShouldDownloadAssets,
|
||||
ShouldReuseAssets,
|
||||
|
||||
@@ -8855,6 +8855,8 @@ internal static class EmojiIndex
|
||||
["united_nations"] = "🇺🇳",
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<string> GetAllNames() => _toCodes.Keys;
|
||||
|
||||
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
|
||||
|
||||
public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code);
|
||||
|
||||
@@ -194,6 +194,23 @@ public class DiscordClient(
|
||||
return Application.Parse(response);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureMessageContentIntentAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (await ResolveTokenKindAsync(cancellationToken) != TokenKind.Bot)
|
||||
return;
|
||||
|
||||
var application = await GetApplicationAsync(cancellationToken);
|
||||
if (application.IsMessageContentIntentEnabled)
|
||||
return;
|
||||
|
||||
throw new DiscordChatExporterException(
|
||||
"Provided bot account is missing the MESSAGE_CONTENT privileged intent.",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public async ValueTask<User?> TryGetUserAsync(
|
||||
Snowflake userId,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -429,6 +446,11 @@ public class DiscordClient(
|
||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||
.ToArray();
|
||||
|
||||
// Track yielded thread IDs to avoid duplicates that can occur when a thread transitions
|
||||
// from active to archived between the two separate API calls used to fetch threads.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1433
|
||||
var seenThreadIds = new HashSet<Snowflake>();
|
||||
|
||||
// User accounts can only fetch threads using the search endpoint
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
{
|
||||
@@ -472,7 +494,9 @@ public class DiscordClient(
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
@@ -511,7 +535,12 @@ public class DiscordClient(
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
if (filteredChannels.Contains(parent))
|
||||
yield return Channel.Parse(threadJson, parent);
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, parent);
|
||||
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,12 +576,14 @@ public class DiscordClient(
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
yield return thread;
|
||||
|
||||
currentBefore = threadJson
|
||||
.GetProperty("thread_metadata")
|
||||
.GetProperty("archive_timestamp")
|
||||
.GetString();
|
||||
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
}
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
@@ -564,6 +595,24 @@ public class DiscordClient(
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetFirstMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "1")
|
||||
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? before = null,
|
||||
@@ -624,20 +673,8 @@ public class DiscordClient(
|
||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||
// have the Message Content Intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (
|
||||
messages.All(m => m.IsEmpty)
|
||||
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
|
||||
)
|
||||
{
|
||||
var application = await GetApplicationAsync(cancellationToken);
|
||||
if (!application.IsMessageContentIntentEnabled)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
"Provided bot account does not have the Message Content Intent enabled.",
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
@@ -670,6 +707,76 @@ public class DiscordClient(
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Message> GetMessagesInReverseAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null,
|
||||
IProgress<Percentage>? progress = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Get the first message in the specified range, so we can later calculate the
|
||||
// progress based on the difference between message timestamps.
|
||||
// Snapshotting is not necessary here because new messages can't appear in the past.
|
||||
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
|
||||
|
||||
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
|
||||
yield break;
|
||||
|
||||
// Keep track of the last message in range in order to calculate the progress
|
||||
var lastMessage = default(Message);
|
||||
|
||||
var currentBefore = before;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("before", currentBefore?.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
|
||||
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
if (!messages.Any())
|
||||
yield break;
|
||||
|
||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||
// have the Message Content Intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
lastMessage ??= message;
|
||||
|
||||
// Report progress based on timestamps
|
||||
if (progress is not null)
|
||||
{
|
||||
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
|
||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||
|
||||
progress.Report(
|
||||
Percentage.FromFraction(
|
||||
// Avoid division by zero if all messages have the exact same timestamp
|
||||
// (which happens when there's only one message in the channel)
|
||||
totalDuration > TimeSpan.Zero
|
||||
? exportedDuration / totalDuration
|
||||
: 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
yield return message;
|
||||
}
|
||||
|
||||
currentBefore = messages.Last().Id;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake messageId,
|
||||
|
||||
@@ -64,15 +64,23 @@ public class ChannelExporter(DiscordClient discord)
|
||||
);
|
||||
}
|
||||
|
||||
await foreach (
|
||||
var message in discord.GetMessagesAsync(
|
||||
var messages = !request.IsReverseMessageOrder
|
||||
? discord.GetMessagesAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
: discord.GetMessagesInReverseAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await foreach (var message in messages)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -37,6 +37,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
if (reuse && File.Exists(filePath))
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
|
||||
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
|
||||
// to the new naming scheme to preserve backwards compatibility with existing exports
|
||||
if (reuse)
|
||||
{
|
||||
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
|
||||
if (File.Exists(legacyFilePath))
|
||||
{
|
||||
// Overwrite in case the destination file was created concurrently between our
|
||||
// earlier existence check and this move operation
|
||||
try
|
||||
{
|
||||
File.Move(legacyFilePath, filePath, overwrite: true);
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The legacy file was moved or deleted concurrently or something else happened.
|
||||
// Upgrading old files is not crucial, so we can just move on.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(workingDirPath);
|
||||
|
||||
await Http.ResiliencePipeline.ExecuteAsync(
|
||||
@@ -56,34 +78,23 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
|
||||
internal partial class ExportAssetDownloader
|
||||
{
|
||||
private static string GetUrlHash(string url)
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
// Remove signature parameters from Discord CDN URLs to normalize them
|
||||
static string NormalizeUrl(string url)
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
query.Remove("is");
|
||||
query.Remove("hm");
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
query.Remove("is");
|
||||
query.Remove("hm");
|
||||
|
||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||
}
|
||||
|
||||
return SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
// 5 chars ought to be enough for anybody
|
||||
.Truncate(5);
|
||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url)
|
||||
private static string GetFileNameFromUrl(string url, string urlHash)
|
||||
{
|
||||
var urlHash = GetUrlHash(url);
|
||||
|
||||
// Try to extract the file name from URL
|
||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
@@ -107,4 +118,25 @@ internal partial class ExportAssetDownloader
|
||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||
);
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url) =>
|
||||
GetFileNameFromUrl(
|
||||
url,
|
||||
// 16 chars = 64 bits, reaches 1% collision probability at ~609 million files
|
||||
SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
.Truncate(16)
|
||||
);
|
||||
|
||||
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
|
||||
private static string GetLegacyFileNameFromUrl(string url) =>
|
||||
GetFileNameFromUrl(
|
||||
url,
|
||||
SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
|
||||
.Truncate(5)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ public partial class ExportRequest
|
||||
|
||||
public MessageFilter MessageFilter { get; }
|
||||
|
||||
public bool IsReverseMessageOrder { get; }
|
||||
|
||||
public bool ShouldFormatMarkdown { get; }
|
||||
|
||||
public bool ShouldDownloadAssets { get; }
|
||||
@@ -55,6 +57,7 @@ public partial class ExportRequest
|
||||
Snowflake? before,
|
||||
PartitionLimit partitionLimit,
|
||||
MessageFilter messageFilter,
|
||||
bool isReverseMessageOrder,
|
||||
bool shouldFormatMarkdown,
|
||||
bool shouldDownloadAssets,
|
||||
bool shouldReuseAssets,
|
||||
@@ -69,6 +72,7 @@ public partial class ExportRequest
|
||||
Before = before;
|
||||
PartitionLimit = partitionLimit;
|
||||
MessageFilter = messageFilter;
|
||||
IsReverseMessageOrder = isReverseMessageOrder;
|
||||
ShouldFormatMarkdown = shouldFormatMarkdown;
|
||||
ShouldDownloadAssets = shouldDownloadAssets;
|
||||
ShouldReuseAssets = shouldReuseAssets;
|
||||
|
||||
@@ -17,6 +17,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
private readonly HtmlMinifier _minifier = new();
|
||||
private readonly List<Message> _messageGroup = [];
|
||||
|
||||
// Note: in reverse order, last message appears earlier than the first message
|
||||
private bool CanJoinGroup(Message message)
|
||||
{
|
||||
// If the group is empty, any message can join it
|
||||
|
||||
@@ -23,13 +23,11 @@ internal static class PlainTextMessageExtensions
|
||||
: "Removed a recipient.",
|
||||
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
message
|
||||
$"Started a call that lasted {message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
|
||||
@@ -208,53 +208,18 @@ internal static partial class MarkdownParser
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(
|
||||
@"("
|
||||
+
|
||||
// Country flag emoji (two regional indicator surrogate pairs)
|
||||
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|"
|
||||
+
|
||||
// Digit emoji (digit followed by enclosing mark)
|
||||
@"\d\p{Me}|"
|
||||
+
|
||||
// Surrogate pair
|
||||
@"\p{Cs}{2}|"
|
||||
+
|
||||
// Miscellaneous characters
|
||||
@"["
|
||||
+ @"\u2600-\u2604"
|
||||
+ @"\u260E\u2611"
|
||||
+ @"\u2614-\u2615"
|
||||
+ @"\u2618\u261D\u2620"
|
||||
+ @"\u2622-\u2623"
|
||||
+ @"\u2626\u262A"
|
||||
+ @"\u262E-\u262F"
|
||||
+ @"\u2638-\u263A"
|
||||
+ @"\u2640\u2642"
|
||||
+ @"\u2648-\u2653"
|
||||
+ @"\u265F-\u2660"
|
||||
+ @"\u2663"
|
||||
+ @"\u2665-\u2666"
|
||||
+ @"\u2668\u267B"
|
||||
+ @"\u267E-\u267F"
|
||||
+ @"\u2692-\u2697"
|
||||
+ @"\u2699"
|
||||
+ @"\u269B-\u269C"
|
||||
+ @"\u26A0-\u26A1"
|
||||
+ @"\u26A7"
|
||||
+ @"\u26AA-\u26AB"
|
||||
+ @"\u26B0-\u26B1"
|
||||
+ @"\u26BD-\u26BE"
|
||||
+ @"\u26C4-\u26C5"
|
||||
+ @"\u26C8"
|
||||
+ @"\u26CE-\u26CF"
|
||||
+ @"\u26D1"
|
||||
+ @"\u26D3-\u26D4"
|
||||
+ @"\u26E9-\u26EA"
|
||||
+ @"\u26F0-\u26F5"
|
||||
+ @"\u26F7-\u26FA"
|
||||
+ @"\u26FD"
|
||||
+ @"]"
|
||||
+ @")",
|
||||
// Build a pattern from all known emoji, sorted longest-first so that compound
|
||||
// emoji (e.g. sequences with ZWJ or skin-tone modifiers) are matched before
|
||||
// their individual components.
|
||||
"("
|
||||
+ string.Join(
|
||||
"|",
|
||||
EmojiIndex
|
||||
.GetAllNames()
|
||||
.OrderByDescending(e => e.Length)
|
||||
.Select(Regex.Escape)
|
||||
)
|
||||
+ ")",
|
||||
DefaultRegexOptions
|
||||
),
|
||||
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
||||
|
||||
@@ -104,6 +104,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Message filter",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Reverse messages",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Export messages in reverse chronological order (newest first)",
|
||||
[nameof(FormatMarkdownLabel)] = "Format markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Process markdown, mentions, and other special tokens",
|
||||
|
||||
@@ -106,6 +106,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Filtre de messages",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Inclure uniquement les messages satisfaisant ce filtre (ex. 'from:foo#1234' ou 'has:image'). Voir la documentation pour plus d'informations.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Inverser l'ordre des messages",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Exporter les messages en ordre chronologique inversé (les plus récents en premier)",
|
||||
[nameof(FormatMarkdownLabel)] = "Formater le markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Traiter le markdown, les mentions et autres tokens spéciaux",
|
||||
|
||||
@@ -110,6 +110,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Nachrichtenfilter",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Nur Nachrichten einschließen, die diesem Filter entsprechen (z. B. 'from:foo#1234' oder 'has:image'). Weitere Informationen finden Sie in der Dokumentation.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Nachrichtenreihenfolge umkehren",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Nachrichten in umgekehrter chronologischer Reihenfolge exportieren (neueste zuerst)",
|
||||
[nameof(FormatMarkdownLabel)] = "Markdown formatieren",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Markdown, Erwähnungen und andere spezielle Token verarbeiten",
|
||||
|
||||
@@ -104,6 +104,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Filtro de mensajes",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Solo incluir mensajes que satisfagan este filtro (p. ej. 'from:foo#1234' o 'has:image'). Consulte la documentación para más información.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Invertir orden de mensajes",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Exportar mensajes en orden cronológico inverso (los más recientes primero)",
|
||||
[nameof(FormatMarkdownLabel)] = "Formatear markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Procesar markdown, menciones y otros tokens especiales",
|
||||
|
||||
@@ -104,6 +104,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Фільтр повідомлень",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Включати лише повідомлення, що відповідають цьому фільтру (напр. 'from:foo#1234' або 'has:image'). Дивіться документацію для більш детальної інформації.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Зворотній порядок повідомлень",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Експортувати повідомлення у зворотному хронологічному порядку (найновіші спочатку)",
|
||||
[nameof(FormatMarkdownLabel)] = "Форматувати markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Обробляти markdown, згадки та інші спеціальні токени",
|
||||
|
||||
@@ -135,6 +135,8 @@ public partial class LocalizationManager
|
||||
public string PartitionLimitTooltip => Get();
|
||||
public string MessageFilterLabel => Get();
|
||||
public string MessageFilterTooltip => Get();
|
||||
public string ReverseMessageOrderLabel => Get();
|
||||
public string ReverseMessageOrderTooltip => Get();
|
||||
public string FormatMarkdownLabel => Get();
|
||||
public string FormatMarkdownTooltip => Get();
|
||||
public string DownloadAssetsLabel => Get();
|
||||
|
||||
@@ -61,6 +61,9 @@ public partial class SettingsService()
|
||||
[ObservableProperty]
|
||||
public partial string? LastMessageFilterValue { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool LastIsReverseMessageOrder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool LastShouldFormatMarkdown { get; set; } = true;
|
||||
|
||||
|
||||
@@ -275,6 +275,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||
dialog.PartitionLimit,
|
||||
dialog.MessageFilter,
|
||||
dialog.IsReverseMessageOrder,
|
||||
dialog.ShouldFormatMarkdown,
|
||||
dialog.ShouldDownloadAssets,
|
||||
dialog.ShouldReuseAssets,
|
||||
|
||||
@@ -62,6 +62,9 @@ public partial class ExportSetupViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(MessageFilter))]
|
||||
public partial string? MessageFilterValue { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsReverseMessageOrder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool ShouldFormatMarkdown { get; set; }
|
||||
|
||||
@@ -106,6 +109,7 @@ public partial class ExportSetupViewModel(
|
||||
SelectedFormat = settingsService.LastExportFormat;
|
||||
PartitionLimitValue = settingsService.LastPartitionLimitValue;
|
||||
MessageFilterValue = settingsService.LastMessageFilterValue;
|
||||
IsReverseMessageOrder = settingsService.LastIsReverseMessageOrder;
|
||||
ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown;
|
||||
ShouldDownloadAssets = settingsService.LastShouldDownloadAssets;
|
||||
ShouldReuseAssets = settingsService.LastShouldReuseAssets;
|
||||
@@ -120,7 +124,8 @@ public partial class ExportSetupViewModel(
|
||||
|| !string.IsNullOrWhiteSpace(MessageFilterValue)
|
||||
|| ShouldDownloadAssets
|
||||
|| ShouldReuseAssets
|
||||
|| !string.IsNullOrWhiteSpace(AssetsDirPath);
|
||||
|| !string.IsNullOrWhiteSpace(AssetsDirPath)
|
||||
|| IsReverseMessageOrder;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -184,6 +189,7 @@ public partial class ExportSetupViewModel(
|
||||
settingsService.LastExportFormat = SelectedFormat;
|
||||
settingsService.LastPartitionLimitValue = PartitionLimitValue;
|
||||
settingsService.LastMessageFilterValue = MessageFilterValue;
|
||||
settingsService.LastIsReverseMessageOrder = IsReverseMessageOrder;
|
||||
settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
|
||||
settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
|
||||
settingsService.LastShouldReuseAssets = ShouldReuseAssets;
|
||||
|
||||
@@ -196,6 +196,15 @@
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.MessageFilterTooltip}" />
|
||||
|
||||
<!-- Reverse message order -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.ReverseMessageOrderTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReverseMessageOrderLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsReverseMessageOrder}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Markdown formatting -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
|
||||
Reference in New Issue
Block a user