Improve performance (#162)

This commit is contained in:
Alexey Golub 2019-04-10 23:45:21 +03:00 committed by GitHub
parent 359278afec
commit 4bfb2ec7fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 1242 additions and 900 deletions

View file

@ -1,31 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\ExportTemplates\PlainText\Template.txt" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark\Template.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight\Template.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlShared\Main.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlShared\Main.css" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark\Theme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight\Theme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Csv\Template.csv" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Failsafe" Version="1.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
<PackageReference Include="Onova" Version="2.4.2" />
<PackageReference Include="Scriban" Version="2.0.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
</ItemGroup>
</Project>

View file

@ -1,19 +0,0 @@
using System;
using System.Net;
namespace DiscordChatExporter.Core.Exceptions
{
public class HttpErrorStatusCodeException : Exception
{
public HttpStatusCode StatusCode { get; }
public string ReasonPhrase { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase)
: base($"Error HTTP status code: {statusCode} - {reasonPhrase}")
{
StatusCode = statusCode;
ReasonPhrase = reasonPhrase;
}
}
}

View file

@ -1,59 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Helpers
{
public static class ExportHelper
{
public static bool IsDirectoryPath(string path)
=> path.Last() == Path.DirectorySeparatorChar ||
path.Last() == Path.AltDirectorySeparatorChar ||
Path.GetExtension(path).IsBlank();
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
DateTime? from = null, DateTime? to = null)
{
var result = new StringBuilder();
// Append guild and channel names
result.Append($"{guild.Name} - {channel.Name} [{channel.Id}]");
// Append date range
if (from != null || to != null)
{
result.Append(" (");
// Both 'from' and 'to' are set
if (from != null && to != null)
{
result.Append($"{from:yyyy-MM-dd} to {to:yyyy-MM-dd}");
}
// Only 'from' is set
else if (from != null)
{
result.Append($"after {from:yyyy-MM-dd}");
}
// Only 'to' is set
else
{
result.Append($"before {to:yyyy-MM-dd}");
}
result.Append(")");
}
// Append extension
result.Append($".{format.GetFileExtension()}");
// Replace invalid chars
foreach (var invalidChar in Path.GetInvalidFileNameChars())
result.Replace(invalidChar, '_');
return result.ToString();
}
}
}

View file

@ -1,52 +0,0 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Net;
namespace DiscordChatExporter.Core.Internal
{
internal static class Extensions
{
public static string ToSnowflake(this DateTime dateTime)
{
const long epoch = 62135596800000;
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
}
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
public static string HtmlEncode(this string value) => WebUtility.HtmlEncode(value);
public static IEnumerable<IReadOnlyList<T>> GroupAdjacentWhile<T>(this IEnumerable<T> source,
Func<IReadOnlyList<T>, T, bool> groupPredicate)
{
// Create buffer
var buffer = new List<T>();
// Enumerate source
foreach (var element in source)
{
// If buffer is not empty and group predicate failed - yield and flush buffer
if (buffer.Any() && !groupPredicate(buffer, element))
{
yield return buffer;
buffer = new List<T>(); // new instance to reset reference
}
// Add element to buffer
buffer.Add(element);
}
// If buffer still has something after the source has been enumerated - yield
if (buffer.Any())
yield return buffer;
}
public static IEnumerable<IReadOnlyList<T>> GroupAdjacentWhile<T>(this IEnumerable<T> source,
Func<IReadOnlyList<T>, bool> groupPredicate)
=> source.GroupAdjacentWhile((buffer, _) => groupPredicate(buffer));
}
}

View file

@ -1,39 +0,0 @@
using System;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#attachment-object
public class Attachment
{
public string Id { get; }
public string Url { get; }
public int? Width { get; }
public int? Height { get; }
public string FileName { get; }
public bool IsImage => FileName.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
FileName.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
FileName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
FileName.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ||
FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase);
public FileSize FileSize { get; }
public Attachment(string id, int? width, int? height, string url, string fileName, FileSize fileSize)
{
Id = id;
Url = url;
Width = width;
Height = height;
FileName = fileName;
FileSize = fileSize;
}
public override string ToString() => FileName;
}
}

View file

@ -1,15 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
public class AuthToken
{
public AuthTokenType Type { get; }
public string Value { get; }
public AuthToken(AuthTokenType type, string value)
{
Type = type;
Value = value;
}
}
}

View file

@ -1,8 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
public enum AuthTokenType
{
User,
Bot
}
}

View file

@ -1,37 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object
public partial class Channel
{
public string Id { get; }
public string ParentId { get; }
public string GuildId { get; }
public string Name { get; }
public string Topic { get; }
public ChannelType Type { get; }
public Channel(string id, string parentId, string guildId, string name, string topic, ChannelType type)
{
Id = id;
ParentId = parentId;
GuildId = guildId;
Name = name;
Topic = topic;
Type = type;
}
public override string ToString() => Name;
}
public partial class Channel
{
public static Channel CreateDeletedChannel(string id) =>
new Channel(id, null, null, "deleted-channel", null, ChannelType.GuildTextChat);
}
}

View file

@ -1,13 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
public enum ChannelType
{
GuildTextChat,
DirectTextChat,
GuildVoiceChat,
DirectGroupTextChat,
Category
}
}

View file

@ -1,33 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public class ChatLog
{
public Guild Guild { get; }
public Channel Channel { get; }
public DateTime? From { get; }
public DateTime? To { get; }
public IReadOnlyList<Message> Messages { get; }
public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
IReadOnlyList<Message> messages, Mentionables mentionables)
{
Guild = guild;
Channel = channel;
From = from;
To = to;
Messages = messages;
Mentionables = mentionables;
}
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
}
}

