Compare commits

..

35 Commits

Author SHA1 Message Date
Oleksii Holub f63ea41188 Update version 2018-07-25 11:53:05 +03:00
Alexey Golub 3572a21aad Allow using bot token instead of user token (#70) 2018-07-24 22:47:45 +03:00
Alexey Golub 37ee0b8be3 Cleanup templates and change how model is referenced 2018-07-03 22:58:46 +03:00
trainboy2019 ed146bac22 Add support for animated avatars (#66) 2018-07-02 21:12:32 +03:00
Alexey Golub b9c449709f Update version 2018-07-01 21:12:06 +03:00
Alexey Golub 25207c5093 Improve the templates a bit 2018-07-01 21:04:03 +03:00
Alexey Golub 4fbec95208 Fix CSV and PlainText outputs not rendering timestamps correctly
Fixes #65
2018-07-01 16:53:33 +03:00
Alexey Golub 0a48da4c56 Update version 2018-06-28 00:36:26 +03:00
Alexey Golub 3f5f62585c Make emojis larger if they are the only thing in content 2018-06-28 00:29:47 +03:00
Alexey Golub 49f39c7097 Add retry policy for 429 responses 2018-06-28 00:20:31 +03:00
Alexey Golub e3eac10fb8 Bring HTML light theme in line with dark theme 2018-06-27 23:16:28 +03:00
Alexey Golub 23f9331e4e Cleanup 2018-06-27 00:45:29 +03:00
Alexey Golub 69088b83eb Html-encode date ranges in html export 2018-06-26 23:23:18 +03:00
Alexey Golub 7ecf90d1cb Refactor css for 'pre' with modifiers 2018-06-26 01:14:54 +03:00
Alexey Golub f0cd2dce1b Improve reactions 2018-06-26 01:09:23 +03:00
IntriguingTiles e8436faf66 Add support for reactions (#62) 2018-06-26 00:27:45 +03:00
Alexey Golub 61dce7c1a8 Small cleanup 2018-06-25 23:48:40 +03:00
Alexey Golub 6fee160bc7 Show an error when trying to export deleted channel
Closes #51
2018-06-25 23:34:29 +03:00
Alexey Golub 8678043f0d Implement progress reporting when downloading messages (#57) 2018-06-25 23:11:34 +03:00
Oleksii Holub e4f0b8193f Output date range in the export file
Closes #56
2018-06-25 18:44:50 +03:00
Oleksii Holub df811d0b1a Add loading placeholder for guild icons 2018-06-25 17:38:28 +03:00
Oleksii Holub 08da5d685b Prevent choosing invalid from/to dates 2018-06-25 17:11:52 +03:00
Alexey Golub c2cebe99e3 Fix wrong json node being used to parse mentioned users in messages 2018-06-25 02:28:38 +03:00
Alexey Golub 481991bd00 Architecture refactor (#63) 2018-06-25 01:59:15 +03:00
Malcolm Diller d958f613a3 Add support for embeds (#46) 2018-05-06 13:09:25 +03:00
Alexey Golub 3b7da21c24 Update version 2018-04-15 17:19:46 +03:00
Alexey Golub 1d0a13c613 Always open exported file on completion 2018-04-15 17:13:26 +03:00
Alexey Golub 67036f59ce Order channels by name in the UI 2018-04-15 17:11:45 +03:00
Alexey Golub 517542730a Print available export formats in CLI help 2018-04-15 17:03:48 +03:00
Alexey Golub 19dc2334e2 Update readme 2018-04-15 17:01:22 +03:00
Alexey Golub 4e1f783145 Update readme 2018-04-15 16:59:39 +03:00
Alexey Golub ff6ef480ab Remove comments from csv exporter 2018-04-15 16:58:47 +03:00
Tim van den Essen e1e8c73613 Add csv exporting (#44) 2018-04-15 16:52:02 +03:00
Alexey Golub e0284e281c Update readme 2018-04-15 16:26:53 +03:00
Alexey Golub 435510baf9 Remove AmmyUI 2018-04-15 16:08:57 +03:00
80 changed files with 2645 additions and 1944 deletions
+1 -4
View File
@@ -258,7 +258,4 @@ paket-files/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Ammy auto-generated XAML
*.g.xaml
*.pyc
+29
View File
@@ -1,3 +1,32 @@
### v2.6 (25-Jul-2018)
- Added support for bot tokens as an alternative to user tokens. For GUI, use the button in the top-left to switch between user and bot token. For CLI, pass the `--bot` switch to indicate that the given token is a bot token.
- Added support for animated avatars.
### v2.5.1 (01-Jul-2018)
- Fixed a bug that would prevent timestamps from rendering properly in CSV and PlainText exports.
- Fixed incorrect layout in CSV export.
- Improved readability of PlainText export.
### v2.5 (28-Jun-2018)
- Added support for embeds and webhooks (HTML only).
- Implemented progress reporting when downloading messages.
- Added support for reactions (HTML only).
- Selected date range is now shown in the export (HTML and PlainText).
- Added placeholders for guild icons to display while the icons are loading.
- Added support for jumbo emojis (custom emojis only).
- Added basic retry policy for 429 error responses.
- Added validation for from/to dates in the UI which prevents choosing invalid date ranges.
- Added an error message when trying to export a deleted channel.
### v2.4.1 (15-Apr-2018)
- Added CSV export format.
- Channels are now ordered by name in the UI.
- Exported file is now always opened upon completion.
### v2.4 (08-Apr-2018)
- Added channel topic to output.
+1 -1
View File
@@ -8,4 +8,4 @@ $files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporte
# CLI
$files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.Cli.zip" -Force
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
+3 -1
View File
@@ -5,7 +5,9 @@ namespace DiscordChatExporter.Cli
{
public class CliOptions
{
public string Token { get; set; }
public string TokenValue { get; set; }
public bool IsBotToken { get; set; }
public string ChannelId { get; set; }
@@ -3,7 +3,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework>
<Version>2.4</Version>
<Version>2.6</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
@@ -13,7 +13,7 @@
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
<PackageReference Include="FluentCommandLineParser" Version="1.4.3" />
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
</ItemGroup>
<ItemGroup>
+27 -9
View File
@@ -2,6 +2,7 @@
using System.Reflection;
using DiscordChatExporter.Core.Models;
using Fclp;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli
{
@@ -12,32 +13,43 @@ namespace DiscordChatExporter.Cli
private static void ShowHelp()
{
var version = Assembly.GetExecutingAssembly().GetName().Version;
var availableFormats = Enum.GetNames(typeof(ExportFormat));
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ===");
Console.WriteLine();
Console.WriteLine("[-t] [--token] Discord authorization token.");
Console.WriteLine("[-b] [--bot] Whether this is a bot token.");
Console.WriteLine("[-c] [--channel] Discord channel ID.");
Console.WriteLine("[-f] [--format] Export format (PlainText/HtmlDark/HtmlLight). Optional.");
Console.WriteLine("[-f] [--format] Export format. Optional.");
Console.WriteLine("[-o] [--output] Output file path. Optional.");
Console.WriteLine(" [--datefrom] Limit to messages after this date. Optional.");
Console.WriteLine(" [--dateto] Limit to messages before this date. Optional.");
Console.WriteLine(" [--dateformat] Date format. Optional.");
Console.WriteLine(" [--grouplimit] Message group limit. Optional.");
Console.WriteLine();
Console.WriteLine("# To get authorization token:");
Console.WriteLine($"Available export formats: {availableFormats.JoinToString(", ")}");
Console.WriteLine();
Console.WriteLine("# To get user token:");
Console.WriteLine(" - Open Discord app");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Press Ctrl+Shift+I");
Console.WriteLine(" - Navigate to Application tab");
Console.WriteLine(" - Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" - Navigate to the Application tab");
Console.WriteLine(" - Expand Storage > Local Storage > https://discordapp.com");
Console.WriteLine(" - Find \"token\" under key and copy the value");
Console.WriteLine(" - Find the \"token\" key and copy its value");
Console.WriteLine();
Console.WriteLine("# To get bot token:");
Console.WriteLine(" - Go to Discord developer portal");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Open your application's settings");
Console.WriteLine(" - Navigate to the Bot section on the left");
Console.WriteLine(" - Under Token click Copy");
Console.WriteLine();
Console.WriteLine("# To get channel ID:");
Console.WriteLine(" - Open Discord app");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Go to any channel you want to export");
Console.WriteLine(" - Press Ctrl+Shift+I");
Console.WriteLine(" - Navigate to Console tab");
Console.WriteLine(" - Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" - Navigate to the Console tab");
Console.WriteLine(" - Type \"document.URL\" and press Enter");
Console.WriteLine(" - Copy the long sequence of numbers after last slash");
}
@@ -48,7 +60,8 @@ namespace DiscordChatExporter.Cli
var settings = Container.SettingsService;
argsParser.Setup(o => o.Token).As('t', "token").Required();
argsParser.Setup(o => o.TokenValue).As('t', "token").Required();
argsParser.Setup(o => o.IsBotToken).As('b', "bot").SetDefault(false);
argsParser.Setup(o => o.ChannelId).As('c', "channel").Required();
argsParser.Setup(o => o.ExportFormat).As('f', "format").SetDefault(ExportFormat.HtmlDark);
argsParser.Setup(o => o.FilePath).As('o', "output").SetDefault(null);
@@ -88,10 +101,15 @@ namespace DiscordChatExporter.Cli
settings.DateFormat = options.DateFormat;
settings.MessageGroupLimit = options.MessageGroupLimit;
// Create token
var token = new AuthToken(
options.IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
options.TokenValue);
// Export
var vm = Container.MainViewModel;
vm.ExportAsync(
options.Token,
token,
options.ChannelId,
options.FilePath,
options.ExportFormat,
@@ -6,7 +6,7 @@ namespace DiscordChatExporter.Cli.ViewModels
{
public interface IMainViewModel
{
Task ExportAsync(string token, string channelId, string filePath, ExportFormat format, DateTime? from,
Task ExportAsync(AuthToken token, string channelId, string filePath, ExportFormat format, DateTime? from,
DateTime? to);
}
}
@@ -21,8 +21,8 @@ namespace DiscordChatExporter.Cli.ViewModels
_exportService = exportService;
}
public async Task ExportAsync(string token, string channelId, string filePath, ExportFormat format, DateTime? from,
DateTime? to)
public async Task ExportAsync(AuthToken token, string channelId, string filePath, ExportFormat format,
DateTime? from, DateTime? to)
{
// Get channel and guild
var channel = await _dataService.GetChannelAsync(token, channelId);
@@ -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, from, to, messageGroups, mentionables);
// Export
await _exportService.ExportAsync(format, filePath, log);
_exportService.Export(format, filePath, log);
}
}
}
@@ -2,22 +2,30 @@
<PropertyGroup>
<TargetFramework>net461</TargetFramework>
<Version>2.4</Version>
<Version>2.6</Version>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\PlainText.txt" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight.html" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\Core.html" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\Shared.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Csv.csv" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="mscorlib" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="Onova" Version="2.1.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.0" />
<PackageReference Include="Polly" Version="6.0.1" />
<PackageReference Include="Scriban" Version="1.2.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
</ItemGroup>
@@ -7,9 +7,13 @@ namespace DiscordChatExporter.Core.Exceptions
{
public HttpStatusCode StatusCode { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode)
public string ReasonPhrase { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase)
: base($"Error HTTP status code: {statusCode} - {reasonPhrase}")
{
StatusCode = statusCode;
ReasonPhrase = reasonPhrase;
}
}
}
+14 -13
View File
@@ -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);
}
}
+13 -4
View File
@@ -1,24 +1,33 @@
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 string Url { get; }
public int? Width { get; }
public int? Height { get; }
public bool IsImage => Width != null;
public string FileName { get; }
public long FileSize { get; }
public Attachment(string id, AttachmentType type, string url, string fileName, long fileSize)
public Attachment(string id, int? width, int? height, string url, string fileName, long fileSize)
{
Id = id;
Type = type;
Url = url;
Width = width;
Height = height;
FileName = fileName;
FileSize = fileSize;
}
public override string ToString() => FileName;
}
}
@@ -0,0 +1,15 @@
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;
}
}
}
@@ -1,8 +1,8 @@
namespace DiscordChatExporter.Core.Models
{
public enum AttachmentType
public enum AuthTokenType
{
Other,
Image
User,
Bot
}
}
+5 -8
View File
@@ -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);
}
}
@@ -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<MessageGroup> MessageGroups { get; }
public int TotalMessageCount { get; }
public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList<MessageGroup> messageGroups,
int totalMessageCount)
{
Guild = guild;
Channel = channel;
MessageGroups = messageGroups;
TotalMessageCount = totalMessageCount;
}
}
}
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
public enum ChannelType
{
GuildTextChat,
@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Linq;
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<MessageGroup> MessageGroups { get; }
public int TotalMessageCount => MessageGroups.Sum(g => g.Messages.Count);
public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
IReadOnlyList<MessageGroup> messageGroups, Mentionables mentionables)
{
Guild = guild;
Channel = channel;
From = from;
To = to;
MessageGroups = messageGroups;
Mentionables = mentionables;
}
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
}
}
+48
View File
@@ -0,0 +1,48 @@
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;
}
}
@@ -0,0 +1,22 @@
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;
}
}
@@ -0,0 +1,22 @@
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}";
}
}
@@ -0,0 +1,19 @@
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;
}
}
@@ -0,0 +1,20 @@
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;
}
}
}
@@ -4,6 +4,7 @@
{
PlainText,
HtmlDark,
HtmlLight
HtmlLight,
Csv
}
}
@@ -12,6 +12,8 @@ namespace DiscordChatExporter.Core.Models
return "html";
if (format == ExportFormat.HtmlLight)
return "html";
if (format == ExportFormat.Csv)
return "csv";
throw new ArgumentOutOfRangeException(nameof(format));
}
@@ -24,6 +26,8 @@ namespace DiscordChatExporter.Core.Models
return "HTML (Dark)";
if (format == ExportFormat.HtmlLight)
return "HTML (Light)";
if (format == ExportFormat.Csv)
return "Comma Seperated Values (CSV)";
throw new ArgumentOutOfRangeException(nameof(format));
}
+6 -11
View File
@@ -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<Role> Roles { get; }
public Guild(string id, string name, string iconHash, IReadOnlyList<Role> 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);
}
}
@@ -0,0 +1,30 @@
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);
}
}
+16 -19
View File
@@ -3,6 +3,8 @@ using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object
public class Message
{
public string Id { get; }
@@ -13,42 +15,37 @@ 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; }
public IReadOnlyList<Attachment> Attachments { get; }
public IReadOnlyList<Embed> Embeds { get; }
public IReadOnlyList<Reaction> Reactions { get; }
public IReadOnlyList<User> MentionedUsers { get; }
public IReadOnlyList<Role> MentionedRoles { get; }
public IReadOnlyList<Channel> MentionedChannels { get; }
public Message(string id, string channelId, MessageType type,
User author, DateTime timeStamp,
DateTime? editedTimeStamp, string content,
IReadOnlyList<Attachment> attachments, IReadOnlyList<User> mentionedUsers,
IReadOnlyList<Role> mentionedRoles, IReadOnlyList<Channel> mentionedChannels)
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;
Timestamp = timestamp;
EditedTimestamp = editedTimestamp;
Content = content;
Attachments = attachments;
Embeds = embeds;
Reactions = reactions;
MentionedUsers = mentionedUsers;
MentionedRoles = mentionedRoles;
MentionedChannels = mentionedChannels;
}
public override string ToString()
{
return Content;
}
public override string ToString() => Content;
}
}
@@ -7,15 +7,17 @@ namespace DiscordChatExporter.Core.Models
{
public User Author { get; }
public DateTime TimeStamp { get; }
public DateTime Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime timeStamp, IReadOnlyList<Message> messages)
public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
{
Author = author;
TimeStamp = timeStamp;
Timestamp = timestamp;
Messages = messages;
}
public override string ToString() => $"{Author.FullName} | {Timestamp} | {Messages.Count} messages";
}
}
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
public enum MessageType
{
Default,
@@ -0,0 +1,20 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#reaction-object
public class Reaction
{
public int Count { get; }
public string EmojiId { get; }
public string EmojiName { get; }
public Reaction(int count, string emojiId, string emojiName)
{
Count = count;
EmojiId = emojiId;
EmojiName = emojiName;
}
}
}
+5 -8
View File
@@ -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");
}
}
+36 -11
View File
@@ -1,8 +1,11 @@
using Tyrrrz.Extensions;
using System;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
public class User
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class User
{
public string Id { get; }
@@ -12,13 +15,32 @@ namespace DiscordChatExporter.Core.Models
public string FullName => $"{Name}#{Discriminator:0000}";
public string AvatarHash { get; }
public string DefaultAvatarHash => $"{Discriminator % 5}";
public string AvatarUrl => AvatarHash.IsNotBlank()
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
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)
{
@@ -28,9 +50,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) =>
new User(id, 0, "Unknown", null);
}
}
@@ -1,142 +0,0 @@
body {
background-color: #36393E;
color: rgba(255, 255, 255, 0.7);
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
color: #0096CF;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
div.pre {
background-color: #2F3136;
color: rgb(131, 148, 150);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
}
span.pre {
background-color: #2F3136;
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 {
color: #FFFFFF;
font-size: 1.4em;
}
div.channel-name {
color: #FFFFFF;
font-size: 1.2em;
}
div.channel-topic {
margin-top: 2px;
color: #FFFFFF;
}
div.channel-messagecount {
margin-top: 2px;
}
div.msg {
border-top: 1px solid rgba(255, 255, 255, 0.04);
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 {
color: #FFFFFF;
font-size: 1em;
}
span.msg-date {
color: rgba(255, 255, 255, 0.2);
font-size: .75em;
margin-left: 5px;
}
span.msg-edited {
color: rgba(255, 255, 255, 0.2);
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%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -1,142 +0,0 @@
body {
background-color: #FFFFFF;
color: #737F8D;
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
color: #00B0F4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
div.pre {
background-color: #F9F9F9;
color: rgb(101, 123, 131);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
}
span.pre {
background-color: #F9F9F9;
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 {
color: #2F3136;
font-size: 1.4em;
}
div.channel-name {
color: #2F3136;
font-size: 1.2em;
}
div.channel-topic {
margin-top: 2px;
color: #2F3136;
}
div.channel-messagecount {
margin-top: 2px;
}
div.msg {
border-top: 1px solid #ECEEEF;
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 {
color: #2F3136;
font-size: 1em;
}
span.msg-date {
color: #99AAB5;
font-size: .75em;
margin-left: 5px;
}
span.msg-edited {
color: #99AAB5;
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%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -0,0 +1,12 @@
Author;Date;Content;Attachments;
{{~ for group in Model.MessageGroups -}}
{{- for message in group.Messages -}}
{{- message.Author.FullName }};
{{- message.Timestamp | FormatDate }};
{{- message.Content | FormatContent }};
{{- message.Attachments | array.map "Url" | array.join "," }};
{{~ end -}}
{{- end -}}
Can't render this file because it contains an unexpected character in line 10 and column 41.
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<style>
{{ StyleSheet }}
</style>
</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.TotalMessageCount | 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.MessageGroups ~}}
<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">
{{ message.Content | FormatContent }}
{{~ # 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 | FormatFileSize }})
{{~ 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 | FormatColor }})"></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 }}">{{ embed.Title | FormatContent }}</a>
{{~ else ~}}
{{ embed.Title | FormatContent }}
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</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">{{ field.Name | FormatContent }}</div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</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">
<span class="chatlog__reaction-emoji">
{{~ # Custom emoji ~}}
{{~ if reaction.EmojiId ~}}
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
{{~ # Default emoji ~}}
{{~ else ~}}
{{ reaction.EmojiName }}
{{~ end ~}}
</span>
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ end ~}}
</div>
</div>
{{~ end ~}}
</div>
</body>
</html>
@@ -0,0 +1,97 @@
/* === GENERAL === */
body {
background-color: #36393e;
color: #ffffffb3;
}
a {
color: #0096cf;
}
.pre {
background-color: #2f3136;
}
.pre--multiline {
border-color: #282b30;
color: #839496;
}
.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-field-name {
color: #ffffff;
}
.chatlog__embed-field-value {
color: #ffffff99;
}
.chatlog__embed-footer {
color: #ffffff99;
}
.chatlog__reaction {
background-color: #ffffff0a;
}
.chatlog__reaction-count {
color: #ffffff4d;
}
@@ -0,0 +1,97 @@
/* === GENERAL === */
body {
background-color: #ffffff;
color: #737f8d;
}
a {
color: #00b0f4;
}
.pre {
background-color: #f9f9f9;
}
.pre--multiline {
border-color: #f3f3f3;
color: #657b83;
}
.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: #f9f9f94d;
border-color: #cccccc4d;
}
.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: #4f535b99;
}
.chatlog__reaction {
background-color: #4f545c0f;
}
.chatlog__reaction-count {
color: #99aab5;
}
@@ -0,0 +1,303 @@
/* === 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 {
font-family: "Consolas", "Courier New", Courier, Monospace;
white-space: pre-wrap;
}
.pre--multiline {
margin-top: 4px;
padding: 8px;
border: 2px solid;
border-radius: 5px;
}
.pre--inline {
padding: 2px;
border-radius: 3px;
}
.emoji {
width: 24px;
height: 24px;
margin: 0 1px;
vertical-align: middle;
}
.emoji--small {
width: 16px;
height: 16px;
}
.emoji--large {
width: 32px;
height: 32px;
}
.mention {
font-weight: 600;
}
/* === 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%;
}
.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;
}
.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: 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 {
margin: 6px 2px 2px 2px;
padding: 2px 6px 2px 2px;
border-radius: 3px;
}
.chatlog__reaction-emoji {
margin-left: 3px;
vertical-align: middle;
}
.chatlog__reaction-count {
font-size: .875em;
vertical-align: middle;
}
@@ -0,0 +1,7 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.DarkTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -0,0 +1,7 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.LightTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -0,0 +1,25 @@
{{~ # Info ~}}
==============================================================
Guild: {{ Model.Guild.Name }}
Channel: {{ Model.Channel.Name }}
Topic: {{ Model.Channel.Topic }}
Messages: {{ Model.TotalMessageCount | 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 group in Model.MessageGroups ~}}
{{~ # Author name and timestamp ~}}
{{~ }}[{{ group.Timestamp | FormatDate }}] {{ group.Author.FullName }}
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ message.Content | FormatContent }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ end ~}}
{{~ end ~}}
@@ -0,0 +1,196 @@
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 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, 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 fileSize = json["size"].Value<long>();
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 Reaction ParseReaction(JToken json)
{
var count = json["count"].Value<int>();
var emojiId = json["emoji"]["id"]?.Value<string>();
var emojiName = json["emoji"]["name"].Value<string>();
return new Reaction(count, emojiId, emojiName);
}
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);
}
}
}
+157 -262
View File
@@ -2,325 +2,220 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
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 Polly;
using Tyrrrz.Extensions;
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<string, Role> _roleCache = new Dictionary<string, Role>();
private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>();
private User ParseUser(JToken token)
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
params string[] parameters)
{
var id = token["id"].Value<string>();
var discriminator = token["discriminator"].Value<int>();
var name = token["username"].Value<string>();
var avatarHash = token["avatar"].Value<string>();
// Create request policy
var policy = Policy
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
return new User(id, discriminator, name, avatarHash);
}
private Role ParseRole(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
return new Role(id, name);
}
private Guild ParseGuild(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
var iconHash = token["icon"].Value<string>();
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<string>();
var guildId = token["guild_id"]?.Value<string>();
var type = (ChannelType) token["type"].Value<int>();
var topic = token["topic"]?.Value<string>();
// Extract name based on type
string name;
if (type.IsEither(ChannelType.DirectTextChat, ChannelType.DirectGroupTextChat))
// Send request
return await policy.ExecuteAsync(async () =>
{
guildId = Guild.DirectMessages.Id;
// Create request
const string apiRoot = "https://discordapp.com/api/v6";
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
{
// Add url parameter for the user token
if (token.Type == AuthTokenType.User)
request.RequestUri = request.RequestUri.SetQueryParameter("token", token.Value);
// Try to get name if it's set
name = token["name"]?.Value<string>();
// Add header for the bot token
if (token.Type == AuthTokenType.Bot)
request.Headers.Authorization = new AuthenticationHeaderValue("Bot", token.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<string>();
}
// Add parameters
foreach (var parameter in parameters)
{
var key = parameter.SubstringUntil("=");
var value = parameter.SubstringAfter("=");
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
}
return new Channel(id, guildId, name, topic, type);
// 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);
}
}
});
}
private Message ParseMessage(JToken token)
public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
{
// Get basic data
var id = token["id"].Value<string>();
var channelId = token["channel_id"].Value<string>();
var timeStamp = token["timestamp"].Value<DateTime>();
var editedTimeStamp = token["edited_timestamp"]?.Value<DateTime?>();
var content = token["content"].Value<string>();
var type = (MessageType) token["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(token["author"]);
// Get attachment
var attachments = new List<Attachment>();
foreach (var attachmentJson in token["attachments"].EmptyIfNull())
{
var attachmentId = attachmentJson["id"].Value<string>();
var attachmentUrl = attachmentJson["url"].Value<string>();
var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image
: AttachmentType.Other;
var attachmentFileName = attachmentJson["filename"].Value<string>();
var attachmentFileSize = attachmentJson["size"].Value<long>();
var attachment = new Attachment(
attachmentId, attachmentType, attachmentUrl,
attachmentFileName, attachmentFileSize);
attachments.Add(attachment);
}
// Get user mentions
var mentionedUsers = token["mentions"].Select(ParseUser).ToArray();
// Get role mentions
var mentionedRoles = token["mention_roles"]
.Values<string>()
.Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(id))
.ToArray();
// Get channel mentions
var mentionedChannels = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(id))
.ToArray();
return new Message(id, channelId, type, author, timeStamp, editedTimeStamp, content, attachments,
mentionedUsers, mentionedRoles, mentionedChannels);
}
private async Task<string> GetStringAsync(string url)
{
using (var response = await _httpClient.GetAsync(url))
{
// 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);
// Get content
return await response.Content.ReadAsStringAsync();
}
}
public async Task<Guild> 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<Channel> GetChannelAsync(string token, string channelId)
public async Task<Channel> GetChannelAsync(AuthToken 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<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
{
// 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<IReadOnlyList<Guild>> 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<string>());
// Get full guild infos
var guilds = new List<Guild>();
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<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken 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<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to)
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>();
// We are going backwards from last message to first
// collecting everything between them in batches
var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
while (true)
// Report indeterminate progress
progress?.Report(-1);
// Get the snowflakes for the selected range
var firstId = from != null ? from.Value.ToSnowflake() : "0";
var lastId = to != null ? to.Value.ToSnowflake() : DateTime.MaxValue.ToSnowflake();
// Get the last message
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=1", $"before={lastId}");
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)
{
// 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);
// Parse
var messages = JArray.Parse(content).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)
{
currentMessageId = null;
break;
}
// Add message
result.Add(message);
currentMessageId = message.Id;
}
// If no messages - break
if (currentMessageId == null)
break;
// Otherwise offset the next request
beforeId = currentMessageId;
progress?.Report(1);
return result;
}
// Messages appear newest first, we need to reverse
result.Reverse();
// Get other messages
var offsetId = firstId;
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;
}
protected virtual void Dispose(bool disposing)
public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages)
{
if (disposing)
// 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)
{
_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();
return new Mentionables(users, channels, roles);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
public partial class DataService
{
private static string DateTimeToSnowflake(DateTime dateTime)
{
const long epoch = 62135596800000;
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
_httpClient.Dispose();
}
}
}
@@ -1,208 +0,0 @@
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 FormatMessageContentHtml(Message message)
{
// A lot of these regexes were inspired by or taken from MarkdownSharp
var content = message.Content;
// 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,
@"((https?|ftp)://[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\]\(\);]*[-a-zA-Z0-9+&@#/%=~_|\[\])])(?=$|\W)",
m => $"\x1AL{Base64Encode(m.Groups[1].Value)}\x1AL");
// Process bold (**text**)
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
// Process underline (__text__)
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
// Process italic (*text* or _text_)
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
// Process strike through (~~text~~)
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
// Decode and process multiline codeblocks
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
m => $"<div class=\"pre\">{Base64Decode(m.Groups[1].Value)}</div>");
// Decode and process inline codeblocks
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre\">{Base64Decode(m.Groups[1].Value)}</span>");
// Decode and process URLs
content = Regex.Replace(content, "\x1AL(.*?)\x1AL",
m => $"<a href=\"{Base64Decode(m.Groups[1].Value)}\">{Base64Decode(m.Groups[1].Value)}</a>");
// New lines
content = content.Replace("\n", "<br />");
// Meta mentions (@everyone)
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
{
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
{
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
{
content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css)
{
// Generation info
await output.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Html start
await output.WriteLineAsync("<!DOCTYPE html>");
await output.WriteLineAsync("<html lang=\"en\">");
// HEAD
await output.WriteLineAsync("<head>");
await output.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
await output.WriteLineAsync("<meta charset=\"utf-8\" />");
await output.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await output.WriteLineAsync($"<style>{css}</style>");
await output.WriteLineAsync("</head>");
// Body start
await output.WriteLineAsync("<body>");
// Guild and channel info
await output.WriteLineAsync("<div id=\"info\">");
await output.WriteLineAsync("<div class=\"info-left\">");
await output.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await output.WriteLineAsync("</div>"); // info-left
await output.WriteLineAsync("<div class=\"info-right\">");
await output.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-topic\">{log.Channel.Topic}</div>");
await output.WriteLineAsync(
$"<div class=\"channel-messagecount\">{log.TotalMessageCount:N0} messages</div>");
await output.WriteLineAsync("</div>"); // info-right
await output.WriteLineAsync("</div>"); // info
// Chat log
await output.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{
await output.WriteLineAsync("<div class=\"msg\">");
await output.WriteLineAsync("<div class=\"msg-left\">");
await output.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
await output.WriteLineAsync("</div>");
await output.WriteLineAsync("<div class=\"msg-right\">");
await output.WriteAsync(
$"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author.FullName)}\">");
await output.WriteAsync(HtmlEncode(group.Author.Name));
await output.WriteLineAsync("</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
await output.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
await output.WriteLineAsync("<div class=\"msg-content\">");
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(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
await output.WriteLineAsync("</div>"); // msg-content
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.Type == AttachmentType.Image)
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await output.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
else
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
await output.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
}
}
await output.WriteLineAsync("</div>"); // msg-right
await output.WriteLineAsync("</div>"); // msg
}
await output.WriteLineAsync("</div>"); // log
await output.WriteLineAsync("</body>");
await output.WriteLineAsync("</html>");
}
}
}
@@ -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();
}
}
}
}
@@ -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));
}
}
}
}
@@ -0,0 +1,323 @@
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 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<Match>()
.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<Match>()
.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<Match>()
.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", "<b>$2</b>");
// Process underline (__text__)
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
// Process italic (*text* or _text_)
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
// Process strike through (~~text~~)
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
// Decode and process multiline codeblocks
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
m => $"<div class=\"pre pre--multiline\">{m.Groups[1].Value.Base64Decode()}</div>");
// Decode and process inline codeblocks
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre pre--inline\">{m.Groups[1].Value.Base64Decode()}</span>");
// Decode and process links
if (allowLinks)
{
content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL",
m => $"<a href=\"{m.Groups[2].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
}
// Decode and process URLs
content = Regex.Replace(content, "\x1AU(.*?)\x1AU",
m => $"<a href=\"{m.Groups[1].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
// Process new lines
content = content.Replace("\n", "<br />");
// Meta mentions (@everyone)
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "&lt;@!?(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"&lt;@!?{mentionedUserId}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "&lt;#(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"&lt;#{mentionedChannelId}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "&lt;@&amp;(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"&lt;@&amp;{mentionedRoleId}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
var isJumboable = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;", "").IsBlank();
var emojiClass = isJumboable ? "emoji emoji--large" : "emoji";
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
$"<img class=\"{emojiClass}\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
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<Match>()
.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<Match>()
.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<Match>()
.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.SetValue("Model", _log, true);
// Import functions
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
scriptObject.Import(nameof(FormatColor), new Func<Color, string>(FormatColor));
scriptObject.Import(nameof(FormatContent), new Func<string, bool, string>(FormatContent));
return scriptObject;
}
}
}
}
@@ -1,11 +1,7 @@
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
{
@@ -18,64 +14,38 @@ 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)
{
// 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, log, _settingsService.DateFormat);
context.PushGlobal(templateModel.GetScriptObject());
// Render output
using (var output = File.CreateText(filePath))
{
if (format == ExportFormat.PlainText)
{
await ExportAsPlainTextAsync(log, output);
}
// Configure output
context.PushOutput(new TextWriterOutput(output));
else if (format == ExportFormat.HtmlDark)
{
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css");
await ExportAsHtmlAsync(log, output, css);
}
else if (format == ExportFormat.HtmlLight)
{
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css");
await ExportAsHtmlAsync(log, output, css);
}
else throw new ArgumentOutOfRangeException(nameof(format));
// Render template
template.Render(context);
}
}
}
public partial class ExportService
{
private static string Base64Encode(string str)
{
return str.GetBytes().ToBase64();
}
private static string Base64Decode(string str)
{
return str.FromBase64().GetString();
}
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static 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]}";
}
}
}
@@ -7,17 +7,22 @@ namespace DiscordChatExporter.Core.Services
{
public interface IDataService
{
Task<Guild> GetGuildAsync(string token, string guildId);
Task<Guild> GetGuildAsync(AuthToken token, string guildId);
Task<Channel> GetChannelAsync(string token, string channelId);
Task<Channel> GetChannelAsync(AuthToken token, string channelId);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to);
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages);
}
}
@@ -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);
}
}
@@ -5,6 +5,6 @@ namespace DiscordChatExporter.Core.Services
{
public interface IMessageGroupService
{
IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages);
IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages);
}
}
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Services
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
string LastToken { get; set; }
AuthToken LastToken { get; set; }
ExportFormat LastExportFormat { get; set; }
void Load();
@@ -13,7 +13,7 @@ namespace DiscordChatExporter.Core.Services
_settingsService = settingsService;
}
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
public IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{
var result = new List<MessageGroup>();
@@ -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);
}
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Core.Services
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20;
public string LastToken { get; set; }
public AuthToken LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public SettingsService()
-69
View File
@@ -1,69 +0,0 @@
Application "DiscordChatExporter.Gui.App" {
StartupUri: "Views/MainWindow.g.xaml"
Startup: App_Startup
Exit: App_Exit
Resources: ResourceDictionary {
// Material Design
#MergeDictionary("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml")
#MergeDictionary("pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml")
// Colors
Color Key="PrimaryColor" { "#343838" }
Color Key="PrimaryLightColor" { "#5E6262" }
Color Key="PrimaryDarkColor" { "#0D1212" }
Color Key="AccentColor" { "#F9A825" }
Color Key="TextColor" { "#000000" }
Color Key="InverseTextColor" { "#FFFFFF" }
// Brushes
SolidColorBrush Key="PrimaryHueLightBrush" { Color: resource dyn "PrimaryLightColor" }
SolidColorBrush Key="PrimaryHueLightForegroundBrush" { Color: resource dyn "InverseTextColor" }
SolidColorBrush Key="PrimaryHueMidBrush" { Color: resource dyn "PrimaryColor" }
SolidColorBrush Key="PrimaryHueMidForegroundBrush" { Color: resource dyn "InverseTextColor" }
SolidColorBrush Key="PrimaryHueDarkBrush" { Color: resource dyn "PrimaryDarkColor" }
SolidColorBrush Key="PrimaryHueDarkForegroundBrush" { Color: resource dyn "InverseTextColor" }
SolidColorBrush Key="SecondaryAccentBrush" { Color: resource dyn "AccentColor" }
SolidColorBrush Key="SecondaryAccentForegroundBrush" { Color: resource dyn "TextColor" }
SolidColorBrush Key="PrimaryTextBrush" { Color: resource dyn "TextColor", Opacity: 0.87 }
SolidColorBrush Key="SecondaryTextBrush" { Color: resource dyn "TextColor", Opacity: 0.64 }
SolidColorBrush Key="DimTextBrush" { Color: resource dyn "TextColor", Opacity: 0.45 }
SolidColorBrush Key="PrimaryInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 1 }
SolidColorBrush Key="SecondaryInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.7 }
SolidColorBrush Key="DimInverseTextBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.52 }
SolidColorBrush Key="AccentTextBrush" { Color: resource dyn "AccentColor", Opacity: 1 }
SolidColorBrush Key="DividerBrush" { Color: resource dyn "TextColor", Opacity: 0.12 }
SolidColorBrush Key="InverseDividerBrush" { Color: resource dyn "InverseTextColor", Opacity: 0.12 }
// Styles
Style {
TargetType: "Image"
#Setter("RenderOptions.BitmapScalingMode", "HighQuality")
}
Style {
TargetType: "ProgressBar"
BasedOn: resource "MaterialDesignLinearProgressBar"
#Setter("Foreground", resource dyn "SecondaryAccentBrush")
#Setter("Height", 2)
#Setter("Minimum", 0)
#Setter("Maximum", 1)
#Setter("BorderThickness", 0)
}
Style {
TargetType: "TextBox"
BasedOn: resource "MaterialDesignTextBox"
#Setter("Foreground", resource dyn "PrimaryTextBrush")
}
Style {
TargetType: "ComboBox"
BasedOn: resource "MaterialDesignComboBox"
#Setter("Foreground", resource dyn "PrimaryTextBrush")
}
// Container
Container Key="Container" { }
}
}
+121
View File
@@ -0,0 +1,121 @@
<Application
x:Class="DiscordChatExporter.Gui.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
Exit="App_Exit"
Startup="App_Startup"
StartupUri="Views/MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
<ResourceDictionary Source="pack://application:,,,/Tyrrrz.WpfExtensions;component/ConvertersDictionary.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- Colors -->
<Color x:Key="PrimaryColor">#343838</Color>
<Color x:Key="PrimaryLightColor">#5E6262</Color>
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
<Color x:Key="AccentColor">#F9A825</Color>
<Color x:Key="AccentDarkColor">#C17900</Color>
<Color x:Key="TextColor">#000000</Color>
<Color x:Key="InverseTextColor">#FFFFFF</Color>
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="PrimaryTextBrush"
Opacity="0.87"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="SecondaryTextBrush"
Opacity="0.64"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="DimTextBrush"
Opacity="0.45"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="PrimaryInverseTextBrush"
Opacity="1"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="SecondaryInverseTextBrush"
Opacity="0.7"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="DimInverseTextBrush"
Opacity="0.52"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="AccentTextBrush"
Opacity="1"
Color="{DynamicResource AccentColor}" />
<SolidColorBrush
x:Key="AccentDarkTextBrush"
Opacity="1"
Color="{DynamicResource AccentDarkColor}" />
<SolidColorBrush
x:Key="DividerBrush"
Opacity="0.12"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="InverseDividerBrush"
Opacity="0.12"
Color="{DynamicResource InverseTextColor}" />
<!-- Styles -->
<Style TargetType="{x:Type Image}">
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
<Setter Property="RenderOptions.EdgeMode" Value="Aliased" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
<Setter Property="Height" Value="2" />
<Setter Property="Maximum" Value="1" />
<Setter Property="Minimum" Value="0" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style
x:Key="MaterialDesignFlatActionToggleButton"
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
TargetType="{x:Type ToggleButton}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
</Trigger>
</Style.Triggers>
</Style>
<!-- Converters -->
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
<!-- Container -->
<local:Container x:Key="Container" />
</ResourceDictionary>
</Application.Resources>
</Application>
@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using System.Windows.Data;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Gui.Converters
{
[ValueConversion(typeof(ExportFormat), typeof(string))]
public class ExportFormatToStringConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var format = (ExportFormat) value;
return format.GetDisplayName();
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
@@ -42,9 +42,6 @@
<StartupObject />
</PropertyGroup>
<ItemGroup>
<Reference Include="AmmySidekick, Version=1.0.0.0, Culture=neutral, PublicKeyToken=7c1296d24569a67d, processorArchitecture=MSIL">
<HintPath>..\packages\Ammy.WPF.1.2.94\lib\net40\AmmySidekick.dll</HintPath>
</Reference>
<Reference Include="CommonServiceLocator, Version=2.0.3.0, Culture=neutral, PublicKeyToken=489b6accfaf20ef0, processorArchitecture=MSIL">
<HintPath>..\packages\CommonServiceLocator.2.0.3\lib\net45\CommonServiceLocator.dll</HintPath>
</Reference>
@@ -71,15 +68,22 @@
<Reference Include="System.Xaml">
<RequiredTargetFramework>4.0</RequiredTargetFramework>
</Reference>
<Reference Include="Tyrrrz.Extensions, Version=1.5.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Extensions.1.5.0\lib\net45\Tyrrrz.Extensions.dll</HintPath>
<Reference Include="Tyrrrz.Extensions, Version=1.5.1.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Extensions.1.5.1\lib\net45\Tyrrrz.Extensions.dll</HintPath>
</Reference>
<Reference Include="Tyrrrz.Settings, Version=1.3.2.0, Culture=neutral, PublicKeyToken=null" />
<Reference Include="Tyrrrz.WpfExtensions, Version=1.0.6269.37170, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.WpfExtensions.1.0.5\lib\net45\Tyrrrz.WpfExtensions.dll</HintPath>
</Reference>
<Reference Include="WindowsBase" />
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
</ItemGroup>
<ItemGroup>
<Compile Include="App.xaml.cs">
<DependentUpon>App.xaml</DependentUpon>
</Compile>
<Compile Include="Converters\ExportFormatToStringConverter.cs" />
<Compile Include="Messages\ShowExportSetupMessage.cs" />
<Compile Include="Messages\ShowNotificationMessage.cs" />
<Compile Include="Messages\ShowSettingsMessage.cs" />
@@ -88,50 +92,18 @@
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
<Compile Include="ViewModels\ISettingsViewModel.cs" />
<Compile Include="ViewModels\SettingsViewModel.cs" />
<Compile Include="Views\ErrorDialog.ammy.cs">
<DependentUpon>ErrorDialog.ammy</DependentUpon>
</Compile>
<Compile Include="Views\ExportSetupDialog.ammy.cs">
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
</Compile>
<Compile Include="Views\SettingsDialog.ammy.cs">
<DependentUpon>SettingsDialog.ammy</DependentUpon>
</Compile>
<Page Include="App.g.xaml">
<SubType>Designer</SubType>
<Generator>XamlIntelliSenseFileGenerator</Generator>
<DependentUpon>App.ammy</DependentUpon>
</Page>
<Page Include="Views\ErrorDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>ErrorDialog.ammy</DependentUpon>
</Page>
<Page Include="Views\ExportSetupDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
</Page>
<Page Include="Views\MainWindow.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>MainWindow.ammy</DependentUpon>
</Page>
<Compile Include="App.ammy.cs">
<DependentUpon>App.ammy</DependentUpon>
</Compile>
<Compile Include="Container.cs" />
<Compile Include="Program.cs" />
<Compile Include="ViewModels\IMainViewModel.cs" />
<Compile Include="ViewModels\MainViewModel.cs" />
<Compile Include="Views\MainWindow.ammy.cs">
<DependentUpon>MainWindow.ammy</DependentUpon>
<Compile Include="Views\ExportSetupDialog.xaml.cs">
<DependentUpon>ExportSetupDialog.xaml</DependentUpon>
</Compile>
<Compile Include="Views\MainWindow.xaml.cs">
<DependentUpon>MainWindow.xaml</DependentUpon>
</Compile>
<Compile Include="Views\SettingsDialog.xaml.cs">
<DependentUpon>SettingsDialog.xaml</DependentUpon>
</Compile>
<Page Include="Views\SettingsDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>SettingsDialog.ammy</DependentUpon>
</Page>
</ItemGroup>
<ItemGroup>
<Compile Include="Properties\AssemblyInfo.cs">
@@ -146,16 +118,10 @@
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="App.ammy" />
<None Include="app.config" />
<None Include="lib.ammy" />
<None Include="packages.config">
<SubType>Designer</SubType>
</None>
<None Include="Views\ErrorDialog.ammy" />
<None Include="Views\ExportSetupDialog.ammy" />
<None Include="Views\MainWindow.ammy" />
<None Include="Views\SettingsDialog.ammy" />
</ItemGroup>
<ItemGroup>
<Resource Include="..\favicon.ico" />
@@ -166,12 +132,24 @@
<Name>DiscordChatExporter.Core</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="App.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</ApplicationDefinition>
<Page Include="Views\ExportSetupDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\MainWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\SettingsDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\packages\Ammy.1.2.94\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.94\build\Ammy.targets')" />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('..\packages\Ammy.1.2.94\build\Ammy.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Ammy.1.2.94\build\Ammy.targets'))" />
</Target>
</Project>
-17
View File
@@ -1,17 +0,0 @@
using System;
using AmmySidekick;
namespace DiscordChatExporter.Gui
{
public static class Program
{
[STAThread]
public static void Main()
{
var app = new App();
app.InitializeComponent();
RuntimeUpdateHandler.Register(app, $"/{Ammy.GetAssemblyName(app)};component/App.g.xaml");
app.Run();
}
}
}
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
[assembly: AssemblyVersion("2.4")]
[assembly: AssemblyFileVersion("2.4")]
[assembly: AssemblyVersion("2.6")]
[assembly: AssemblyFileVersion("2.6")]
@@ -34,7 +34,8 @@ namespace DiscordChatExporter.Gui.ViewModels
}
}
public IReadOnlyList<ExportFormat> AvailableFormats { get; }
public IReadOnlyList<ExportFormat> AvailableFormats =>
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public ExportFormat SelectedFormat
{
@@ -44,9 +45,9 @@ namespace DiscordChatExporter.Gui.ViewModels
Set(ref _format, value);
// Replace extension in path
var newExt = value.GetFileExtension();
if (FilePath != null && !FilePath.EndsWith(newExt))
FilePath = FilePath.SubstringUntilLast(".") + "." + newExt;
var ext = value.GetFileExtension();
if (FilePath != null)
FilePath = Path.ChangeExtension(FilePath, ext);
}
}
@@ -69,9 +70,6 @@ namespace DiscordChatExporter.Gui.ViewModels
{
_settingsService = settingsService;
// Defaults
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
// Commands
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
@@ -93,6 +91,12 @@ namespace DiscordChatExporter.Gui.ViewModels
// Save format
_settingsService.LastExportFormat = SelectedFormat;
// Clamp 'from' and 'to' values
if (From > To)
From = To;
if (To < From)
To = From;
// Start export
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
}
@@ -9,7 +9,11 @@ namespace DiscordChatExporter.Gui.ViewModels
bool IsBusy { get; }
bool IsDataAvailable { get; }
string Token { get; set; }
bool IsProgressIndeterminate { get; }
double Progress { get; }
bool IsBotToken { get; set; }
string TokenValue { get; set; }
IReadOnlyList<Guild> AvailableGuilds { get; }
Guild SelectedGuild { get; set; }
@@ -25,7 +25,9 @@ namespace DiscordChatExporter.Gui.ViewModels
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy;
private string _token;
private double _progress;
private bool _isBotToken;
private string _tokenValue;
private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild;
private IReadOnlyList<Channel> _availableChannels;
@@ -43,15 +45,33 @@ namespace DiscordChatExporter.Gui.ViewModels
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
public string Token
public bool IsProgressIndeterminate => Progress <= 0;
public double Progress
{
get => _token;
get => _progress;
private set
{
Set(ref _progress, value);
RaisePropertyChanged(() => IsProgressIndeterminate);
}
}
public bool IsBotToken
{
get => _isBotToken;
set => Set(ref _isBotToken, value);
}
public string TokenValue
{
get => _tokenValue;
set
{
// Remove invalid chars
value = value?.Trim('"');
Set(ref _token, value);
Set(ref _tokenValue, value);
PullDataCommand.RaiseCanExecuteChanged();
}
}
@@ -72,7 +92,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<Channel>();
ShowExportSetupCommand.RaiseCanExecuteChanged();
}
}
@@ -104,7 +124,7 @@ namespace DiscordChatExporter.Gui.ViewModels
// Commands
ViewLoadedCommand = new RelayCommand(ViewLoaded);
ViewClosedCommand = new RelayCommand(ViewClosed);
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
PullDataCommand = new RelayCommand(PullData, () => TokenValue.IsNotBlank() && !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout);
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
@@ -119,8 +139,12 @@ namespace DiscordChatExporter.Gui.ViewModels
// Load settings
_settingsService.Load();
// Set last token
Token = _settingsService.LastToken;
// Get last token
if (_settingsService.LastToken != null)
{
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
TokenValue = _settingsService.LastToken.Value;
}
// Check and prepare update
try
@@ -156,8 +180,10 @@ namespace DiscordChatExporter.Gui.ViewModels
{
IsBusy = true;
// Copy token so it doesn't get mutated
var token = Token;
// Create token
var token = new AuthToken(
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
TokenValue);
// Save token
_settingsService.LastToken = token;
@@ -171,7 +197,7 @@ namespace DiscordChatExporter.Gui.ViewModels
{
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
var guild = Guild.DirectMessages;
_guildChannelsMap[guild] = channels.ToArray();
_guildChannelsMap[guild] = channels.OrderBy(c => c.Name).ToArray();
}
// Get guild channels
@@ -180,7 +206,9 @@ namespace DiscordChatExporter.Gui.ViewModels
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat)
.OrderBy(c => c.Name)
.ToArray();
}
}
}
@@ -220,29 +248,45 @@ namespace DiscordChatExporter.Gui.ViewModels
// Get last used token
var token = _settingsService.LastToken;
// Get guild
var guild = SelectedGuild;
// Create progress handler
var progressHandler = new Progress<double>(p => Progress = p);
try
{
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to);
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to, progressHandler);
// 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, from, to, messageGroups, mentionables);
// Export
await _exportService.ExportAsync(format, filePath, log);
_exportService.Export(format, filePath, log);
// Open
Process.Start(filePath);
// Notify completion
MessengerInstance.Send(new ShowNotificationMessage("Export complete",
"OPEN", () => Process.Start(filePath)));
MessengerInstance.Send(new ShowNotificationMessage("Export complete"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
MessengerInstance.Send(new ShowNotificationMessage("This channel doesn't exist"));
}
Progress = 0;
IsBusy = false;
}
}
@@ -1,27 +0,0 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Gui.Views.ErrorDialog" {
DataContext: bind ErrorViewModel from $resource Container
Width: 250
StackPanel {
// Message
TextBlock {
Margin: 16
FontSize: 16
TextWrapping: WrapWithOverflow
Text: bind Message
}
// OK
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "OK"
HorizontalAlignment: Right
IsDefault: true
IsCancel: true
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
@@ -1,10 +0,0 @@
namespace DiscordChatExporter.Gui.Views
{
public partial class ErrorDialog
{
public ErrorDialog()
{
InitializeComponent();
}
}
}
@@ -1,89 +0,0 @@
using DiscordChatExporter.Core.Models
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Gui.Views.ExportSetupDialog" {
DataContext: bind ExportSetupViewModel from $resource Container
Width: 325
StackPanel {
// File path
TextBox {
Margin: [16, 16, 16, 8]
HintAssist.Hint: "Output file"
HintAssist.IsFloating: true
IsReadOnly: true
Text: bind FilePath
set [ UpdateSourceTrigger: PropertyChanged ]
}
// Format
ComboBox {
Margin: [16, 8, 16, 8]
HintAssist.Hint: "Export format"
HintAssist.IsFloating: true
IsReadOnly: true
ItemsSource: bind AvailableFormats
SelectedItem: bind SelectedFormat
ItemTemplate: DataTemplate {
TextBlock {
Text: bind
convert (ExportFormat f) => Extensions.GetDisplayName(f)
}
}
}
// Date range
Grid {
#TwoColumns("*", "*")
DatePicker {
#Cell(0, 0)
Margin: [16, 20, 8, 8]
HintAssist.Hint: "From (optional)"
HintAssist.IsFloating: true
SelectedDate: bind From
}
DatePicker {
#Cell(0, 1)
Margin: [8, 20, 16, 8]
HintAssist.Hint: "To (optional)"
HintAssist.IsFloating: true
SelectedDate: bind To
}
}
// Buttons
@StackPanelHorizontal {
HorizontalAlignment: Right
// Browse
Button "BrowseButton" {
Margin: 8
Click: BrowseButton_Click
Content: "BROWSE"
Style: resource dyn "MaterialDesignFlatButton"
}
// Export
Button "ExportButton" {
Margin: 8
Click: ExportButton_Click
Command: bind ExportCommand
Content: "EXPORT"
IsDefault: true
Style: resource dyn "MaterialDesignFlatButton"
}
// Cancel
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "CANCEL"
IsCancel: true
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
}
@@ -0,0 +1,82 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.ExportSetupDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Width="325"
DataContext="{Binding ExportSetupViewModel, Source={StaticResource Container}}">
<StackPanel>
<!-- File path -->
<TextBox
Margin="16,16,16,8"
materialDesign:HintAssist.Hint="Output file"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
Text="{Binding FilePath, UpdateSourceTrigger=PropertyChanged}" />
<!-- Format -->
<ComboBox
Margin="16,8,16,8"
materialDesign:HintAssist.Hint="Export format"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
ItemsSource="{Binding AvailableFormats}"
SelectedItem="{Binding SelectedFormat}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource ExportFormatToStringConverter}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Date limits -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DatePicker
x:Name="FromDatePicker"
Grid.Row="0"
Grid.Column="0"
Margin="16,20,8,8"
materialDesign:HintAssist.Hint="From (optional)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding SelectedDate, ElementName=ToDatePicker}"
SelectedDate="{Binding From}" />
<DatePicker
x:Name="ToDatePicker"
Grid.Row="0"
Grid.Column="1"
Margin="8,20,16,8"
materialDesign:HintAssist.Hint="To (optional)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding SelectedDate, ElementName=FromDatePicker}"
SelectedDate="{Binding To}" />
</Grid>
<!-- Buttons -->
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button
x:Name="BrowseButton"
Margin="8"
Click="BrowseButton_Click"
Content="BROWSE"
Style="{DynamicResource MaterialDesignFlatButton}" />
<Button
x:Name="ExportButton"
Margin="8"
Click="ExportButton_Click"
Command="{Binding ExportCommand}"
Content="EXPORT"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
<Button
Margin="8"
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
Content="CANCEL"
IsCancel="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Gui.Views
{
public partial class ExportSetupDialog
{
private IExportSetupViewModel ViewModel => (IExportSetupViewModel) DataContext;
private IExportSetupViewModel ViewModel => (IExportSetupViewModel)DataContext;
public ExportSetupDialog()
{
@@ -1,290 +0,0 @@
using System.Windows.Interactivity;
using MaterialDesignThemes.Wpf
using MaterialDesignThemes.Wpf.Transitions
Window "DiscordChatExporter.Gui.Views.MainWindow" {
Title: "DiscordChatExporter"
Width: 600
Height: 550
Background: resource dyn "MaterialDesignPaper"
DataContext: bind MainViewModel from $resource Container
FocusManager.FocusedElement: bind from "TokenTextBox"
FontFamily: resource dyn "MaterialDesignFont"
Icon: "/DiscordChatExporter;component/favicon.ico"
SnapsToDevicePixels: true
TextElement.FontSize: 13
TextElement.FontWeight: Regular
TextElement.Foreground: resource dyn "SecondaryTextBrush"
TextOptions.TextFormattingMode: Ideal
TextOptions.TextRenderingMode: Auto
UseLayoutRounding: true
WindowStartupLocation: CenterScreen
Interaction.Triggers: [
Interactivity.EventTrigger {
EventName: "Loaded"
InvokeCommandAction { Command: bind ViewLoadedCommand }
},
Interactivity.EventTrigger {
EventName: "Closed"
InvokeCommandAction { Command: bind ViewClosedCommand }
}
]
DialogHost {
SnackbarMessageQueue: bind MessageQueue from "Snackbar"
DockPanel {
// Toolbar
Border {
DockPanel.Dock: Top
Background: resource dyn "PrimaryHueMidBrush"
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
TextElement.Foreground: resource dyn "SecondaryInverseTextBrush"
StackPanel {
Grid {
#TwoColumns("*", "Auto")
Card {
#Cell(0, 0)
Margin: [6, 6, 0, 6]
Grid {
#TwoColumns("*", "Auto")
// Token
TextBox "TokenTextBox" {
#Cell(0, 0)
Margin: 6
BorderThickness: 0
HintAssist.Hint: "Token"
FontSize: 16
Text: bind Token
set [ UpdateSourceTrigger: PropertyChanged ]
TextFieldAssist.DecorationVisibility: Hidden
TextFieldAssist.TextBoxViewMargin: [0, 0, 2, 0]
}
// Submit
Button {
#Cell(0, 1)
Margin: [0, 6, 6, 6]
Padding: 4
Command: bind PullDataCommand
IsDefault: true
Style: resource dyn "MaterialDesignFlatButton"
PackIcon {
Width: 24
Height: 24
Kind: PackIconKind.ArrowRight
}
}
}
}
// Popup menu
PopupBox {
#Cell(0, 1)
Foreground: resource dyn "PrimaryHueMidForegroundBrush"
PlacementMode: LeftAndAlignTopEdges
StackPanel {
Button {
Command: bind ShowSettingsCommand
Content: "Settings"
}
Button {
Command: bind ShowAboutCommand
Content: "About"
}
}
}
}
// Progress
ProgressBar {
Background: Transparent
IsIndeterminate: true
Visibility: bind IsBusy
convert (bool b) => b ? Visibility.Visible : Visibility.Hidden
}
}
}
// Content
Grid {
DockPanel {
Background: resource dyn "MaterialDesignCardBackground"
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
Visibility: bind IsDataAvailable
convert (bool b) => b ? Visibility.Visible : Visibility.Hidden
// Guilds
Border {
DockPanel.Dock: Left
BorderBrush: resource dyn "DividerBrush"
BorderThickness: "0 0 1 0"
ListBox {
ItemsSource: bind AvailableGuilds
ScrollViewer.VerticalScrollBarVisibility: Hidden
SelectedItem: bind SelectedGuild
VirtualizingStackPanel.IsVirtualizing: false
ItemTemplate: DataTemplate {
TransitioningContent {
OpeningEffect: TransitionEffect {
Duration: "0:0:0.3"
Kind: SlideInFromLeft
}
Border {
Margin: -8
Background: Transparent
Cursor: CursorType.Hand
Image {
Margin: [12, 4, 12, 4]
Width: 48
Height: 48
Source: bind IconUrl
ToolTip: bind Name
OpacityMask: RadialGradientBrush {
GradientStops: [
GradientStop { Color: "#FF000000", Offset: 0 }
GradientStop { Color: "#FF000000", Offset: 0.96 }
GradientStop { Color: "#00000000", Offset: 1 }
]
}
}
}
}
}
}
}
// Channels
Border {
ListBox {
ItemsSource: bind AvailableChannels
HorizontalContentAlignment: Stretch
VirtualizingStackPanel.IsVirtualizing: false
ItemTemplate: DataTemplate {
TransitioningContent {
OpeningEffect: TransitionEffect {
Duration: "0:0:0.3"
Kind: SlideInFromLeft
}
@StackPanelHorizontal {
Margin: -8
Background: Transparent
Cursor: CursorType.Hand
InputBindings: [
MouseBinding {
Command: bind DataContext.ShowExportSetupCommand from $ancestor<ItemsControl>
CommandParameter: bind
MouseAction: LeftClick
}
]
PackIcon {
Margin: [16, 7, 0, 6]
Kind: PackIconKind.Pound
VerticalAlignment: Center
}
TextBlock {
Margin: [3, 8, 8, 8]
FontSize: 14
Text: bind Name
VerticalAlignment: Center
}
}
}
}
}
}
}
// Content placeholder
StackPanel {
Margin: [32, 32, 8, 8]
Visibility: bind IsDataAvailable
convert (bool b) => b ? Visibility.Hidden : Visibility.Visible
TextBlock {
FontSize: 18
Text: "DiscordChatExporter needs your authorization token to work."
}
TextBlock {
Margin: [0, 8, 0, 0]
FontSize: 16
Text: "To obtain it, follow these steps:"
}
TextBlock {
Margin: [8, 0, 0, 0]
FontSize: 14
Run {
Text: "1. Open the Discord app"
}
LineBreak { }
Run {
Text: "2. Log in if you haven't"
}
LineBreak { }
Run {
Text: "3. Press"
}
Run {
Text: "Ctrl+Shift+I"
Foreground: resource dyn "PrimaryTextBrush"
}
LineBreak { }
Run {
Text: "4. Navigate to"
}
Run {
Text: "Application"
Foreground: resource dyn "PrimaryTextBrush"
}
Run { Text: "tab" }
LineBreak { }
Run {
Text: "5. Expand"
}
Run {
Text: "Storage > Local Storage > https://discordapp.com"
Foreground: resource dyn "PrimaryTextBrush"
}
LineBreak { }
Run {
Text: "6. Find"
}
Run {
Text: "&quot;token&quot;"
Foreground: resource dyn "PrimaryTextBrush"
}
Run {
Text: "under key and copy the value"
}
LineBreak { }
Run {
Text: "7. Paste the value in the textbox above"
}
}
}
// Snackbar
Snackbar "Snackbar" {
}
}
}
}
}
@@ -0,0 +1,282 @@
<Window
x:Class="DiscordChatExporter.Gui.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Title="DiscordChatExporter"
Width="600"
Height="550"
Background="{DynamicResource MaterialDesignPaper}"
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
FontFamily="{DynamicResource MaterialDesignFont}"
Icon="/DiscordChatExporter;component/favicon.ico"
SnapsToDevicePixels="True"
TextElement.FontSize="13"
TextElement.FontWeight="Regular"
TextElement.Foreground="{DynamicResource SecondaryTextBrush}"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="Auto"
UseLayoutRounding="True"
WindowStartupLocation="CenterScreen">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Loaded">
<i:InvokeCommandAction Command="{Binding ViewLoadedCommand}" />
</i:EventTrigger>
<i:EventTrigger EventName="Closed">
<i:InvokeCommandAction Command="{Binding ViewClosedCommand}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<materialDesign:DialogHost SnackbarMessageQueue="{Binding ElementName=Snackbar}">
<DockPanel>
<!-- Toolbar -->
<Border
Background="{DynamicResource PrimaryHueMidBrush}"
DockPanel.Dock="Top"
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<materialDesign:Card
Grid.Row="0"
Grid.Column="0"
Margin="6,6,0,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token type -->
<ToggleButton
Grid.Column="0"
Margin="6"
IsChecked="{Binding IsBotToken}"
Style="{StaticResource MaterialDesignFlatActionToggleButton}"
ToolTip="Switch between user token and bot token">
<ToggleButton.Content>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Account" />
</ToggleButton.Content>
<materialDesign:ToggleButtonAssist.OnContent>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Robot" />
</materialDesign:ToggleButtonAssist.OnContent>
</ToggleButton>
<!-- Token value -->
<TextBox
x:Name="TokenValueTextBox"
Grid.Column="1"
Margin="2,6,6,7"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0"
FontSize="16"
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button -->
<Button
Grid.Column="2"
Margin="0,6,6,6"
Padding="4"
Command="{Binding PullDataCommand}"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}">
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="ArrowRight" />
</Button>
</Grid>
</materialDesign:Card>
<!-- Menu -->
<materialDesign:PopupBox
Grid.Row="0"
Grid.Column="1"
Foreground="{DynamicResource PrimaryHueMidForegroundBrush}"
PlacementMode="LeftAndAlignTopEdges">
<StackPanel>
<Button Command="{Binding ShowSettingsCommand}" Content="Settings" />
<Button Command="{Binding ShowAboutCommand}" Content="About" />
</StackPanel>
</materialDesign:PopupBox>
</Grid>
<!-- Progress bar -->
<ProgressBar
Background="Transparent"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
Value="{Binding Progress, Mode=OneWay}" />
</StackPanel>
</Border>
<!-- Content -->
<Grid>
<DockPanel
Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
Visibility="{Binding IsDataAvailable, Converter={StaticResource BoolToVisibilityConverter}}">
<!-- Guilds -->
<Border
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,0,1,0"
DockPanel.Dock="Left">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
VirtualizingStackPanel.IsVirtualizing="False">
<ListBox.ItemTemplate>
<DataTemplate>
<materialDesign:TransitioningContent>
<materialDesign:TransitioningContent.OpeningEffect>
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
</materialDesign:TransitioningContent.OpeningEffect>
<Grid
Margin="-8"
Background="Transparent"
Cursor="Hand"
ToolTip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Fill="{DynamicResource DividerBrush}" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Grid>
</materialDesign:TransitioningContent>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels -->
<Border>
<ListBox
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding AvailableChannels}"
VirtualizingStackPanel.IsVirtualizing="False">
<ListBox.ItemTemplate>
<DataTemplate>
<materialDesign:TransitioningContent>
<materialDesign:TransitioningContent.OpeningEffect>
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
</materialDesign:TransitioningContent.OpeningEffect>
<StackPanel
Margin="-8"
Background="Transparent"
Cursor="Hand"
Orientation="Horizontal">
<StackPanel.InputBindings>
<MouseBinding
Command="{Binding DataContext.ShowExportSetupCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}}"
CommandParameter="{Binding}"
MouseAction="LeftClick" />
</StackPanel.InputBindings>
<materialDesign:PackIcon
Margin="16,7,0,6"
VerticalAlignment="Center"
Kind="Pound" />
<TextBlock
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14"
Text="{Binding Name}" />
</StackPanel>
</materialDesign:TransitioningContent>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</DockPanel>
<!-- Usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
<!-- User token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work." />
<TextBlock
Margin="0,8,0,0"
FontSize="16"
Text="To obtain it, follow these steps:" />
<TextBlock Margin="8,0,0,0" FontSize="14">
<Run Text="1. Open the Discord app" />
<LineBreak />
<Run Text="2. Log in if you haven't" />
<LineBreak />
<Run Text="3. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. Expand" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage &gt; Local Storage &gt; https://discordapp.com" />
<LineBreak />
<Run Text="6. Find the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="&quot;token&quot;" />
<Run Text="key and copy its value" />
<LineBreak />
<Run Text="7. Paste the value in the textbox above" />
</TextBlock>
</StackPanel>
<!-- Bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your bot token to work." />
<TextBlock
Margin="0,8,0,0"
FontSize="16"
Text="To obtain it, follow these steps:" />
<TextBlock Margin="8,0,0,0" FontSize="14">
<Run Text="1. Go to Discord developer portal" />
<LineBreak />
<Run Text="2. Log in if you haven't" />
<LineBreak />
<Run Text="3. Open your application's settings" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="5. Under" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
<Run Text="click" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
<LineBreak />
<Run Text="6. Paste the value in the textbox above" />
</TextBlock>
</StackPanel>
</Grid>
<materialDesign:Snackbar x:Name="Snackbar" />
</Grid>
</DockPanel>
</materialDesign:DialogHost>
</Window>
@@ -28,7 +28,7 @@ namespace DiscordChatExporter.Gui.Views
// Dialog messages
Messenger.Default.Register<ShowExportSetupMessage>(this,
m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this,
Messenger.Default.Register<ShowSettingsMessage>(this,
m => DialogHost.Show(new SettingsDialog()).Forget());
}
}
@@ -1,52 +0,0 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Gui.Views.SettingsDialog" {
DataContext: bind SettingsViewModel from $resource Container
Width: 250
StackPanel {
// Date format
TextBox {
Margin: [16, 16, 16, 8]
HintAssist.Hint: "Date format"
HintAssist.IsFloating: true
Text: bind DateFormat
}
// Group limit
TextBox {
Margin: [16, 8, 16, 8]
HintAssist.Hint: "Message group limit"
HintAssist.IsFloating: true
Text: bind MessageGroupLimit
}
// Auto-update
DockPanel {
LastChildFill: false
TextBlock {
Margin: [16, 8, 16, 8]
DockPanel.Dock: Left
Text: "Auto-updates"
}
ToggleButton {
Margin: [16, 8, 16, 8]
DockPanel.Dock: Right
IsChecked: bind IsAutoUpdateEnabled
}
}
// Save
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "SAVE"
HorizontalAlignment: Right
IsDefault: true
IsCancel: true
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
@@ -0,0 +1,45 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.SettingsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Width="250"
DataContext="{Binding SettingsViewModel, Source={StaticResource Container}}">
<StackPanel>
<!-- Date format -->
<TextBox
Margin="16,16,16,8"
materialDesign:HintAssist.Hint="Date format"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding DateFormat}" />
<!-- Message group limit -->
<TextBox
Margin="16,8,16,8"
materialDesign:HintAssist.Hint="Message group limit"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding MessageGroupLimit}" />
<!-- Auto-updates -->
<DockPanel LastChildFill="False">
<TextBlock
Margin="16,8,16,8"
DockPanel.Dock="Left"
Text="Auto-updates" />
<ToggleButton
Margin="16,8,16,8"
DockPanel.Dock="Right"
IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel>
<!-- Save button -->
<Button
Margin="8"
HorizontalAlignment="Right"
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
Content="SAVE"
IsCancel="True"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
</StackPanel>
</UserControl>
-238
View File
@@ -1,238 +0,0 @@
mixin TwoColumns (one = "*", two = "*") for Grid {
combine ColumnDefinitions: [
ColumnDefinition { Width: $one }
ColumnDefinition { Width: $two }
]
}
mixin ThreeColumns (one = none, two = none, three = none) for Grid {
#TwoColumns($one, $two)
combine ColumnDefinitions: ColumnDefinition { Width: $three }
}
mixin FourColumns (one = none, two = none, three = none, four = none) for Grid {
#ThreeColumns($one, $two, $three)
combine ColumnDefinitions: ColumnDefinition { Width: $four }
}
mixin FiveColumns (one = none, two = none, three = none, four = none, five = none) for Grid {
#FourColumns($one, $two, $three, $four)
combine ColumnDefinitions: ColumnDefinition { Width: $five }
}
mixin TwoRows (one = none, two = none) for Grid
{
combine RowDefinitions: [
RowDefinition { Height: $one }
RowDefinition { Height: $two }
]
}
mixin ThreeRows (one = none, two = none, three = none) for Grid
{
#TwoRows($one, $two)
combine RowDefinitions: RowDefinition { Height: $three }
}
mixin FourRows (one = none, two = none, three = none, four = none) for Grid
{
#ThreeRows($one, $two, $three)
combine RowDefinitions: RowDefinition { Height: $four }
}
mixin FiveRows (one = none, two = none, three = none, four = none, five = none) for Grid
{
#FourRows($one, $two, $three, $four)
combine RowDefinitions: RowDefinition { Height: $five }
}
mixin Cell (row = none, column = none, rowSpan = none, columnSpan = none) for FrameworkElement {
Grid.Row: $row
Grid.Column: $column
Grid.RowSpan: $rowSpan
Grid.ColumnSpan: $columnSpan
}
alias ImageCached(source) {
Image {
Source: BitmapImage {
UriCachePolicy: "Revalidate"
UriSource: $source
}
}
}
mixin Setter(property, value, targetName=none) for Style {
Setter { Property: $property, Value: $value, TargetName: $targetName }
}
/*
mixin AddSetter(property, value, targetName=none) for Style {
combine Setters: #Setter($property, $value, $targetName) {}
}*/
alias DataTrigger(binding, bindingValue) {
DataTrigger { Binding: $binding, Value: $bindingValue }
}
alias Trigger(property, value) {
Trigger { Property: $property, Value: $value }
}
alias EventTrigger(event, sourceName=none) {
EventTrigger { RoutedEvent: $event, SourceName: $sourceName }
}
alias DataTrigger_SetProperty(binding, bindingValue, property, propertyValue) {
@DataTrigger ($binding, $bindingValue) {
#Setter($property, $propertyValue)
}
}
alias Trigger_SetProperty(triggerProperty, triggerValue, property, propertyValue) {
@Trigger ($triggerProperty, $triggerValue) {
#Setter($property, $propertyValue)
}
}
alias EventTrigger_SetProperty(event, property, propertyValue) {
@EventTrigger ($event) {
#Setter($property, $propertyValue)
}
}
alias VisibleIf_DataTrigger(binding, valueForVisible) {
@DataTrigger_SetProperty($binding, $valueForVisible, "Visibility", "Visible") {}
}
alias CollapsedIf_DataTrigger(binding, valueForCollapsed) {
@DataTrigger_SetProperty($binding, $valueForCollapsed, "Visibility", "Collapsed") {}
}
alias StackPanelHorizontal() {
StackPanel {
Orientation: Horizontal
}
}
alias GridItemsControl() {
ItemsControl {
ScrollViewer.HorizontalScrollBarVisibility: Disabled,
ItemsPanel: ItemsPanelTemplate {
WrapPanel {
IsItemsHost: true
Orientation: Horizontal
}
}
}
}
////////////////
// Animations //
////////////////
alias DoubleAnimation(property, frm = "0", to = "1", duration = "0:0:1", targetName=none, beginTime=none) {
DoubleAnimation {
Storyboard.TargetProperty: $property
Storyboard.TargetName: $targetName
From: $frm
To: $to
Duration: $duration
BeginTime: $beginTime
}
}
alias DoubleAnimationStoryboard (property, frm = "0", to = "1", duration = "0:0:1", targetName=none) {
BeginStoryboard {
Storyboard {
@DoubleAnimation($property, $frm, $to, $duration, $targetName) {}
}
}
}
mixin DoubleAnimation_PropertyTrigger(triggerProperty, triggerValue, animationProperty, frm, to, duration) for Style {
combine Triggers: @Trigger ($triggerProperty, $triggerValue) {
EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
}
}
mixin DoubleAnimation_PropertyTrigger_Toggle(triggerProperty, triggerValue, animationProperty, frm, to, duration) for Style {
combine Triggers: @Trigger ($triggerProperty, $triggerValue) {
EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
ExitActions: @DoubleAnimationStoryboard($animationProperty, $to, $frm, $duration) {}
}
}
mixin DoubleAnimation_EventTrigger(triggerEvent, animationProperty, frm, to, duration) for Style {
combine Triggers: EventTrigger {
RoutedEvent: $triggerEvent
@DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
}
}
mixin DoubleAnimation_DataTrigger(binding, value, animationProperty, frm, to, duration) for Style {
combine Triggers: DataTrigger {
Binding: $binding
Value: $value
EnterActions: @DoubleAnimationStoryboard($animationProperty, $frm, $to, $duration) {}
}
}
mixin FadeIn_OnProperty(property, value, frm = "0", to = "1", duration = "0:0:1") for Style {
#DoubleAnimation_PropertyTrigger($property, $value, "Opacity", $frm, $to, $duration)
}
mixin FadeOut_OnProperty(property, value, frm = "1", to = "0", duration = "0:0:1") for Style {
#DoubleAnimation_PropertyTrigger($property, $value, "Opacity", $frm, $to, $duration)
}
mixin FadeIn_OnEvent(event, frm = "0", to = "1", duration = "0:0:1") for Style {
#DoubleAnimation_EventTrigger($event, "Opacity", $frm, $to, $duration)
}
mixin FadeOut_OnEvent(event, frm = "1", to = "0", duration = "0:0:1") for Style {
#DoubleAnimation_EventTrigger($event, "Opacity", $frm, $to, $duration)
}
mixin FadeIn_OnData(binding, value, from_ = "0", to = "1", duration = "0:0:1") for Style {
#DoubleAnimation_DataTrigger($binding, $value, "Opacity", $from_, $to, $duration)
}
mixin FadeOut_OnData(binding, value, from_ = "1", to = "0", duration = "0:0:1") for Style {
#DoubleAnimation_DataTrigger($binding, $value, "Opacity", $from_, $to, $duration)
}
mixin Property_OnBinding(binding, bindingValue, property, propertyValue, initialValue) for Style {
#Setter("Visibility", $initialValue)
combine Triggers: [
@DataTrigger_SetProperty($binding, $bindingValue, $property, $propertyValue) {}
]
}
mixin Visibility_OnBinding(binding, bindingValue, visibilityValue="Visible", initialValue="Collapsed") for Style {
#Property_OnBinding($binding, $bindingValue, "Visibility", $visibilityValue, $initialValue)
}
mixin Fade_OnBinding(binding, bindingValue) for Style {
#Setter("Visibility", "Visible")
#Setter("Opacity", "0")
combine Triggers: [
@DataTrigger($binding, $bindingValue) {
EnterActions: [
@DoubleAnimationStoryboard("Opacity", 0, 1, "0:0:0.5") {}
]
ExitActions: [
@DoubleAnimationStoryboard("Opacity", 1, 0, "0:0:0.5") {}
]
#Setter("Opacity", 1)
}
@Trigger("Opacity", 0) {
#Setter("Visibility", "Hidden")
}
]
}
mixin MergeDictionary (source) for ResourceDictionary {
combine MergedDictionaries: ResourceDictionary { Source: $source }
}
+2 -3
View File
@@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Ammy" version="1.2.94" targetFramework="net461" />
<package id="Ammy.WPF" version="1.2.94" targetFramework="net461" />
<package id="CommonServiceLocator" version="2.0.3" targetFramework="net461" />
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.4.1" targetFramework="net461" />
<package id="Tyrrrz.Extensions" version="1.5.0" targetFramework="net461" />
<package id="Tyrrrz.Extensions" version="1.5.1" targetFramework="net461" />
<package id="Tyrrrz.WpfExtensions" version="1.0.5" targetFramework="net461" />
</packages>
+8 -15
View File
@@ -18,28 +18,21 @@ DiscordChatExporter can be used to export message history from a [Discord](https
## Features
- Intuitive GUI that displays available guilds and channels
- CLI as additional alternative to GUI
- Date ranges to limit messages
- Groups messages by author and time
- Exports to a plain text file
- Exports to an HTML file
- Dark and light themes
- User avatars
- Inline image attachments
- Full markdown support
- Automatic links
- Styled similarly to the app
- Renders custom emojis
- Resolves user, role and channel mentions
- Graphical and command line interfaces
- Supports both user tokens and bot tokens
- Allows retrieving messages in specified date range
- Multiple export formats: HTML (dark/light), TXT and CSV
- Renders all message features including: markdown, attachments, embeds, emojis, mentions, etc
## Libraries used
- [AmmyUI](https://github.com/AmmyUI/AmmyUI)
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [Scriban](https://github.com/lunet-io/scriban)
- [Polly](https://github.com/App-vNext/Polly)
- [Onova](https://github.com/Tyrrrz/Onova)
- [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser)
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
- [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions)
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)