From 481991bd0078d969124dc6e752fd26583fcbe28f Mon Sep 17 00:00:00 2001 From: Alexey Golub Date: Mon, 25 Jun 2018 01:59:15 +0300 Subject: [PATCH] Architecture refactor (#63) --- .../DiscordChatExporter.Cli.csproj | 2 +- .../ViewModels/MainViewModel.cs | 11 +- .../DiscordChatExporter.Core.csproj | 21 +- .../Internal/Extensions.cs | 27 +- DiscordChatExporter.Core/Models/Attachment.cs | 10 +- .../Models/AttachmentType.cs | 8 - DiscordChatExporter.Core/Models/Channel.cs | 13 +- .../Models/ChannelChatLog.cs | 24 - .../Models/ChannelType.cs | 2 + DiscordChatExporter.Core/Models/ChatLog.cs | 29 + DiscordChatExporter.Core/Models/Embed.cs | 61 +-- .../Models/EmbedAuthor.cs | 17 +- DiscordChatExporter.Core/Models/EmbedField.cs | 15 +- .../Models/EmbedFooter.cs | 17 +- DiscordChatExporter.Core/Models/EmbedImage.cs | 12 +- .../Models/EmbedProvider.cs | 20 - DiscordChatExporter.Core/Models/EmbedVideo.cs | 23 - DiscordChatExporter.Core/Models/Guild.cs | 17 +- .../Models/IMentionable.cs | 17 - .../Models/Mentionables.cs | 30 + DiscordChatExporter.Core/Models/Message.cs | 34 +- .../Models/MessageGroup.cs | 8 +- .../Models/MessageType.cs | 2 + DiscordChatExporter.Core/Models/Role.cs | 13 +- DiscordChatExporter.Core/Models/User.cs | 13 +- .../Resources/ExportService/DarkTheme.css | 76 --- .../Resources/ExportService/LightTheme.css | 45 -- .../Resources/ExportService/Shared.css | 396 -------------- .../Resources/ExportTemplates/Csv.csv | 13 + .../Resources/ExportTemplates/Html/Core.html | 175 ++++++ .../ExportTemplates/Html/DarkTheme.css | 90 +++ .../ExportTemplates/Html/LightTheme.css | 51 ++ .../Resources/ExportTemplates/Html/Shared.css | 282 ++++++++++ .../Resources/ExportTemplates/HtmlDark.html | 7 + .../Resources/ExportTemplates/HtmlLight.html | 7 + .../Resources/ExportTemplates/PlainText.txt | 17 + .../Services/DataService.Parsers.cs | 183 +++++++ .../Services/DataService.cs | 515 ++---------------- .../Services/ExportService.Csv.cs | 76 --- .../Services/ExportService.Html.cs | 365 ------------- .../Services/ExportService.PlainText.cs | 79 --- .../Services/ExportService.TemplateLoader.cs | 37 ++ .../Services/ExportService.TemplateModel.cs | 325 +++++++++++ .../Services/ExportService.cs | 92 +--- .../Services/IDataService.cs | 9 +- .../Services/IExportService.cs | 5 +- .../Services/IMessageGroupService.cs | 2 +- .../Services/MessageGroupService.cs | 10 +- .../DiscordChatExporter.Gui.csproj | 4 +- .../ViewModels/MainViewModel.cs | 14 +- DiscordChatExporter.Gui/packages.config | 2 +- Readme.md | 3 +- 52 files changed, 1482 insertions(+), 1844 deletions(-) delete mode 100644 DiscordChatExporter.Core/Models/AttachmentType.cs delete mode 100644 DiscordChatExporter.Core/Models/ChannelChatLog.cs create mode 100644 DiscordChatExporter.Core/Models/ChatLog.cs delete mode 100644 DiscordChatExporter.Core/Models/EmbedProvider.cs delete mode 100644 DiscordChatExporter.Core/Models/EmbedVideo.cs delete mode 100644 DiscordChatExporter.Core/Models/IMentionable.cs create mode 100644 DiscordChatExporter.Core/Models/Mentionables.cs delete mode 100644 DiscordChatExporter.Core/Resources/ExportService/DarkTheme.css delete mode 100644 DiscordChatExporter.Core/Resources/ExportService/LightTheme.css delete mode 100644 DiscordChatExporter.Core/Resources/ExportService/Shared.css create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/Csv.csv create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/Html/Core.html create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/Html/DarkTheme.css create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/Html/LightTheme.css create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/Html/Shared.css create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark.html create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight.html create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/PlainText.txt create mode 100644 DiscordChatExporter.Core/Services/DataService.Parsers.cs delete mode 100644 DiscordChatExporter.Core/Services/ExportService.Csv.cs delete mode 100644 DiscordChatExporter.Core/Services/ExportService.Html.cs delete mode 100644 DiscordChatExporter.Core/Services/ExportService.PlainText.cs create mode 100644 DiscordChatExporter.Core/Services/ExportService.TemplateLoader.cs create mode 100644 DiscordChatExporter.Core/Services/ExportService.TemplateModel.cs diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj index c38512a..3e6dde4 100644 --- a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj +++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj @@ -13,7 +13,7 @@ - + diff --git a/DiscordChatExporter.Cli/ViewModels/MainViewModel.cs b/DiscordChatExporter.Cli/ViewModels/MainViewModel.cs index 460622e..74eed67 100644 --- a/DiscordChatExporter.Cli/ViewModels/MainViewModel.cs +++ b/DiscordChatExporter.Cli/ViewModels/MainViewModel.cs @@ -38,16 +38,19 @@ namespace DiscordChatExporter.Cli.ViewModels } // Get messages - var messages = await _dataService.GetChannelMessagesAsync(token, channelId, from, to); + var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to); - // Group them + // Group messages var messageGroups = _messageGroupService.GroupMessages(messages); + // Get mentionables + var mentionables = await _dataService.GetMentionablesAsync(token, guild.Id, messages); + // Create log - var log = new ChannelChatLog(guild, channel, messageGroups, messages.Count); + var log = new ChatLog(guild, channel, messageGroups, mentionables); // Export - await _exportService.ExportAsync(format, filePath, log); + _exportService.Export(format, filePath, log); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj index e7fc37c..3c1d999 100644 --- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj +++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj @@ -6,24 +6,25 @@ - + + + + + + + + - - - + - - - - - - + + diff --git a/DiscordChatExporter.Core/Internal/Extensions.cs b/DiscordChatExporter.Core/Internal/Extensions.cs index 048f680..90cdfaf 100644 --- a/DiscordChatExporter.Core/Internal/Extensions.cs +++ b/DiscordChatExporter.Core/Internal/Extensions.cs @@ -1,22 +1,23 @@ -using System.IO; -using System.Reflection; -using System.Resources; +using System; +using System.Drawing; +using Tyrrrz.Extensions; namespace DiscordChatExporter.Core.Internal { internal static class Extensions { - public static string GetManifestResourceString(this Assembly assembly, string resourceName) + public static string ToSnowflake(this DateTime dateTime) { - var stream = assembly.GetManifestResourceStream(resourceName); - if (stream == null) - throw new MissingManifestResourceException($"Could not find resource [{resourceName}]."); - - using (stream) - using (var reader = new StreamReader(stream)) - { - return reader.ReadToEnd(); - } + const long epoch = 62135596800000; + var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch; + var value = ((ulong) unixTime - 1420070400000UL) << 22; + return value.ToString(); } + + public static string Base64Encode(this string str) => str.GetBytes().ToBase64(); + + public static string Base64Decode(this string str) => str.FromBase64().GetString(); + + public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/Attachment.cs b/DiscordChatExporter.Core/Models/Attachment.cs index e6acb3e..87da0d1 100644 --- a/DiscordChatExporter.Core/Models/Attachment.cs +++ b/DiscordChatExporter.Core/Models/Attachment.cs @@ -1,10 +1,12 @@ namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/channel#attachment-object + public class Attachment { public string Id { get; } - public AttachmentType Type { get; } + public bool IsImage { get; } public string Url { get; } @@ -12,13 +14,15 @@ public long FileSize { get; } - public Attachment(string id, AttachmentType type, string url, string fileName, long fileSize) + public Attachment(string id, bool isImage, string url, string fileName, long fileSize) { Id = id; - Type = type; + IsImage = isImage; Url = url; FileName = fileName; FileSize = fileSize; } + + public override string ToString() => FileName; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/AttachmentType.cs b/DiscordChatExporter.Core/Models/AttachmentType.cs deleted file mode 100644 index 5dc8ec5..0000000 --- a/DiscordChatExporter.Core/Models/AttachmentType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace DiscordChatExporter.Core.Models -{ - public enum AttachmentType - { - Other, - Image - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/Channel.cs b/DiscordChatExporter.Core/Models/Channel.cs index 6888e5b..66bc3f5 100644 --- a/DiscordChatExporter.Core/Models/Channel.cs +++ b/DiscordChatExporter.Core/Models/Channel.cs @@ -1,5 +1,7 @@ namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/channel#channel-object + public partial class Channel { public string Id { get; } @@ -21,17 +23,12 @@ Type = type; } - public override string ToString() - { - return Name; - } + public override string ToString() => Name; } public partial class Channel { - public static Channel CreateDeletedChannel(string id) - { - return new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat); - } + public static Channel CreateDeletedChannel(string id) => + new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/ChannelChatLog.cs b/DiscordChatExporter.Core/Models/ChannelChatLog.cs deleted file mode 100644 index befbd10..0000000 --- a/DiscordChatExporter.Core/Models/ChannelChatLog.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; - -namespace DiscordChatExporter.Core.Models -{ - public class ChannelChatLog - { - public Guild Guild { get; } - - public Channel Channel { get; } - - public IReadOnlyList MessageGroups { get; } - - public int TotalMessageCount { get; } - - public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList messageGroups, - int totalMessageCount) - { - Guild = guild; - Channel = channel; - MessageGroups = messageGroups; - TotalMessageCount = totalMessageCount; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/ChannelType.cs b/DiscordChatExporter.Core/Models/ChannelType.cs index a7e7f20..78fd0a7 100644 --- a/DiscordChatExporter.Core/Models/ChannelType.cs +++ b/DiscordChatExporter.Core/Models/ChannelType.cs @@ -1,5 +1,7 @@ namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types + public enum ChannelType { GuildTextChat, diff --git a/DiscordChatExporter.Core/Models/ChatLog.cs b/DiscordChatExporter.Core/Models/ChatLog.cs new file mode 100644 index 0000000..c1b385a --- /dev/null +++ b/DiscordChatExporter.Core/Models/ChatLog.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; + +namespace DiscordChatExporter.Core.Models +{ + public class ChatLog + { + public Guild Guild { get; } + + public Channel Channel { get; } + + public IReadOnlyList MessageGroups { get; } + + public int TotalMessageCount => MessageGroups.Sum(g => g.Messages.Count); + + public Mentionables Mentionables { get; } + + public ChatLog(Guild guild, Channel channel, IReadOnlyList messageGroups, + Mentionables mentionables) + { + Guild = guild; + Channel = channel; + MessageGroups = messageGroups; + Mentionables = mentionables; + } + + public override string ToString() => $"{Guild.Name} | {Channel.Name}"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/Embed.cs b/DiscordChatExporter.Core/Models/Embed.cs index f9516c3..340f8d6 100644 --- a/DiscordChatExporter.Core/Models/Embed.cs +++ b/DiscordChatExporter.Core/Models/Embed.cs @@ -2,72 +2,47 @@ using System; using System.Collections.Generic; using System.Drawing; -// https://discordapp.com/developers/docs/resources/channel#embed-object - namespace DiscordChatExporter.Core.Models { - public class Embed : IMentionable + // https://discordapp.com/developers/docs/resources/channel#embed-object + + public class Embed { public string Title { get; } - public string Type { get; } - - public string Description { get; } - public string Url { get; } - public DateTime? TimeStamp { get; } - - public Color? Color { get; } - - public EmbedFooter Footer { get; } - - public EmbedImage Image { get; } + public DateTime? Timestamp { get; } - public EmbedImage Thumbnail { get; } - - public EmbedVideo Video { get; } - - public EmbedProvider Provider { get; } + public Color Color { get; } public EmbedAuthor Author { get; } + public string Description { get; } + public IReadOnlyList Fields { get; } - public List MentionedUsers { get; } + public EmbedImage Thumbnail { get; } - public List MentionedRoles { get; } + public EmbedImage Image { get; } - public List MentionedChannels { get; } + public EmbedFooter Footer { get; } - public Embed(string title, string type, string description, - string url, DateTime? timeStamp, Color? color, - EmbedFooter footer, EmbedImage image, EmbedImage thumbnail, - EmbedVideo video, EmbedProvider provider, EmbedAuthor author, - List fields, List mentionedUsers, - List mentionedRoles, List mentionedChannels) + public Embed(string title, string url, DateTime? timestamp, Color color, EmbedAuthor author, string description, + IReadOnlyList fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer) { Title = title; - Type = type; - Description = description; Url = url; - TimeStamp = timeStamp; + Timestamp = timestamp; Color = color; - Footer = footer; - Image = image; - Thumbnail = thumbnail; - Video = video; - Provider = provider; Author = author; + Description = description; Fields = fields; - MentionedUsers = mentionedUsers; - MentionedRoles = mentionedRoles; - MentionedChannels = mentionedChannels; + Thumbnail = thumbnail; + Image = image; + Footer = footer; } - public override string ToString() - { - return Description; - } + public override string ToString() => Title; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedAuthor.cs b/DiscordChatExporter.Core/Models/EmbedAuthor.cs index 8adbaaa..b4496a3 100644 --- a/DiscordChatExporter.Core/Models/EmbedAuthor.cs +++ b/DiscordChatExporter.Core/Models/EmbedAuthor.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; - -// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure - namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure + public class EmbedAuthor { public string Name { get; } @@ -13,19 +10,13 @@ namespace DiscordChatExporter.Core.Models public string IconUrl { get; } - public string ProxyIconUrl { get; } - - public EmbedAuthor(string name, string url, string iconUrl, string proxyIconUrl) + public EmbedAuthor(string name, string url, string iconUrl) { Name = name; Url = url; IconUrl = iconUrl; - ProxyIconUrl = proxyIconUrl; } - public override string ToString() - { - return Name; - } + public override string ToString() => Name; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedField.cs b/DiscordChatExporter.Core/Models/EmbedField.cs index ceb317c..d594b2e 100644 --- a/DiscordChatExporter.Core/Models/EmbedField.cs +++ b/DiscordChatExporter.Core/Models/EmbedField.cs @@ -1,23 +1,22 @@ -using System; -using System.Collections.Generic; - -// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure - namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure + public class EmbedField { public string Name { get; } public string Value { get; } - public bool? Inline { get; } + public bool IsInline { get; } - public EmbedField(string name, string value, bool? inline) + public EmbedField(string name, string value, bool isInline) { Name = name; Value = value; - Inline = inline; + IsInline = isInline; } + + public override string ToString() => $"{Name} | {Value}"; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedFooter.cs b/DiscordChatExporter.Core/Models/EmbedFooter.cs index 10d6664..38229d1 100644 --- a/DiscordChatExporter.Core/Models/EmbedFooter.cs +++ b/DiscordChatExporter.Core/Models/EmbedFooter.cs @@ -1,28 +1,19 @@ -using System; -using System.Collections.Generic; - -// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure - namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure + public class EmbedFooter { public string Text { get; } public string IconUrl { get; } - public string ProxyIconUrl { get; } - - public EmbedFooter(string text, string iconUrl, string proxyIconUrl) + public EmbedFooter(string text, string iconUrl) { Text = text; IconUrl = iconUrl; - ProxyIconUrl = proxyIconUrl; } - public override string ToString() - { - return Text; - } + public override string ToString() => Text; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedImage.cs b/DiscordChatExporter.Core/Models/EmbedImage.cs index 118d384..edfc7ba 100644 --- a/DiscordChatExporter.Core/Models/EmbedImage.cs +++ b/DiscordChatExporter.Core/Models/EmbedImage.cs @@ -1,24 +1,18 @@ -using System; -using System.Collections.Generic; - -// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure - namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure + public class EmbedImage { public string Url { get; } - public string ProxyUrl { get; } - public int? Height { get; } public int? Width { get; } - public EmbedImage(string url, string proxyUrl, int? height, int? width) + public EmbedImage(string url, int? height, int? width) { Url = url; - ProxyUrl = proxyUrl; Height = height; Width = width; } diff --git a/DiscordChatExporter.Core/Models/EmbedProvider.cs b/DiscordChatExporter.Core/Models/EmbedProvider.cs deleted file mode 100644 index 4dedf42..0000000 --- a/DiscordChatExporter.Core/Models/EmbedProvider.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; - -// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-provider-structure - -namespace DiscordChatExporter.Core.Models -{ - public class EmbedProvider - { - public string Name { get; } - - public string Url { get; } - - public EmbedProvider(string name, string url) - { - Name = name; - Url = url; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/EmbedVideo.cs b/DiscordChatExporter.Core/Models/EmbedVideo.cs deleted file mode 100644 index c55e288..0000000 --- a/DiscordChatExporter.Core/Models/EmbedVideo.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Collections.Generic; - -// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-video-structure - -namespace DiscordChatExporter.Core.Models -{ - public class EmbedVideo - { - public string Url { get; } - - public int? Height { get; } - - public int? Width { get; } - - public EmbedVideo(string url, int? height, int? width) - { - Url = url; - Height = height; - Width = width; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/Guild.cs b/DiscordChatExporter.Core/Models/Guild.cs index ef39b39..3babd4b 100644 --- a/DiscordChatExporter.Core/Models/Guild.cs +++ b/DiscordChatExporter.Core/Models/Guild.cs @@ -1,8 +1,9 @@ -using System.Collections.Generic; -using Tyrrrz.Extensions; +using Tyrrrz.Extensions; namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/guild#guild-object + public partial class Guild { public string Id { get; } @@ -15,24 +16,18 @@ namespace DiscordChatExporter.Core.Models ? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png" : "https://cdn.discordapp.com/embed/avatars/0.png"; - public IReadOnlyList Roles { get; } - - public Guild(string id, string name, string iconHash, IReadOnlyList roles) + public Guild(string id, string name, string iconHash) { Id = id; Name = name; IconHash = iconHash; - Roles = roles; } - public override string ToString() - { - return Name; - } + public override string ToString() => Name; } public partial class Guild { - public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null, new Role[0]); + public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/IMentionable.cs b/DiscordChatExporter.Core/Models/IMentionable.cs deleted file mode 100644 index 388d641..0000000 --- a/DiscordChatExporter.Core/Models/IMentionable.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace DiscordChatExporter.Core.Models -{ - interface IMentionable - { - List MentionedUsers { get; } - - List MentionedRoles { get; } - - List MentionedChannels { get; } - } -} diff --git a/DiscordChatExporter.Core/Models/Mentionables.cs b/DiscordChatExporter.Core/Models/Mentionables.cs new file mode 100644 index 0000000..e92fd92 --- /dev/null +++ b/DiscordChatExporter.Core/Models/Mentionables.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; + +namespace DiscordChatExporter.Core.Models +{ + public class Mentionables + { + public IReadOnlyList Users { get; } + + public IReadOnlyList Channels { get; } + + public IReadOnlyList Roles { get; } + + public Mentionables(IReadOnlyList users, IReadOnlyList channels, IReadOnlyList roles) + { + Users = users; + Channels = channels; + Roles = roles; + } + + public User GetUser(string id) => + Users.FirstOrDefault(u => u.Id == id) ?? User.CreateUnknownUser(id); + + public Channel GetChannel(string id) => + Channels.FirstOrDefault(c => c.Id == id) ?? Channel.CreateDeletedChannel(id); + + public Role GetRole(string id) => + Roles.FirstOrDefault(r => r.Id == id) ?? Role.CreateDeletedRole(id); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/Message.cs b/DiscordChatExporter.Core/Models/Message.cs index 9c44536..9102380 100644 --- a/DiscordChatExporter.Core/Models/Message.cs +++ b/DiscordChatExporter.Core/Models/Message.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; namespace DiscordChatExporter.Core.Models { - public class Message : IMentionable + // https://discordapp.com/developers/docs/resources/channel#message-object + + public class Message { public string Id { get; } @@ -13,9 +15,9 @@ namespace DiscordChatExporter.Core.Models public User Author { get; } - public DateTime TimeStamp { get; } + public DateTime Timestamp { get; } - public DateTime? EditedTimeStamp { get; } + public DateTime? EditedTimestamp { get; } public string Content { get; } @@ -23,36 +25,24 @@ namespace DiscordChatExporter.Core.Models public IReadOnlyList Embeds { get; } - public List MentionedUsers { get; } - - public List MentionedRoles { get; } - - public List MentionedChannels { get; } + public IReadOnlyList MentionedUsers { get; } - public Message(string id, string channelId, MessageType type, - User author, DateTime timeStamp, - DateTime? editedTimeStamp, string content, - IReadOnlyList attachments, IReadOnlyList embeds, - List mentionedUsers, List mentionedRoles, - List mentionedChannels) + public Message(string id, string channelId, MessageType type, User author, DateTime timestamp, + DateTime? editedTimestamp, string content, IReadOnlyList attachments, + IReadOnlyList embeds, IReadOnlyList mentionedUsers) { Id = id; ChannelId = channelId; Type = type; Author = author; - TimeStamp = timeStamp; - EditedTimeStamp = editedTimeStamp; + Timestamp = timestamp; + EditedTimestamp = editedTimestamp; Content = content; Attachments = attachments; Embeds = embeds; MentionedUsers = mentionedUsers; - MentionedRoles = mentionedRoles; - MentionedChannels = mentionedChannels; } - public override string ToString() - { - return Content; - } + public override string ToString() => Content; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/MessageGroup.cs b/DiscordChatExporter.Core/Models/MessageGroup.cs index cbb6538..4e9dce9 100644 --- a/DiscordChatExporter.Core/Models/MessageGroup.cs +++ b/DiscordChatExporter.Core/Models/MessageGroup.cs @@ -7,15 +7,17 @@ namespace DiscordChatExporter.Core.Models { public User Author { get; } - public DateTime TimeStamp { get; } + public DateTime Timestamp { get; } public IReadOnlyList Messages { get; } - public MessageGroup(User author, DateTime timeStamp, IReadOnlyList messages) + public MessageGroup(User author, DateTime timestamp, IReadOnlyList messages) { Author = author; - TimeStamp = timeStamp; + Timestamp = timestamp; Messages = messages; } + + public override string ToString() => $"{Author.FullName} | {Timestamp} | {Messages.Count} messages"; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/MessageType.cs b/DiscordChatExporter.Core/Models/MessageType.cs index 9cfc75e..e2bc56f 100644 --- a/DiscordChatExporter.Core/Models/MessageType.cs +++ b/DiscordChatExporter.Core/Models/MessageType.cs @@ -1,5 +1,7 @@ namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/resources/channel#message-object-message-types + public enum MessageType { Default, diff --git a/DiscordChatExporter.Core/Models/Role.cs b/DiscordChatExporter.Core/Models/Role.cs index 4fb2f98..0f54ac1 100644 --- a/DiscordChatExporter.Core/Models/Role.cs +++ b/DiscordChatExporter.Core/Models/Role.cs @@ -1,5 +1,7 @@ namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/topics/permissions#role-object + public partial class Role { public string Id { get; } @@ -12,17 +14,12 @@ Name = name; } - public override string ToString() - { - return Name; - } + public override string ToString() => Name; } public partial class Role { - public static Role CreateDeletedRole(string id) - { - return new Role(id, "deleted-role"); - } + public static Role CreateDeletedRole(string id) => + new Role(id, "deleted-role"); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Models/User.cs b/DiscordChatExporter.Core/Models/User.cs index 268631b..e953009 100644 --- a/DiscordChatExporter.Core/Models/User.cs +++ b/DiscordChatExporter.Core/Models/User.cs @@ -2,6 +2,8 @@ namespace DiscordChatExporter.Core.Models { + // https://discordapp.com/developers/docs/topics/permissions#role-object + public partial class User { public string Id { get; } @@ -28,17 +30,12 @@ namespace DiscordChatExporter.Core.Models AvatarHash = avatarHash; } - public override string ToString() - { - return FullName; - } + public override string ToString() => FullName; } public partial class User { - public static User CreateUnknownUser(string id) - { - return new User(id, 0, "Unknown", null); - } + public static User CreateUnknownUser(string id) => + new User(id, 0, "Unknown", null); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportService/DarkTheme.css b/DiscordChatExporter.Core/Resources/ExportService/DarkTheme.css deleted file mode 100644 index b6fc984..0000000 --- a/DiscordChatExporter.Core/Resources/ExportService/DarkTheme.css +++ /dev/null @@ -1,76 +0,0 @@ -body { - background-color: #36393E; - color: rgba(255, 255, 255, 0.7); -} - -a { - color: #0096CF; -} - -div.pre { - background-color: #2F3136; - color: rgb(131, 148, 150); -} - -span.pre { - background-color: #2F3136; -} - -div.guild-name { - color: #FFFFFF; -} - -div.channel-name { - color: #FFFFFF; -} - -div.channel-topic { - color: #FFFFFF; -} - -div.msg { - border-top: 1px solid rgba(255, 255, 255, 0.04); -} - -span.msg-user { - color: #FFFFFF; -} - -span.msg-date { - color: rgba(255, 255, 255, 0.2); -} - -span.msg-edited { - color: rgba(255, 255, 255, 0.2); -} - -.embed-wrapper .embed-color-pill { - background-color: #4f545c -} - -.embed { - background-color: rgba(46, 48, 54, .3); - border-color: rgba(46, 48, 54, .6) -} - -.embed .embed-footer, -.embed .embed-provider { - color: hsla(0, 0%, 100%, .6) -} - -.embed .embed-author-name { - color: #fff!important -} - -.embed div.embed-title { - color: #fff -} - -.embed .embed-description, -.embed .embed-fields { - color: hsla(0, 0%, 100%, .6) -} - -.embed .embed-fields .embed-field-name { - color: #fff -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportService/LightTheme.css b/DiscordChatExporter.Core/Resources/ExportService/LightTheme.css deleted file mode 100644 index abb2526..0000000 --- a/DiscordChatExporter.Core/Resources/ExportService/LightTheme.css +++ /dev/null @@ -1,45 +0,0 @@ -body { - background-color: #FFFFFF; - color: #737F8D; -} - -a { - color: #00B0F4; -} - -div.pre { - background-color: #F9F9F9; - color: rgb(101, 123, 131); -} - -span.pre { - background-color: #F9F9F9; -} - -div.guild-name { - color: #2F3136; -} - -div.channel-name { - color: #2F3136; -} - -div.channel-topic { - color: #2F3136; -} - -div.msg { - border-top: 1px solid #ECEEEF; -} - -span.msg-user { - color: #2F3136; -} - -span.msg-date { - color: #99AAB5; -} - -span.msg-edited { - color: #99AAB5; -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportService/Shared.css b/DiscordChatExporter.Core/Resources/ExportService/Shared.css deleted file mode 100644 index ba41c3d..0000000 --- a/DiscordChatExporter.Core/Resources/ExportService/Shared.css +++ /dev/null @@ -1,396 +0,0 @@ -body { - font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif; - font-size: 16px; -} - -a { - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -div.pre { - font-family: Consolas, Courier New, Courier, Monospace; - margin-top: 4px; - padding: 8px; - white-space: pre-wrap; -} - -span.pre { - font-family: Consolas, Courier New, Courier, Monospace; - padding-left: 2px; - padding-right: 2px; - white-space: pre-wrap; -} - -div#info { - display: flex; - margin-bottom: 10px; - margin-left: 5px; - margin-right: 5px; - max-width: 100%; -} - -div#log { - max-width: 100%; -} - -img.guild-icon { - max-height: 64px; - max-width: 64px; -} - -div.info-right { - flex: 1; - margin-left: 10px; -} - -div.guild-name { - font-size: 1.4em; -} - -div.channel-name { - font-size: 1.2em; -} - -div.channel-topic { - margin-top: 2px; -} - -div.channel-messagecount { - margin-top: 2px; -} - -div.msg { - display: flex; - margin-left: 10px; - margin-right: 10px; - padding-bottom: 15px; - padding-top: 15px; -} - -div.msg-left { - height: 40px; - width: 40px; -} - -img.msg-avatar { - border-radius: 50%; - height: 40px; - width: 40px; -} - -div.msg-right { - flex: 1; - margin-left: 20px; - min-width: 50%; -} - -span.msg-user { - font-size: 1em; -} - -span.msg-date { - font-size: .75em; - margin-left: 5px; -} - -span.msg-edited { - font-size: .8em; - margin-left: 5px; -} - -div.msg-content { - font-size: .9375em; - padding-top: 5px; - word-wrap: break-word; -} - -div.msg-attachment { - margin-bottom: 5px; - margin-top: 5px; -} - -img.msg-attachment { - max-height: 500px; - max-width: 50%; -} - -span.mention { - font-weight: 600; - color: #7289da; - background-color: rgba(115, 139, 215, 0.1); -} - -.emoji { - -o-object-fit: contain; - object-fit: contain; - width: 24px; - height: 24px; - margin: 0 .05em 0 .1em!important; - vertical-align: -.4em -} - -.emoji.jumboable { - width: 32px; - height: 32px -} - -.image { - display: inline-block; - position: relative; - -webkit-user-select: text; - -moz-user-select: text; - -ms-user-select: text; - user-select: text -} - -.embed, -.embed-wrapper { - display: -webkit-box; - display: -ms-flexbox -} - -.embed-wrapper { - position: relative; - margin-top: 5px; - max-width: 520px; - display: flex -} - -.embed-wrapper .embed-color-pill { - width: 4px; - background: #cacbce; - border-radius: 3px 0 0 3px; - -ms-flex-negative: 0; - flex-shrink: 0 -} - -.embed { - padding: 8px 10px; - box-sizing: border-box; - background: hsla(0, 0%, 98%, .3); - border: 1px solid hsla(0, 0%, 80%, .3); - border-radius: 0 3px 3px 0; - display: flex; - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column -} - -.embed .embed-content, -.embed.embed-rich { - display: -webkit-box; - display: -ms-flexbox -} - -.embed .embed-fields, -.embed.embed-link { - -webkit-box-orient: horizontal; - -webkit-box-direction: normal -} - -.embed div.embed-title { - color: #4f545c -} - -.embed .embed-content { - width: 100%; - display: flex; - margin-bottom: 10px -} - -.embed .embed-content .embed-content-inner { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1 -} - -.embed.embed-rich { - position: relative; - display: flex; - border-radius: 0 3px 3px 0 -} - -.embed.embed-rich .embed-rich-thumb { - max-height: 80px; - max-width: 80px; - border-radius: 3px; - width: auto; - -o-object-fit: contain; - object-fit: contain; - -ms-flex-negative: 0; - flex-shrink: 0; - margin-left: 20px -} - -.embed.embed-inline { - padding: 0; - margin: 4px 0; - border-radius: 3px -} - -.embed .image, -.embed video { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - overflow: hidden; - border-radius: 2px -} - -.embed .embed-content-inner>:last-child, -.embed .embed-content:last-child, -.embed .embed-inner>:last-child, -.embed>:last-child { - margin-bottom: 0!important -} - -.embed .embed-provider { - display: inline-block; - color: #87909c; - font-weight: 400; - font-size: 12px; - margin-bottom: 5px -} - -.embed .embed-author { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - margin-bottom: 5px -} - -.embed .embed-author-name, -.embed .embed-footer, -.embed .embed-title { - display: inline-block; - font-weight: 600 -} - -.embed .embed-author-name { - font-size: 14px; - color: #4f545c!important -} - -.embed .embed-author-icon { - margin-right: 9px; - width: 20px; - height: 20px; - -o-object-fit: contain; - object-fit: contain; - border-radius: 50% -} - -.embed .embed-footer { - font-size: 12px; - color: rgba(79, 83, 91, .6); - letter-spacing: 0 -} - -.embed .embed-footer-icon { - margin-right: 10px; - height: 18px; - width: 18px; - -o-object-fit: contain; - object-fit: contain; - float: left; - border-radius: 2.45px -} - -.embed .embed-title { - margin-bottom: 4px; - font-size: 14px -} - -.embed .embed-title+.embed-description { - margin-top: -3px!important -} - -.embed .embed-description { - display: block; - font-size: 14px; - font-weight: 500; - margin-bottom: 10px; - color: rgba(79, 83, 91, .9); - letter-spacing: 0 -} - -.embed .embed-description.markup { - white-space: pre-line; - margin-top: 0!important; - font-size: 14px!important; - line-height: 16px!important -} - -.embed .embed-description.markup pre { - max-width: 100%!important -} - -.embed .embed-fields { - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -ms-flex-direction: row; - flex-direction: row; - -ms-flex-wrap: wrap; - flex-wrap: wrap; - color: #36393e; - margin-top: -10px; - margin-bottom: 10px -} - -.embed .embed-fields .embed-field { - -webkit-box-flex: 0; - -ms-flex: 0; - flex: 0; - padding-top: 10px; - min-width: 100%; - max-width: 506px -} - -.embed .embed-fields .embed-field.embed-field-inline { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; - min-width: 150px; - -ms-flex-preferred-size: auto; - flex-basis: auto -} - -.embed .embed-fields .embed-field .embed-field-name { - font-size: 14px; - margin-bottom: 4px; - font-weight: 600 -} - -.embed .embed-fields .embed-field .embed-field-value { - font-size: 14px; - font-weight: 500 -} - -.embed .embed-thumbnail, -.embed .embed-thumbnail-gifv { - position: relative; - display: inline-block -} - -.embed .embed-thumbnail { - margin-bottom: 10px -} - -.embed .embed-thumbnail img { - margin: 0; - max-width: 500px; - max-height: 400px; -} - -.comment>:last-child .embed { - margin-bottom: auto -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Csv.csv b/DiscordChatExporter.Core/Resources/ExportTemplates/Csv.csv new file mode 100644 index 0000000..aa830f9 --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/Csv.csv @@ -0,0 +1,13 @@ +Author;Date;Content;Attachments; + +{{- for group in MessageGroups -}} + {{- for message in group.Messages -}} + {{- message.Author.FullName }}; + + {{- message.TimeStamp | FormatDate }}; + + {{- message.Content | FormatContent }}; + + {{- message.Attachments | array.map "Url" | array.join "," }}; + {{~ end -}} +{{- end -}} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Core.html b/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Core.html new file mode 100644 index 0000000..b4aa607 --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Core.html @@ -0,0 +1,175 @@ + + + + + {{ Guild.Name | HtmlEncode }} - {{ Channel.Name | HtmlEncode }} + + + + + + +{{~ # Info }} +
+
+ +
+ +
+ +{{~ # Log }} +
+ {{ for group in MessageGroups }} +
+ {{~ # Avatar }} +
+ +
+ {{~ # Author name and timestamp }} +
+ {{ group.Author.Name | HtmlEncode }} + {{ group.Timestamp | FormatDate | HtmlEncode }} + + {{~ # Messages }} + {{ for message in group.Messages }} + {{~ # Content }} + {{ if message.Content }} +
+ {{ message.Content | FormatContent }} + + {{~ # Edited timestamp }} + {{ if message.EditedTimestamp }} + (edited) + {{ end }} +
+ {{ end }} + + {{~ # Attachments }} + {{ for attachment in message.Attachments }} + + {{ end }} + + {{~ # Embeds }} + {{ for embed in message.Embeds }} +
+
+
+
+
+ {{~ # Author }} + {{ if embed.Author }} +
+ {{ if embed.Author.IconUrl }} + + {{ end }} + {{ if embed.Author.Name }} + + {{ if embed.Author.Url }} + {{ embed.Author.Name | HtmlEncode }} + {{ else }} + {{ embed.Author.Name | HtmlEncode }} + {{ end }} + + {{ end }} +
+ {{ end }} + + {{~ # Title }} + {{ if embed.Title }} +
+ {{ if embed.Url }} + {{ embed.Title | FormatContent }} + {{ else }} + {{ embed.Title | FormatContent }} + {{ end }} +
+ {{ end }} + + {{~ # Description }} + {{ if embed.Description }} +
{{ embed.Description | FormatContent true }}
+ {{ end }} + + {{~ # Fields }} +
+ {{ for field in embed.Fields }} +
+ {{ if field.Name }} +
{{ field.Name | FormatContent }}
+ {{ end }} + {{ if field.Value }} +
{{ field.Value | FormatContent true }}
+ {{ end }} +
+ {{ end }} +
+
+ + {{~ # Thumbnail }} + {{ if embed.Thumbnail }} +
+ + + +
+ {{ end }} +
+ + {{~ # Image }} + {{ if embed.Image }} +
+ + + +
+ {{ end }} + + {{~ # Footer }} + {{ if embed.Footer || embed.Timestamp }} + + {{ end }} +
+
+ {{ end }} + {{ end }} +
+
+ {{ end }} +
+ + + \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/DarkTheme.css b/DiscordChatExporter.Core/Resources/ExportTemplates/Html/DarkTheme.css new file mode 100644 index 0000000..388af8f --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/Html/DarkTheme.css @@ -0,0 +1,90 @@ +/* === GENERAL === */ + +body { + background-color: #36393e; + color: #ffffffb3; +} + +a { + color: #0096cf; +} + +.pre-multiline { + background-color: #2F3136; + border-color: #282b30; + color: #839496; +} + +.pre-inline { + background-color: #2f3136; +} + +.mention { + background-color: #738bd71a; + color: #7289da; +} + +/* === INFO === */ + +.info__guild-name { + color: #ffffff; +} + +.info__channel-name { + color: #ffffff; +} + +.info__channel-topic { + color: #ffffff; +} + +/* === CHATLOG === */ + +.chatlog__message-group { + border-color: #ffffff0a; +} + +.chatlog__author-name { + color: #ffffff; +} + +.chatlog__timestamp { + color: #ffffff33; +} + +.chatlog__edited-timestamp { + color: #ffffff33; +} + +.chatlog__embed-content-container { + background-color: #2e30364d; + border-color: #2e303699; +} + +.chatlog__embed-author-name { + color: #ffffff; +} + +.chatlog__embed-author-name-link { + color: #ffffff; +} + +.chatlog__embed-title { + color: #ffffff; +} + +.chatlog__embed-description { + color: #ffffff99; +} + +.chatlog__embed-fields { + color: #ffffff99; +} + +.chatlog__embed-field-name { + color: #ffffff; +} + +.chatlog__embed-footer { + color: #ffffff99; +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/LightTheme.css b/DiscordChatExporter.Core/Resources/ExportTemplates/Html/LightTheme.css new file mode 100644 index 0000000..48ea7fa --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/Html/LightTheme.css @@ -0,0 +1,51 @@ +/* === GENERAL === */ + +body { + background-color: #fff; + color: #737f8d; +} + +a { + color: #00b0f4; +} + +.pre-multiline { + background-color: #f9f9f9; + color: #657b83; +} + +.pre-inline { + background-color: #f9f9f9; +} + +/* === INFO === */ + +.info__guild-name { + color: #2f3136; +} + +.info__channel-name { + color: #2f3136; +} + +.info__channel-topic { + color: #2f3136; +} + +/* === CHATLOG === */ + +.chatlog__message-group { + border-top: 1px solid #eceeef; +} + +.chatlog__author-name { + color: #2f3136; +} + +.chatlog__timestamp { + color: #99aab5; +} + +.chatlog__edited-timestamp { + color: #99aab5; +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Shared.css b/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Shared.css new file mode 100644 index 0000000..fd2bf9b --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Shared.css @@ -0,0 +1,282 @@ +/* === GENERAL === */ + +body { + font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 16px; +} + +a { + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +img { + object-fit: contain; +} + +.pre-multiline { + margin-top: 4px; + padding: 8px; + border: 2px solid; + border-radius: 5px; + font-family: "Consolas", "Courier New", Courier, Monospace; + white-space: pre-wrap; +} + +.pre-inline { + padding: 2px; + border-radius: 3px; + font-family: "Consolas", "Courier New", Courier, Monospace; + white-space: pre-wrap; +} + +.emoji { + margin-left: 1px; + margin-right: 1px; + width: 24px; + height: 24px; + vertical-align: -.4em; +} + +.emoji-jumboable { + width: 32px; + height: 32px; +} + +.mention { + font-weight: 600; +} + +/* === INFO === */ + +.info { + display: flex; + max-width: 100%; + margin-bottom: 10px; + margin-left: 5px; + margin-right: 5px; +} + +.info__guild-icon-container { + flex: 0; +} + +.info__guild-icon { + max-width: 64px; + max-height: 64px; +} + +.info__metadata { + flex: 1; + margin-left: 10px; +} + +.info__guild-name { + font-size: 1.4em; +} + +.info__channel-name { + font-size: 1.2em; +} + +.info__channel-topic { + margin-top: 2px; +} + +.info__channel-message-count { + margin-top: 2px; +} + +/* === CHATLOG === */ + +.chatlog { + max-width: 100%; +} + +.chatlog__message-group { + display: flex; + margin-left: 10px; + margin-right: 10px; + padding-top: 15px; + padding-bottom: 15px; + border-top: 1px solid; +} + +.chatlog__author-avatar-container { + flex: 0; + width: 40px; + height: 40px; +} + +.chatlog__author-avatar { + border-radius: 50%; + height: 40px; + width: 40px; +} + +.chatlog__messages { + flex: 1; + min-width: 50%; + margin-left: 20px; +} + +.chatlog__author-name { + font-size: 1em; +} + +.chatlog__timestamp { + margin-left: 5px; + font-size: .75em; +} + +.chatlog__content { + padding-top: 5px; + font-size: .9375em; + word-wrap: break-word; +} + +.chatlog__edited-timestamp { + margin-left: 5px; + font-size: .8em; +} + +.chatlog__attachment { + margin-top: 5px; + margin-bottom: 5px; +} + +.chatlog__attachment-thumbnail { + max-width: 50%; + max-height: 500px; + border-radius: 3px; +} + +.chatlog__embed { + display: flex; + max-width: 520px; + margin-top: 5px; +} + +.chatlog__embed-color-pill { + flex-shrink: 0; + width: 4px; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.chatlog__embed-content-container { + display: flex; + flex-direction: column; + padding-left: 10px; + padding-top: 8px; + padding-right: 10px; + padding-bottom: 8px; + border: 1px solid; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.chatlog__embed-content { + width: 100%; + display: flex; +} + +.chatlog__embed-text { + flex: 1; +} + +.chatlog__embed-author { + display: flex; + align-items: center; + margin-bottom: 5px; +} + +.chatlog__embed-author-icon { + width: 20px; + height: 20px; + margin-right: 9px; + border-radius: 50%; +} + +.chatlog__embed-author-name { + font-size: .875em; + font-weight: 600; +} + +.chatlog__embed-title { + margin-bottom: 4px; + font-size: .875em; + font-weight: 600; +} + +.chatlog__embed-description { + font-weight: 500; + font-size: 14px; +} + +.chatlog__embed-fields { + display: flex; + flex-wrap: wrap; +} + +.chatlog__embed-field { + flex: 0; + min-width: 100%; + max-width: 506px; + padding-top: 10px; +} + +.chatlog__embed-field--inline { + flex: 1; + flex-basis: auto; + min-width: 150px; +} + +.chatlog__embed-field-name { + margin-bottom: 4px; + font-size: .875em; + font-weight: 600; +} + +.chatlog__embed-field-value { + font-size: .875em; + font-weight: 500; +} + +.chatlog__embed-thumbnail { + flex: 0; + margin-left: 20px; + max-width: 80px; + max-height: 80px; + border-radius: 3px; +} + +.chatlog__embed-image-container { + margin-top: 10px; +} + +.chatlog__embed-image { + max-width: 500px; + max-height: 400px; + border-radius: 3px; +} + +.chatlog__embed-footer { + margin-top: 10px; +} + +.chatlog__embed-footer-icon { + margin-right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + vertical-align: middle; +} + +.chatlog__embed-footer-text { + font-weight: 600; + font-size: .75em; +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark.html b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark.html new file mode 100644 index 0000000..24575e3 --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark.html @@ -0,0 +1,7 @@ +{{ + $SharedStyleSheet = include "Html.Shared.css" + $ThemeStyleSheet = include "Html.DarkTheme.css" + StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet +}} + +{{ include "Html.Core.html" }} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight.html b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight.html new file mode 100644 index 0000000..84f7a1b --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight.html @@ -0,0 +1,7 @@ +{{ + $SharedStyleSheet = include "Html.Shared.css" + $ThemeStyleSheet = include "Html.LightTheme.css" + StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet +}} + +{{ include "Html.Core.html" }} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/PlainText.txt b/DiscordChatExporter.Core/Resources/ExportTemplates/PlainText.txt new file mode 100644 index 0000000..ca0bf09 --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/PlainText.txt @@ -0,0 +1,17 @@ +============================================================== +Guild: {{ Guild.Name }} +Channel: {{ Channel.Name }} +Topic: {{ Channel.Topic }} +Messages: {{ TotalMessageCount | Format "N0" }} +============================================================== + +{{~ for group in MessageGroups ~}} + {{~ group.Author.FullName }} [{{ group.TimeStamp | FormatDate }}] + {{~ for message in group.Messages ~}} + {{~ message.Content | FormatContent }} + {{~ for attachment in message.Attachments ~}} + {{~ attachment.Url }} + {{~ end ~}} + {{~ end ~}} + +{{~ end ~}} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/DataService.Parsers.cs b/DiscordChatExporter.Core/Services/DataService.Parsers.cs new file mode 100644 index 0000000..c197a57 --- /dev/null +++ b/DiscordChatExporter.Core/Services/DataService.Parsers.cs @@ -0,0 +1,183 @@ +using System; +using System.Drawing; +using System.Linq; +using DiscordChatExporter.Core.Internal; +using DiscordChatExporter.Core.Models; +using Newtonsoft.Json.Linq; +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Core.Services +{ + public partial class DataService + { + private User ParseUser(JToken json) + { + var id = json["id"].Value(); + var discriminator = json["discriminator"].Value(); + var name = json["username"].Value(); + var avatarHash = json["avatar"].Value(); + + return new User(id, discriminator, name, avatarHash); + } + + private Guild ParseGuild(JToken json) + { + var id = json["id"].Value(); + var name = json["name"].Value(); + var iconHash = json["icon"].Value(); + + return new Guild(id, name, iconHash); + } + + private Channel ParseChannel(JToken json) + { + // Get basic data + var id = json["id"].Value(); + var type = (ChannelType) json["type"].Value(); + var topic = json["topic"]?.Value(); + + // Try to extract guild ID + var guildId = json["guild_id"]?.Value(); + + // If the guild ID is blank, it's direct messages + if (guildId.IsBlank()) + guildId = Guild.DirectMessages.Id; + + // Try to extract name + var name = json["name"]?.Value(); + + // If the name is blank, it's direct messages + if (name.IsBlank()) + name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", "); + + return new Channel(id, guildId, name, topic, type); + } + + private Role ParseRole(JToken json) + { + var id = json["id"].Value(); + var name = json["name"].Value(); + + return new Role(id, name); + } + + private EmbedAuthor ParseEmbedAuthor(JToken json) + { + var name = json["name"]?.Value(); + var url = json["url"]?.Value(); + var iconUrl = json["icon_url"]?.Value(); + + return new EmbedAuthor(name, url, iconUrl); + } + + private EmbedField ParseEmbedField(JToken json) + { + var name = json["name"].Value(); + var value = json["value"].Value(); + var isInline = json["inline"]?.Value() ?? false; + + return new EmbedField(name, value, isInline); + } + + private Attachment ParseAttachment(JToken json) + { + var id = json["id"].Value(); + var url = json["url"].Value(); + var isImage = json["width"] != null; + var fileName = json["filename"].Value(); + var fileSize = json["size"].Value(); + + return new Attachment(id, isImage, url, fileName, fileSize); + } + + private EmbedImage ParseEmbedImage(JToken json) + { + var url = json["url"]?.Value(); + var height = json["height"]?.Value(); + var width = json["width"]?.Value(); + + return new EmbedImage(url, height, width); + } + + private EmbedFooter ParseEmbedFooter(JToken json) + { + var text = json["text"].Value(); + var iconUrl = json["icon_url"]?.Value(); + + return new EmbedFooter(text, iconUrl); + } + + private Embed ParseEmbed(JToken json) + { + // Get basic data + var title = json["title"]?.Value(); + var description = json["description"]?.Value(); + var url = json["url"]?.Value(); + var timestamp = json["timestamp"]?.Value(); + + // Get color + var color = json["color"] != null + ? Color.FromArgb(json["color"].Value()).ResetAlpha() + : Color.FromArgb(79, 84, 92); // default color + + // Get author + var author = json["author"] != null ? ParseEmbedAuthor(json["author"]) : null; + + // Get fields + var fields = json["fields"].EmptyIfNull().Select(ParseEmbedField).ToArray(); + + // Get thumbnail + var thumbnail = json["thumbnail"] != null ? ParseEmbedImage(json["thumbnail"]) : null; + + // Get image + var image = json["image"] != null ? ParseEmbedImage(json["image"]) : null; + + // Get footer + var footer = json["footer"] != null ? ParseEmbedFooter(json["footer"]) : null; + + return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer); + } + + private Message ParseMessage(JToken json) + { + // Get basic data + var id = json["id"].Value(); + var channelId = json["channel_id"].Value(); + var timestamp = json["timestamp"].Value(); + var editedTimestamp = json["edited_timestamp"]?.Value(); + var content = json["content"].Value(); + var type = (MessageType) json["type"].Value(); + + // Workarounds for non-default types + if (type == MessageType.RecipientAdd) + content = "Added a recipient."; + else if (type == MessageType.RecipientRemove) + content = "Removed a recipient."; + else if (type == MessageType.Call) + content = "Started a call."; + else if (type == MessageType.ChannelNameChange) + content = "Changed the channel name."; + else if (type == MessageType.ChannelIconChange) + content = "Changed the channel icon."; + else if (type == MessageType.ChannelPinnedMessage) + content = "Pinned a message."; + else if (type == MessageType.GuildMemberJoin) + content = "Joined the server."; + + // Get author + var author = ParseUser(json["author"]); + + // Get attachments + var attachments = json["attachments"].EmptyIfNull().Select(ParseAttachment).ToArray(); + + // Get embeds + var embeds = json["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray(); + + // Get mentioned users + var mentionedUsers = json["mentioned_users"].EmptyIfNull().Select(ParseUser).ToArray(); + + return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds, + mentionedUsers); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/DataService.cs b/DiscordChatExporter.Core/Services/DataService.cs index 3b8b9dc..d20f23d 100644 --- a/DiscordChatExporter.Core/Services/DataService.cs +++ b/DiscordChatExporter.Core/Services/DataService.cs @@ -2,297 +2,29 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; -using System.Text.RegularExpressions; using System.Threading.Tasks; using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Models; using Newtonsoft.Json.Linq; -using Tyrrrz.Extensions; -using System.Drawing; -using System.Numerics; +using DiscordChatExporter.Core.Internal; namespace DiscordChatExporter.Core.Services { public partial class DataService : IDataService, IDisposable { - private const string ApiRoot = "https://discordapp.com/api/v6"; - private readonly HttpClient _httpClient = new HttpClient(); - private readonly Dictionary _userCache = new Dictionary(); - private readonly Dictionary _roleCache = new Dictionary(); - private readonly Dictionary _channelCache = new Dictionary(); - - private User ParseUser(JToken token) - { - var id = token["id"].Value(); - var discriminator = token["discriminator"].Value(); - var name = token["username"].Value(); - var avatarHash = token["avatar"].Value(); - - return new User(id, discriminator, name, avatarHash); - } - - private Role ParseRole(JToken token) - { - var id = token["id"].Value(); - var name = token["name"].Value(); - - return new Role(id, name); - } - - private Guild ParseGuild(JToken token) - { - var id = token["id"].Value(); - var name = token["name"].Value(); - var iconHash = token["icon"].Value(); - var roles = token["roles"].Select(ParseRole).ToArray(); - - return new Guild(id, name, iconHash, roles); - } - - private Channel ParseChannel(JToken token) - { - // Get basic data - var id = token["id"].Value(); - var guildId = token["guild_id"]?.Value(); - var type = (ChannelType) token["type"].Value(); - var topic = token["topic"]?.Value(); - - // Extract name based on type - string name; - if (type.IsEither(ChannelType.DirectTextChat, ChannelType.DirectGroupTextChat)) - { - guildId = Guild.DirectMessages.Id; - - // Try to get name if it's set - name = token["name"]?.Value(); - - // Otherwise use recipients as the name - if (name.IsBlank()) - name = token["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", "); - } - else - { - name = token["name"].Value(); - } - - return new Channel(id, guildId, name, topic, type); - } - - private Embed ParseEmbed(JToken token) - { - - // var embedFileSize = embedJson["size"].Value(); - var title = token["title"]?.Value(); - var type = token["type"]?.Value(); - var description = token["description"]?.Value(); - var url = token["url"]?.Value(); - var timestamp = token["timestamp"]?.Value(); - var color = token["color"] != null - ? Color.FromArgb(token["color"].Value()) - : (Color?)null; - - var footerNode = token["footer"]; - var footer = footerNode != null - ? new EmbedFooter( - footerNode["text"]?.Value(), - footerNode["icon_url"]?.Value(), - footerNode["proxy_icon_url"]?.Value()) - : null; - - var imageNode = token["image"]; - var image = imageNode != null - ? new EmbedImage( - imageNode["url"]?.Value(), - imageNode["proxy_url"]?.Value(), - imageNode["height"]?.Value(), - imageNode["width"]?.Value()) - : null; - - var thumbnailNode = token["thumbnail"]; - var thumbnail = thumbnailNode != null - ? new EmbedImage( - thumbnailNode["url"]?.Value(), - thumbnailNode["proxy_url"]?.Value(), - thumbnailNode["height"]?.Value(), - thumbnailNode["width"]?.Value()) - : null; - - var videoNode = token["video"]; - var video = videoNode != null - ? new EmbedVideo( - videoNode["url"]?.Value(), - videoNode["height"]?.Value(), - videoNode["width"]?.Value()) - : null; - - var providerNode = token["provider"]; - var provider = providerNode != null - ? new EmbedProvider( - providerNode["name"]?.Value(), - providerNode["url"]?.Value()) - : null; - - var authorNode = token["author"]; - var author = authorNode != null - ? new EmbedAuthor( - authorNode["name"]?.Value(), - authorNode["url"]?.Value(), - authorNode["icon_url"]?.Value(), - authorNode["proxy_icon_url"]?.Value()) - : null; - - var fields = new List(); - foreach (var fieldNode in token["fields"].EmptyIfNull()) - { - fields.Add(new EmbedField( - fieldNode["name"]?.Value(), - fieldNode["value"]?.Value(), - fieldNode["inline"]?.Value())); - } - - var mentionableContent = description ?? ""; - fields.ForEach(f => mentionableContent += f.Value); - - // Get user mentions - var mentionedUsers = Regex.Matches(mentionableContent, "<@!?(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .Select(i => _userCache.GetOrDefault(i) ?? User.CreateUnknownUser(i)) - .ToList(); - - // Get role mentions - var mentionedRoles = Regex.Matches(mentionableContent, "<@&(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(i)) - .ToList(); - - // Get channel mentions - var mentionedChannels = Regex.Matches(mentionableContent, "<#(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(i)) - .ToList(); - - return new Embed( - title, type, description, - url, timestamp, color, - footer, image, thumbnail, - video, provider, author, - fields, mentionedUsers, mentionedRoles, mentionedChannels); - } - - private Message ParseMessage(JToken token) - { - // Get basic data - var id = token["id"].Value(); - var channelId = token["channel_id"].Value(); - var timeStamp = token["timestamp"].Value(); - var editedTimeStamp = token["edited_timestamp"]?.Value(); - var content = token["content"].Value(); - var type = (MessageType) token["type"].Value(); - - // Workarounds for non-default types - if (type == MessageType.RecipientAdd) - content = "Added a recipient."; - else if (type == MessageType.RecipientRemove) - content = "Removed a recipient."; - else if (type == MessageType.Call) - content = "Started a call."; - else if (type == MessageType.ChannelNameChange) - content = "Changed the channel name."; - else if (type == MessageType.ChannelIconChange) - content = "Changed the channel icon."; - else if (type == MessageType.ChannelPinnedMessage) - content = "Pinned a message."; - else if (type == MessageType.GuildMemberJoin) - content = "Joined the server."; - - // Get author - var author = ParseUser(token["author"]); - - // Get attachment - var attachments = new List(); - foreach (var attachmentJson in token["attachments"].EmptyIfNull()) - { - var attachmentId = attachmentJson["id"].Value(); - var attachmentUrl = attachmentJson["url"].Value(); - var attachmentType = attachmentJson["width"] != null - ? AttachmentType.Image - : AttachmentType.Other; - var attachmentFileName = attachmentJson["filename"].Value(); - var attachmentFileSize = attachmentJson["size"].Value(); - - var attachment = new Attachment( - attachmentId, attachmentType, attachmentUrl, - attachmentFileName, attachmentFileSize); - attachments.Add(attachment); - } - - // Get embeds - var embeds = token["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray(); - - // Get user mentions - var mentionedUsers = token["mentions"].Select(ParseUser).ToList(); - - // Get role mentions - var mentionedRoles = token["mention_roles"] - .Values() - .Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(i)) - .ToList(); - - // Get channel mentions - var mentionedChannels = Regex.Matches(content, "<#(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(i)) - .ToList(); - return new Message(id, channelId, type, author, timeStamp, editedTimeStamp, content, attachments, embeds, - mentionedUsers, mentionedRoles, mentionedChannels); - } - - /// - /// Attempts to query for users, channels, and roles if they havent been found yet, and set them in the mentionable - /// - private async Task FillMentionable(string token, string guildId, IMentionable mentionable) + private async Task GetApiResponseAsync(string token, string resource, string endpoint, params string[] parameters) { - for (int i = 0; i < mentionable.MentionedUsers.Count; i++) - { - var user = mentionable.MentionedUsers[i]; - if (user.Name == "Unknown" && user.Discriminator == 0) - { - try - { - mentionable.MentionedUsers[i] = _userCache.GetOrDefault(user.Id) ?? (await GetMemberAsync(token, guildId, user.Id)); - } - catch (HttpErrorStatusCodeException e) { } // This likely means the user doesnt exist any more, so ignore - } - } + // Format URL + const string apiRoot = "https://discordapp.com/api/v6"; + var url = $"{apiRoot}/{resource}/{endpoint}?token={token}"; - for (int i = 0; i < mentionable.MentionedChannels.Count; i++) - { - var channel = mentionable.MentionedChannels[i]; - if (channel.Name == "deleted-channel" && channel.GuildId == null) - { - try - { - mentionable.MentionedChannels[i] = _channelCache.GetOrDefault(channel.Id) ?? (await GetChannelAsync(token, channel.Id)); - } - catch (HttpErrorStatusCodeException e) { } // This likely means the user doesnt exist any more, so ignore - } - } + // Add parameters + foreach (var parameter in parameters) + url += $"&{parameter}"; - // Roles are already gotten via GetGuildRolesAsync at the start - } - - private async Task GetStringAsync(string url) - { + // Send request using (var response = await _httpClient.GetAsync(url)) { // Check status code @@ -301,213 +33,84 @@ namespace DiscordChatExporter.Core.Services throw new HttpErrorStatusCodeException(response.StatusCode); // Get content - return await response.Content.ReadAsStringAsync(); + var raw = await response.Content.ReadAsStringAsync(); + + // Parse + return JToken.Parse(raw); } } public async Task GetGuildAsync(string token, string guildId) { - // Form request url - var url = $"{ApiRoot}/guilds/{guildId}?token={token}"; - - // Get response - var content = await GetStringAsync(url); - - // Parse - var guild = ParseGuild(JToken.Parse(content)); - - // Add roles to cache - foreach (var role in guild.Roles) - _roleCache[role.Id] = role; + var response = await GetApiResponseAsync(token, "guilds", guildId); + var guild = ParseGuild(response); return guild; } public async Task GetChannelAsync(string token, string channelId) { - // Form request url - var url = $"{ApiRoot}/channels/{channelId}?token={token}"; - - // Get response - var content = await GetStringAsync(url); - - // Parse - var channel = ParseChannel(JToken.Parse(content)); - - // Add channel to cache - _channelCache[channel.Id] = channel; + var response = await GetApiResponseAsync(token, "channels", channelId); + var channel = ParseChannel(response); return channel; } - public async Task GetMemberAsync(string token, string guildId, string memberId) - { - // Form request url - var url = $"{ApiRoot}/guilds/{guildId}/members/{memberId}?token={token}"; - - // Get response - var content = await GetStringAsync(url); - - // Parse - var user = ParseUser(JToken.Parse(content)["user"]); - - // Add user to cache - _userCache[user.Id] = user; - - return user; - } - - public async Task> GetGuildChannelsAsync(string token, string guildId) - { - // Form request url - var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}"; - - // Get response - var content = await GetStringAsync(url); - - // Parse - var channels = JArray.Parse(content).Select(ParseChannel).ToArray(); - - // Add channels to cache - foreach (var channel in channels) - _channelCache[channel.Id] = channel; - - return channels; - } - - - public async Task> GetGuildRolesAsync(string token, string guildId) - { - // Form request url - var url = $"{ApiRoot}/guilds/{guildId}/roles?token={token}"; - - // Get response - var content = await GetStringAsync(url); - - // Parse - var roles = JArray.Parse(content).Select(ParseRole).ToArray(); - - // Add roles to cache - foreach (var role in roles) - _roleCache[role.Id] = role; - - return roles; - } - public async Task> GetUserGuildsAsync(string token) { - // Form request url - var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100"; - - // Get response - var content = await GetStringAsync(url); - - // Parse IDs - var guildIds = JArray.Parse(content).Select(t => t["id"].Value()); - - // Get full guild infos - var guilds = new List(); - foreach (var guildId in guildIds) - { - var guild = await GetGuildAsync(token, guildId); - guilds.Add(guild); - } + var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100"); + var guilds = response.Select(ParseGuild).ToArray(); return guilds; } public async Task> GetDirectMessageChannelsAsync(string token) { - // Form request url - var url = $"{ApiRoot}/users/@me/channels?token={token}"; - - // Get response - var content = await GetStringAsync(url); - - // Parse - var channels = JArray.Parse(content).Select(ParseChannel).ToArray(); + var response = await GetApiResponseAsync(token, "users", "@me/channels"); + var channels = response.Select(ParseChannel).ToArray(); return channels; } - public async Task> GetGuildMembersAsync(string token, string guildId) + public async Task> GetGuildChannelsAsync(string token, string guildId) { - var result = new List(); - - var afterId = ""; - while (true) - { - // Form request url - var url = $"{ApiRoot}/guilds/{guildId}/members?token={token}&limit=1000"; - if (afterId.IsNotBlank()) - url += $"&after={afterId}"; + var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels"); + var channels = response.Select(ParseChannel).ToArray(); - // Get response - var content = await GetStringAsync(url); - - // Parse - var users = JArray.Parse(content).Select(m => ParseUser(m["user"])); - - // Add user to cache - foreach (var user in users) - _userCache[user.Id] = user; - - // Add users to list - string currentUserId = null; - foreach (var user in users) - { - // Add user - result.Add(user); - if (currentUserId == null || BigInteger.Parse(user.Id) > BigInteger.Parse(currentUserId)) - currentUserId = user.Id; - } - - // If no users - break - if (currentUserId == null) - break; + return channels; + } - // Otherwise offset the next request - afterId = currentUserId; - } + public async Task> GetGuildRolesAsync(string token, string guildId) + { + var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles"); + var roles = response.Select(ParseRole).ToArray(); - return result; + return roles; } public async Task> GetChannelMessagesAsync(string token, string channelId, DateTime? from, DateTime? to) { - Channel channel = await GetChannelAsync(token, channelId); - - try - { - await GetGuildRolesAsync(token, channel.GuildId); - } - catch (HttpErrorStatusCodeException e) { } // This will be thrown if the user doesnt have the MANAGE_ROLES permission for the guild - var result = new List(); // We are going backwards from last message to first // collecting everything between them in batches - var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null; + var beforeId = to?.ToSnowflake() ?? DateTime.MaxValue.ToSnowflake(); while (true) { - // Form request url - var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100"; - if (beforeId.IsNotBlank()) - url += $"&before={beforeId}"; - // Get response - var content = await GetStringAsync(url); + var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages", + "limit=100", $"before={beforeId}"); // Parse - var messages = JArray.Parse(content).Select(ParseMessage); + var messages = response.Select(ParseMessage); // Add messages to list string currentMessageId = null; foreach (var message in messages) { // Break when the message is older than from date - if (from != null && message.TimeStamp < from) + if (from != null && message.Timestamp < from) { currentMessageId = null; break; @@ -529,39 +132,39 @@ namespace DiscordChatExporter.Core.Services // Messages appear newest first, we need to reverse result.Reverse(); - foreach (var message in result) - { - await FillMentionable(token, channel.GuildId, message); - foreach (var embed in message.Embeds) - await FillMentionable(token, channel.GuildId, embed); - } - return result; } - protected virtual void Dispose(bool disposing) + public async Task GetMentionablesAsync(string token, string guildId, + IEnumerable messages) { - if (disposing) + // Get channels and roles + var channels = guildId != Guild.DirectMessages.Id + ? await GetGuildChannelsAsync(token, guildId) + : Array.Empty(); + var roles = guildId != Guild.DirectMessages.Id + ? await GetGuildRolesAsync(token, guildId) + : Array.Empty(); + + // Get users + var userMap = new Dictionary(); + foreach (var message in messages) { - _httpClient.Dispose(); + // Author + userMap[message.Author.Id] = message.Author; + + // Mentioned users + foreach (var mentionedUser in message.MentionedUsers) + userMap[mentionedUser.Id] = mentionedUser; } - } + var users = userMap.Values.ToArray(); - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); + return new Mentionables(users, channels, roles); } - } - public partial class DataService - { - private static string DateTimeToSnowflake(DateTime dateTime) + public void Dispose() { - const long epoch = 62135596800000; - var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch; - var value = ((ulong) unixTime - 1420070400000UL) << 22; - return value.ToString(); + _httpClient.Dispose(); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/ExportService.Csv.cs b/DiscordChatExporter.Core/Services/ExportService.Csv.cs deleted file mode 100644 index b8a6304..0000000 --- a/DiscordChatExporter.Core/Services/ExportService.Csv.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using CsvHelper; -using DiscordChatExporter.Core.Models; -using Tyrrrz.Extensions; - -namespace DiscordChatExporter.Core.Services -{ - public partial class ExportService - { - private string FormatMessageContentCsv(Message message) - { - var content = message.Content; - - // New lines - content = content.Replace("\n", ", "); - - // User mentions (<@id> and <@!id>) - foreach (var mentionedUser in message.MentionedUsers) - content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}"); - - // Role mentions (<@&id>) - foreach (var mentionedRole in message.MentionedRoles) - content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}"); - - // Channel mentions (<#id>) - foreach (var mentionedChannel in message.MentionedChannels) - content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}"); - - // Custom emojis (<:name:id>) - content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1"); - - return content; - } - - private async Task ExportAsCsvAsync(ChannelChatLog log, TextWriter output) - { - using (var writer = new CsvWriter(output)) - { - // Headers - writer.WriteField("Author"); - writer.WriteField("Date"); - writer.WriteField("Content"); - writer.WriteField("Attachments"); - await writer.NextRecordAsync(); - - // Chat log - foreach (var group in log.MessageGroups) - { - // Messages - foreach (var msg in group.Messages) - { - // Author - writer.WriteField(msg.Author.FullName); - - // Date - var timeStampFormatted = msg.TimeStamp.ToString(_settingsService.DateFormat); - writer.WriteField(timeStampFormatted); - - // Content - var contentFormatted = msg.Content.IsNotBlank() ? FormatMessageContentCsv(msg) : null; - writer.WriteField(contentFormatted); - - // Attachments - var attachmentsFormatted = msg.Attachments.Select(a => a.Url).JoinToString(","); - writer.WriteField(attachmentsFormatted); - - await writer.NextRecordAsync(); - } - } - } - } - } -} diff --git a/DiscordChatExporter.Core/Services/ExportService.Html.cs b/DiscordChatExporter.Core/Services/ExportService.Html.cs deleted file mode 100644 index 1b79a77..0000000 --- a/DiscordChatExporter.Core/Services/ExportService.Html.cs +++ /dev/null @@ -1,365 +0,0 @@ -using System.IO; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using Tyrrrz.Extensions; -using System.Drawing; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace DiscordChatExporter.Core.Services -{ - public partial class ExportService - { - private string MarkdownToHtml(string content, IMentionable mentionable = null, bool allowLinks = false) - { - // A lot of these regexes were inspired by or taken from MarkdownSharp - - // HTML-encode content - content = HtmlEncode(content); - - // Encode multiline codeblocks (```text```) - content = Regex.Replace(content, - @"```+(?:[^`]*?\n)?([^`]+)\n?```+", - m => $"\x1AM{Base64Encode(m.Groups[1].Value)}\x1AM"); - - // Encode inline codeblocks (`text`) - content = Regex.Replace(content, - @"`([^`]+)`", - m => $"\x1AI{Base64Encode(m.Groups[1].Value)}\x1AI"); - - // Encode URLs - content = Regex.Replace(content, - @"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))", - m => $"\x1AL{Base64Encode(m.Groups[1].Value)}\x1AL"); - - // Process bold (**text**) - content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "$2"); - - // Process underline (__text__) - content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "$2"); - - // Process italic (*text* or _text_) - content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "$2"); - - // Process strike through (~~text~~) - content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "$2"); - - // Decode and process multiline codeblocks - content = Regex.Replace(content, "\x1AM(.*?)\x1AM", - m => $"
{Base64Decode(m.Groups[1].Value)}
"); - - // Decode and process inline codeblocks - content = Regex.Replace(content, "\x1AI(.*?)\x1AI", - m => $"{Base64Decode(m.Groups[1].Value)}"); - - if (allowLinks) - { - content = Regex.Replace(content, "\\[([^\\]]+)\\]\\(\x1AL(.*?)\x1AL\\)", - m => $"{m.Groups[1].Value}"); - } - - // Decode and process URLs - content = Regex.Replace(content, "\x1AL(.*?)\x1AL", - m => $"{Base64Decode(m.Groups[1].Value)}"); - - // New lines - content = content.Replace("\n", "
"); - - // Meta mentions (@everyone) - content = content.Replace("@everyone", "@everyone"); - - // Meta mentions (@here) - content = content.Replace("@here", "@here"); - - if (mentionable != null) - { - // User mentions (<@id> and <@!id>) - foreach (var mentionedUser in mentionable.MentionedUsers) - { - content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", - $"" + - $"@{HtmlEncode(mentionedUser.Name)}" + - ""); - } - - // Role mentions (<@&id>) - foreach (var mentionedRole in mentionable.MentionedRoles) - { - content = content.Replace($"<@&{mentionedRole.Id}>", - "" + - $"@{HtmlEncode(mentionedRole.Name)}" + - ""); - } - - // Channel mentions (<#id>) - foreach (var mentionedChannel in mentionable.MentionedChannels) - { - content = content.Replace($"<#{mentionedChannel.Id}>", - "" + - $"#{HtmlEncode(mentionedChannel.Name)}" + - ""); - } - } - - // Custom emojis (<:name:id>) - content = Regex.Replace(content, "<(:.*?:)(\\d*)>", - ""); - - return content; - } - - private string FormatMessageContentHtml(Message message) - { - return MarkdownToHtml(message.Content, message); - } - - // The code used to convert embeds to html was based heavily off of the Embed Visualizer project, from this file: - // https://github.com/leovoel/embed-visualizer/blob/master/src/components/embed.jsx - - private string EmbedColorPillToHtml(Color? color) - { - string backgroundColor = ""; - - if (color != null) - backgroundColor = $"rgba({color?.R},{color?.G},{color?.B},1)"; - - return $"
"; - } - - private string EmbedTitleToHtml(string title, string url) - { - if (title == null) - return null; - - string computed = $"
{MarkdownToHtml(title)}
"; - if (url != null) - computed = $"{MarkdownToHtml(title)}"; - - return computed; - } - - private string EmbedDescriptionToHtml(string content, IMentionable mentionable) - { - if (content == null) - return null; - - return $"
{MarkdownToHtml(content, mentionable, true)}
"; - } - - private string EmbedAuthorToHtml(string name, string url, string icon_url) - { - if (name == null) - return null; - - string authorName = null; - if (name != null) - { - authorName = $"{name}"; - if (url != null) - authorName = $"{name}"; - } - - string authorIcon = icon_url != null ? $"" : null; - - return $"
{authorIcon}{authorName}
"; - } - - private string EmbedFieldToHtml(string name, string value, bool? inline, IMentionable mentionable) - { - if (name == null && value == null) - return null; - - string cls = "embed-field" + (inline == true ? " embed-field-inline" : ""); - - string fieldName = name != null ? $"
{MarkdownToHtml(name)}
" : null; - string fieldValue = value != null ? $"
{MarkdownToHtml(value, mentionable, true)}
" : null; - - return $"
{fieldName}{fieldValue}
"; - } - - private string EmbedThumbnailToHtml(string url) - { - if (url == null) - return null; - - return $@" - "; - } - - private string EmbedImageToHtml(string url) - { - if (url == null) - return null; - - return $""; - } - - private string EmbedFooterToHtml(DateTime? timestamp, string text, string icon_url) - { - if (text == null && timestamp == null) - return null; - - // format: ddd MMM Do, YYYY [at] h:mm A - - string time = timestamp != null ? HtmlEncode(timestamp?.ToString(_settingsService.DateFormat)) : null; - - string footerText = string.Join(" | ", new List { text, time }.Where(s => s != null)); - string footerIcon = text != null && icon_url != null - ? $"" - : null; - - return $"
{footerIcon}{footerText}
"; - } - - private string EmbedFieldsToHtml(IReadOnlyList fields, IMentionable mentionable) - { - if (fields.Count == 0) - return null; - - return $"
{string.Join("", fields.Select(f => EmbedFieldToHtml(f.Name, f.Value, f.Inline, mentionable)))}
"; - } - - private string FormatEmbedHtml(Embed embed) - { - return $@" -
-
- {EmbedColorPillToHtml(embed.Color)} -
-
-
- {EmbedAuthorToHtml(embed.Author?.Name, embed.Author?.Url, embed.Author?.IconUrl)} - {EmbedTitleToHtml(embed.Title, embed.Url)} - {EmbedDescriptionToHtml(embed.Description, embed)} - {EmbedFieldsToHtml(embed.Fields, embed)} -
- {EmbedThumbnailToHtml(embed.Thumbnail?.Url)} -
- {EmbedImageToHtml(embed.Image?.Url)} - {EmbedFooterToHtml(embed.TimeStamp, embed.Footer?.Text, embed.Footer?.IconUrl)} -
-
-
"; - } - - private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css) - { - // Generation info - await output.WriteLineAsync(""); - - // Html start - await output.WriteLineAsync(""); - await output.WriteLineAsync(""); - - // HEAD - await output.WriteLineAsync(""); - await output.WriteLineAsync($"{log.Guild.Name} - {log.Channel.Name}"); - await output.WriteLineAsync(""); - await output.WriteLineAsync(""); - await output.WriteLineAsync($""); - await output.WriteLineAsync(""); - - // Body start - await output.WriteLineAsync(""); - - // Guild and channel info - await output.WriteLineAsync("
"); - await output.WriteLineAsync("
"); - await output.WriteLineAsync($""); - await output.WriteLineAsync("
"); // info-left - await output.WriteLineAsync("
"); - await output.WriteLineAsync($"
{log.Guild.Name}
"); - await output.WriteLineAsync($"
{log.Channel.Name}
"); - await output.WriteLineAsync($"
{log.Channel.Topic}
"); - await output.WriteLineAsync( - $"
{log.TotalMessageCount:N0} messages
"); - await output.WriteLineAsync("
"); // info-right - await output.WriteLineAsync("
"); // info - - // Chat log - await output.WriteLineAsync("
"); - foreach (var group in log.MessageGroups) - { - await output.WriteLineAsync("
"); - await output.WriteLineAsync("
"); - await output.WriteLineAsync($""); - await output.WriteLineAsync("
"); - - await output.WriteLineAsync("
"); - await output.WriteAsync( - $""); - await output.WriteAsync(HtmlEncode(group.Author.Name)); - await output.WriteLineAsync(""); - var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat)); - await output.WriteLineAsync($"{timeStampFormatted}"); - - // Messages - foreach (var message in group.Messages) - { - // Content - if (message.Content.IsNotBlank()) - { - await output.WriteLineAsync("
"); - var contentFormatted = FormatMessageContentHtml(message); - await output.WriteAsync(contentFormatted); - - // Edited timestamp - if (message.EditedTimeStamp != null) - { - var editedTimeStampFormatted = - HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat)); - await output.WriteAsync( - $"(edited)"); - } - - await output.WriteLineAsync("
"); // msg-content - } - - // Attachments - foreach (var attachment in message.Attachments) - { - if (attachment.Type == AttachmentType.Image) - { - await output.WriteLineAsync("
"); - await output.WriteLineAsync($""); - await output.WriteLineAsync($""); - await output.WriteLineAsync(""); - await output.WriteLineAsync("
"); - } - else - { - await output.WriteLineAsync(""); - } - } - - // Embeds - foreach (var embed in message.Embeds) - { - var contentFormatted = FormatEmbedHtml(embed); - await output.WriteAsync(contentFormatted); - } - } - - await output.WriteLineAsync("
"); // msg-right - await output.WriteLineAsync("
"); // msg - } - - await output.WriteLineAsync("
"); // log - - await output.WriteLineAsync(""); - await output.WriteLineAsync(""); - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/ExportService.PlainText.cs b/DiscordChatExporter.Core/Services/ExportService.PlainText.cs deleted file mode 100644 index af0734a..0000000 --- a/DiscordChatExporter.Core/Services/ExportService.PlainText.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.IO; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using Tyrrrz.Extensions; - -namespace DiscordChatExporter.Core.Services -{ - public partial class ExportService - { - private string FormatMessageContentPlainText(Message message) - { - var content = message.Content; - - // New lines - content = content.Replace("\n", Environment.NewLine); - - // User mentions (<@id> and <@!id>) - foreach (var mentionedUser in message.MentionedUsers) - content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}"); - - // Role mentions (<@&id>) - foreach (var mentionedRole in message.MentionedRoles) - content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}"); - - // Channel mentions (<#id>) - foreach (var mentionedChannel in message.MentionedChannels) - content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}"); - - // Custom emojis (<:name:id>) - content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1"); - - return content; - } - - private async Task ExportAsPlainTextAsync(ChannelChatLog log, TextWriter output) - { - // Generation info - await output.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter"); - await output.WriteLineAsync(); - - // Guild and channel info - await output.WriteLineAsync('='.Repeat(48)); - await output.WriteLineAsync($"Guild: {log.Guild.Name}"); - await output.WriteLineAsync($"Channel: {log.Channel.Name}"); - await output.WriteLineAsync($"Topic: {log.Channel.Topic}"); - await output.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}"); - await output.WriteLineAsync('='.Repeat(48)); - await output.WriteLineAsync(); - - // Chat log - foreach (var group in log.MessageGroups) - { - var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat); - await output.WriteLineAsync($"{group.Author.FullName} [{timeStampFormatted}]"); - - // Messages - foreach (var message in group.Messages) - { - // Content - if (message.Content.IsNotBlank()) - { - var contentFormatted = FormatMessageContentPlainText(message); - await output.WriteLineAsync(contentFormatted); - } - - // Attachments - foreach (var attachment in message.Attachments) - { - await output.WriteLineAsync(attachment.Url); - } - } - - await output.WriteLineAsync(); - } - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/ExportService.TemplateLoader.cs b/DiscordChatExporter.Core/Services/ExportService.TemplateLoader.cs new file mode 100644 index 0000000..62f0675 --- /dev/null +++ b/DiscordChatExporter.Core/Services/ExportService.TemplateLoader.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using DiscordChatExporter.Core.Models; +using Scriban; +using Scriban.Parsing; +using Scriban.Runtime; +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Core.Services +{ + public partial class ExportService + { + private class TemplateLoader : ITemplateLoader + { + private const string ResourceRootNamespace = "DiscordChatExporter.Core.Resources.ExportTemplates"; + + public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) + { + return $"{ResourceRootNamespace}.{templateName}"; + } + + public string GetPath(ExportFormat format) + { + return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}"; + } + + public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) + { + return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath); + } + + public string Load(ExportFormat format) + { + return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format)); + } + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/ExportService.TemplateModel.cs b/DiscordChatExporter.Core/Services/ExportService.TemplateModel.cs new file mode 100644 index 0000000..8063b19 --- /dev/null +++ b/DiscordChatExporter.Core/Services/ExportService.TemplateModel.cs @@ -0,0 +1,325 @@ +using System; +using System.Drawing; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using DiscordChatExporter.Core.Internal; +using DiscordChatExporter.Core.Models; +using Scriban.Runtime; +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Core.Services +{ + public partial class ExportService + { + private class TemplateModel + { + private readonly ExportFormat _format; + private readonly ChatLog _log; + private readonly string _dateFormat; + + public TemplateModel(ExportFormat format, ChatLog log, string dateFormat) + { + _format = format; + _log = log; + _dateFormat = dateFormat; + } + + private string HtmlEncode(string str) => WebUtility.HtmlEncode(str); + + private string HtmlDecode(string str) => WebUtility.HtmlDecode(str); + + private string Format(IFormattable obj, string format) => + obj.ToString(format, CultureInfo.InvariantCulture); + + private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat); + + private string FormatFileSize(long fileSize) + { + string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}; + double size = fileSize; + var unit = 0; + + while (size >= 1024) + { + size /= 1024; + ++unit; + } + + return $"{size:0.#} {units[unit]}"; + } + + private string FormatColor(Color color) + { + return $"{color.R},{color.G},{color.B},{color.A}"; + } + + private string FormatContentPlainText(string content) + { + // New lines + content = content.Replace("\n", Environment.NewLine); + + // User mentions (<@id> and <@!id>) + var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedUserId in mentionedUserIds) + { + var mentionedUser = _log.Mentionables.GetUser(mentionedUserId); + content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}"); + } + + // Channel mentions (<#id>) + var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedChannelId in mentionedChannelIds) + { + var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId); + content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}"); + } + + // Role mentions (<@&id>) + var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedRoleId in mentionedRoleIds) + { + var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId); + content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}"); + } + + // Custom emojis (<:name:id>) + content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1"); + + return content; + } + + private string FormatContentHtml(string content, bool allowLinks = false) + { + // HTML-encode content + content = HtmlEncode(content); + + // Encode multiline codeblocks (```text```) + content = Regex.Replace(content, + @"```+(?:[^`]*?\n)?([^`]+)\n?```+", + m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM"); + + // Encode inline codeblocks (`text`) + content = Regex.Replace(content, + @"`([^`]+)`", + m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI"); + + // Encode links + if (allowLinks) + { + content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)", + m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL"); + } + + // Encode URLs + content = Regex.Replace(content, + @"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))", + m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU"); + + // Process bold (**text**) + content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "$2"); + + // Process underline (__text__) + content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "$2"); + + // Process italic (*text* or _text_) + content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "$2"); + + // Process strike through (~~text~~) + content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "$2"); + + // Decode and process multiline codeblocks + content = Regex.Replace(content, "\x1AM(.*?)\x1AM", + m => $"
{m.Groups[1].Value.Base64Decode()}
"); + + // Decode and process inline codeblocks + content = Regex.Replace(content, "\x1AI(.*?)\x1AI", + m => $"{m.Groups[1].Value.Base64Decode()}"); + + // Decode and process links + if (allowLinks) + { + content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL", + m => $"{m.Groups[1].Value.Base64Decode()}"); + } + + // Decode and process URLs + content = Regex.Replace(content, "\x1AU(.*?)\x1AU", + m => $"{m.Groups[1].Value.Base64Decode()}"); + + // Process new lines + content = content.Replace("\n", "
"); + + // Meta mentions (@everyone) + content = content.Replace("@everyone", "@everyone"); + + // Meta mentions (@here) + content = content.Replace("@here", "@here"); + + // User mentions (<@id> and <@!id>) + var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedUserId in mentionedUserIds) + { + var mentionedUser = _log.Mentionables.GetUser(mentionedUserId); + content = Regex.Replace(content, $"<@!?{mentionedUserId}>", + $"" + + $"@{HtmlEncode(mentionedUser.Name)}" + + ""); + } + + // Channel mentions (<#id>) + var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedChannelId in mentionedChannelIds) + { + var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId); + content = content.Replace($"<#{mentionedChannelId}>", + "" + + $"#{HtmlEncode(mentionedChannel.Name)}" + + ""); + } + + // Role mentions (<@&id>) + var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedRoleId in mentionedRoleIds) + { + var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId); + content = content.Replace($"<@&{mentionedRoleId}>", + "" + + $"@{HtmlEncode(mentionedRole.Name)}" + + ""); + } + + // Custom emojis (<:name:id>) + content = Regex.Replace(content, "<(:.*?:)(\\d*)>", + ""); + + return content; + } + + private string FormatContentCsv(string content) + { + // New lines + content = content.Replace("\n", ", "); + + // Escape quotes + content = content.Replace("\"", "\"\""); + + // Escape commas and semicolons + if (content.Contains(",") || content.Contains(";")) + content = $"\"{content}\""; + + // User mentions (<@id> and <@!id>) + var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedUserId in mentionedUserIds) + { + var mentionedUser = _log.Mentionables.GetUser(mentionedUserId); + content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}"); + } + + // Channel mentions (<#id>) + var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedChannelId in mentionedChannelIds) + { + var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId); + content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}"); + } + + // Role mentions (<@&id>) + var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>") + .Cast() + .Select(m => m.Groups[1].Value) + .ExceptBlank() + .ToArray(); + + foreach (var mentionedRoleId in mentionedRoleIds) + { + var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId); + content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}"); + } + + // Custom emojis (<:name:id>) + content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1"); + + return content; + } + + private string FormatContent(string content, bool allowLinks = false) + { + if (_format == ExportFormat.PlainText) + return FormatContentPlainText(content); + + if (_format == ExportFormat.HtmlDark) + return FormatContentHtml(content, allowLinks); + + if (_format == ExportFormat.HtmlLight) + return FormatContentHtml(content, allowLinks); + + if (_format == ExportFormat.Csv) + return FormatContentCsv(content); + + throw new ArgumentOutOfRangeException(nameof(_format)); + } + + public ScriptObject GetScriptObject() + { + // Create instance + var scriptObject = new ScriptObject(); + + // Import chat log + scriptObject.Import(_log, TemplateMemberFilter, TemplateMemberRenamer); + + // Import functions + scriptObject.Import(nameof(HtmlEncode), new Func(HtmlEncode)); + scriptObject.Import(nameof(HtmlDecode), new Func(HtmlDecode)); + scriptObject.Import(nameof(Format), new Func(Format)); + scriptObject.Import(nameof(FormatDate), new Func(FormatDate)); + scriptObject.Import(nameof(FormatFileSize), new Func(FormatFileSize)); + scriptObject.Import(nameof(FormatColor), new Func(FormatColor)); + scriptObject.Import(nameof(FormatContent), new Func(FormatContent)); + + return scriptObject; + } + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/ExportService.cs b/DiscordChatExporter.Core/Services/ExportService.cs index 945ce27..4fbb334 100644 --- a/DiscordChatExporter.Core/Services/ExportService.cs +++ b/DiscordChatExporter.Core/Services/ExportService.cs @@ -1,16 +1,15 @@ -using System; -using System.IO; -using System.Net; -using System.Reflection; -using System.Threading.Tasks; -using DiscordChatExporter.Core.Internal; +using System.IO; using DiscordChatExporter.Core.Models; -using Tyrrrz.Extensions; +using Scriban; +using Scriban.Runtime; namespace DiscordChatExporter.Core.Services { public partial class ExportService : IExportService { + private static readonly MemberRenamerDelegate TemplateMemberRenamer = m => m.Name; + private static readonly MemberFilterDelegate TemplateMemberFilter = m => true; + private readonly ISettingsService _settingsService; public ExportService(ISettingsService settingsService) @@ -18,69 +17,36 @@ namespace DiscordChatExporter.Core.Services _settingsService = settingsService; } - public async Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log) + public void Export(ExportFormat format, string filePath, ChatLog log) { - using (var output = File.CreateText(filePath)) - { - var sharedCss = Assembly.GetExecutingAssembly() - .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.Shared.css"); + // Create template loader + var loader = new TemplateLoader(); - if (format == ExportFormat.PlainText) - { - await ExportAsPlainTextAsync(log, output); - } - else if (format == ExportFormat.HtmlDark) - { - var css = Assembly.GetExecutingAssembly() - .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css"); - await ExportAsHtmlAsync(log, output, $"{sharedCss}\n{css}"); - } - else if (format == ExportFormat.HtmlLight) - { - var css = Assembly.GetExecutingAssembly() - .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css"); - await ExportAsHtmlAsync(log, output, $"{sharedCss}\n{css}"); - } - else if (format == ExportFormat.Csv) - { - await ExportAsCsvAsync(log, output); - } - - else throw new ArgumentOutOfRangeException(nameof(format)); - } - } - } - - public partial class ExportService - { - private static string Base64Encode(string str) - { - return str.GetBytes().ToBase64(); - } + // Get template + var templateCode = loader.Load(format); + var template = Template.Parse(templateCode); - private static string Base64Decode(string str) - { - return str.FromBase64().GetString(); - } - - private static string HtmlEncode(string str) - { - return WebUtility.HtmlEncode(str); - } + // Create template context + var context = new TemplateContext + { + TemplateLoader = loader, + MemberRenamer = TemplateMemberRenamer, + MemberFilter = TemplateMemberFilter + }; - private static string FormatFileSize(long fileSize) - { - string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}; - double size = fileSize; - var unit = 0; + // Create template model + var templateModel = new TemplateModel(format, log, _settingsService.DateFormat); + context.PushGlobal(templateModel.GetScriptObject()); - while (size >= 1024) + // Render output + using (var output = File.CreateText(filePath)) { - size /= 1024; - ++unit; - } + // Configure output + context.PushOutput(new TextWriterOutput(output)); - return $"{size:0.#} {units[unit]}"; + // Render template + template.Render(context); + } } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/IDataService.cs b/DiscordChatExporter.Core/Services/IDataService.cs index 524d286..f0173c8 100644 --- a/DiscordChatExporter.Core/Services/IDataService.cs +++ b/DiscordChatExporter.Core/Services/IDataService.cs @@ -11,13 +11,18 @@ namespace DiscordChatExporter.Core.Services Task GetChannelAsync(string token, string channelId); - Task> GetGuildChannelsAsync(string token, string guildId); - Task> GetUserGuildsAsync(string token); Task> GetDirectMessageChannelsAsync(string token); + Task> GetGuildChannelsAsync(string token, string guildId); + + Task> GetGuildRolesAsync(string token, string guildId); + Task> GetChannelMessagesAsync(string token, string channelId, DateTime? from, DateTime? to); + + Task GetMentionablesAsync(string token, string guildId, + IEnumerable messages); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/IExportService.cs b/DiscordChatExporter.Core/Services/IExportService.cs index c8713f9..938c4d3 100644 --- a/DiscordChatExporter.Core/Services/IExportService.cs +++ b/DiscordChatExporter.Core/Services/IExportService.cs @@ -1,10 +1,9 @@ -using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Core.Models; namespace DiscordChatExporter.Core.Services { public interface IExportService { - Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log); + void Export(ExportFormat format, string filePath, ChatLog log); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/IMessageGroupService.cs b/DiscordChatExporter.Core/Services/IMessageGroupService.cs index 5b5d8f6..b21f1fb 100644 --- a/DiscordChatExporter.Core/Services/IMessageGroupService.cs +++ b/DiscordChatExporter.Core/Services/IMessageGroupService.cs @@ -5,6 +5,6 @@ namespace DiscordChatExporter.Core.Services { public interface IMessageGroupService { - IReadOnlyList GroupMessages(IReadOnlyList messages); + IReadOnlyList GroupMessages(IEnumerable messages); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/MessageGroupService.cs b/DiscordChatExporter.Core/Services/MessageGroupService.cs index 0ef2e3c..b9882ae 100644 --- a/DiscordChatExporter.Core/Services/MessageGroupService.cs +++ b/DiscordChatExporter.Core/Services/MessageGroupService.cs @@ -13,7 +13,7 @@ namespace DiscordChatExporter.Core.Services _settingsService = settingsService; } - public IReadOnlyList GroupMessages(IReadOnlyList messages) + public IReadOnlyList GroupMessages(IEnumerable messages) { var result = new List(); @@ -28,15 +28,15 @@ namespace DiscordChatExporter.Core.Services groupFirst != null && ( message.Author.Id != groupFirst.Author.Id || - (message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 || - message.TimeStamp.Hour != groupFirst.TimeStamp.Hour || + (message.Timestamp - groupFirst.Timestamp).TotalHours > 1 || + message.Timestamp.Hour != groupFirst.Timestamp.Hour || groupBuffer.Count >= _settingsService.MessageGroupLimit ); // If condition is true - flush buffer if (breakCondition) { - var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray()); + var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray()); result.Add(group); groupBuffer.Clear(); } @@ -49,7 +49,7 @@ namespace DiscordChatExporter.Core.Services if (groupBuffer.Any()) { var groupFirst = groupBuffer.First(); - var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray()); + var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray()); result.Add(group); } diff --git a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj index 21f87a5..77fb8c3 100644 --- a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj +++ b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj @@ -68,8 +68,8 @@ 4.0 - - ..\packages\Tyrrrz.Extensions.1.5.0\lib\net45\Tyrrrz.Extensions.dll + + ..\packages\Tyrrrz.Extensions.1.5.1\lib\net45\Tyrrrz.Extensions.dll diff --git a/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs index 33a4251..79c314d 100644 --- a/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs @@ -72,7 +72,7 @@ namespace DiscordChatExporter.Gui.ViewModels set { Set(ref _selectedGuild, value); - AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0]; + AvailableChannels = value != null ? _guildChannelsMap[value] : Array.Empty(); ShowExportSetupCommand.RaiseCanExecuteChanged(); } } @@ -222,19 +222,25 @@ namespace DiscordChatExporter.Gui.ViewModels // Get last used token var token = _settingsService.LastToken; + // Get guild + var guild = SelectedGuild; + try { // Get messages var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to); - // Group them + // Group messages var messageGroups = _messageGroupService.GroupMessages(messages); + // Get mentionables + var mentionables = await _dataService.GetMentionablesAsync(token, guild.Id, messages); + // Create log - var log = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count); + var log = new ChatLog(guild, channel, messageGroups, mentionables); // Export - await _exportService.ExportAsync(format, filePath, log); + _exportService.Export(format, filePath, log); // Open Process.Start(filePath); diff --git a/DiscordChatExporter.Gui/packages.config b/DiscordChatExporter.Gui/packages.config index ca0c226..0fa3f9d 100644 --- a/DiscordChatExporter.Gui/packages.config +++ b/DiscordChatExporter.Gui/packages.config @@ -4,6 +4,6 @@ - + \ No newline at end of file diff --git a/Readme.md b/Readme.md index c59e35b..d093182 100644 --- a/Readme.md +++ b/Readme.md @@ -27,6 +27,7 @@ DiscordChatExporter can be used to export message history from a [Discord](https - Dark and light themes - User avatars - Inline image attachments + - Embeds and webhooks - Full markdown support - Automatic links - Styled similarly to the app @@ -39,7 +40,7 @@ DiscordChatExporter can be used to export message history from a [Discord](https - [GalaSoft.MVVMLight](http://www.mvvmlight.net) - [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit) - [Newtonsoft.Json](http://www.newtonsoft.com/json) -- [CsvHelper](https://github.com/JoshClose/CsvHelper) +- [Scriban](https://github.com/lunet-io/scriban) - [Onova](https://github.com/Tyrrrz/Onova) - [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser) - [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)