View file

@ -1,48 +0,0 @@
using System;
using System.Collections.Generic;
using System.Drawing;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object
public class Embed
{
public string Title { get; }
public string Url { get; }
public DateTime? Timestamp { get; }
public Color Color { get; }
public EmbedAuthor Author { get; }
public string Description { get; }
public IReadOnlyList<EmbedField> Fields { get; }
public EmbedImage Thumbnail { get; }
public EmbedImage Image { get; }
public EmbedFooter Footer { get; }
public Embed(string title, string url, DateTime? timestamp, Color color, EmbedAuthor author, string description,
IReadOnlyList<EmbedField> fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer)
{
Title = title;
Url = url;
Timestamp = timestamp;
Color = color;
Author = author;
Description = description;
Fields = fields;
Thumbnail = thumbnail;
Image = image;
Footer = footer;
}
public override string ToString() => Title;
}
}

View file

@ -1,22 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
public class EmbedAuthor
{
public string Name { get; }
public string Url { get; }
public string IconUrl { get; }
public EmbedAuthor(string name, string url, string iconUrl)
{
Name = name;
Url = url;
IconUrl = iconUrl;
}
public override string ToString() => Name;
}
}

View file

@ -1,22 +0,0 @@
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 IsInline { get; }
public EmbedField(string name, string value, bool isInline)
{
Name = name;
Value = value;
IsInline = isInline;
}
public override string ToString() => $"{Name} | {Value}";
}
}

View file

@ -1,19 +0,0 @@
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 EmbedFooter(string text, string iconUrl)
{
Text = text;
IconUrl = iconUrl;
}
public override string ToString() => Text;
}
}

View file

@ -1,20 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
public class EmbedImage
{
public string Url { get; }
public int? Width { get; }
public int? Height { get; }
public EmbedImage(string url, int? width, int? height)
{
Url = url;
Height = height;
Width = width;
}
}
}

View file

@ -1,56 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
public partial class Emoji
{
public string Id { get; }
public string Name { get; }
public bool IsAnimated { get; }
public string ImageUrl
{
get
{
// Custom emoji
if (Id.IsNotBlank())
{
// Animated
if (IsAnimated)
return $"https://cdn.discordapp.com/emojis/{Id}.gif";
// Non-animated
return $"https://cdn.discordapp.com/emojis/{Id}.png";
}
// Standard unicode emoji (via twemoji)
return $"https://twemoji.maxcdn.com/2/72x72/{GetTwemojiName(Name)}.png";
}
}
public Emoji(string id, string name, bool isAnimated)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
}
}
public partial class Emoji
{
private static IEnumerable<int> GetCodePoints(string emoji)
{
for (var i = 0; i < emoji.Length; i += char.IsHighSurrogate(emoji[i]) ? 2 : 1)
yield return char.ConvertToUtf32(emoji, i);
}
private static string GetTwemojiName(string emoji)
=> GetCodePoints(emoji).Select(i => i.ToString("x")).JoinToString("-");
}
}

View file

@ -1,10 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
public enum ExportFormat
{
PlainText,
HtmlDark,
HtmlLight,
Csv
}
}

View file

@ -1,35 +0,0 @@
using System;
namespace DiscordChatExporter.Core.Models
{
public static class Extensions
{
public static string GetFileExtension(this ExportFormat format)
{
if (format == ExportFormat.PlainText)
return "txt";
if (format == ExportFormat.HtmlDark)
return "html";
if (format == ExportFormat.HtmlLight)
return "html";
if (format == ExportFormat.Csv)
return "csv";
throw new ArgumentOutOfRangeException(nameof(format));
}
public static string GetDisplayName(this ExportFormat format)
{
if (format == ExportFormat.PlainText)
return "Plain Text";
if (format == ExportFormat.HtmlDark)
return "HTML (Dark)";
if (format == ExportFormat.HtmlLight)
return "HTML (Light)";
if (format == ExportFormat.Csv)
return "Comma Seperated Values (CSV)";
throw new ArgumentOutOfRangeException(nameof(format));
}
}
}

View file

@ -1,84 +0,0 @@
using System;
namespace DiscordChatExporter.Core.Models
{
// Loosely based on https://github.com/omar/ByteSize (MIT license)
public struct FileSize
{
public const long BytesInKiloByte = 1024;
public const long BytesInMegaByte = 1024 * BytesInKiloByte;
public const long BytesInGigaByte = 1024 * BytesInMegaByte;
public const long BytesInTeraByte = 1024 * BytesInGigaByte;
public const long BytesInPetaByte = 1024 * BytesInTeraByte;
public const string ByteSymbol = "B";
public const string KiloByteSymbol = "KB";
public const string MegaByteSymbol = "MB";
public const string GigaByteSymbol = "GB";
public const string TeraByteSymbol = "TB";
public const string PetaByteSymbol = "PB";
public double Bytes { get; }
public double KiloBytes => Bytes / BytesInKiloByte;
public double MegaBytes => Bytes / BytesInMegaByte;
public double GigaBytes => Bytes / BytesInGigaByte;
public double TeraBytes => Bytes / BytesInTeraByte;
public double PetaBytes => Bytes / BytesInPetaByte;
public string LargestWholeNumberSymbol
{
get
{
// Absolute value is used to deal with negative values
if (Math.Abs(PetaBytes) >= 1)
return PetaByteSymbol;
if (Math.Abs(TeraBytes) >= 1)
return TeraByteSymbol;
if (Math.Abs(GigaBytes) >= 1)
return GigaByteSymbol;
if (Math.Abs(MegaBytes) >= 1)
return MegaByteSymbol;
if (Math.Abs(KiloBytes) >= 1)
return KiloByteSymbol;
return ByteSymbol;
}
}
public double LargestWholeNumberValue
{
get
{
// Absolute value is used to deal with negative values
if (Math.Abs(PetaBytes) >= 1)
return PetaBytes;
if (Math.Abs(TeraBytes) >= 1)
return TeraBytes;
if (Math.Abs(GigaBytes) >= 1)
return GigaBytes;
if (Math.Abs(MegaBytes) >= 1)
return MegaBytes;
if (Math.Abs(KiloBytes) >= 1)
return KiloBytes;
return Bytes;
}
}
public FileSize(double bytes)
{
Bytes = bytes;
}
public override string ToString() => $"{LargestWholeNumberValue:0.##} {LargestWholeNumberSymbol}";
}
}

