Embrace Snowflake as first class citizen

This commit is contained in:
Tyrrrz 2020-12-27 19:41:28 +02:00
parent 4ff7990967
commit 3d9ee3b339
36 changed files with 243 additions and 195 deletions

View file

@ -1,49 +1,40 @@
using System; using System.IO;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Utilities; using CliFx.Utilities;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting; using DiscordChatExporter.Domain.Exporting;
namespace DiscordChatExporter.Cli.Commands.Base namespace DiscordChatExporter.Cli.Commands.Base
{ {
public abstract partial class ExportCommandBase : TokenCommandBase public abstract class ExportCommandBase : TokenCommandBase
{ {
[CommandOption("output", 'o', [CommandOption("output", 'o', Description = "Output file or directory path.")]
Description = "Output file or directory path.")] public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', [CommandOption("format", 'f', Description = "Export format.")]
Description = "Export format.")] public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
[CommandOption("after", [CommandOption("after", Description = "Only include messages sent after this date or message ID.")]
Description = "Only include messages sent after this date. Alternatively, provide the ID of a message.")] public Snowflake? After { get; init; }
public string? After { get; set; }
[CommandOption("before", [CommandOption("before", Description = "Only include messages sent before this date or message ID.")]
Description = "Only include messages sent before this date. Alternatively, provide the ID of a message.")] public Snowflake? Before { get; init; }
public string? Before { get; set; }
[CommandOption("partition", 'p', [CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
Description = "Split output into partitions limited to this number of messages.")] public int? PartitionLimit { get; init; }
public int? PartitionLimit { get; set; }
[CommandOption("media", [CommandOption("media", Description = "Download referenced media content.")]
Description = "Download referenced media content.")] public bool ShouldDownloadMedia { get; init; }
public bool ShouldDownloadMedia { get; set; }
[CommandOption("reuse-media", [CommandOption("reuse-media", Description = "Reuse already existing media content to skip redundant downloads.")]
Description = "Reuse already existing media content to skip redundant downloads.")] public bool ShouldReuseMedia { get; init; }
public bool ShouldReuseMedia { get; set; }
[CommandOption("dateformat", [CommandOption("dateformat", Description = "Format used when writing dates.")]
Description = "Format used when writing dates.")] public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
protected ChannelExporter GetChannelExporter() => new(GetDiscordClient()); protected ChannelExporter GetChannelExporter() => new(GetDiscordClient());
@ -57,8 +48,8 @@ namespace DiscordChatExporter.Cli.Commands.Base
channel, channel,
OutputPath, OutputPath,
ExportFormat, ExportFormat,
ParseRangeOption(After, "--after"), After,
ParseRangeOption(Before, "--before"), Before,
PartitionLimit, PartitionLimit,
ShouldDownloadMedia, ShouldDownloadMedia,
ShouldReuseMedia, ShouldReuseMedia,
@ -77,7 +68,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
await ExportAsync(console, guild, channel); await ExportAsync(console, guild, channel);
} }
protected async ValueTask ExportAsync(IConsole console, string channelId) protected async ValueTask ExportAsync(IConsole console, Snowflake channelId)
{ {
var channel = await GetDiscordClient().GetChannelAsync(channelId); var channel = await GetDiscordClient().GetChannelAsync(channelId);
await ExportAsync(console, channel); await ExportAsync(console, channel);
@ -93,29 +84,4 @@ namespace DiscordChatExporter.Cli.Commands.Base
return default; return default;
} }
} }
public abstract partial class ExportCommandBase : TokenCommandBase
{
protected static DateTimeOffset? ParseRangeOption(string? value, string optionName)
{
if (value == null) return null;
var isSnowflake = Regex.IsMatch(value, @"^\d{18}$");
var isDate = DateTimeOffset.TryParse(value, out var datetime);
if (!isSnowflake && !isDate)
{
throw new ArgumentException($"Value for ${optionName} must be either a date or a message ID.");
}
return isSnowflake ? ExtractDateTimeFromSnowflake() : datetime;
DateTimeOffset ExtractDateTimeFromSnowflake()
{
var unixTimestampMs = (long.Parse(value) / 4194304 + 1420070400000);
return DateTimeOffset.FromUnixTimeMilliseconds(unixTimestampMs);
}
}
}
} }

View file

