diff --git a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs
index 25e0cec9..572d8831 100644
--- a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs
+++ b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs
@@ -30,7 +30,7 @@ public class DateRangeSpecs
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
- After = Snowflake.FromDate(after),
+ After = Snowflake.FromDate(after, true),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -118,7 +118,7 @@ public class DateRangeSpecs
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
Before = Snowflake.FromDate(before),
- After = Snowflake.FromDate(after),
+ After = Snowflake.FromDate(after, true),
}.ExecuteAsync(new FakeConsole());
// Assert
diff --git a/DiscordChatExporter.Core/Discord/Snowflake.cs b/DiscordChatExporter.Core/Discord/Snowflake.cs
index e5321047..2773c194 100644
--- a/DiscordChatExporter.Core/Discord/Snowflake.cs
+++ b/DiscordChatExporter.Core/Discord/Snowflake.cs
@@ -19,8 +19,24 @@ public partial record struct Snowflake
{
public static Snowflake Zero { get; } = new(0);
- public static Snowflake FromDate(DateTimeOffset instant) =>
- new(((ulong)instant.ToUnixTimeMilliseconds() - 1420070400000UL) << 22);
+ ///
+ /// Creates and returns a Snowflake representing the given timestamp.
+ ///
+ ///
+ /// The timestamp that should be returned as a Snowflake.
+ ///
+ ///
+ /// Whether the Snowflake will be used to determine the messages starting at the given timestamp.
+ /// If true, the Snowflake doesn't precisely represent the given timestamp. Instead, it then is the latest possible
+ /// Snowflake just before that timestamp.
+ /// This is necessary to prevent the first Discord message in that specific millisecond from being excluded.
+ ///
+ /// A Snowflake representing the given timestamp.
+ public static Snowflake FromDate(DateTimeOffset timestamp, bool startTimestamp = false) =>
+ new(
+ (((ulong)timestamp.ToUnixTimeMilliseconds() - 1420070400000UL) << 22)
+ - (startTimestamp ? 1UL : 0UL)
+ );
public static Snowflake? TryParse(string? value, IFormatProvider? formatProvider = null)
{
diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs
index 68ca75b4..b5ef7bc0 100644
--- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs
+++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs
@@ -1,4 +1,5 @@
using System;
+using System.IO;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord;
@@ -27,13 +28,59 @@ public class ChannelExporter(DiscordClient discord)
);
}
+ var currentPartitionIndex = 0;
+ // TODO: Maybe add a way to search for old files after a username change
+ if (File.Exists(request.OutputFilePath))
+ {
+ // TODO: Add a way for the user to choose the setting
+ var choice = FileExistsHandling.Abort;
+
+ switch (choice)
+ {
+ case FileExistsHandling.Abort:
+ Console.WriteLine("Channel aborted");
+ return;
+ case FileExistsHandling.Overwrite:
+ Console.WriteLine("Removing old files");
+ MessageExporter.RemoveExistingFiles(request.OutputFilePath);
+ break;
+ case FileExistsHandling.Append:
+ var lastMessageSnowflake = MessageExporter.GetLastMessageSnowflake(
+ request.OutputFilePath,
+ request.Format
+ );
+ if (lastMessageSnowflake != null)
+ {
+ request.LastPriorMessage = lastMessageSnowflake.Value;
+
+ if (!request.Channel.MayHaveMessagesAfter(request.LastPriorMessage.Value))
+ {
+ Console.WriteLine("Download already up to date");
+ return;
+ }
+
+ Console.WriteLine(
+ "Downloading data after " + lastMessageSnowflake.Value.ToDate()
+ );
+ currentPartitionIndex = MessageExporter.GetPartitionCount(
+ request.OutputFilePath
+ );
+ }
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Unknown FileExistsHandling value '{choice}'."
+ );
+ }
+ }
+
// Build context
var context = new ExportContext(discord, request);
await context.PopulateChannelsAndRolesAsync(cancellationToken);
// Initialize the exporter before further checks to ensure the file is created even if
// an exception is thrown after this point.
- await using var messageExporter = new MessageExporter(context);
+ await using var messageExporter = new MessageExporter(context, currentPartitionIndex);
// Check if the channel is empty
if (request.Channel.IsEmpty)
@@ -67,7 +114,7 @@ public class ChannelExporter(DiscordClient discord)
await foreach (
var message in discord.GetMessagesAsync(
request.Channel.Id,
- request.After,
+ request.LastPriorMessage ?? request.After,
request.Before,
progress,
cancellationToken
diff --git a/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs b/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs
index f6911ce3..9df2bba3 100644
--- a/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs
+++ b/DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs
@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Text;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
@@ -12,6 +15,8 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class CsvMessageWriter(Stream stream, ExportContext context)
: MessageWriter(stream, context)
{
+ private const int HeaderSize = 1;
+
private readonly TextWriter _writer = new StreamWriter(stream);
private async ValueTask FormatMarkdownAsync(
@@ -117,6 +122,51 @@ internal partial class CsvMessageWriter(Stream stream, ExportContext context)
await _writer.DisposeAsync();
await base.DisposeAsync();
}
+
+ ///
+ /// Retrieves and returns the timestamp of the last written message in the Discord channel that has been exported
+ /// with the CsvMessageWriter to the given file path as a Snowflake.
+ /// This timestamp has millisecond-level precision.
+ ///
+ ///
+ /// The path of the Discord channel CSV export whose last message's timestamp should be returned.
+ ///
+ ///
+ /// The timestamp of the last written message in the Discord channel CSV export under the given path as a Snowflake.
+ /// Null, if the Discord channel CSV export doesn't include any message.
+ ///
+ ///
+ /// Thrown if the file at the given path isn't a correctly formatted Discord channel CSV export.
+ ///
+ public static Snowflake? GetLastMessageDate(string filePath)
+ {
+ try
+ {
+ var fileLines = File.ReadAllLines(filePath)
+ .SkipWhile(string.IsNullOrWhiteSpace)
+ .ToArray();
+ if (fileLines.Length <= HeaderSize)
+ return null;
+
+ const string columnPattern = "(?:[^\"]?(?:\"\")?)*";
+ var messageDatePattern = string.Format(
+ "^\"{0}\",\"{0}\",\"({0})\",\"{0}\",\"{0}\",\"{0}\"$",
+ columnPattern
+ );
+ var messageDateRegex = new Regex(messageDatePattern);
+
+ var timestampMatch = messageDateRegex.Match(fileLines[^1]);
+ var timestampString = timestampMatch.Groups[1].Value;
+ var timestamp = DateTimeOffset.Parse(timestampString);
+ return Snowflake.FromDate(timestamp, true);
+ }
+ catch (Exception ex) when (ex is IndexOutOfRangeException or FormatException)
+ {
+ throw new FormatException(
+ "The CSV file is not correctly formatted; the last message timestamp could not be retrieved."
+ );
+ }
+ }
}
internal partial class CsvMessageWriter
diff --git a/DiscordChatExporter.Core/Exporting/ExportRequest.cs b/DiscordChatExporter.Core/Exporting/ExportRequest.cs
index 2804e6c2..7ba9b4e2 100644
--- a/DiscordChatExporter.Core/Exporting/ExportRequest.cs
+++ b/DiscordChatExporter.Core/Exporting/ExportRequest.cs
@@ -30,6 +30,8 @@ public partial class ExportRequest
public Snowflake? Before { get; }
+ public Snowflake? LastPriorMessage { get; set; }
+
public PartitionLimit PartitionLimit { get; }
public MessageFilter MessageFilter { get; }
diff --git a/DiscordChatExporter.Core/Exporting/FileExistsHandling.cs b/DiscordChatExporter.Core/Exporting/FileExistsHandling.cs
new file mode 100644
index 00000000..d6cfd41c
--- /dev/null
+++ b/DiscordChatExporter.Core/Exporting/FileExistsHandling.cs
@@ -0,0 +1,23 @@
+namespace DiscordChatExporter.Core.Exporting;
+
+///
+/// Represents the setting on how to handle the export of a channel that has already been exported.
+///
+public enum FileExistsHandling
+{
+ ///
+ /// If a channel had previously been exported, its export will be aborted.
+ ///
+ Abort,
+
+ ///
+ /// If a channel had previously been exported, the existing export will be removed, and it will be exported again.
+ ///
+ Overwrite,
+
+ ///
+ /// If a channel had previously been exported, the existing export will be appended, which means that only messages
+ /// after the last export will be exported.
+ ///
+ Append,
+}
diff --git a/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs b/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs
index 05b1bba2..e6916fd2 100644
--- a/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs
+++ b/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs
@@ -2,14 +2,16 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using WebMarkupMin.Core;
namespace DiscordChatExporter.Core.Exporting;
-internal class HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
+internal partial class HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
: MessageWriter(stream, context)
{
private readonly TextWriter _writer = new StreamWriter(stream);
@@ -142,4 +144,50 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
await _writer.DisposeAsync();
await base.DisposeAsync();
}
+
+ ///
+ /// Returns the statically created regex that detects and captures the timestamp of a message in a channel HTML
+ /// export.
+ ///
+ ///
+ /// The regex that detects and captures the timestamp of a message in a Discord channel HTML export.
+ ///
+ [GeneratedRegex("")]
+ private static partial Regex MessageDateRegex();
+
+ ///
+ /// Retrieves and returns the approximate timestamp of the last written message in the Discord channel that has
+ /// been exported with the HtmlMessageWriter to the given file path as a Snowflake.
+ /// This timestamp only has minute-level precision.
+ ///
+ ///
+ /// The path of the Discord channel HTML export whose last message's timestamp should be returned.
+ ///
+ ///
+ /// The approximate timestamp of the last written message in the Discord channel HTML export under the given path
+ /// as a Snowflake.
+ /// Null, if the Discord channel HTML export doesn't include any message.
+ ///
+ ///
+ /// Thrown if the file at the given path isn't a correctly formatted Discord channel HTML export.
+ ///
+ public static Snowflake? GetLastMessageDate(string filePath)
+ {
+ try
+ {
+ var fileContent = File.ReadAllText(filePath);
+ var messageDateRegex = MessageDateRegex();
+
+ var timestampMatches = messageDateRegex.Matches(fileContent);
+ var timestampString = timestampMatches[^1].Groups[1].Value;
+ var timestamp = DateTimeOffset.Parse(timestampString);
+ return Snowflake.FromDate(timestamp, true);
+ }
+ catch (Exception ex) when (ex is IndexOutOfRangeException or FormatException)
+ {
+ throw new FormatException(
+ "The HTML file is not correctly formatted; the last message timestamp could not be retrieved."
+ );
+ }
+ }
}
diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs
index 5b1dfe48..a1dec60d 100644
--- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs
+++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs
@@ -6,6 +6,7 @@ using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Markdown.Parsing;
@@ -579,4 +580,53 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
await _writer.DisposeAsync();
await base.DisposeAsync();
}
+
+ ///
+ /// Retrieves and returns the Snowflake of the last written message in the Discord channel that has been
+ /// exported with the JsonMessageWriter to the given file path.
+ /// This Snowflake contains the message timestamp with millisecond-level precision and the message index.
+ ///
+ ///
+ /// The path of the Discord channel JSON export whose last message's timestamp should be returned.
+ ///
+ ///
+ /// The Snowflake of the last written message in the Discord channel JSON export under the given path.
+ /// Null, if the Discord channel JSON export doesn't include any message.
+ ///
+ ///
+ /// Thrown if the file at the given path isn't a correctly formatted Discord channel JSON export.
+ ///
+ public static Snowflake? GetLastMessageSnowflake(string filePath)
+ {
+ try
+ {
+ var fileContent = File.ReadAllText(filePath);
+ using var fileJson = JsonDocument.Parse(fileContent);
+ var messagesJson = fileJson
+ .RootElement.GetProperty("messages")
+ .EnumerateArray()
+ .ToArray();
+
+ if (messagesJson.Length == 0)
+ return null;
+
+ var lastMessage = messagesJson[^1];
+ var snowflakeInt = ulong.Parse(lastMessage.GetProperty("id").GetString()!);
+ var snowflake = new Snowflake(snowflakeInt);
+ return snowflake;
+ }
+ catch (Exception ex)
+ when (ex
+ is JsonException
+ or KeyNotFoundException
+ or InvalidOperationException
+ or FormatException
+ or NullReferenceException
+ )
+ {
+ throw new FormatException(
+ "The JSON file is not correctly formatted; the last message timestamp could not be retrieved."
+ );
+ }
+ }
}
diff --git a/DiscordChatExporter.Core/Exporting/MessageExporter.cs b/DiscordChatExporter.Core/Exporting/MessageExporter.cs
index 1f0f7ca4..fe23b7b8 100644
--- a/DiscordChatExporter.Core/Exporting/MessageExporter.cs
+++ b/DiscordChatExporter.Core/Exporting/MessageExporter.cs
@@ -2,13 +2,15 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
+using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting;
-internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
+internal partial class MessageExporter(ExportContext context, int initialPartitionIndex = 0)
+ : IAsyncDisposable
{
- private int _partitionIndex;
+ private int _partitionIndex = initialPartitionIndex;
private MessageWriter? _writer;
public long MessagesExported { get; private set; }
@@ -101,21 +103,148 @@ internal partial class MessageExporter
string filePath,
ExportFormat format,
ExportContext context
- ) =>
- format switch
+ )
+ {
+ // Don't accidentally overwrite anything
+ if (File.Exists(filePath))
{
- ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
- ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
- ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
- ExportFormat.HtmlLight => new HtmlMessageWriter(
- File.Create(filePath),
- context,
- "Light"
- ),
- ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
+ throw new InvalidOperationException(
+ "Error: An exported file already exists. This should never happen."
+ );
+ }
+
+ var file = File.Create(filePath);
+ return format switch
+ {
+ ExportFormat.PlainText => new PlainTextMessageWriter(file, context),
+ ExportFormat.Csv => new CsvMessageWriter(file, context),
+ ExportFormat.HtmlDark => new HtmlMessageWriter(file, context, "Dark"),
+ ExportFormat.HtmlLight => new HtmlMessageWriter(file, context, "Light"),
+ ExportFormat.Json => new JsonMessageWriter(file, context),
_ => throw new ArgumentOutOfRangeException(
nameof(format),
$"Unknown export format '{format}'."
),
};
+ }
+
+ ///
+ /// Retrieves and returns the (approximate) Snowflake of the last written message in the Discord channel that has
+ /// previously been exported to the given file path in the given format.
+ ///
+ /// If the used format is JSON, this returns its exact Snowflake (which contains the precise message timestamp and
+ /// index and can therefore correctly determine which messages succeed it).
+ /// Otherwise, it returns an approximate Snowflake (which only contains the (possibly approximate) message
+ /// timestamp and may therefore label slightly earlier messages as succeeding).
+ ///
+ /// This automatically determines the number of partitions the export has been split up into and uses the last one.
+ ///
+ ///
+ /// The path of the first partition of the Discord channel export whose last message's timestamp should be returned.
+ ///
+ ///
+ /// The format of the Discord channel export whose last message's timestamp should be returned.
+ ///
+ ///
+ /// The (approximate) Snowflake of the last written message in the Discord channel that has been previously
+ /// exported to the given file path in the given format.
+ /// Null, if the Discord channel hasn't previously been exported or if the export doesn't include any message.
+ ///
+ ///
+ /// Thrown if the last Discord channel export partition file isn't correctly formatted according to the given
+ /// format.
+ ///
+ public static Snowflake? GetLastMessageSnowflake(string baseFilePath, ExportFormat format)
+ {
+ var partitionAmounts = GetPartitionCount(baseFilePath);
+ var lastPartitionFile = GetPartitionFilePath(baseFilePath, partitionAmounts - 1);
+
+ var fileInfo = new FileInfo(lastPartitionFile);
+ if (fileInfo.Length == 0)
+ return null;
+
+ return format switch
+ {
+ ExportFormat.PlainText => PlainTextMessageWriter.GetLastMessageDate(lastPartitionFile),
+ ExportFormat.Csv => CsvMessageWriter.GetLastMessageDate(lastPartitionFile),
+ ExportFormat.HtmlDark or ExportFormat.HtmlLight => HtmlMessageWriter.GetLastMessageDate(
+ lastPartitionFile
+ ),
+ ExportFormat.Json => JsonMessageWriter.GetLastMessageSnowflake(lastPartitionFile),
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(format),
+ $"Unknown export format '{format}'."
+ ),
+ };
+ }
+
+ ///
+ /// Removes all partitions of the previously exported Discord channel with the given base file path.
+ ///
+ ///
+ /// The path of the first partition of the Discord channel export that should be removed.
+ ///
+ public static void RemoveExistingFiles(string baseFilePath)
+ {
+ var currentPartition = 0;
+ while (true)
+ {
+ var currentFilePath = GetPartitionFilePath(baseFilePath, currentPartition);
+ if (File.Exists(currentFilePath))
+ {
+ File.Delete(currentFilePath);
+ }
+ else
+ {
+ return;
+ }
+ currentPartition++;
+ }
+ }
+
+ ///
+ /// Determines and returns the number of partitions of the previously exported Discord channel with the given base
+ /// file path.
+ ///
+ ///
+ /// The path of the first partition of the Discord channel export whose number of partitions should be returned.
+ ///
+ ///
+ /// The number of partitions of the previously exported Discord channel with the given base file path.
+ ///
+ public static int GetPartitionCount(string baseFilePath)
+ {
+ // Use linear search to quickly determine the number of partitions
+ var currentPartition = 1;
+ while (true)
+ {
+ var currentFilePath = GetPartitionFilePath(baseFilePath, currentPartition - 1);
+ if (File.Exists(currentFilePath))
+ {
+ currentPartition *= 2;
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ var leftBorder = currentPartition / 2;
+ var rightBorder = currentPartition;
+ while (rightBorder - 1 > leftBorder)
+ {
+ var middle = (leftBorder + rightBorder) / 2;
+ var currentFilePath = GetPartitionFilePath(baseFilePath, middle - 1);
+ if (File.Exists(currentFilePath))
+ {
+ leftBorder = middle;
+ }
+ else
+ {
+ rightBorder = middle;
+ }
+ }
+
+ return leftBorder;
+ }
}
diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs
index 85b46a64..f96248ad 100644
--- a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs
+++ b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs
@@ -1,17 +1,22 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting;
-internal class PlainTextMessageWriter(Stream stream, ExportContext context)
+internal partial class PlainTextMessageWriter(Stream stream, ExportContext context)
: MessageWriter(stream, context)
{
+ private const int HeaderSize = 4;
+
private readonly TextWriter _writer = new StreamWriter(stream);
private async ValueTask FormatMarkdownAsync(
@@ -271,4 +276,60 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.DisposeAsync();
await base.DisposeAsync();
}
+
+ ///
+ /// Returns the statically created regex that detects and captures the timestamp of a message in a channel TXT
+ /// export.
+ ///
+ ///
+ /// The regex that detects and captures the timestamp of a message in a Discord channel TXT export.
+ ///
+ [GeneratedRegex(@"^\[(.*)\] .*")]
+ private static partial Regex MessageDateRegex();
+
+ ///
+ /// Retrieves and returns the approximate timestamp of the last written message in the Discord channel that has
+ /// been exported with the PlainTextMessageWriter to the given file path as a Snowflake.
+ /// This timestamp only has minute-level precision.
+ ///
+ ///
+ /// The path of the Discord channel TXT export whose last message's timestamp should be returned.
+ ///
+ ///
+ /// The approximate timestamp of the last written message in the Discord channel TXT export under the given path
+ /// as a Snowflake.
+ /// Null, if the Discord channel TXT export doesn't include any message.
+ ///
+ ///
+ /// Thrown if the file at the given path isn't a correctly formatted Discord channel TXT export.
+ ///
+ public static Snowflake? GetLastMessageDate(string filePath)
+ {
+ var fileLines = File.ReadAllLines(filePath);
+ if (fileLines.SkipWhile(string.IsNullOrWhiteSpace).ToArray().Length <= HeaderSize)
+ return null;
+
+ var messageDateRegex = MessageDateRegex();
+
+ // Find the last line with a message timestamp
+ for (var i = fileLines.Length - 1; i >= HeaderSize; i--)
+ {
+ var timestampMatch = messageDateRegex.Match(fileLines[i]);
+ if (!timestampMatch.Success)
+ continue;
+
+ var timestampString = timestampMatch.Groups[1].Value;
+ if (
+ DateTimeOffset.TryParse(timestampString, out var timestamp)
+ && fileLines[i - 1] == ""
+ )
+ {
+ return Snowflake.FromDate(timestamp, true);
+ }
+ }
+
+ throw new FormatException(
+ "The TXT file is not correctly formatted; the last message timestamp could not be retrieved."
+ );
+ }
}
diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs
index fd754e70..a03d62d8 100644
--- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs
+++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs
@@ -268,8 +268,8 @@ public partial class DashboardViewModel : ViewModelBase
dialog.OutputPath!,
dialog.AssetsDirPath,
dialog.SelectedFormat,
- dialog.After?.Pipe(Snowflake.FromDate),
- dialog.Before?.Pipe(Snowflake.FromDate),
+ dialog.After?.Pipe(timestamp => Snowflake.FromDate(timestamp, true)),
+ dialog.Before?.Pipe(timestamp => Snowflake.FromDate(timestamp)),
dialog.PartitionLimit,
dialog.MessageFilter,
dialog.ShouldFormatMarkdown,
diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs
index 4574f6ec..e2f8d658 100644
--- a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs
+++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs
@@ -128,8 +128,8 @@ public partial class ExportSetupViewModel(
Guild!,
Channels!.Single(),
SelectedFormat,
- After?.Pipe(Snowflake.FromDate),
- Before?.Pipe(Snowflake.FromDate)
+ After?.Pipe(timestamp => Snowflake.FromDate(timestamp, true)),
+ Before?.Pipe(timestamp => Snowflake.FromDate(timestamp))
);
var extension = SelectedFormat.GetFileExtension();