mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-11 08:21:55 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f63ea41188 | |||
| 3572a21aad | |||
| 37ee0b8be3 | |||
| ed146bac22 | |||
| b9c449709f | |||
| 25207c5093 | |||
| 4fbec95208 | |||
| 0a48da4c56 | |||
| 3f5f62585c | |||
| 49f39c7097 | |||
| e3eac10fb8 | |||
| 23f9331e4e | |||
| 69088b83eb | |||
| 7ecf90d1cb | |||
| f0cd2dce1b | |||
| e8436faf66 | |||
| 61dce7c1a8 | |||
| 6fee160bc7 | |||
| 8678043f0d | |||
| e4f0b8193f | |||
| df811d0b1a | |||
| 08da5d685b | |||
| c2cebe99e3 | |||
| 481991bd00 | |||
| d958f613a3 |
@@ -1,3 +1,26 @@
|
|||||||
|
### 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)
|
### v2.4.1 (15-Apr-2018)
|
||||||
|
|
||||||
- Added CSV export format.
|
- Added CSV export format.
|
||||||
|
|||||||
+1
-1
@@ -8,4 +8,4 @@ $files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporte
|
|||||||
# CLI
|
# CLI
|
||||||
$files = @()
|
$files = @()
|
||||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
$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
|
||||||
@@ -5,7 +5,9 @@ namespace DiscordChatExporter.Cli
|
|||||||
{
|
{
|
||||||
public class CliOptions
|
public class CliOptions
|
||||||
{
|
{
|
||||||
public string Token { get; set; }
|
public string TokenValue { get; set; }
|
||||||
|
|
||||||
|
public bool IsBotToken { get; set; }
|
||||||
|
|
||||||
public string ChannelId { get; set; }
|
public string ChannelId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net461</TargetFramework>
|
<TargetFramework>net461</TargetFramework>
|
||||||
<Version>2.4.1</Version>
|
<Version>2.6</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
|
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
|
||||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
|
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
|
||||||
<PackageReference Include="FluentCommandLineParser" Version="1.4.3" />
|
<PackageReference Include="FluentCommandLineParser" Version="1.4.3" />
|
||||||
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace DiscordChatExporter.Cli
|
|||||||
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ===");
|
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ===");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine("[-t] [--token] Discord authorization token.");
|
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("[-c] [--channel] Discord channel ID.");
|
||||||
Console.WriteLine("[-f] [--format] Export format. Optional.");
|
Console.WriteLine("[-f] [--format] Export format. Optional.");
|
||||||
Console.WriteLine("[-o] [--output] Output file path. Optional.");
|
Console.WriteLine("[-o] [--output] Output file path. Optional.");
|
||||||
@@ -28,20 +29,27 @@ namespace DiscordChatExporter.Cli
|
|||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine($"Available export formats: {availableFormats.JoinToString(", ")}");
|
Console.WriteLine($"Available export formats: {availableFormats.JoinToString(", ")}");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine("# To get authorization token:");
|
Console.WriteLine("# To get user token:");
|
||||||
Console.WriteLine(" - Open Discord app");
|
Console.WriteLine(" - Open Discord app");
|
||||||
Console.WriteLine(" - Log in if you haven't");
|
Console.WriteLine(" - Log in if you haven't");
|
||||||
Console.WriteLine(" - Press Ctrl+Shift+I");
|
Console.WriteLine(" - Press Ctrl+Shift+I to show developer tools");
|
||||||
Console.WriteLine(" - Navigate to Application tab");
|
Console.WriteLine(" - Navigate to the Application tab");
|
||||||
Console.WriteLine(" - Expand Storage > Local Storage > https://discordapp.com");
|
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();
|
||||||
Console.WriteLine("# To get channel ID:");
|
Console.WriteLine("# To get channel ID:");
|
||||||
Console.WriteLine(" - Open Discord app");
|
Console.WriteLine(" - Open Discord app");
|
||||||
Console.WriteLine(" - Log in if you haven't");
|
Console.WriteLine(" - Log in if you haven't");
|
||||||
Console.WriteLine(" - Go to any channel you want to export");
|
Console.WriteLine(" - Go to any channel you want to export");
|
||||||
Console.WriteLine(" - Press Ctrl+Shift+I");
|
Console.WriteLine(" - Press Ctrl+Shift+I to show developer tools");
|
||||||
Console.WriteLine(" - Navigate to Console tab");
|
Console.WriteLine(" - Navigate to the Console tab");
|
||||||
Console.WriteLine(" - Type \"document.URL\" and press Enter");
|
Console.WriteLine(" - Type \"document.URL\" and press Enter");
|
||||||
Console.WriteLine(" - Copy the long sequence of numbers after last slash");
|
Console.WriteLine(" - Copy the long sequence of numbers after last slash");
|
||||||
}
|
}
|
||||||
@@ -52,7 +60,8 @@ namespace DiscordChatExporter.Cli
|
|||||||
|
|
||||||
var settings = Container.SettingsService;
|
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.ChannelId).As('c', "channel").Required();
|
||||||
argsParser.Setup(o => o.ExportFormat).As('f', "format").SetDefault(ExportFormat.HtmlDark);
|
argsParser.Setup(o => o.ExportFormat).As('f', "format").SetDefault(ExportFormat.HtmlDark);
|
||||||
argsParser.Setup(o => o.FilePath).As('o', "output").SetDefault(null);
|
argsParser.Setup(o => o.FilePath).As('o', "output").SetDefault(null);
|
||||||
@@ -92,10 +101,15 @@ namespace DiscordChatExporter.Cli
|
|||||||
settings.DateFormat = options.DateFormat;
|
settings.DateFormat = options.DateFormat;
|
||||||
settings.MessageGroupLimit = options.MessageGroupLimit;
|
settings.MessageGroupLimit = options.MessageGroupLimit;
|
||||||
|
|
||||||
|
// Create token
|
||||||
|
var token = new AuthToken(
|
||||||
|
options.IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
|
||||||
|
options.TokenValue);
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
var vm = Container.MainViewModel;
|
var vm = Container.MainViewModel;
|
||||||
vm.ExportAsync(
|
vm.ExportAsync(
|
||||||
options.Token,
|
token,
|
||||||
options.ChannelId,
|
options.ChannelId,
|
||||||
options.FilePath,
|
options.FilePath,
|
||||||
options.ExportFormat,
|
options.ExportFormat,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace DiscordChatExporter.Cli.ViewModels
|
|||||||
{
|
{
|
||||||
public interface IMainViewModel
|
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);
|
DateTime? to);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,8 @@ namespace DiscordChatExporter.Cli.ViewModels
|
|||||||
_exportService = exportService;
|
_exportService = exportService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ExportAsync(string token, string channelId, string filePath, ExportFormat format, DateTime? from,
|
public async Task ExportAsync(AuthToken token, string channelId, string filePath, ExportFormat format,
|
||||||
DateTime? to)
|
DateTime? from, DateTime? to)
|
||||||
{
|
{
|
||||||
// Get channel and guild
|
// Get channel and guild
|
||||||
var channel = await _dataService.GetChannelAsync(token, channelId);
|
var channel = await _dataService.GetChannelAsync(token, channelId);
|
||||||
@@ -38,16 +38,19 @@ namespace DiscordChatExporter.Cli.ViewModels
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get messages
|
// 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);
|
var messageGroups = _messageGroupService.GroupMessages(messages);
|
||||||
|
|
||||||
|
// Get mentionables
|
||||||
|
var mentionables = await _dataService.GetMentionablesAsync(token, guild.Id, messages);
|
||||||
|
|
||||||
// Create log
|
// Create log
|
||||||
var log = new ChannelChatLog(guild, channel, messageGroups, messages.Count);
|
var log = new ChatLog(guild, channel, from, to, messageGroups, mentionables);
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
await _exportService.ExportAsync(format, filePath, log);
|
_exportService.Export(format, filePath, log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,23 +2,30 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net461</TargetFramework>
|
<TargetFramework>net461</TargetFramework>
|
||||||
<Version>2.4.1</Version>
|
<Version>2.6</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
|
<EmbeddedResource Include="Resources\ExportTemplates\PlainText.txt" />
|
||||||
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="System.Net.Http" />
|
<Reference Include="mscorlib" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="7.1.0" />
|
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
||||||
<PackageReference Include="Onova" Version="2.1.0" />
|
<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" />
|
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ namespace DiscordChatExporter.Core.Exceptions
|
|||||||
{
|
{
|
||||||
public HttpStatusCode StatusCode { get; }
|
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;
|
StatusCode = statusCode;
|
||||||
|
ReasonPhrase = reasonPhrase;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
using System.IO;
|
using System;
|
||||||
using System.Reflection;
|
using System.Drawing;
|
||||||
using System.Resources;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Internal
|
namespace DiscordChatExporter.Core.Internal
|
||||||
{
|
{
|
||||||
internal static class Extensions
|
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);
|
const long epoch = 62135596800000;
|
||||||
if (stream == null)
|
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
|
||||||
throw new MissingManifestResourceException($"Could not find resource [{resourceName}].");
|
var value = ((ulong) unixTime - 1420070400000UL) << 22;
|
||||||
|
return value.ToString();
|
||||||
using (stream)
|
|
||||||
using (var reader = new StreamReader(stream))
|
|
||||||
{
|
|
||||||
return reader.ReadToEnd();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,33 @@
|
|||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
||||||
|
|
||||||
public class Attachment
|
public class Attachment
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
public AttachmentType Type { get; }
|
|
||||||
|
|
||||||
public string Url { get; }
|
public string Url { get; }
|
||||||
|
|
||||||
|
public int? Width { get; }
|
||||||
|
|
||||||
|
public int? Height { get; }
|
||||||
|
|
||||||
|
public bool IsImage => Width != null;
|
||||||
|
|
||||||
public string FileName { get; }
|
public string FileName { get; }
|
||||||
|
|
||||||
public long FileSize { 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;
|
Id = id;
|
||||||
Type = type;
|
|
||||||
Url = url;
|
Url = url;
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
FileName = fileName;
|
FileName = fileName;
|
||||||
FileSize = fileSize;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
public enum AttachmentType
|
public enum AuthTokenType
|
||||||
{
|
{
|
||||||
Other,
|
User,
|
||||||
Image
|
Bot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#channel-object
|
||||||
|
|
||||||
public partial class Channel
|
public partial class Channel
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -21,17 +23,12 @@
|
|||||||
Type = type;
|
Type = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => Name;
|
||||||
{
|
|
||||||
return Name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Channel
|
public partial class Channel
|
||||||
{
|
{
|
||||||
public static Channel CreateDeletedChannel(string id)
|
public static Channel CreateDeletedChannel(string id) =>
|
||||||
{
|
new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
|
||||||
return 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
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
|
|
||||||
public enum ChannelType
|
public enum ChannelType
|
||||||
{
|
{
|
||||||
GuildTextChat,
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
using System.Collections.Generic;
|
using Tyrrrz.Extensions;
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/guild#guild-object
|
||||||
|
|
||||||
public partial class Guild
|
public partial class Guild
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -15,24 +16,18 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
|
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
|
||||||
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
|
|
||||||
public IReadOnlyList<Role> Roles { get; }
|
public Guild(string id, string name, string iconHash)
|
||||||
|
|
||||||
public Guild(string id, string name, string iconHash, IReadOnlyList<Role> roles)
|
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
IconHash = iconHash;
|
IconHash = iconHash;
|
||||||
Roles = roles;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => Name;
|
||||||
{
|
|
||||||
return Name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Guild
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#message-object
|
||||||
|
|
||||||
public class Message
|
public class Message
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -13,42 +15,37 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public User Author { get; }
|
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 string Content { get; }
|
||||||
|
|
||||||
public IReadOnlyList<Attachment> Attachments { get; }
|
public IReadOnlyList<Attachment> Attachments { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Embed> Embeds { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Reaction> Reactions { get; }
|
||||||
|
|
||||||
public IReadOnlyList<User> MentionedUsers { get; }
|
public IReadOnlyList<User> MentionedUsers { get; }
|
||||||
|
|
||||||
public IReadOnlyList<Role> MentionedRoles { get; }
|
public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
|
||||||
|
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
|
||||||
public IReadOnlyList<Channel> MentionedChannels { get; }
|
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
ChannelId = channelId;
|
ChannelId = channelId;
|
||||||
Type = type;
|
Type = type;
|
||||||
Author = author;
|
Author = author;
|
||||||
TimeStamp = timeStamp;
|
Timestamp = timestamp;
|
||||||
EditedTimeStamp = editedTimeStamp;
|
EditedTimestamp = editedTimestamp;
|
||||||
Content = content;
|
Content = content;
|
||||||
Attachments = attachments;
|
Attachments = attachments;
|
||||||
|
Embeds = embeds;
|
||||||
|
Reactions = reactions;
|
||||||
MentionedUsers = mentionedUsers;
|
MentionedUsers = mentionedUsers;
|
||||||
MentionedRoles = mentionedRoles;
|
|
||||||
MentionedChannels = mentionedChannels;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => Content;
|
||||||
{
|
|
||||||
return Content;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,17 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
{
|
{
|
||||||
public User Author { get; }
|
public User Author { get; }
|
||||||
|
|
||||||
public DateTime TimeStamp { get; }
|
public DateTime Timestamp { get; }
|
||||||
|
|
||||||
public IReadOnlyList<Message> Messages { 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;
|
Author = author;
|
||||||
TimeStamp = timeStamp;
|
Timestamp = timestamp;
|
||||||
Messages = messages;
|
Messages = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"{Author.FullName} | {Timestamp} | {Messages.Count} messages";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
|
||||||
|
|
||||||
public enum MessageType
|
public enum MessageType
|
||||||
{
|
{
|
||||||
Default,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
|
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||||
|
|
||||||
public partial class Role
|
public partial class Role
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -12,17 +14,12 @@
|
|||||||
Name = name;
|
Name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => Name;
|
||||||
{
|
|
||||||
return Name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Role
|
public partial class Role
|
||||||
{
|
{
|
||||||
public static Role CreateDeletedRole(string id)
|
public static Role CreateDeletedRole(string id) =>
|
||||||
{
|
new Role(id, "deleted-role");
|
||||||
return new Role(id, "deleted-role");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
using Tyrrrz.Extensions;
|
using System;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
public class User
|
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||||
|
|
||||||
|
public partial class User
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
@@ -12,13 +15,32 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public string FullName => $"{Name}#{Discriminator:0000}";
|
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||||
|
|
||||||
public string AvatarHash { get; }
|
|
||||||
|
|
||||||
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
||||||
|
|
||||||
public string AvatarUrl => AvatarHash.IsNotBlank()
|
public string AvatarHash { get; }
|
||||||
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
|
|
||||||
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
|
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)
|
public User(string id, int discriminator, string name, string avatarHash)
|
||||||
{
|
{
|
||||||
@@ -28,9 +50,12 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
AvatarHash = avatarHash;
|
AvatarHash = avatarHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => FullName;
|
||||||
{
|
}
|
||||||
return 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,325 +2,220 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text.RegularExpressions;
|
using System.Net.Http.Headers;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using DiscordChatExporter.Core.Internal;
|
||||||
|
using Polly;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
namespace DiscordChatExporter.Core.Services
|
||||||
{
|
{
|
||||||
public partial class DataService : IDataService, IDisposable
|
public partial class DataService : IDataService, IDisposable
|
||||||
{
|
{
|
||||||
private const string ApiRoot = "https://discordapp.com/api/v6";
|
|
||||||
|
|
||||||
private readonly HttpClient _httpClient = new HttpClient();
|
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>();
|
// Create request policy
|
||||||
var discriminator = token["discriminator"].Value<int>();
|
var policy = Policy
|
||||||
var name = token["username"].Value<string>();
|
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
|
||||||
var avatarHash = token["avatar"].Value<string>();
|
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
|
||||||
|
|
||||||
return new User(id, discriminator, name, avatarHash);
|
// Send request
|
||||||
}
|
return await policy.ExecuteAsync(async () =>
|
||||||
|
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
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
|
// Add header for the bot token
|
||||||
name = token["name"]?.Value<string>();
|
if (token.Type == AuthTokenType.Bot)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bot", token.Value);
|
||||||
|
|
||||||
// Otherwise use recipients as the name
|
// Add parameters
|
||||||
if (name.IsBlank())
|
foreach (var parameter in parameters)
|
||||||
name = token["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
|
{
|
||||||
}
|
var key = parameter.SubstringUntil("=");
|
||||||
else
|
var value = parameter.SubstringAfter("=");
|
||||||
{
|
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
||||||
name = token["name"].Value<string>();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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 response = await GetApiResponseAsync(token, "guilds", guildId);
|
||||||
var id = token["id"].Value<string>();
|
var guild = ParseGuild(response);
|
||||||
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;
|
|
||||||
|
|
||||||
return guild;
|
return guild;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Channel> GetChannelAsync(string token, string channelId)
|
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
|
||||||
{
|
{
|
||||||
// Form request url
|
var response = await GetApiResponseAsync(token, "channels", channelId);
|
||||||
var url = $"{ApiRoot}/channels/{channelId}?token={token}";
|
var channel = ParseChannel(response);
|
||||||
|
|
||||||
// Get response
|
|
||||||
var content = await GetStringAsync(url);
|
|
||||||
|
|
||||||
// Parse
|
|
||||||
var channel = ParseChannel(JToken.Parse(content));
|
|
||||||
|
|
||||||
// Add channel to cache
|
|
||||||
_channelCache[channel.Id] = channel;
|
|
||||||
|
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
|
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
|
||||||
{
|
{
|
||||||
// Form request url
|
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
|
||||||
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
|
var guilds = response.Select(ParseGuild).ToArray();
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return guilds;
|
return guilds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
|
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
|
||||||
{
|
{
|
||||||
// Form request url
|
var response = await GetApiResponseAsync(token, "users", "@me/channels");
|
||||||
var url = $"{ApiRoot}/users/@me/channels?token={token}";
|
var channels = response.Select(ParseChannel).ToArray();
|
||||||
|
|
||||||
// Get response
|
|
||||||
var content = await GetStringAsync(url);
|
|
||||||
|
|
||||||
// Parse
|
|
||||||
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
|
|
||||||
|
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
|
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
|
||||||
DateTime? from, DateTime? to)
|
{
|
||||||
|
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>();
|
var result = new List<Message>();
|
||||||
|
|
||||||
// We are going backwards from last message to first
|
// Report indeterminate progress
|
||||||
// collecting everything between them in batches
|
progress?.Report(-1);
|
||||||
var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
|
|
||||||
while (true)
|
// 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
|
progress?.Report(1);
|
||||||
var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
|
return result;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages appear newest first, we need to reverse
|
// Get other messages
|
||||||
result.Reverse();
|
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;
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Dispose(true);
|
_httpClient.Dispose();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CsvHelper;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public partial class ExportService
|
|
||||||
{
|
|
||||||
private string FormatMessageContentCsv(Message message)
|
|
||||||
{
|
|
||||||
var content = message.Content;
|
|
||||||
|
|
||||||
// New lines
|
|
||||||
content = content.Replace("\n", ", ");
|
|
||||||
|
|
||||||
// User mentions (<@id> and <@!id>)
|
|
||||||
foreach (var mentionedUser in message.MentionedUsers)
|
|
||||||
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
|
|
||||||
|
|
||||||
// Role mentions (<@&id>)
|
|
||||||
foreach (var mentionedRole in message.MentionedRoles)
|
|
||||||
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
|
|
||||||
|
|
||||||
// Channel mentions (<#id>)
|
|
||||||
foreach (var mentionedChannel in message.MentionedChannels)
|
|
||||||
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
|
|
||||||
|
|
||||||
// Custom emojis (<:name:id>)
|
|
||||||
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExportAsCsvAsync(ChannelChatLog log, TextWriter output)
|
|
||||||
{
|
|
||||||
using (var writer = new CsvWriter(output))
|
|
||||||
{
|
|
||||||
// Headers
|
|
||||||
writer.WriteField("Author");
|
|
||||||
writer.WriteField("Date");
|
|
||||||
writer.WriteField("Content");
|
|
||||||
writer.WriteField("Attachments");
|
|
||||||
await writer.NextRecordAsync();
|
|
||||||
|
|
||||||
// Chat log
|
|
||||||
foreach (var group in log.MessageGroups)
|
|
||||||
{
|
|
||||||
// Messages
|
|
||||||
foreach (var msg in group.Messages)
|
|
||||||
{
|
|
||||||
// Author
|
|
||||||
writer.WriteField(msg.Author.FullName);
|
|
||||||
|
|
||||||
// Date
|
|
||||||
var timeStampFormatted = msg.TimeStamp.ToString(_settingsService.DateFormat);
|
|
||||||
writer.WriteField(timeStampFormatted);
|
|
||||||
|
|
||||||
// Content
|
|
||||||
var contentFormatted = msg.Content.IsNotBlank() ? FormatMessageContentCsv(msg) : null;
|
|
||||||
writer.WriteField(contentFormatted);
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
var attachmentsFormatted = msg.Attachments.Select(a => a.Url).JoinToString(",");
|
|
||||||
writer.WriteField(attachmentsFormatted);
|
|
||||||
|
|
||||||
await writer.NextRecordAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, $"<@!?{mentionedUser.Id}>",
|
|
||||||
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
|
|
||||||
$"@{HtmlEncode(mentionedUser.Name)}" +
|
|
||||||
"</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role mentions (<@&id>)
|
|
||||||
foreach (var mentionedRole in message.MentionedRoles)
|
|
||||||
{
|
|
||||||
content = content.Replace($"<@&{mentionedRole.Id}>",
|
|
||||||
"<span class=\"mention\">" +
|
|
||||||
$"@{HtmlEncode(mentionedRole.Name)}" +
|
|
||||||
"</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel mentions (<#id>)
|
|
||||||
foreach (var mentionedChannel in message.MentionedChannels)
|
|
||||||
{
|
|
||||||
content = content.Replace($"<#{mentionedChannel.Id}>",
|
|
||||||
"<span class=\"mention\">" +
|
|
||||||
$"#{HtmlEncode(mentionedChannel.Name)}" +
|
|
||||||
"</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom emojis (<:name:id>)
|
|
||||||
content = Regex.Replace(content, "<(:.*?:)(\\d*)>",
|
|
||||||
"<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, "<@!?(\\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}>",
|
||||||
|
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
|
||||||
|
$"@{HtmlEncode(mentionedUser.Name)}" +
|
||||||
|
"</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}>",
|
||||||
|
"<span class=\"mention\">" +
|
||||||
|
$"#{HtmlEncode(mentionedChannel.Name)}" +
|
||||||
|
"</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}>",
|
||||||
|
"<span class=\"mention\">" +
|
||||||
|
$"@{HtmlEncode(mentionedRole.Name)}" +
|
||||||
|
"</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom emojis (<:name:id>)
|
||||||
|
var isJumboable = Regex.Replace(content, "<(:.*?:)(\\d*)>", "").IsBlank();
|
||||||
|
var emojiClass = isJumboable ? "emoji emoji--large" : "emoji";
|
||||||
|
content = Regex.Replace(content, "<(:.*?:)(\\d*)>",
|
||||||
|
$"<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.IO;
|
|
||||||
using System.Net;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DiscordChatExporter.Core.Internal;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
using Tyrrrz.Extensions;
|
using Scriban;
|
||||||
|
using Scriban.Runtime;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
namespace DiscordChatExporter.Core.Services
|
||||||
{
|
{
|
||||||
@@ -18,69 +14,38 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
_settingsService = settingsService;
|
_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))
|
using (var output = File.CreateText(filePath))
|
||||||
{
|
{
|
||||||
if (format == ExportFormat.PlainText)
|
// Configure output
|
||||||
{
|
context.PushOutput(new TextWriterOutput(output));
|
||||||
await ExportAsPlainTextAsync(log, output);
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (format == ExportFormat.HtmlDark)
|
// Render template
|
||||||
{
|
template.Render(context);
|
||||||
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 if (format == ExportFormat.Csv)
|
|
||||||
{
|
|
||||||
await ExportAsCsvAsync(log, output);
|
|
||||||
}
|
|
||||||
|
|
||||||
else throw new ArgumentOutOfRangeException(nameof(format));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class ExportService
|
|
||||||
{
|
|
||||||
private static string Base64Encode(string str)
|
|
||||||
{
|
|
||||||
return str.GetBytes().ToBase64();
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
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,
|
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
|
||||||
DateTime? from, DateTime? to);
|
|
||||||
|
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
|
namespace DiscordChatExporter.Core.Services
|
||||||
{
|
{
|
||||||
public interface IExportService
|
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
|
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; }
|
string DateFormat { get; set; }
|
||||||
int MessageGroupLimit { get; set; }
|
int MessageGroupLimit { get; set; }
|
||||||
|
|
||||||
string LastToken { get; set; }
|
AuthToken LastToken { get; set; }
|
||||||
ExportFormat LastExportFormat { get; set; }
|
ExportFormat LastExportFormat { get; set; }
|
||||||
|
|
||||||
void Load();
|
void Load();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
|
public IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
||||||
{
|
{
|
||||||
var result = new List<MessageGroup>();
|
var result = new List<MessageGroup>();
|
||||||
|
|
||||||
@@ -28,15 +28,15 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
groupFirst != null &&
|
groupFirst != null &&
|
||||||
(
|
(
|
||||||
message.Author.Id != groupFirst.Author.Id ||
|
message.Author.Id != groupFirst.Author.Id ||
|
||||||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
|
(message.Timestamp - groupFirst.Timestamp).TotalHours > 1 ||
|
||||||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour ||
|
message.Timestamp.Hour != groupFirst.Timestamp.Hour ||
|
||||||
groupBuffer.Count >= _settingsService.MessageGroupLimit
|
groupBuffer.Count >= _settingsService.MessageGroupLimit
|
||||||
);
|
);
|
||||||
|
|
||||||
// If condition is true - flush buffer
|
// If condition is true - flush buffer
|
||||||
if (breakCondition)
|
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);
|
result.Add(group);
|
||||||
groupBuffer.Clear();
|
groupBuffer.Clear();
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
if (groupBuffer.Any())
|
if (groupBuffer.Any())
|
||||||
{
|
{
|
||||||
var groupFirst = groupBuffer.First();
|
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);
|
result.Add(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
public int MessageGroupLimit { get; set; } = 20;
|
public int MessageGroupLimit { get; set; } = 20;
|
||||||
|
|
||||||
public string LastToken { get; set; }
|
public AuthToken LastToken { get; set; }
|
||||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
public SettingsService()
|
public SettingsService()
|
||||||
|
|||||||
@@ -96,6 +96,21 @@
|
|||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||||
</Style>
|
</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 -->
|
||||||
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
|
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,8 @@
|
|||||||
<Reference Include="System.Xaml">
|
<Reference Include="System.Xaml">
|
||||||
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Tyrrrz.Extensions, Version=1.5.0.0, Culture=neutral, processorArchitecture=MSIL">
|
<Reference Include="Tyrrrz.Extensions, Version=1.5.1.0, Culture=neutral, processorArchitecture=MSIL">
|
||||||
<HintPath>..\packages\Tyrrrz.Extensions.1.5.0\lib\net45\Tyrrrz.Extensions.dll</HintPath>
|
<HintPath>..\packages\Tyrrrz.Extensions.1.5.1\lib\net45\Tyrrrz.Extensions.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Tyrrrz.Settings, Version=1.3.2.0, Culture=neutral, PublicKeyToken=null" />
|
<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">
|
<Reference Include="Tyrrrz.WpfExtensions, Version=1.0.6269.37170, Culture=neutral, processorArchitecture=MSIL">
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
[assembly: AssemblyTitle("DiscordChatExporter")]
|
[assembly: AssemblyTitle("DiscordChatExporter")]
|
||||||
[assembly: AssemblyCompany("Tyrrrz")]
|
[assembly: AssemblyCompany("Tyrrrz")]
|
||||||
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
|
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
|
||||||
[assembly: AssemblyVersion("2.4.1")]
|
[assembly: AssemblyVersion("2.6")]
|
||||||
[assembly: AssemblyFileVersion("2.4.1")]
|
[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
|
public ExportFormat SelectedFormat
|
||||||
{
|
{
|
||||||
@@ -44,9 +45,9 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
Set(ref _format, value);
|
Set(ref _format, value);
|
||||||
|
|
||||||
// Replace extension in path
|
// Replace extension in path
|
||||||
var newExt = value.GetFileExtension();
|
var ext = value.GetFileExtension();
|
||||||
if (FilePath != null && !FilePath.EndsWith(newExt))
|
if (FilePath != null)
|
||||||
FilePath = FilePath.SubstringUntilLast(".") + "." + newExt;
|
FilePath = Path.ChangeExtension(FilePath, ext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +70,6 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
{
|
{
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
|
|
||||||
// Defaults
|
|
||||||
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
|
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
|
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
|
||||||
|
|
||||||
@@ -93,6 +91,12 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
// Save format
|
// Save format
|
||||||
_settingsService.LastExportFormat = SelectedFormat;
|
_settingsService.LastExportFormat = SelectedFormat;
|
||||||
|
|
||||||
|
// Clamp 'from' and 'to' values
|
||||||
|
if (From > To)
|
||||||
|
From = To;
|
||||||
|
if (To < From)
|
||||||
|
To = From;
|
||||||
|
|
||||||
// Start export
|
// Start export
|
||||||
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
|
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
bool IsBusy { get; }
|
bool IsBusy { get; }
|
||||||
bool IsDataAvailable { 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; }
|
IReadOnlyList<Guild> AvailableGuilds { get; }
|
||||||
Guild SelectedGuild { get; set; }
|
Guild SelectedGuild { get; set; }
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
|
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
|
||||||
|
|
||||||
private bool _isBusy;
|
private bool _isBusy;
|
||||||
private string _token;
|
private double _progress;
|
||||||
|
private bool _isBotToken;
|
||||||
|
private string _tokenValue;
|
||||||
private IReadOnlyList<Guild> _availableGuilds;
|
private IReadOnlyList<Guild> _availableGuilds;
|
||||||
private Guild _selectedGuild;
|
private Guild _selectedGuild;
|
||||||
private IReadOnlyList<Channel> _availableChannels;
|
private IReadOnlyList<Channel> _availableChannels;
|
||||||
@@ -43,15 +45,33 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
|
|
||||||
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
|
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
|
set
|
||||||
{
|
{
|
||||||
// Remove invalid chars
|
// Remove invalid chars
|
||||||
value = value?.Trim('"');
|
value = value?.Trim('"');
|
||||||
|
|
||||||
Set(ref _token, value);
|
Set(ref _tokenValue, value);
|
||||||
PullDataCommand.RaiseCanExecuteChanged();
|
PullDataCommand.RaiseCanExecuteChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +92,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
Set(ref _selectedGuild, value);
|
Set(ref _selectedGuild, value);
|
||||||
AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0];
|
AvailableChannels = value != null ? _guildChannelsMap[value] : Array.Empty<Channel>();
|
||||||
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +124,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
// Commands
|
// Commands
|
||||||
ViewLoadedCommand = new RelayCommand(ViewLoaded);
|
ViewLoadedCommand = new RelayCommand(ViewLoaded);
|
||||||
ViewClosedCommand = new RelayCommand(ViewClosed);
|
ViewClosedCommand = new RelayCommand(ViewClosed);
|
||||||
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
|
PullDataCommand = new RelayCommand(PullData, () => TokenValue.IsNotBlank() && !IsBusy);
|
||||||
ShowSettingsCommand = new RelayCommand(ShowSettings);
|
ShowSettingsCommand = new RelayCommand(ShowSettings);
|
||||||
ShowAboutCommand = new RelayCommand(ShowAbout);
|
ShowAboutCommand = new RelayCommand(ShowAbout);
|
||||||
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
|
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
|
||||||
@@ -119,8 +139,12 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
// Load settings
|
// Load settings
|
||||||
_settingsService.Load();
|
_settingsService.Load();
|
||||||
|
|
||||||
// Set last token
|
// Get last token
|
||||||
Token = _settingsService.LastToken;
|
if (_settingsService.LastToken != null)
|
||||||
|
{
|
||||||
|
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
|
||||||
|
TokenValue = _settingsService.LastToken.Value;
|
||||||
|
}
|
||||||
|
|
||||||
// Check and prepare update
|
// Check and prepare update
|
||||||
try
|
try
|
||||||
@@ -156,8 +180,10 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
|
|
||||||
// Copy token so it doesn't get mutated
|
// Create token
|
||||||
var token = Token;
|
var token = new AuthToken(
|
||||||
|
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
|
||||||
|
TokenValue);
|
||||||
|
|
||||||
// Save token
|
// Save token
|
||||||
_settingsService.LastToken = token;
|
_settingsService.LastToken = token;
|
||||||
@@ -222,19 +248,28 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
// Get last used token
|
// Get last used token
|
||||||
var token = _settingsService.LastToken;
|
var token = _settingsService.LastToken;
|
||||||
|
|
||||||
|
// Get guild
|
||||||
|
var guild = SelectedGuild;
|
||||||
|
|
||||||
|
// Create progress handler
|
||||||
|
var progressHandler = new Progress<double>(p => Progress = p);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get messages
|
// 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);
|
var messageGroups = _messageGroupService.GroupMessages(messages);
|
||||||
|
|
||||||
|
// Get mentionables
|
||||||
|
var mentionables = await _dataService.GetMentionablesAsync(token, guild.Id, messages);
|
||||||
|
|
||||||
// Create log
|
// Create log
|
||||||
var log = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
|
var log = new ChatLog(guild, channel, from, to, messageGroups, mentionables);
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
await _exportService.ExportAsync(format, filePath, log);
|
_exportService.Export(format, filePath, log);
|
||||||
|
|
||||||
// Open
|
// Open
|
||||||
Process.Start(filePath);
|
Process.Start(filePath);
|
||||||
@@ -246,7 +281,12 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
{
|
{
|
||||||
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
|
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;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,18 +36,22 @@
|
|||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
|
x:Name="FromDatePicker"
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="16,20,8,8"
|
Margin="16,20,8,8"
|
||||||
materialDesign:HintAssist.Hint="From (optional)"
|
materialDesign:HintAssist.Hint="From (optional)"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
DisplayDateEnd="{Binding SelectedDate, ElementName=ToDatePicker}"
|
||||||
SelectedDate="{Binding From}" />
|
SelectedDate="{Binding From}" />
|
||||||
<DatePicker
|
<DatePicker
|
||||||
|
x:Name="ToDatePicker"
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="8,20,16,8"
|
Margin="8,20,16,8"
|
||||||
materialDesign:HintAssist.Hint="To (optional)"
|
materialDesign:HintAssist.Hint="To (optional)"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
DisplayDateStart="{Binding SelectedDate, ElementName=FromDatePicker}"
|
||||||
SelectedDate="{Binding To}" />
|
SelectedDate="{Binding To}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Height="550"
|
Height="550"
|
||||||
Background="{DynamicResource MaterialDesignPaper}"
|
Background="{DynamicResource MaterialDesignPaper}"
|
||||||
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
|
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
|
||||||
FocusManager.FocusedElement="{Binding ElementName=TokenTextBox}"
|
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
|
||||||
FontFamily="{DynamicResource MaterialDesignFont}"
|
FontFamily="{DynamicResource MaterialDesignFont}"
|
||||||
Icon="/DiscordChatExporter;component/favicon.ico"
|
Icon="/DiscordChatExporter;component/favicon.ico"
|
||||||
SnapsToDevicePixels="True"
|
SnapsToDevicePixels="True"
|
||||||
@@ -48,27 +48,47 @@
|
|||||||
Margin="6,6,0,6">
|
Margin="6,6,0,6">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Token -->
|
<!-- Token type -->
|
||||||
<TextBox
|
<ToggleButton
|
||||||
x:Name="TokenTextBox"
|
|
||||||
Grid.Row="0"
|
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="6"
|
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:HintAssist.Hint="Token"
|
||||||
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||||
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
|
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
|
||||||
<!-- Pull data button -->
|
<!-- Pull data button -->
|
||||||
<Button
|
<Button
|
||||||
Grid.Row="0"
|
Grid.Column="2"
|
||||||
Grid.Column="1"
|
|
||||||
Margin="0,6,6,6"
|
Margin="0,6,6,6"
|
||||||
Padding="4"
|
Padding="4"
|
||||||
Command="{Binding PullDataCommand}"
|
Command="{Binding PullDataCommand}"
|
||||||
@@ -98,8 +118,9 @@
|
|||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
IsIndeterminate="True"
|
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
|
||||||
|
Value="{Binding Progress, Mode=OneWay}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -126,27 +147,28 @@
|
|||||||
<materialDesign:TransitioningContent.OpeningEffect>
|
<materialDesign:TransitioningContent.OpeningEffect>
|
||||||
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
|
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
|
||||||
</materialDesign:TransitioningContent.OpeningEffect>
|
</materialDesign:TransitioningContent.OpeningEffect>
|
||||||
<Border
|
<Grid
|
||||||
Margin="-8"
|
Margin="-8"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
Cursor="Hand">
|
Cursor="Hand"
|
||||||
<Image
|
ToolTip="{Binding Name}">
|
||||||
|
<!-- Guild icon placeholder -->
|
||||||
|
<Ellipse
|
||||||
Width="48"
|
Width="48"
|
||||||
Height="48"
|
Height="48"
|
||||||
Margin="12,4,12,4"
|
Margin="12,4,12,4"
|
||||||
Source="{Binding IconUrl}"
|
Fill="{DynamicResource DividerBrush}" />
|
||||||
ToolTip="{Binding Name}">
|
|
||||||
<Image.OpacityMask>
|
<!-- Guild icon -->
|
||||||
<RadialGradientBrush>
|
<Ellipse
|
||||||
<RadialGradientBrush.GradientStops>
|
Width="48"
|
||||||
<GradientStop Offset="0" Color="#FF000000" />
|
Height="48"
|
||||||
<GradientStop Offset="0.96" Color="#FF000000" />
|
Margin="12,4,12,4">
|
||||||
<GradientStop Offset="1" Color="#00000000" />
|
<Ellipse.Fill>
|
||||||
</RadialGradientBrush.GradientStops>
|
<ImageBrush ImageSource="{Binding IconUrl}" />
|
||||||
</RadialGradientBrush>
|
</Ellipse.Fill>
|
||||||
</Image.OpacityMask>
|
</Ellipse>
|
||||||
</Image>
|
</Grid>
|
||||||
</Border>
|
|
||||||
</materialDesign:TransitioningContent>
|
</materialDesign:TransitioningContent>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
@@ -194,34 +216,65 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Usage instructions -->
|
<!-- Usage instructions -->
|
||||||
<StackPanel Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
<Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||||
<TextBlock FontSize="18" Text="DiscordChatExporter needs your authorization token to work." />
|
<!-- User token -->
|
||||||
<TextBlock
|
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||||
Margin="0,8,0,0"
|
<TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work." />
|
||||||
FontSize="16"
|
<TextBlock
|
||||||
Text="To obtain it, follow these steps:" />
|
Margin="0,8,0,0"
|
||||||
<TextBlock Margin="8,0,0,0" FontSize="14">
|
FontSize="16"
|
||||||
<Run Text="1. Open the Discord app" />
|
Text="To obtain it, follow these steps:" />
|
||||||
<LineBreak />
|
<TextBlock Margin="8,0,0,0" FontSize="14">
|
||||||
<Run Text="2. Log in if you haven't" />
|
<Run Text="1. Open the Discord app" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="3. Press" />
|
<Run Text="2. Log in if you haven't" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
<LineBreak />
|
||||||
<LineBreak />
|
<Run Text="3. Press" />
|
||||||
<Run Text="4. Navigate to" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
<Run Text="to show developer tools" />
|
||||||
<Run Text="tab" />
|
<LineBreak />
|
||||||
<LineBreak />
|
<Run Text="4. Navigate to the" />
|
||||||
<Run Text="5. Expand" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage > Local Storage > https://discordapp.com" />
|
<Run Text="tab" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="6. Find" />
|
<Run Text="5. Expand" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text=""token"" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage > Local Storage > https://discordapp.com" />
|
||||||
<Run Text="under key and copy the value" />
|
<LineBreak />
|
||||||
<LineBreak />
|
<Run Text="6. Find the" />
|
||||||
<Run Text="7. Paste the value in the textbox above" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text=""token"" />
|
||||||
</TextBlock>
|
<Run Text="key and copy its value" />
|
||||||
</StackPanel>
|
<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" />
|
<materialDesign:Snackbar x:Name="Snackbar" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
|
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
|
||||||
<package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" />
|
<package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" />
|
||||||
<package id="MvvmLightLibs" version="5.4.1" 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" />
|
<package id="Tyrrrz.WpfExtensions" version="1.0.5" targetFramework="net461" />
|
||||||
</packages>
|
</packages>
|
||||||
@@ -18,28 +18,19 @@ DiscordChatExporter can be used to export message history from a [Discord](https
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Intuitive GUI that displays available guilds and channels
|
- Graphical and command line interfaces
|
||||||
- CLI as additional alternative to GUI
|
- Supports both user tokens and bot tokens
|
||||||
- Date ranges to limit messages
|
- Allows retrieving messages in specified date range
|
||||||
- Groups messages by author and time
|
- Multiple export formats: HTML (dark/light), TXT and CSV
|
||||||
- Exports to a plain text file
|
- Renders all message features including: markdown, attachments, embeds, emojis, mentions, etc
|
||||||
- Exports to an HTML file
|
|
||||||
- Dark and light themes
|
|
||||||
- User avatars
|
|
||||||
- Inline image attachments
|
|
||||||
- Full markdown support
|
|
||||||
- Automatic links
|
|
||||||
- Styled similarly to the app
|
|
||||||
- Exports to a CSV file
|
|
||||||
- Renders custom emojis
|
|
||||||
- Resolves user, role and channel mentions
|
|
||||||
|
|
||||||
## Libraries used
|
## Libraries used
|
||||||
|
|
||||||
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
|
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
|
||||||
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
|
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
|
||||||
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
|
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
|
||||||
- [CsvHelper](https://github.com/JoshClose/CsvHelper)
|
- [Scriban](https://github.com/lunet-io/scriban)
|
||||||
|
- [Polly](https://github.com/App-vNext/Polly)
|
||||||
- [Onova](https://github.com/Tyrrrz/Onova)
|
- [Onova](https://github.com/Tyrrrz/Onova)
|
||||||
- [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser)
|
- [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser)
|
||||||
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
|
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
|
||||||
|
|||||||
Reference in New Issue
Block a user