Compare commits

..

7 Commits

Author SHA1 Message Date
Tyrrrz f22b048d32 Update version 2021-02-06 21:22:42 +02:00
Lucas LaBuff a8031ad3aa Fix crash when parsing position on DM channels (#496) 2021-02-06 17:00:29 +02:00
Frederic Portaria-Janicki e49cf997ea [CLI] Fix missing channel category bug when API returns empty string (#492) 2021-02-02 14:42:04 +02:00
Lucas LaBuff 8b9afe45b9 [CLI] Fix sorting by channel/category position (#490) 2021-01-31 17:23:46 +02:00
Lucas LaBuff 77b7977324 Formatting output paths (#472) 2021-01-28 22:01:13 +02:00
Lucas LaBuff 915f4c8d9f Fix crash on encountering invalid snowflake (#479) 2021-01-18 19:21:22 +02:00
Tyrrrz 0872d6d44b Update screenshots 2020-12-29 21:06:45 +02:00
19 changed files with 187 additions and 43 deletions
+1
View File
@@ -4,6 +4,7 @@
*.userosscache *.userosscache
*.sln.docstates *.sln.docstates
.idea/ .idea/
.vs/
# Build results # Build results
[Dd]ebug/ [Dd]ebug/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+5
View File
@@ -1,3 +1,8 @@
### v2.26.1 (06-Feb-2021)
- [CLI] Added support for file name templates, which allow you to dynamically generate output file names based on channel and guild metadata. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
- Fixed an issue where the application sometimes crashed with `Invalid snowflake` message when exporting chat logs that contained invalid or outdated mentions. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
### v2.26 (29-Dec-2020) ### v2.26 (29-Dec-2020)
- [HTML] Added support for replies. (Thanks [@Sanqui](https://github.com/Sanqui)) - [HTML] Added support for replies. (Thanks [@Sanqui](https://github.com/Sanqui))
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>netcoreapp3.1</TargetFramework>
<Version>2.26</Version> <Version>2.26.1</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright> <Copyright>Copyright (c) Alexey Golub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
@@ -4,6 +4,7 @@ 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.Discord;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities; using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
@@ -18,7 +19,7 @@ namespace DiscordChatExporter.Cli.Commands
{ {
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId); var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name)) foreach (var channel in channels.OrderBy(c => c.Category, PositionBasedComparer.Instance).ThenBy(c => c.Name))
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}"); console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
} }
} }
@@ -4,6 +4,7 @@ using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities; using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
@@ -15,7 +16,7 @@ namespace DiscordChatExporter.Cli.Commands
{ {
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id); var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name)) foreach (var channel in channels.OrderBy(c => c.Name))
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}"); console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
} }
} }
@@ -117,26 +117,33 @@ namespace DiscordChatExporter.Domain.Discord
{ {
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels"); var response = await GetJsonResponseAsync($"guilds/{guildId}/channels");
var categories = response var orderedResponse = response
.EnumerateArray() .EnumerateArray()
.ToDictionary( .OrderBy(j => j.GetProperty("position").GetInt32())
j => j.GetProperty("id").GetString(), .ThenBy(j => ulong.Parse(j.GetProperty("id").GetString()));
j => j.GetProperty("name").GetString()
);
foreach (var channelJson in response.EnumerateArray()) var categories = orderedResponse
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelType.GuildCategory)
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
.ToDictionary(j => j.Id.ToString());
var position = 0;
foreach (var channelJson in orderedResponse)
{ {
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString(); var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString();
var category = !string.IsNullOrWhiteSpace(parentId) var category = !string.IsNullOrWhiteSpace(parentId)
? categories.GetValueOrDefault(parentId) ? categories.GetValueOrDefault(parentId)
: null; : null;
var channel = Channel.Parse(channelJson, category); var channel = Channel.Parse(channelJson, category, position);
// Skip non-text channels // Skip non-text channels
if (!channel.IsTextChannel) if (!channel.IsTextChannel)
continue; continue;
position++;
yield return channel; yield return channel;
} }
} }
@@ -162,10 +169,22 @@ namespace DiscordChatExporter.Domain.Discord
return response?.Pipe(Member.Parse); return response?.Pipe(Member.Parse);
} }
private async ValueTask<string> GetChannelCategoryAsync(Snowflake channelParentId) public async ValueTask<ChannelCategory> GetChannelCategoryAsync(Snowflake channelId)
{ {
var response = await GetJsonResponseAsync($"channels/{channelParentId}"); try
return response.GetProperty("name").GetString(); {
var response = await GetJsonResponseAsync($"channels/{channelId}");
return ChannelCategory.Parse(response);
}
/***
* In some cases, the Discord API returns an empty body when requesting some channel category info.
* Instead, we use an empty channel category as a fallback.
*/
catch (DiscordChatExporterException)
{
return ChannelCategory.Empty;
}
} }
public async ValueTask<Channel> GetChannelAsync(Snowflake channelId) public async ValueTask<Channel> GetChannelAsync(Snowflake channelId)
@@ -1,4 +1,5 @@
using System.Linq; using System;
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 DiscordChatExporter.Domain.Utilities;
@@ -21,7 +22,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 : IHasIdAndPosition
{ {
public Snowflake Id { get; } public Snowflake Id { get; }
@@ -36,28 +37,34 @@ namespace DiscordChatExporter.Domain.Discord.Models
public Snowflake GuildId { get; } public Snowflake GuildId { get; }
public string Category { get; } public ChannelCategory Category { get; }
public string Name { get; } public string Name { get; }
public int? Position { get; }
public string? Topic { get; } public string? Topic { get; }
public Channel(Snowflake id, ChannelType type, Snowflake guildId, string category, string name, string? topic) public Channel(Snowflake id, ChannelType type, Snowflake guildId, ChannelCategory? category, string name, int? position, string? topic)
{ {
Id = id; Id = id;
Type = type; Type = type;
GuildId = guildId; GuildId = guildId;
Category = category; Category = category ?? GetDefaultCategory(type);
Name = name; Name = name;
Position = position;
Topic = topic; Topic = topic;
} }
public override string ToString() => Name; public override string ToString() => Name;
} }
public partial class Channel public partial class Channel
{ {
private static string GetDefaultCategory(ChannelType channelType) => channelType switch private static ChannelCategory GetDefaultCategory(ChannelType channelType) => new(
Snowflake.Zero,
channelType switch
{ {
ChannelType.GuildTextChat => "Text", ChannelType.GuildTextChat => "Text",
ChannelType.DirectTextChat => "Private", ChannelType.DirectTextChat => "Private",
@@ -65,27 +72,32 @@ namespace DiscordChatExporter.Domain.Discord.Models
ChannelType.GuildNews => "News", ChannelType.GuildNews => "News",
ChannelType.GuildStore => "Store", ChannelType.GuildStore => "Store",
_ => "Default" _ => "Default"
}; },
0
);
public static Channel Parse(JsonElement json, string? category = null) public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? position = null)
{ {
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse); 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();
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.ToString(); id.ToString();
position ??= json.GetPropertyOrNull("position")?.GetInt32();
return new Channel( return new Channel(
id, id,
type, type,
guildId ?? Guild.DirectMessages.Id, guildId ?? Guild.DirectMessages.Id,
category ?? GetDefaultCategory(type), category ?? GetDefaultCategory(type),
name, name,
position,
topic topic
); );
} }
@@ -0,0 +1,50 @@
using System;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{
public partial class ChannelCategory : IHasIdAndPosition
{
public Snowflake Id { get; }
public string Name { get; }
public int? Position { get; }
public ChannelCategory(Snowflake id, string name, int? position)
{
Id = id;
Name = name;
Position = position;
}
public override string ToString() => Name;
}
public partial class ChannelCategory
{
public static ChannelCategory Parse(JsonElement json, int? position = null)
{
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
position ??= json.GetPropertyOrNull("position")?.GetInt32();
var name = json.GetPropertyOrNull("name")?.GetString() ??
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
id.ToString();
return new ChannelCategory(
id,
name,
position
);
}
public static ChannelCategory Empty { get; } = new(Snowflake.Zero, "Missing", 0);
}
}
@@ -0,0 +1,7 @@
namespace DiscordChatExporter.Domain.Discord.Models.Common
{
public interface IHasIdAndPosition : IHasId
{
int? Position { get; }
}
}
@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Domain.Discord.Models.Common
{
public partial class PositionBasedComparer : IComparer<IHasIdAndPosition>
{
public int Compare(IHasIdAndPosition? x, IHasIdAndPosition? y)
{
int result = Comparer<int?>.Default.Compare(x?.Position, y?.Position);
if (result == 0)
{
result = Comparer<ulong?>.Default.Compare(x?.Id.Value, y?.Id.Value);
}
return result;
}
}
public partial class PositionBasedComparer
{
public static PositionBasedComparer Instance { get; } = new();
}
}
@@ -1,5 +1,7 @@
using System.IO; using System;
using System.IO;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using DiscordChatExporter.Domain.Discord; using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Internal; using DiscordChatExporter.Domain.Internal;
@@ -81,6 +83,26 @@ namespace DiscordChatExporter.Domain.Exporting
Snowflake? after = null, Snowflake? after = null,
Snowflake? before = null) Snowflake? before = null)
{ {
// Formats path
outputPath = Regex.Replace(outputPath, "%.", m =>
PathEx.EscapePath(m.Value switch
{
"%g" => guild.Id.ToString(),
"%G" => guild.Name,
"%t" => channel.Category.Id.ToString(),
"%T" => channel.Category.Name,
"%c" => channel.Id.ToString(),
"%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0",
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
"%%" => "%",
_ => m.Value
})
);
// 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)))
{ {
@@ -102,7 +124,7 @@ namespace DiscordChatExporter.Domain.Exporting
var buffer = new StringBuilder(); var buffer = new StringBuilder();
// Guild and channel names // Guild and channel names
buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]"); buffer.Append($"{guild.Name} - {channel.Category.Name} - {channel.Name} [{channel.Id}]");
// Date range // Date range
if (after != null || before != null) if (after != null || before != null)
@@ -192,7 +192,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteStartObject("channel"); _writer.WriteStartObject("channel");
_writer.WriteString("id", Context.Request.Channel.Id.ToString()); _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.Name);
_writer.WriteString("name", Context.Request.Channel.Name); _writer.WriteString("name", Context.Request.Channel.Name);
_writer.WriteString("topic", Context.Request.Channel.Topic); _writer.WriteString("topic", Context.Request.Channel.Topic);
_writer.WriteEndObject(); _writer.WriteEndObject();
@@ -7,6 +7,7 @@ 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;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
{ {
@@ -76,6 +77,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
protected override MarkdownNode VisitMention(MentionNode mention) protected override MarkdownNode VisitMention(MentionNode mention)
{ {
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Type == MentionType.Meta) if (mention.Type == MentionType.Meta)
{ {
_buffer _buffer
@@ -85,7 +87,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
} }
else if (mention.Type == MentionType.User) else if (mention.Type == MentionType.User)
{ {
var member = _context.TryGetMember(Snowflake.Parse(mention.Id)); var member = mentionId?.Pipe(_context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown"; var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown"; var nick = member?.Nick ?? "Unknown";
@@ -96,7 +98,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
} }
else if (mention.Type == MentionType.Channel) else if (mention.Type == MentionType.Channel)
{ {
var channel = _context.TryGetChannel(Snowflake.Parse(mention.Id)); var channel = mentionId?.Pipe(_context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer _buffer
@@ -106,7 +108,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
} }
else if (mention.Type == MentionType.Role) else if (mention.Type == MentionType.Role)
{ {
var role = _context.TryGetRole(Snowflake.Parse(mention.Id)); var role = mentionId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role"; var name = role?.Name ?? "deleted-role";
var color = role?.Color; var color = role?.Color;
@@ -2,6 +2,7 @@
using DiscordChatExporter.Domain.Discord; using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Markdown; using DiscordChatExporter.Domain.Markdown;
using DiscordChatExporter.Domain.Markdown.Ast; using DiscordChatExporter.Domain.Markdown.Ast;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
{ {
@@ -24,27 +25,28 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
protected override MarkdownNode VisitMention(MentionNode mention) protected override MarkdownNode VisitMention(MentionNode mention)
{ {
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Type == MentionType.Meta) if (mention.Type == MentionType.Meta)
{ {
_buffer.Append($"@{mention.Id}"); _buffer.Append($"@{mention.Id}");
} }
else if (mention.Type == MentionType.User) else if (mention.Type == MentionType.User)
{ {
var member = _context.TryGetMember(Snowflake.Parse(mention.Id)); var member = mentionId?.Pipe(_context.TryGetMember);
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(Snowflake.Parse(mention.Id)); var channel = mentionId?.Pipe(_context.TryGetChannel);
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(Snowflake.Parse(mention.Id)); var role = mentionId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role"; var name = role?.Name ?? "deleted-role";
_buffer.Append($"@{name}"); _buffer.Append($"@{name}");
@@ -113,7 +113,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
{ {
await _writer.WriteLineAsync('='.Repeat(62)); await _writer.WriteLineAsync('='.Repeat(62));
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}"); await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category} / {Context.Request.Channel.Name}"); await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic)) if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}"); await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
+2 -2
View File
@@ -27,10 +27,10 @@
<Window.Resources> <Window.Resources>
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}"> <CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
<CollectionViewSource.GroupDescriptions> <CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Category" /> <PropertyGroupDescription PropertyName="Category.Name" />
</CollectionViewSource.GroupDescriptions> </CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions> <CollectionViewSource.SortDescriptions>
<componentModel:SortDescription Direction="Ascending" PropertyName="Category" /> <componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" /> <componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
</CollectionViewSource.SortDescriptions> </CollectionViewSource.SortDescriptions>
</CollectionViewSource> </CollectionViewSource>
+1 -1
View File
@@ -1,5 +1,5 @@
DiscordChatExporter DiscordChatExporter
Copyright (C) 2017-2020 Alexey Golub Copyright (C) 2017-2021 Alexey Golub
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by