View file

@ -1,33 +0,0 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/guild#guild-object
public partial class Guild
{
public string Id { get; }
public string Name { get; }
public string IconHash { get; }
public string IconUrl => IconHash.IsNotBlank()
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
public Guild(string id, string name, string iconHash)
{
Id = id;
Name = name;
IconHash = iconHash;
}
public override string ToString() => Name;
}
public partial class Guild
{
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
}
}

View file

@ -1,30 +0,0 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Models
{
public class Mentionables
{
public IReadOnlyList<User> Users { get; }
public IReadOnlyList<Channel> Channels { get; }
public IReadOnlyList<Role> Roles { get; }
public Mentionables(IReadOnlyList<User> users, IReadOnlyList<Channel> channels, IReadOnlyList<Role> 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);
}
}

View file

@ -1,51 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object
public class Message
{
public string Id { get; }
public string ChannelId { get; }
public MessageType Type { get; }
public User Author { get; }
public DateTime Timestamp { get; }
public DateTime? EditedTimestamp { get; }
public string Content { get; }
public IReadOnlyList<Attachment> Attachments { get; }
public IReadOnlyList<Embed> Embeds { get; }
public IReadOnlyList<Reaction> Reactions { get; }
public IReadOnlyList<User> MentionedUsers { get; }
public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
{
Id = id;
ChannelId = channelId;
Type = type;
Author = author;
Timestamp = timestamp;
EditedTimestamp = editedTimestamp;
Content = content;
Attachments = attachments;
Embeds = embeds;
Reactions = reactions;
MentionedUsers = mentionedUsers;
}
public override string ToString() => Content;
}
}

View file

@ -1,16 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
public enum MessageType
{
Default,
RecipientAdd,
RecipientRemove,
Call,
ChannelNameChange,
ChannelIconChange,
ChannelPinnedMessage,
GuildMemberJoin
}
}

View file

@ -1,17 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#reaction-object
public class Reaction
{
public int Count { get; }
public Emoji Emoji { get; }
public Reaction(int count, Emoji emoji)
{
Count = count;
Emoji = emoji;
}
}
}

View file

@ -1,25 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class Role
{
public string Id { get; }
public string Name { get; }
public Role(string id, string name)
{
Id = id;
Name = name;
}
public override string ToString() => Name;
}
public partial class Role
{
public static Role CreateDeletedRole(string id) =>
new Role(id, "deleted-role");
}
}

View file

@ -1,61 +0,0 @@
using System;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class User
{
public string Id { get; }
public int Discriminator { get; }
public string Name { get; }
public string FullName => $"{Name}#{Discriminator:0000}";
public string DefaultAvatarHash => $"{Discriminator % 5}";
public string AvatarHash { get; }
public bool IsAvatarAnimated =>
AvatarHash.IsNotBlank() && AvatarHash.StartsWith("a_", StringComparison.Ordinal);
public string AvatarUrl
{
get
{
// Custom avatar
if (AvatarHash.IsNotBlank())
{
// Animated
if (IsAvatarAnimated)
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.gif";
// Non-animated
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png";
}
// Default avatar
return $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
}
}
public User(string id, int discriminator, string name, string avatarHash)
{
Id = id;
Discriminator = discriminator;
Name = name;
AvatarHash = avatarHash;
}
public override string ToString() => FullName;
}
public partial class User
{
public static User CreateUnknownUser(string id) =>
new User(id, 0, "Unknown", null);
}
}

View file

@ -1,10 +0,0 @@
Author;Date;Content;Attachments;
{{~ for message in Model.Messages -}}
{{- }}"{{ message.Author.FullName }}";
{{- }}"{{ message.Timestamp | FormatDate }}";
{{- }}"{{ message.Content | FormatMarkdown | string.replace "\"" "\"\"" }}";
{{- }}"{{ message.Attachments | array.map "Url" | array.join "," }}";
{{~ end -}}
Can't render this file because it has a wrong number of fields in line 2.

View file

@ -1,3 +0,0 @@
{{~ ThemeStyleSheet = include "HtmlDark.Theme.css" ~}}
{{~ HighlightJsStyleName = "solarized-dark" ~}}
{{~ include "HtmlShared.Main.html" ~}}

View file