@ -17,9 +17,8 @@ namespace DiscordChatExporter.Cli.Commands.Base
{ {
public abstract class ExportMultipleCommandBase : ExportCommandBase public abstract class ExportMultipleCommandBase : ExportCommandBase
{ {
[CommandOption("parallel", [CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
Description = "Limits how many channels can be exported in parallel.")] public int ParallelLimit { get; init; } = 1;
public int ParallelLimit { get; set; } = 1;
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
@ -47,8 +46,8 @@ namespace DiscordChatExporter.Cli.Commands.Base
channel, channel,
OutputPath, OutputPath,
ExportFormat, ExportFormat,
ParseRangeOption(After, "--after"), After,
ParseRangeOption(Before, "--before"), Before,
PartitionLimit, PartitionLimit,
ShouldDownloadMedia, ShouldDownloadMedia,
ShouldReuseMedia, ShouldReuseMedia,

View file

@ -7,15 +7,11 @@ namespace DiscordChatExporter.Cli.Commands.Base
{ {
public abstract class TokenCommandBase : ICommand public abstract class TokenCommandBase : ICommand
{ {
[CommandOption("token", 't', IsRequired = true, [CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN", Description = "Authorization token.")]
EnvironmentVariableName = "DISCORD_TOKEN", public string TokenValue { get; init; } = "";
Description = "Authorization token.")]
public string TokenValue { get; set; } = "";
[CommandOption("bot", 'b', [CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT", Description = "Authorize as a bot.")]
EnvironmentVariableName = "DISCORD_TOKEN_BOT", public bool IsBotToken { get; init; }
Description = "Authorize as a bot.")]
public bool IsBotToken { get; set; }
protected AuthToken GetAuthToken() => new( protected AuthToken GetAuthToken() => new(
IsBotToken IsBotToken

View file

@ -10,9 +10,8 @@ namespace DiscordChatExporter.Cli.Commands
[Command("exportall", Description = "Export all accessible channels.")] [Command("exportall", Description = "Export all accessible channels.")]
public class ExportAllCommand : ExportMultipleCommandBase public class ExportAllCommand : ExportMultipleCommandBase
{ {
[CommandOption("include-dm", [CommandOption("include-dm", Description = "Include direct message channels.")]
Description = "Include direct message channels.")] public bool IncludeDirectMessages { get; init; } = true;
public bool IncludeDirectMessages { get; set; } = true;
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {

View file

@ -2,15 +2,15 @@
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
{ {
[Command("export", Description = "Export a channel.")] [Command("export", Description = "Export a channel.")]
public class ExportChannelCommand : ExportCommandBase public class ExportChannelCommand : ExportCommandBase
{ {
[CommandOption("channel", 'c', IsRequired = true, [CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")]
Description = "Channel ID.")] public Snowflake ChannelId { get; init; }
public string ChannelId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {

View file

@ -2,6 +2,7 @@
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Utilities; using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
@ -9,9 +10,8 @@ namespace DiscordChatExporter.Cli.Commands
[Command("exportguild", Description = "Export all channels within specified guild.")] [Command("exportguild", Description = "Export all channels within specified guild.")]
public class ExportGuildCommand : ExportMultipleCommandBase public class ExportGuildCommand : ExportMultipleCommandBase
{ {
[CommandOption("guild", 'g', IsRequired = true, [CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
Description = "Guild ID.")] public Snowflake GuildId { get; init; }
public string GuildId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {

View file

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Utilities; using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
@ -10,9 +11,8 @@ namespace DiscordChatExporter.Cli.Commands
[Command("channels", Description = "Get the list of channels in a guild.")] [Command("channels", Description = "Get the list of channels in a guild.")]
public class GetChannelsCommand : TokenCommandBase public class GetChannelsCommand : TokenCommandBase
{ {
[CommandOption("guild", 'g', IsRequired = true, [CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
Description = "Guild ID.")] public Snowflake GuildId { get; init; }
public string GuildId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {

View file

@ -0,0 +1,9 @@
// ReSharper disable CheckNamespace
// TODO: remove after moving to .NET 5
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit
{
}
}

View file

@ -8,7 +8,7 @@ using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions; using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Internal; using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Http; using JsonExtensions.Http;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@ -70,13 +70,14 @@ namespace DiscordChatExporter.Domain.Discord
{ {
yield return Guild.DirectMessages; yield return Guild.DirectMessages;
var afterId = ""; var currentAfter = Snowflake.Zero;
while (true) while (true)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
.SetPath("users/@me/guilds") .SetPath("users/@me/guilds")
.SetQueryParameter("limit", "100") .SetQueryParameter("limit", "100")
.SetQueryParameter("after", afterId) .SetQueryParameter("after", currentAfter.ToString())
.Build(); .Build();
var response = await GetJsonResponseAsync(url); var response = await GetJsonResponseAsync(url);
@ -86,7 +87,7 @@ namespace DiscordChatExporter.Domain.Discord
{ {
yield return guild; yield return guild;
afterId = guild.Id; currentAfter = guild.Id;
isEmpty = false; isEmpty = false;
} }
@ -95,7 +96,7 @@ namespace DiscordChatExporter.Domain.Discord
} }
} }
public async ValueTask<Guild> GetGuildAsync(string guildId) public async ValueTask<Guild> GetGuildAsync(Snowflake guildId)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages; return Guild.DirectMessages;
@ -104,7 +105,7 @@ namespace DiscordChatExporter.Domain.Discord
return Guild.Parse(response); return Guild.Parse(response);
} }
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(string guildId) public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(Snowflake guildId)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
{ {
@ -141,7 +142,7 @@ namespace DiscordChatExporter.Domain.Discord
} }
} }
public async IAsyncEnumerable<Role> GetGuildRolesAsync(string guildId) public async IAsyncEnumerable<Role> GetGuildRolesAsync(Snowflake guildId)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
yield break; yield break;
@ -152,7 +153,7 @@ namespace DiscordChatExporter.Domain.Discord
yield return Role.Parse(roleJson); yield return Role.Parse(roleJson);
} }
public async ValueTask<Member?> TryGetGuildMemberAsync(string guildId, User user) public async ValueTask<Member?> TryGetGuildMemberAsync(Snowflake guildId, User user)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
return Member.CreateForUser(user); return Member.CreateForUser(user);
@ -161,30 +162,31 @@ namespace DiscordChatExporter.Domain.Discord
return response?.Pipe(Member.Parse); return response?.Pipe(Member.Parse);
} }
private async ValueTask<string> GetChannelCategoryAsync(string channelParentId) private async ValueTask<string> GetChannelCategoryAsync(Snowflake channelParentId)
{ {
var response = await GetJsonResponseAsync($"channels/{channelParentId}"); var response = await GetJsonResponseAsync($"channels/{channelParentId}");
return response.GetProperty("name").GetString(); return response.GetProperty("name").GetString();
} }
public async ValueTask<Channel> GetChannelAsync(string channelId) public async ValueTask<Channel> GetChannelAsync(Snowflake channelId)
{ {
var response = await GetJsonResponseAsync($"channels/{channelId}"); var response = await GetJsonResponseAsync($"channels/{channelId}");
var parentId = response.GetPropertyOrNull("parent_id")?.GetString(); var parentId = response.GetPropertyOrNull("parent_id")?.GetString().Pipe(Snowflake.Parse);
var category = !string.IsNullOrWhiteSpace(parentId)
? await GetChannelCategoryAsync(parentId) var category = parentId != null
? await GetChannelCategoryAsync(parentId.Value)
: null; : null;
return Channel.Parse(response, category); return Channel.Parse(response, category);
} }
private async ValueTask<Message?> TryGetLastMessageAsync(string channelId, DateTimeOffset? before = null) private async ValueTask<Message?> TryGetLastMessageAsync(Snowflake channelId, Snowflake? before = null)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages") .SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "1") .SetQueryParameter("limit", "1")
.SetQueryParameter("before", before?.ToSnowflake()) .SetQueryParameter("before", before?.ToString())
.Build(); .Build();
var response = await GetJsonResponseAsync(url); var response = await GetJsonResponseAsync(url);
@ -192,9 +194,9 @@ namespace DiscordChatExporter.Domain.Discord
} }
public async IAsyncEnumerable<Message> GetMessagesAsync( public async IAsyncEnumerable<Message> GetMessagesAsync(
string channelId, Snowflake channelId,
DateTimeOffset? after = null, Snowflake? after = null,
DateTimeOffset? before = null, Snowflake? before = null,
IProgress<double>? progress = null) IProgress<double>? progress = null)
{ {
// Get the last message in the specified range. // Get the last message in the specified range.
@ -202,19 +204,19 @@ namespace DiscordChatExporter.Domain.Discord
// will not appear in the output. // will not appear in the output.
// Additionally, it provides the date of the last message, which is used to calculate progress. // Additionally, it provides the date of the last message, which is used to calculate progress.
var lastMessage = await TryGetLastMessageAsync(channelId, before); var lastMessage = await TryGetLastMessageAsync(channelId, before);
if (lastMessage == null || lastMessage.Timestamp < after) if (lastMessage == null || lastMessage.Timestamp < after?.ToDate())
yield break; yield break;
// Keep track of first message in range in order to calculate progress // Keep track of first message in range in order to calculate progress
var firstMessage = default(Message); var firstMessage = default(Message);
var afterId = after?.ToSnowflake() ?? "0"; var currentAfter = after ?? Snowflake.Zero;
while (true) while (true)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages") .SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "100") .SetQueryParameter("limit", "100")
.SetQueryParameter("after", afterId) .SetQueryParameter("after", currentAfter.ToString())
.Build(); .Build();
var response = await GetJsonResponseAsync(url); var response = await GetJsonResponseAsync(url);
@ -244,7 +246,7 @@ namespace DiscordChatExporter.Domain.Discord
); );
yield return message; yield return message;
afterId = message.Id; currentAfter = message.Id;
} }
} }
} }

View file

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
@ -11,7 +11,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/channel#attachment-object // https://discord.com/developers/docs/resources/channel#attachment-object
public partial class Attachment : IHasId public partial class Attachment : IHasId
{ {
public string Id { get; } public Snowflake Id { get; }
public string Url { get; } public string Url { get; }
@ -32,7 +32,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public FileSize FileSize { get; } public FileSize FileSize { get; }
public Attachment(string id, string url, string fileName, int? width, int? height, FileSize fileSize) public Attachment(Snowflake id, string url, string fileName, int? width, int? height, FileSize fileSize)
{ {
Id = id; Id = id;
Url = url; Url = url;
@ -58,7 +58,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public static Attachment Parse(JsonElement json) public static Attachment Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetString(); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var url = json.GetProperty("url").GetString(); var url = json.GetProperty("url").GetString();
var width = json.GetPropertyOrNull("width")?.GetInt32(); var width = json.GetPropertyOrNull("width")?.GetInt32();
var height = json.GetPropertyOrNull("height")?.GetInt32(); var height = json.GetPropertyOrNull("height")?.GetInt32();

View file

@ -1,6 +1,7 @@
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading; using JsonExtensions.Reading;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
@ -22,7 +23,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/channel#channel-object // https://discord.com/developers/docs/resources/channel#channel-object
public partial class Channel : IHasId public partial class Channel : IHasId
{ {
public string Id { get; } public Snowflake Id { get; }
public ChannelType Type { get; } public ChannelType Type { get; }
@ -33,7 +34,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
Type == ChannelType.GuildNews || Type == ChannelType.GuildNews ||
Type == ChannelType.GuildStore; Type == ChannelType.GuildStore;
public string GuildId { get; } public Snowflake GuildId { get; }
public string Category { get; } public string Category { get; }
@ -41,7 +42,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public string? Topic { get; } public string? Topic { get; }
public Channel(string id, ChannelType type, string guildId, string category, string name, string? topic) public Channel(Snowflake id, ChannelType type, Snowflake guildId, string category, string name, string? topic)
{ {
Id = id; Id = id;
Type = type; Type = type;
@ -68,8 +69,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
public static Channel Parse(JsonElement json, string? category = null) public static Channel Parse(JsonElement json, string? category = null)
{ {
var id = json.GetProperty("id").GetString(); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var guildId = json.GetPropertyOrNull("guild_id")?.GetString(); var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse);
var topic = json.GetPropertyOrNull("topic")?.GetString(); var topic = json.GetPropertyOrNull("topic")?.GetString();
var type = (ChannelType) json.GetProperty("type").GetInt32(); var type = (ChannelType) json.GetProperty("type").GetInt32();
@ -77,7 +78,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
var name = var name =
json.GetPropertyOrNull("name")?.GetString() ?? json.GetPropertyOrNull("name")?.GetString() ??
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ?? json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
id; id.ToString();
return new Channel( return new Channel(
id, id,

View file

@ -2,6 +2,6 @@
{ {
public interface IHasId public interface IHasId
{ {
string Id { get; } Snowflake Id { get; }
} }
} }

View file

@ -5,9 +5,9 @@ namespace DiscordChatExporter.Domain.Discord.Models.Common
{ {
public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId> public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId>
{ {
public bool Equals(IHasId? x, IHasId? y) => StringComparer.Ordinal.Equals(x?.Id, y?.Id); public bool Equals(IHasId? x, IHasId? y) => x?.Id == y?.Id;
public int GetHashCode(IHasId obj) => StringComparer.Ordinal.GetHashCode(obj.Id); public int GetHashCode(IHasId obj) => obj.Id.GetHashCode();
} }
public partial class IdBasedEqualityComparer public partial class IdBasedEqualityComparer

View file

@ -4,6 +4,7 @@ using System.Drawing;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models

View file

@ -1,18 +1,19 @@
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discord.com/developers/docs/resources/guild#guild-object // https://discord.com/developers/docs/resources/guild#guild-object
public partial class Guild : IHasId public partial class Guild : IHasId
{ {
public string Id { get; } public Snowflake Id { get; }
public string Name { get; } public string Name { get; }
public string IconUrl { get; } public string IconUrl { get; }
public Guild(string id, string name, string iconUrl) public Guild(Snowflake id, string name, string iconUrl)
{ {
Id = id; Id = id;
Name = name; Name = name;
@ -24,17 +25,17 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Guild public partial class Guild
{ {
public static Guild DirectMessages { get; } = new("@me", "Direct Messages", GetDefaultIconUrl()); public static Guild DirectMessages { get; } = new(Snowflake.Zero, "Direct Messages", GetDefaultIconUrl());
private static string GetDefaultIconUrl() => private static string GetDefaultIconUrl() =>
"https://cdn.discordapp.com/embed/avatars/0.png"; "https://cdn.discordapp.com/embed/avatars/0.png";
private static string GetIconUrl(string id, string iconHash) => private static string GetIconUrl(Snowflake id, string iconHash) =>
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"; $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
public static Guild Parse(JsonElement json) public static Guild Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetString(); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetString(); var name = json.GetProperty("name").GetString();
var iconHash = json.GetProperty("icon").GetString(); var iconHash = json.GetProperty("icon").GetString();

View file

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
@ -11,15 +11,15 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/guild#guild-member-object // https://discord.com/developers/docs/resources/guild#guild-member-object
public partial class Member : IHasId public partial class Member : IHasId
{ {
public string Id => User.Id; public Snowflake Id => User.Id;
public User User { get; } public User User { get; }
public string Nick { get; } public string Nick { get; }
public IReadOnlyList<string> RoleIds { get; } public IReadOnlyList<Snowflake> RoleIds { get; }
public Member(User user, string? nick, IReadOnlyList<string> roleIds) public Member(User user, string? nick, IReadOnlyList<Snowflake> roleIds)
{ {
User = user; User = user;
Nick = nick ?? user.Name; Nick = nick ?? user.Name;
@ -31,7 +31,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Member public partial class Member
{ {
public static Member CreateForUser(User user) => new(user, null, Array.Empty<string>()); public static Member CreateForUser(User user) => new(user, null, Array.Empty<Snowflake>());
public static Member Parse(JsonElement json) public static Member Parse(JsonElement json)
{ {
@ -39,8 +39,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
var nick = json.GetPropertyOrNull("nick")?.GetString(); var nick = json.GetPropertyOrNull("nick")?.GetString();
var roleIds = var roleIds =
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ?? json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString().Pipe(Snowflake.Parse)).ToArray() ??
Array.Empty<string>(); Array.Empty<Snowflake>();
return new Member( return new Member(
user, user,

View file

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
@ -24,7 +24,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/channel#message-object // https://discord.com/developers/docs/resources/channel#message-object
public partial class Message : IHasId public partial class Message : IHasId
{ {
public string Id { get; } public Snowflake Id { get; }
public MessageType Type { get; } public MessageType Type { get; }
@ -49,7 +49,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public IReadOnlyList<User> MentionedUsers { get; } public IReadOnlyList<User> MentionedUsers { get; }
public Message( public Message(
string id, Snowflake id,
MessageType type, MessageType type,
User author, User author,
DateTimeOffset timestamp, DateTimeOffset timestamp,
@ -83,7 +83,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
{ {
public static Message Parse(JsonElement json) public static Message Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetString(); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var author = json.GetProperty("author").Pipe(User.Parse); var author = json.GetProperty("author").Pipe(User.Parse);
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset(); var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset(); var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();

View file

@ -1,5 +1,5 @@
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {

View file

@ -1,14 +1,16 @@
using System.Drawing; using System.Drawing;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discord.com/developers/docs/topics/permissions#role-object // https://discord.com/developers/docs/topics/permissions#role-object
public partial class Role public partial class Role : IHasId
{ {
public string Id { get; } public Snowflake Id { get; }
public string Name { get; } public string Name { get; }
@ -16,7 +18,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public Color? Color { get; } public Color? Color { get; }
public Role(string id, string name, int position, Color? color) public Role(Snowflake id, string name, int position, Color? color)
{ {
Id = id; Id = id;
Name = name; Name = name;
@ -31,7 +33,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
{ {
public static Role Parse(JsonElement json) public static Role Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetString(); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetString(); var name = json.GetProperty("name").GetString();
var position = json.GetProperty("position").GetInt32(); var position = json.GetProperty("position").GetInt32();

View file

@ -1,7 +1,7 @@
using System; using System;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
@ -9,7 +9,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/user#user-object // https://discord.com/developers/docs/resources/user#user-object
public partial class User : IHasId public partial class User : IHasId
{ {
public string Id { get; } public Snowflake Id { get; }
public bool IsBot { get; } public bool IsBot { get; }
@ -21,7 +21,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public string AvatarUrl { get; } public string AvatarUrl { get; }
public User(string id, bool isBot, int discriminator, string name, string avatarUrl) public User(Snowflake id, bool isBot, int discriminator, string name, string avatarUrl)
{ {
Id = id; Id = id;
IsBot = isBot; IsBot = isBot;
@ -38,7 +38,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
private static string GetDefaultAvatarUrl(int discriminator) => private static string GetDefaultAvatarUrl(int discriminator) =>
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png"; $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
private static string GetAvatarUrl(string id, string avatarHash) private static string GetAvatarUrl(Snowflake id, string avatarHash)
{ {
// Animated // Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal)) if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
@ -50,7 +50,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public static User Parse(JsonElement json) public static User Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetString(); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse); var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
var name = json.GetProperty("username").GetString(); var name = json.GetProperty("username").GetString();
var avatarHash = json.GetProperty("avatar").GetString(); var avatarHash = json.GetProperty("avatar").GetString();

View file

@ -0,0 +1,68 @@
using System;
using System.Globalization;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Domain.Discord
{
public readonly partial struct Snowflake
{
public ulong Value { get; }
public Snowflake(ulong value) => Value = value;
public DateTimeOffset ToDate() =>
DateTimeOffset.FromUnixTimeMilliseconds((long) ((Value >> 22) + 1420070400000UL)).ToLocalTime();
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
}
public partial struct Snowflake
{
public static Snowflake Zero { get; } = new(0);
public static Snowflake FromDate(DateTimeOffset date)
{
var value = ((ulong) date.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
return new Snowflake(value);
}
public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null)
{
if (string.IsNullOrWhiteSpace(str))
return null;
// As number
if (Regex.IsMatch(str, @"^\d{15,}$") &&
ulong.TryParse(str, NumberStyles.Number, formatProvider, out var value))
{
return new Snowflake(value);
}
// As date
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var date))
{
return FromDate(date);
}
return null;
}
public static Snowflake Parse(string str, IFormatProvider? formatProvider) =>
TryParse(str, formatProvider) ?? throw new FormatException($"Invalid snowflake: {str}.");
public static Snowflake Parse(string str) => Parse(str, null);
}
public partial struct Snowflake : IEquatable<Snowflake>
{
public bool Equals(Snowflake other) => Value == other.Value;
public override bool Equals(object? obj) => obj is Snowflake other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public static bool operator ==(Snowflake left, Snowflake right) => left.Equals(right);
public static bool operator !=(Snowflake left, Snowflake right) => !(left == right);
}
}

View file

@ -5,6 +5,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
@ -44,16 +45,13 @@ namespace DiscordChatExporter.Domain.Exporting
var dateFormat => date.ToLocalString(dateFormat) var dateFormat => date.ToLocalString(dateFormat)
}; };
public Member? TryGetMember(string id) => public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
Members.FirstOrDefault(m => m.Id == id);
public Channel? TryGetChannel(string id) => public Channel? TryGetChannel(Snowflake id) => Channels.FirstOrDefault(c => c.Id == id);
Channels.FirstOrDefault(c => c.Id == id);
public Role? TryGetRole(string id) => public Role? TryGetRole(Snowflake id) => Roles.FirstOrDefault(r => r.Id == id);
Roles.FirstOrDefault(r => r.Id == id);
public Color? TryGetUserColor(string id) public Color? TryGetUserColor(Snowflake id)
{ {
var member = TryGetMember(id); var member = TryGetMember(id);
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role); var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);

View file

@ -1,6 +1,6 @@
using System; using System.IO;
using System.IO;
using System.Text; using System.Text;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Internal; using DiscordChatExporter.Domain.Internal;
@ -22,9 +22,9 @@ namespace DiscordChatExporter.Domain.Exporting
public ExportFormat Format { get; } public ExportFormat Format { get; }
public DateTimeOffset? After { get; } public Snowflake? After { get; }
public DateTimeOffset? Before { get; } public Snowflake? Before { get; }
public int? PartitionLimit { get; } public int? PartitionLimit { get; }
@ -39,8 +39,8 @@ namespace DiscordChatExporter.Domain.Exporting
Channel channel, Channel channel,
string outputPath, string outputPath,
ExportFormat format, ExportFormat format,
DateTimeOffset? after, Snowflake? after,
DateTimeOffset? before, Snowflake? before,
int? partitionLimit, int? partitionLimit,
bool shouldDownloadMedia, bool shouldDownloadMedia,
bool shouldReuseMedia, bool shouldReuseMedia,
@ -78,8 +78,8 @@ namespace DiscordChatExporter.Domain.Exporting
Channel channel, Channel channel,
string outputPath, string outputPath,
ExportFormat format, ExportFormat format,
DateTimeOffset? after = null, Snowflake? after = null,
DateTimeOffset? before = null) Snowflake? before = null)
{ {
// Output is a directory // Output is a directory
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))) if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
@ -96,8 +96,8 @@ namespace DiscordChatExporter.Domain.Exporting
Guild guild, Guild guild,
Channel channel, Channel channel,
ExportFormat format, ExportFormat format,
DateTimeOffset? after = null, Snowflake? after = null,
DateTimeOffset? before = null) Snowflake? before = null)
{ {
var buffer = new StringBuilder(); var buffer = new StringBuilder();
@ -112,17 +112,17 @@ namespace DiscordChatExporter.Domain.Exporting
// Both 'after' and 'before' are set // Both 'after' and 'before' are set
if (after != null && before != null) if (after != null && before != null)
{ {
buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}"); buffer.Append($"{after?.ToDate():yyyy-MM-dd} to {before?.ToDate():yyyy-MM-dd}");
} }
// Only 'after' is set // Only 'after' is set
else if (after != null) else if (after != null)
{ {
buffer.Append($"after {after:yyyy-MM-dd}"); buffer.Append($"after {after?.ToDate():yyyy-MM-dd}");
} }
// Only 'before' is set // Only 'before' is set
else else
{ {
buffer.Append($"before {before:yyyy-MM-dd}"); buffer.Append($"before {before?.ToDate():yyyy-MM-dd}");
} }
buffer.Append(")"); buffer.Append(")");

View file

@ -9,6 +9,7 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Domain.Internal; using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Exporting namespace DiscordChatExporter.Domain.Exporting
{ {

View file

@ -59,7 +59,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
public override async ValueTask WriteMessageAsync(Message message) public override async ValueTask WriteMessageAsync(Message message)
{ {
// Author ID // Author ID
await _writer.WriteAsync(CsvEncode(message.Author.Id)); await _writer.WriteAsync(CsvEncode(message.Author.Id.ToString()));
await _writer.WriteAsync(','); await _writer.WriteAsync(',');
// Author name // Author name

View file

@ -25,7 +25,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.Html
internal partial class MessageGroup internal partial class MessageGroup
{ {
public static bool CanJoin(Message message1, Message message2) => public static bool CanJoin(Message message1, Message message2) =>
string.Equals(message1.Author.Id, message2.Author.Id, StringComparison.Ordinal) && message1.Author.Id == message2.Author.Id &&
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) && string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7; (message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7;

View file

@ -83,15 +83,15 @@
<div class="preamble__entry preamble__entry--small"> <div class="preamble__entry preamble__entry--small">
@if (Model.ExportContext.Request.After != null && Model.ExportContext.Request.Before != null) @if (Model.ExportContext.Request.After != null && Model.ExportContext.Request.Before != null)
{ {
@($"Between {FormatDate(Model.ExportContext.Request.After.Value)} and {FormatDate(Model.ExportContext.Request.Before.Value)}") @($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
} }
else if (Model.ExportContext.Request.After != null) else if (Model.ExportContext.Request.After != null)
{ {
@($"After {FormatDate(Model.ExportContext.Request.After.Value)}") @($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
} }
else if (Model.ExportContext.Request.Before != null) else if (Model.ExportContext.Request.Before != null)
{ {
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value)}") @($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
} }
</div> </div>
} }

View file

@ -32,7 +32,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("id", attachment.Id); _writer.WriteString("id", attachment.Id.ToString());
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url)); _writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url));
_writer.WriteString("fileName", attachment.FileName); _writer.WriteString("fileName", attachment.FileName);
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes); _writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
@ -166,7 +166,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("id", mentionedUser.Id); _writer.WriteString("id", mentionedUser.Id.ToString());
_writer.WriteString("name", mentionedUser.Name); _writer.WriteString("name", mentionedUser.Name);
_writer.WriteNumber("discriminator", mentionedUser.Discriminator); _writer.WriteNumber("discriminator", mentionedUser.Discriminator);
_writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name); _writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name);
@ -183,14 +183,14 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
// Guild // Guild
_writer.WriteStartObject("guild"); _writer.WriteStartObject("guild");
_writer.WriteString("id", Context.Request.Guild.Id); _writer.WriteString("id", Context.Request.Guild.Id.ToString());
_writer.WriteString("name", Context.Request.Guild.Name); _writer.WriteString("name", Context.Request.Guild.Name);
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl)); _writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl));
_writer.WriteEndObject(); _writer.WriteEndObject();
// Channel // Channel
_writer.WriteStartObject("channel"); _writer.WriteStartObject("channel");
_writer.WriteString("id", Context.Request.Channel.Id); _writer.WriteString("id", Context.Request.Channel.Id.ToString());
_writer.WriteString("type", Context.Request.Channel.Type.ToString()); _writer.WriteString("type", Context.Request.Channel.Type.ToString());
_writer.WriteString("category", Context.Request.Channel.Category); _writer.WriteString("category", Context.Request.Channel.Category);
_writer.WriteString("name", Context.Request.Channel.Name); _writer.WriteString("name", Context.Request.Channel.Name);
@ -199,8 +199,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
// Date range // Date range
_writer.WriteStartObject("dateRange"); _writer.WriteStartObject("dateRange");
_writer.WriteString("after", Context.Request.After); _writer.WriteString("after", Context.Request.After?.ToDate());
_writer.WriteString("before", Context.Request.Before); _writer.WriteString("before", Context.Request.Before?.ToDate());
_writer.WriteEndObject(); _writer.WriteEndObject();
// Message array (start) // Message array (start)
@ -213,7 +213,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteStartObject(); _writer.WriteStartObject();
// Metadata // Metadata
_writer.WriteString("id", message.Id); _writer.WriteString("id", message.Id.ToString());
_writer.WriteString("type", message.Type.ToString()); _writer.WriteString("type", message.Type.ToString());
_writer.WriteString("timestamp", message.Timestamp); _writer.WriteString("timestamp", message.Timestamp);
_writer.WriteString("timestampEdited", message.EditedTimestamp); _writer.WriteString("timestampEdited", message.EditedTimestamp);
@ -225,7 +225,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
// Author // Author
_writer.WriteStartObject("author"); _writer.WriteStartObject("author");
_writer.WriteString("id", message.Author.Id); _writer.WriteString("id", message.Author.Id.ToString());
_writer.WriteString("name", message.Author.Name); _writer.WriteString("name", message.Author.Name);
_writer.WriteString("discriminator", $"{message.Author.Discriminator:0000}"); _writer.WriteString("discriminator", $"{message.Author.Discriminator:0000}");
_writer.WriteBoolean("isBot", message.Author.IsBot); _writer.WriteBoolean("isBot", message.Author.IsBot);

View file

@ -3,6 +3,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Markdown; using DiscordChatExporter.Domain.Markdown;
using DiscordChatExporter.Domain.Markdown.Ast; using DiscordChatExporter.Domain.Markdown.Ast;
@ -84,7 +85,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
} }
else if (mention.Type == MentionType.User) else if (mention.Type == MentionType.User)
{ {
var member = _context.TryGetMember(mention.Id); var member = _context.TryGetMember(Snowflake.Parse(mention.Id));
var fullName = member?.User.FullName ?? "Unknown"; var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown"; var nick = member?.Nick ?? "Unknown";
@ -95,7 +96,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
} }
else if (mention.Type == MentionType.Channel) else if (mention.Type == MentionType.Channel)
{ {
var channel = _context.TryGetChannel(mention.Id); var channel = _context.TryGetChannel(Snowflake.Parse(mention.Id));
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer _buffer
@ -105,7 +106,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
} }
else if (mention.Type == MentionType.Role) else if (mention.Type == MentionType.Role)
{ {
var role = _context.TryGetRole(mention.Id); var role = _context.TryGetRole(Snowflake.Parse(mention.Id));
var name = role?.Name ?? "deleted-role"; var name = role?.Name ?? "deleted-role";
var color = role?.Color; var color = role?.Color;

View file

@ -1,4 +1,5 @@
using System.Text; using System.Text;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Markdown; using DiscordChatExporter.Domain.Markdown;
using DiscordChatExporter.Domain.Markdown.Ast; using DiscordChatExporter.Domain.Markdown.Ast;
@ -29,21 +30,21 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
} }
else if (mention.Type == MentionType.User) else if (mention.Type == MentionType.User)
{ {
var member = _context.TryGetMember(mention.Id); var member = _context.TryGetMember(Snowflake.Parse(mention.Id));
var name = member?.User.Name ?? "Unknown"; var name = member?.User.Name ?? "Unknown";
_buffer.Append($"@{name}"); _buffer.Append($"@{name}");
} }
else if (mention.Type == MentionType.Channel) else if (mention.Type == MentionType.Channel)
{ {
var channel = _context.TryGetChannel(mention.Id); var channel = _context.TryGetChannel(Snowflake.Parse(mention.Id));
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer.Append($"#{name}"); _buffer.Append($"#{name}");
} }
else if (mention.Type == MentionType.Role) else if (mention.Type == MentionType.Role)
{ {
var role = _context.TryGetRole(mention.Id); var role = _context.TryGetRole(Snowflake.Parse(mention.Id));
var name = role?.Name ?? "deleted-role"; var name = role?.Name ?? "deleted-role";
_buffer.Append($"@{name}"); _buffer.Append($"@{name}");

View file

@ -119,10 +119,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}"); await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
if (Context.Request.After != null) if (Context.Request.After != null)
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value)}"); await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}");
if (Context.Request.Before != null) if (Context.Request.Before != null)
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value)}"); await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}");
await _writer.WriteLineAsync('='.Repeat(62)); await _writer.WriteLineAsync('='.Repeat(62));
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();

View file

@ -5,12 +5,6 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
{ {
internal static class DateExtensions internal static class DateExtensions
{ {
public static string ToSnowflake(this DateTimeOffset dateTime)
{
var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
return value.ToString();
}
public static string ToLocalString(this DateTimeOffset dateTime, string format) => public static string ToLocalString(this DateTimeOffset dateTime, string format) =>
dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture); dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture);
} }

View file

@ -4,8 +4,6 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
{ {
internal static class GenericExtensions internal static class GenericExtensions
{ {
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct => public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct =>
!predicate(value) !predicate(value)
? value ? value

View file

@ -0,0 +1,9 @@
using System;
namespace DiscordChatExporter.Domain.Utilities
{
public static class GeneralExtensions
{
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
}
}

View file

@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting; using DiscordChatExporter.Domain.Exporting;
using DiscordChatExporter.Domain.Utilities;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
@ -82,8 +84,8 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
Guild!, Guild!,
channel, channel,
SelectedFormat, SelectedFormat,
After, After?.Pipe(Snowflake.FromDate),
Before Before?.Pipe(Snowflake.FromDate)
); );
// Filter // Filter

View file

@ -211,8 +211,8 @@ namespace DiscordChatExporter.Gui.ViewModels
channel!, channel!,
dialog.OutputPath!, dialog.OutputPath!,
dialog.SelectedFormat, dialog.SelectedFormat,
dialog.After, dialog.After?.Pipe(Snowflake.FromDate),
dialog.Before, dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit, dialog.PartitionLimit,
dialog.ShouldDownloadMedia, dialog.ShouldDownloadMedia,
_settingsService.ShouldReuseMedia, _settingsService.ShouldReuseMedia,