@ -1,100 +0,0 @@
/* === GENERAL === */
body {
background-color: #36393e;
color: #dcddde;
}
a {
color: #0096cf;
}
.spoiler {
background-color: rgba(255, 255, 255, 0.1);
}
.pre {
background-color: #2f3136 !important;
}
.pre--multiline {
border-color: #282b30 !important;
color: #839496 !important;
}
.mention {
color: #7289da;
}
/* === INFO === */
.info__guild-name {
color: #ffffff;
}
.info__channel-name {
color: #ffffff;
}
.info__channel-topic {
color: #ffffff;
}
/* === CHATLOG === */
.chatlog__message-group {
border-color: rgba(255, 255, 255, 0.1);
}
.chatlog__author-name {
color: #ffffff;
}
.chatlog__timestamp {
color: rgba(255, 255, 255, 0.2);
}
.chatlog__edited-timestamp {
color: rgba(255, 255, 255, 0.2);
}
.chatlog__embed-content-container {
background-color: rgba(46, 48, 54, 0.3);
border-color: rgba(46, 48, 54, 0.6);
}
.chatlog__embed-author-name {
color: #ffffff;
}
.chatlog__embed-author-name-link {
color: #ffffff;
}
.chatlog__embed-title {
color: #ffffff;
}
.chatlog__embed-description {
color: rgba(255, 255, 255, 0.6);
}
.chatlog__embed-field-name {
color: #ffffff;
}
.chatlog__embed-field-value {
color: rgba(255, 255, 255, 0.6);
}
.chatlog__embed-footer {
color: rgba(255, 255, 255, 0.6);
}
.chatlog__reaction {
background-color: rgba(255, 255, 255, 0.05);
}
.chatlog__reaction-count {
color: rgba(255, 255, 255, 0.3);
}

View file

@ -1,3 +0,0 @@
{{~ ThemeStyleSheet = include "HtmlLight.Theme.css" ~}}
{{~ HighlightJsStyleName = "solarized-light" ~}}
{{~ include "HtmlShared.Main.html" ~}}

View file

@ -1,101 +0,0 @@
/* === GENERAL === */
body {
background-color: #ffffff;
color: #747f8d;
}
a {
color: #00b0f4;
}
.spoiler {
background-color: rgba(0, 0, 0, 0.1);
}
.pre {
background-color: #f9f9f9 !important;
}
.pre--multiline {
border-color: #f3f3f3 !important;
color: #657b83 !important;
}
.mention {
background-color: #f1f3fb;
color: #7289da;
}
/* === INFO === */
.info__guild-name {
color: #2f3136;
}
.info__channel-name {
color: #2f3136;
}
.info__channel-topic {
color: #2f3136;
}
/* === CHATLOG === */
.chatlog__message-group {
border-color: #eceeef;
}
.chatlog__author-name {
color: #2f3136;
}
.chatlog__timestamp {
color: #99aab5;
}
.chatlog__edited-timestamp {
color: #99aab5;
}
.chatlog__embed-content-container {
background-color: rgba(249, 249, 249, 0.3);
border-color: rgba(204, 204, 204, 0.3);
}
.chatlog__embed-author-name {
color: #4f545c;
}
.chatlog__embed-author-name-link {
color: #4f545c;
}
.chatlog__embed-title {
color: #4f545c;
}
.chatlog__embed-description {
color: #737f8d;
}
.chatlog__embed-field-name {
color: #36393e;
}
.chatlog__embed-field-value {
color: #737f8d;
}
.chatlog__embed-footer {
color: rgba(79, 83, 91, 0.4);
}
.chatlog__reaction {
background-color: rgba(79, 84, 92, 0.06);
}
.chatlog__reaction-count {
color: #99aab5;
}

View file

@ -1,312 +0,0 @@
/* === 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;
}
.markdown {
white-space: pre-wrap;
line-height: 1.3;
overflow-wrap: break-word;
}
.spoiler {
border-radius: 3px;
}
.pre {
font-family: "Consolas", "Courier New", Courier, Monospace;
}
.pre--multiline {
margin-top: 4px;
padding: 8px;
border: 2px solid;
border-radius: 5px;
}
.pre--inline {
padding: 2px;
border-radius: 3px;
}
.mention {
font-weight: 500;
}
.emoji {
width: 1.45em;
height: 1.45em;
margin: 0 1px;
vertical-align: -0.4em;
}
.emoji--small {
width: 1rem;
height: 1rem;
}
.emoji--large {
width: 2rem;
height: 2rem;
}
/* === INFO === */
.info {
display: flex;
max-width: 100%;
margin: 0 5px 10px 5px;
}
.info__guild-icon-container {
flex: 0;
}
.info__guild-icon {
max-width: 88px;
max-height: 88px;
}
.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;
}
.info__channel-date-range {
margin-top: 2px;
}
/* === CHATLOG === */
.chatlog {
max-width: 100%;
margin-bottom: 24px;
}
.chatlog__message-group {
display: flex;
margin: 0 10px;
padding: 15px 0;
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;
font-weight: 500;
}
.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: 3px;
font-size: .8em;
}
.chatlog__attachment {
margin: 5px 0;
}
.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: 8px 10px;
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;
}
.chatlog__reactions {
display: flex;
}
.chatlog__reaction {
display: flex;
align-items: center;
margin: 6px 2px 2px 2px;
padding: 3px 6px;
border-radius: 3px;
}
.chatlog__reaction-count {
min-width: 9px;
margin-left: 6px;
font-size: .875em;
}

View file

@ -1,225 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{~ # Metadata ~}}
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
{{~ # Styles ~}}
<style>
{{ include "HtmlShared.Main.css" }}
</style>
<style>
{{ ThemeStyleSheet }}
</style>
{{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach((block) => {
hljs.highlightBlock(block);
});
});
</script>
</head>
<body>
{{~ # Info ~}}
<div class="info">
<div class="info__guild-icon-container">
<img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
</div>
<div class="info__metadata">
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
{{~ if Model.Channel.Topic ~}}
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
{{~ end ~}}
<div class="info__channel-message-count">{{ Model.Messages | array.size | Format "N0" }} messages</div>
{{~ if Model.From || Model.To ~}}
<div class="info__channel-date-range">
{{~ if Model.From && Model.To ~}}
Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
{{~ else if Model.From ~}}
After {{ Model.From | FormatDate | html.escape }}
{{~ else if Model.To ~}}
Before {{ Model.To | FormatDate | html.escape }}
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
{{~ # Log ~}}
<div class="chatlog">
{{~ for group in Model.Messages | GroupMessages ~}}
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}">{{ group.Author.Name | html.escape }}</span>
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
<span class="markdown">{{ message.Content | FormatMarkdown }}</span>
{{~ # Edited timestamp ~}}
{{~ if message.EditedTimestamp ~}}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment">
<a href="{{ attachment.Url }}">
{{ # Image }}
{{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
{{~ end ~}}
</a>
</div>
{{~ end ~}}
{{~ # Embeds ~}}
{{~ for embed in message.Embeds ~}}
<div class="chatlog__embed">
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
{{~ # Author ~}}
{{~ if embed.Author ~}}
<div class="chatlog__embed-author">
{{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
{{~ end ~}}
{{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name">
{{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{~ else ~}}
{{ embed.Author.Name | html.escape }}
{{~ end ~}}
</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Title ~}}
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a>
{{~ else ~}}
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ # Fields ~}}
{{~ if embed.Fields | array.size > 0 ~}}
<div class="chatlog__embed-fields">
{{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}}
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ # Thumbnail ~}}
{{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
</a>
</div>
{{~ end ~}}
</div>
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
</a>
</div>
{{~ end ~}}
{{~ # Footer ~}}
{{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
{{~ end ~}}
{{~ end ~}}
<span class="chatlog__embed-footer-text">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }}
{{~ end ~}}
{{~ end ~}}
{{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | html.escape }}
{{~ end ~}}
</span>
</div>
{{~ end ~}}
</div>
</div>
{{~ end ~}}
{{~ # Reactions ~}}
{{~ if message.Reactions | array.size > 0 ~}}
<div class="chatlog__reactions">
{{~ for reaction in message.Reactions ~}}
<div class="chatlog__reaction">
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}" />
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ end ~}}
</div>
</div>
{{~ end ~}}
</div>
</body>
</html>

View file

@ -1,21 +0,0 @@
{{~ # Info ~}}
==============================================================
Guild: {{ Model.Guild.Name }}
Channel: {{ Model.Channel.Name }}
Topic: {{ Model.Channel.Topic }}
Messages: {{ Model.Messages | array.size | Format "N0" }}
Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model.From || Model.To }}->{{ end }}{{ if Model.To }} {{ Model.To | FormatDate }}{{ end }}
==============================================================
{{~ # Log ~}}
{{~ for message in Model.Messages ~}}
{{~ # Author name and timestamp ~}}
{{~ }}[{{ message.Timestamp | FormatDate }}] {{ message.Author.FullName }}
{{~ # Content ~}}
{{~ message.Content | FormatMarkdown }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ end ~}}

View file

@ -1,207 +0,0 @@
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<string>();
var discriminator = json["discriminator"].Value<int>();
var name = json["username"].Value<string>();
var avatarHash = json["avatar"].Value<string>();
return new User(id, discriminator, name, avatarHash);
}
private Guild ParseGuild(JToken json)
{
var id = json["id"].Value<string>();
var name = json["name"].Value<string>();
var iconHash = json["icon"].Value<string>();
return new Guild(id, name, iconHash);
}
private Channel ParseChannel(JToken json)
{
// Get basic data
var id = json["id"].Value<string>();
var parentId = json["parent_id"]?.Value<string>();
var type = (ChannelType) json["type"].Value<int>();
var topic = json["topic"]?.Value<string>();
// Try to extract guild ID
var guildId = json["guild_id"]?.Value<string>();
// 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<string>();
// 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, parentId, guildId, name, topic, type);
}
private Role ParseRole(JToken json)
{
var id = json["id"].Value<string>();
var name = json["name"].Value<string>();
return new Role(id, name);
}
private Attachment ParseAttachment(JToken json)
{
var id = json["id"].Value<string>();
var url = json["url"].Value<string>();
var width = json["width"]?.Value<int>();
var height = json["height"]?.Value<int>();
var fileName = json["filename"].Value<string>();
var fileSizeBytes = json["size"].Value<long>();
var fileSize = new FileSize(fileSizeBytes);
return new Attachment(id, width, height, url, fileName, fileSize);
}
private EmbedAuthor ParseEmbedAuthor(JToken json)
{
var name = json["name"]?.Value<string>();
var url = json["url"]?.Value<string>();
var iconUrl = json["icon_url"]?.Value<string>();
return new EmbedAuthor(name, url, iconUrl);
}
private EmbedField ParseEmbedField(JToken json)
{
var name = json["name"].Value<string>();
var value = json["value"].Value<string>();
var isInline = json["inline"]?.Value<bool>() ?? false;
return new EmbedField(name, value, isInline);
}
private EmbedImage ParseEmbedImage(JToken json)
{
var url = json["url"]?.Value<string>();
var width = json["width"]?.Value<int>();
var height = json["height"]?.Value<int>();
return new EmbedImage(url, width, height);
}
private EmbedFooter ParseEmbedFooter(JToken json)
{
var text = json["text"].Value<string>();
var iconUrl = json["icon_url"]?.Value<string>();
return new EmbedFooter(text, iconUrl);
}
private Embed ParseEmbed(JToken json)
{
// Get basic data
var title = json["title"]?.Value<string>();
var description = json["description"]?.Value<string>();
var url = json["url"]?.Value<string>();
var timestamp = json["timestamp"]?.Value<DateTime>();
// Get color
var color = json["color"] != null
? Color.FromArgb(json["color"].Value<int>()).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 Emoji ParseEmoji(JToken json)
{
var id = json["id"]?.Value<string>();
var name = json["name"]?.Value<string>();
var isAnimated = json["animated"]?.Value<bool>() ?? false;
return new Emoji(id, name, isAnimated);
}
private Reaction ParseReaction(JToken json)
{
var count = json["count"].Value<int>();
var emoji = ParseEmoji(json["emoji"]);
return new Reaction(count, emoji);
}
private Message ParseMessage(JToken json)
{
// Get basic data
var id = json["id"].Value<string>();
var channelId = json["channel_id"].Value<string>();
var timestamp = json["timestamp"].Value<DateTime>();
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>();
var content = json["content"].Value<string>();
var type = (MessageType) json["type"].Value<int>();
// 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 reactions
var reactions = json["reactions"].EmptyIfNull().Select(ParseReaction).ToArray();
// Get mentioned users
var mentionedUsers = json["mentions"].EmptyIfNull().Select(ParseUser).ToArray();
return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
reactions, mentionedUsers);
}
}
}

View file

@ -1,256 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq;
using DiscordChatExporter.Core.Internal;
using Failsafe;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService : IDisposable
{
private readonly HttpClient _httpClient = new HttpClient();
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
params string[] parameters)
{
// Create retry policy
var retry = Retry.Create()
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode >= 500)
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode == 429)
.WithMaxTryCount(10)
.WithDelay(TimeSpan.FromSeconds(0.4));
// Send request
return await retry.ExecuteAsync(async () =>
{
// Create request
const string apiRoot = "https://discordapp.com/api/v6";
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
{
// Set authorization header
request.Headers.Authorization = token.Type == AuthTokenType.Bot
? new AuthenticationHeaderValue("Bot", token.Value)
: new AuthenticationHeaderValue(token.Value);
// Add parameters
foreach (var parameter in parameters.ExceptBlank())
{
var key = parameter.SubstringUntil("=");
var value = parameter.SubstringAfter("=");
// Skip empty values
if (value.IsBlank())
continue;
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
}
// Get response
using (var response = await _httpClient.SendAsync(request))
{
// Check status code
// We throw our own exception here because default one doesn't have status code
if (!response.IsSuccessStatusCode)
throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase);
// Get content
var raw = await response.Content.ReadAsStringAsync();
// Parse
return JToken.Parse(raw);
}
}
});
}
public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
{
// Special case for direct messages pseudo-guild
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
var response = await GetApiResponseAsync(token, "guilds", guildId);
var guild = ParseGuild(response);
return guild;
}
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
{
var response = await GetApiResponseAsync(token, "channels", channelId);
var channel = ParseChannel(response);
return channel;
}
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
{
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
var guilds = response.Select(ParseGuild).ToArray();
return guilds;
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
{
var response = await GetApiResponseAsync(token, "users", "@me/channels");
var channels = response.Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
{
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
var channels = response.Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
{
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
var roles = response.Select(ParseRole).ToArray();
return roles;
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
var result = new List<Message>();
// Get the last message
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=1", $"before={to?.ToSnowflake()}");
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
// If the last message doesn't exist or it's outside of range - return
if (lastMessage == null || lastMessage.Timestamp < from)
{
progress?.Report(1);
return result;
}
// Get other messages
var offsetId = from?.ToSnowflake() ?? "0";
while (true)
{
// Get message batch
response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=100", $"after={offsetId}");
// Parse
var messages = response
.Select(ParseMessage)
.Reverse() // reverse because messages appear newest first
.ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
break;
// Trim messages to range (until last message)
var messagesInRange = messages
.TakeWhile(m => m.Id != lastMessage.Id && m.Timestamp < lastMessage.Timestamp)
.ToArray();
// Add to result
result.AddRange(messagesInRange);
// Break if messages were trimmed (which means the last message was encountered)
if (messagesInRange.Length != messages.Length)
break;
// Report progress (based on the time range of parsed messages compared to total)
progress?.Report((result.Last().Timestamp - result.First().Timestamp).TotalSeconds /
(lastMessage.Timestamp - result.First().Timestamp).TotalSeconds);
// Move offset
offsetId = result.Last().Id;
}
// Add last message
result.Add(lastMessage);
// Report progress
progress?.Report(1);
return result;
}
public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages)
{
// Get channels and roles
var channels = guildId != Guild.DirectMessages.Id
? await GetGuildChannelsAsync(token, guildId)
: Array.Empty<Channel>();
var roles = guildId != Guild.DirectMessages.Id
? await GetGuildRolesAsync(token, guildId)
: Array.Empty<Role>();
// Get users
var userMap = new Dictionary<string, User>();
foreach (var message in messages)
{
// Author
userMap[message.Author.Id] = message.Author;
// Mentioned users
foreach (var mentionedUser in message.MentionedUsers)
userMap[mentionedUser.Id] = mentionedUser;
}
var users = userMap.Values.ToArray();
return new Mentionables(users, channels, roles);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
// Get messages
var messages = await GetChannelMessagesAsync(token, channel.Id, from, to, progress);
// Get mentionables
var mentionables = await GetMentionablesAsync(token, guild.Id, messages);
return new ChatLog(guild, channel, from, to, messages, mentionables);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Channel channel,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
// Get guild
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await GetGuildAsync(token, channel.GuildId);
// Get the chat log
return await GetChatLogAsync(token, guild, channel, from, to, progress);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
// Get channel
var channel = await GetChannelAsync(token, channelId);
// Get the chat log
return await GetChatLogAsync(token, channel, from, to, progress);
}
public void Dispose()
{
_httpClient.Dispose();
}
}
}

View file

@ -1,25 +0,0 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private class MessageGroup
{
public User Author { get; }
public DateTime Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
{
Author = author;
Timestamp = timestamp;
Messages = messages;
}
}
}
}

View file

@ -1,43 +0,0 @@
using System.Reflection;
using System.Threading.Tasks;
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}.Template.{format.GetFileExtension()}";
}
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
{
return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath);
}
public ValueTask<string> LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath)
{
return new ValueTask<string>(Load(context, callerSpan, templatePath));
}
public string Load(ExportFormat format)
{
return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format));
}
}
}
}

View file

@ -1,222 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Markdown;
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 IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
=> messages.GroupAdjacentWhile((buffer, message) =>
{
// Break group if the author changed
if (buffer.Last().Author.Id != message.Author.Id)
return false;
// Break group if last message was more than 7 minutes ago
if ((message.Timestamp - buffer.Last().Timestamp).TotalMinutes > 7)
return false;
return true;
}).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g));
private string Format(IFormattable obj, string format)
=> obj.ToString(format, CultureInfo.InvariantCulture);
private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat);
private string FormatMarkdownPlainText(IReadOnlyList<Node> nodes)
{
var buffer = new StringBuilder();
foreach (var node in nodes)
{
if (node is FormattedNode formattedNode)
{
var innerText = FormatMarkdownPlainText(formattedNode.Children);
buffer.Append($"{formattedNode.Token}{innerText}{formattedNode.Token}");
}
else if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
{
if (mentionNode.Type == MentionType.User)
{
var user = _log.Mentionables.GetUser(mentionNode.Id);
buffer.Append($"@{user.Name}");
}
else if (mentionNode.Type == MentionType.Channel)
{
var channel = _log.Mentionables.GetChannel(mentionNode.Id);
buffer.Append($"#{channel.Name}");
}
else if (mentionNode.Type == MentionType.Role)
{
var role = _log.Mentionables.GetRole(mentionNode.Id);
buffer.Append($"@{role.Name}");
}
}
else if (node is EmojiNode emojiNode)
{
buffer.Append(emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : node.Lexeme);
}
else
{
buffer.Append(node.Lexeme);
}
}
return buffer.ToString();
}
private string FormatMarkdownPlainText(string input)
=> FormatMarkdownPlainText(MarkdownParser.Parse(input));
private string FormatMarkdownHtml(IReadOnlyList<Node> nodes, int depth = 0)
{
var buffer = new StringBuilder();
foreach (var node in nodes)
{
if (node is TextNode textNode)
{
buffer.Append(textNode.Text.HtmlEncode());
}
else if (node is FormattedNode formattedNode)
{
var innerHtml = FormatMarkdownHtml(formattedNode.Children, depth + 1);
if (formattedNode.Formatting == TextFormatting.Bold)
buffer.Append($"<strong>{innerHtml}</strong>");
else if (formattedNode.Formatting == TextFormatting.Italic)
buffer.Append($"<em>{innerHtml}</em>");
else if (formattedNode.Formatting == TextFormatting.Underline)
buffer.Append($"<u>{innerHtml}</u>");
else if (formattedNode.Formatting == TextFormatting.Strikethrough)
buffer.Append($"<s>{innerHtml}</s>");
else if (formattedNode.Formatting == TextFormatting.Spoiler)
buffer.Append($"<span class=\"spoiler\">{innerHtml}</span>");
}
else if (node is InlineCodeBlockNode inlineCodeBlockNode)
{
buffer.Append($"<span class=\"pre pre--inline\">{inlineCodeBlockNode.Code.HtmlEncode()}</span>");
}
else if (node is MultilineCodeBlockNode multilineCodeBlockNode)
{
// Set language class for syntax highlighting
var languageCssClass = multilineCodeBlockNode.Language.IsNotBlank()
? "language-" + multilineCodeBlockNode.Language
: null;
buffer.Append(
$"<div class=\"pre pre--multiline {languageCssClass}\">{multilineCodeBlockNode.Code.HtmlEncode()}</div>");
}
else if (node is MentionNode mentionNode)
{
if (mentionNode.Type == MentionType.Meta)
{
buffer.Append($"<span class=\"mention\">@{mentionNode.Id.HtmlEncode()}</span>");
}
else if (mentionNode.Type == MentionType.User)
{
var user = _log.Mentionables.GetUser(mentionNode.Id);
buffer.Append($"<span class=\"mention\" title=\"{user.FullName}\">@{user.Name.HtmlEncode()}</span>");
}
else if (mentionNode.Type == MentionType.Channel)
{
var channel = _log.Mentionables.GetChannel(mentionNode.Id);
buffer.Append($"<span class=\"mention\">#{channel.Name.HtmlEncode()}</span>");
}
else if (mentionNode.Type == MentionType.Role)
{
var role = _log.Mentionables.GetRole(mentionNode.Id);
buffer.Append($"<span class=\"mention\">@{role.Name.HtmlEncode()}</span>");
}
}
else if (node is EmojiNode emojiNode)
{
// Get emoji image URL
var emojiImageUrl = new Emoji(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated).ImageUrl;
// Emoji can be jumboable if it's the only top-level node
var jumboableCssClass = depth == 0 && nodes.Count == 1
? "emoji--large"
: null;
buffer.Append($"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />");
}
else if (node is LinkNode linkNode)
{
var escapedUrl = Uri.EscapeUriString(linkNode.Url);
buffer.Append($"<a href=\"{escapedUrl}\">{linkNode.Title.HtmlEncode()}</a>");
}
}
return buffer.ToString();
}
private string FormatMarkdownHtml(string input)
=> FormatMarkdownHtml(MarkdownParser.Parse(input));
private string FormatMarkdown(string input)
{
return _format == ExportFormat.HtmlDark || _format == ExportFormat.HtmlLight
? FormatMarkdownHtml(input)
: FormatMarkdownPlainText(input);
}
public ScriptObject GetScriptObject()
{
// Create instance
var scriptObject = new ScriptObject();
// Import model
scriptObject.SetValue("Model", _log, true);
// Import functions
scriptObject.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
scriptObject.Import(nameof(FormatMarkdown), new Func<string, string>(FormatMarkdown));
return scriptObject;
}
}
}
}

View file

@ -1,107 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private readonly SettingsService _settingsService;
public ExportService(SettingsService settingsService)
{
_settingsService = settingsService;
}
private async Task ExportChatLogSingleAsync(ChatLog chatLog, string filePath, ExportFormat format)
{
// Create template loader
var loader = new TemplateLoader();
// Get template
var templateCode = loader.Load(format);
var template = Template.Parse(templateCode);
// Create template context
var context = new TemplateContext
{
TemplateLoader = loader,
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
// Create template model
var templateModel = new TemplateModel(format, chatLog, _settingsService.DateFormat);
context.PushGlobal(templateModel.GetScriptObject());
// Create directory
var dirPath = Path.GetDirectoryName(filePath);
if (dirPath.IsNotBlank())
Directory.CreateDirectory(dirPath);
// Render output
using (var output = File.CreateText(filePath))
{
// Configure output
context.PushOutput(new TextWriterOutput(output));
// Render output
await context.EvaluateAsync(template.Page);
}
}
private async Task ExportChatLogPartitionedAsync(IReadOnlyList<ChatLog> partitions, string filePath, ExportFormat format)
{
// Split file path into components
var dirPath = Path.GetDirectoryName(filePath);
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
var fileExt = Path.GetExtension(filePath);
// Export each partition separately
var partitionNumber = 1;
foreach (var partition in partitions)
{
// Compose new file name
var partitionFilePath = $"{fileNameWithoutExt} [{partitionNumber} of {partitions.Count}]{fileExt}";
// Compose full file path
if (dirPath.IsNotBlank())
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
// Export
await ExportChatLogSingleAsync(partition, partitionFilePath, format);
// Increment partition number
partitionNumber++;
}
}
public async Task ExportChatLogAsync(ChatLog chatLog, string filePath, ExportFormat format,
int? partitionLimit = null)
{
// If partitioning is disabled or there are fewer messages in chat log than the limit - process it without partitioning
if (partitionLimit == null || partitionLimit <= 0 || chatLog.Messages.Count <= partitionLimit)
{
await ExportChatLogSingleAsync(chatLog, filePath, format);
}
// Otherwise split into partitions and export separately
else
{
// Create partitions by grouping up to X adjacent messages into separate chat logs
var partitions = chatLog.Messages.GroupAdjacentWhile(g => g.Count < partitionLimit.Value)
.Select(g => new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, g, chatLog.Mentionables))
.ToArray();
await ExportChatLogPartitionedAsync(partitions, filePath, format);
}
}
}
}

View file

@ -1,23 +0,0 @@
using DiscordChatExporter.Core.Models;
using Tyrrrz.Settings;
namespace DiscordChatExporter.Core.Services
{
public class SettingsService : SettingsManager
{
public bool IsAutoUpdateEnabled { get; set; } = true;
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public AuthToken LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public int? LastPartitionLimit { get; set; }
public SettingsService()
{
Configuration.StorageSpace = StorageSpace.Instance;
Configuration.SubDirectoryPath = "";
Configuration.FileName = "Settings.dat";
}
}
}

View file

@ -1,79 +0,0 @@
using System;
using System.Threading.Tasks;
using Onova;
using Onova.Exceptions;
using Onova.Services;
namespace DiscordChatExporter.Core.Services
{
public class UpdateService : IDisposable
{
private readonly SettingsService _settingsService;
private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
private Version _updateVersion;
private bool _updaterLaunched;
public UpdateService(SettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task<Version> CheckPrepareUpdateAsync()
{
try
{
// If auto-update is disabled - don't check for updates
if (!_settingsService.IsAutoUpdateEnabled)
return null;
// Check for updates
var check = await _updateManager.CheckForUpdatesAsync();
if (!check.CanUpdate)
return null;
// Prepare the update
await _updateManager.PrepareUpdateAsync(check.LastVersion);
return _updateVersion = check.LastVersion;
}
catch (UpdaterAlreadyLaunchedException)
{
return null;
}
catch (LockFileNotAcquiredException)
{
return null;
}
}
public void FinalizeUpdate(bool needRestart)
{
try
{
// Check if an update is pending
if (_updateVersion == null)
return;
// Check if the updater has already been launched
if (_updaterLaunched)
return;
// Launch the updater
_updateManager.LaunchUpdater(_updateVersion, needRestart);
_updaterLaunched = true;
}
catch (UpdaterAlreadyLaunchedException)
{
}
catch (LockFileNotAcquiredException)
{
}
}
public void Dispose() => _updateManager.Dispose();
}
}