mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97a36028b9 | |||
| f0d99676a3 | |||
| 8e38adae7e | |||
| d95bc37592 | |||
| 3f6354053c | |||
| a9bab60ba6 | |||
| 614bd8590d | |||
| bd9dc6455f | |||
| 0faa427970 | |||
| dd9da4831e | |||
| 0d816cee5d | |||
| 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 | |||
| 3b7da21c24 | |||
| 1d0a13c613 | |||
| 67036f59ce | |||
| 517542730a | |||
| 19dc2334e2 | |||
| 4e1f783145 | |||
| ff6ef480ab | |||
| e1e8c73613 | |||
| e0284e281c | |||
| 435510baf9 | |||
| cc2583f96b | |||
| de763f8aaa | |||
| 7b67cbc385 | |||
| 7ffb799136 | |||
| 5a84fb25e6 | |||
| 9988300942 | |||
| 4540134a98 | |||
| 656e5a5b0d | |||
| 63e3ba8891 | |||
| d7572d9fd3 | |||
| 63c835df88 | |||
| 7bfd645e8e | |||
| 4e5d3cb4ed | |||
| cff976e2e4 | |||
| 0c69fd6e45 | |||
| 909a58b641 | |||
| 2db4ad46cc | |||
| ffa1023c50 | |||
| f7ef3bbb44 | |||
| bb2b04d2e5 | |||
| 1f36fb608a | |||
| b6e93e5770 | |||
| 8515efe11b | |||
| 7da82f9ef4 | |||
| 6ccba43807 | |||
| 2ce9e2c77e | |||
| a6a54d688f | |||
| e0d2f9c1ba | |||
| 01f8cf0e32 | |||
| ebab80fda1 | |||
| c432a75f62 | |||
| be4989ea34 | |||
| f8dae72411 | |||
| ab46361489 | |||
| 4a986fe5df | |||
| e3d2afac87 | |||
| 0cfcaca6b2 | |||
| 107c9de265 | |||
| 5d6edfd937 | |||
| 60a5a69aa4 | |||
| cc565598d3 | |||
| 6a7000cbc9 | |||
| 6706ff95f1 | |||
| ecc95c9c7f | |||
| d4f2049db6 | |||
| 4d5118de76 | |||
| 171718989a | |||
| 75e8d59cc3 | |||
| 1d7264a71b | |||
| 2c0a63352c | |||
| 42d4d64695 | |||
| 6968f987ce | |||
| 5ecbfd50b8 | |||
| e59a1ea8b4 | |||
| 540ce7f3c3 | |||
| a0d261f966 | |||
| 10446064d5 | |||
| 6709821324 | |||
| 705e60c76d | |||
| 8ac8c16db3 | |||
| 1850caba71 | |||
| 6ccaf3f106 | |||
| d7345e91d3 | |||
| e5e9d4c9ff | |||
| b280f8b736 | |||
| ca452d2421 | |||
| 9e7d4033e0 | |||
| 263a606bff | |||
| 1e853f11b0 | |||
| 77d0956f5d | |||
| d251e8ecf6 | |||
| 01f0e6952d | |||
| 28a67ab60d | |||
| 42bb4e72d2 | |||
| a79d72f975 | |||
| b110413c08 | |||
| a556926fe6 | |||
| 5a57b4a6b1 | |||
| d3b311b88f | |||
| d8db4fca90 | |||
| b7979d6d1f | |||
| 749a55a30e | |||
| 7b9de314ab | |||
| 69184a74fe | |||
| efae0e99b8 | |||
| 8afe9852fb | |||
| f2178a0445 | |||
| c01b9d0e5f | |||
| 151c5e2f23 | |||
| 6d7a8ae063 | |||
| d8bbe8c8c8 | |||
| 9a1a257f1e | |||
| 5e0a39df5e | |||
| fb3b14f1ce | |||
| 34afce2a66 | |||
| 95d581797b | |||
| 22d7dd43ec | |||
| dd942824ec | |||
| 0974dd77bf |
@@ -0,0 +1,54 @@
|
||||
### v2.7 (14-Sep-2018)
|
||||
|
||||
- Updated instructions on how to obtain the user token.
|
||||
- Expanded CLI with new commands: `channels` (get a list of channels in a guild), `dms` (get a list of DM channels), `guilds` (get a list of guilds), on top of `export` (export chatlog).
|
||||
- Improved help text and error messages in CLI.
|
||||
- In CLI, if the provided output file path is a directory, a file name will be generated and appended to it automatically.
|
||||
|
||||
### v2.6 (25-Jul-2018)
|
||||
|
||||
- Added support for bot tokens as an alternative to user tokens. For GUI, use the button in the top-left to switch between user and bot token. For CLI, pass the `--bot` switch to indicate that the given token is a bot token.
|
||||
- Added support for animated avatars.
|
||||
|
||||
### v2.5.1 (01-Jul-2018)
|
||||
|
||||
- Fixed a bug that would prevent timestamps from rendering properly in CSV and PlainText exports.
|
||||
- Fixed incorrect layout in CSV export.
|
||||
- Improved readability of PlainText export.
|
||||
|
||||
### v2.5 (28-Jun-2018)
|
||||
|
||||
- Added support for embeds and webhooks (HTML only).
|
||||
- Implemented progress reporting when downloading messages.
|
||||
- Added support for reactions (HTML only).
|
||||
- Selected date range is now shown in the export (HTML and PlainText).
|
||||
- Added placeholders for guild icons to display while the icons are loading.
|
||||
- Added support for jumbo emojis (custom emojis only).
|
||||
- Added basic retry policy for 429 error responses.
|
||||
- Added validation for from/to dates in the UI which prevents choosing invalid date ranges.
|
||||
- Added an error message when trying to export a deleted channel.
|
||||
|
||||
### v2.4.1 (15-Apr-2018)
|
||||
|
||||
- Added CSV export format.
|
||||
- Channels are now ordered by name in the UI.
|
||||
- Exported file is now always opened upon completion.
|
||||
|
||||
### v2.4 (08-Apr-2018)
|
||||
|
||||
- Added channel topic to output.
|
||||
- Added a command line interface alternative to the current graphical user interface.
|
||||
- Implemented application auto-update (can be disabled in settings).
|
||||
- Improved some GUI elements and replaced some dialogs with notifications.
|
||||
- Fixed a lot of issues with the markdown processor.
|
||||
- Fixed DM group chats that have a custom name being shown as blank in the list.
|
||||
|
||||
### v2.3 (27-Oct-2017)
|
||||
|
||||
- Improved message date filtering, it's now marginally faster.
|
||||
- Fixed underscores not recognized as italics in markdown.
|
||||
- Added support for custom emojis.
|
||||
- Added support for user and role mentions.
|
||||
- Added support for channel mentions.
|
||||
- Fixed text in pre blocks not being wrapped correctly.
|
||||
- Added workaround for non-default message types.
|
||||
@@ -0,0 +1,11 @@
|
||||
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
|
||||
|
||||
# GUI
|
||||
$files = @()
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
|
||||
|
||||
# CLI
|
||||
$files = @()
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
|
||||
@@ -0,0 +1,27 @@
|
||||
using CommonServiceLocator;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using GalaSoft.MvvmLight.Ioc;
|
||||
|
||||
namespace DiscordChatExporter.Cli
|
||||
{
|
||||
public class Container
|
||||
{
|
||||
public Container()
|
||||
{
|
||||
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
|
||||
SimpleIoc.Default.Reset();
|
||||
|
||||
// Services
|
||||
SimpleIoc.Default.Register<IDataService, DataService>();
|
||||
SimpleIoc.Default.Register<IExportService, ExportService>();
|
||||
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
|
||||
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
|
||||
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
|
||||
}
|
||||
|
||||
public T Resolve<T>(string key = null)
|
||||
{
|
||||
return ServiceLocator.Current.GetInstance<T>(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
<Version>2.7</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.2.1" />
|
||||
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
|
||||
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Cli.Verbs;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
|
||||
namespace DiscordChatExporter.Cli
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
private static void PrintTokenHelp()
|
||||
{
|
||||
Console.WriteLine("# To get user token:");
|
||||
Console.WriteLine(" 1. Open Discord app");
|
||||
Console.WriteLine(" 2. Log in if you haven't");
|
||||
Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||
Console.WriteLine(" 4. Press Ctrl+R to trigger reload");
|
||||
Console.WriteLine(" 5. Navigate to the Application tab");
|
||||
Console.WriteLine(" 6. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
|
||||
Console.WriteLine(" 7. Find \"token\" under key and copy the value");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("# To get bot token:");
|
||||
Console.WriteLine(" 1. Go to Discord developer portal");
|
||||
Console.WriteLine(" 2. Log in if you haven't");
|
||||
Console.WriteLine(" 3. Open your application's settings");
|
||||
Console.WriteLine(" 4. Navigate to the Bot section on the left");
|
||||
Console.WriteLine(" 5. Under Token click Copy");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("# To get guild or channel ID:");
|
||||
Console.WriteLine(" 1. Open Discord app");
|
||||
Console.WriteLine(" 2. Log in if you haven't");
|
||||
Console.WriteLine(" 3. Open Settings");
|
||||
Console.WriteLine(" 4. Go to Appearance section");
|
||||
Console.WriteLine(" 5. Enable Developer Mode");
|
||||
Console.WriteLine(" 6. Right click on the desired guild or channel and click Copy ID");
|
||||
}
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
// Get all verb types
|
||||
var verbTypes = new[]
|
||||
{
|
||||
typeof(ExportChatOptions),
|
||||
typeof(GetChannelsOptions),
|
||||
typeof(GetDirectMessageChannelsOptions),
|
||||
typeof(GetGuildsOptions),
|
||||
//typeof(UpdateAppOptions)
|
||||
};
|
||||
|
||||
// Parse command line arguments
|
||||
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
|
||||
|
||||
// Execute commands
|
||||
parsedArgs.WithParsed<ExportChatOptions>(o => new ExportChatVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
|
||||
parsedArgs.WithParsed<UpdateAppOptions>(o => new UpdateAppVerb(o).Execute());
|
||||
|
||||
// Show token help if help requested or no verb specified
|
||||
parsedArgs.WithNotParsed(errs =>
|
||||
{
|
||||
var err = errs.First();
|
||||
|
||||
if (err.Tag == ErrorType.NoVerbSelectedError)
|
||||
PrintTokenHelp();
|
||||
|
||||
if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
|
||||
PrintTokenHelp();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class ExportChatVerb : Verb<ExportChatOptions>
|
||||
{
|
||||
public ExportChatVerb(ExportChatOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get services
|
||||
var container = new Container();
|
||||
var settingsService = container.Resolve<ISettingsService>();
|
||||
var dataService = container.Resolve<IDataService>();
|
||||
var messageGroupService = container.Resolve<IMessageGroupService>();
|
||||
var exportService = container.Resolve<IExportService>();
|
||||
|
||||
// Configure settings
|
||||
if (Options.DateFormat.IsNotBlank())
|
||||
settingsService.DateFormat = Options.DateFormat;
|
||||
if (Options.MessageGroupLimit > 0)
|
||||
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
|
||||
|
||||
// Get channel and guild
|
||||
var channel = await dataService.GetChannelAsync(Options.GetToken(), Options.ChannelId);
|
||||
var guild = channel.GuildId == Guild.DirectMessages.Id
|
||||
? Guild.DirectMessages
|
||||
: await dataService.GetGuildAsync(Options.GetToken(), channel.GuildId);
|
||||
|
||||
// Generate file path if not set
|
||||
var filePath = Options.FilePath;
|
||||
if (filePath == null || filePath.EndsWith("/") || filePath.EndsWith("\\"))
|
||||
{
|
||||
filePath += $"{guild.Name} - {channel.Name}.{Options.ExportFormat.GetFileExtension()}"
|
||||
.Replace(Path.GetInvalidFileNameChars(), '_');
|
||||
}
|
||||
|
||||
// TODO: extract this to make it reusable across implementations
|
||||
// Get messages
|
||||
var messages =
|
||||
await dataService.GetChannelMessagesAsync(Options.GetToken(), channel.Id,
|
||||
Options.After, Options.Before);
|
||||
|
||||
// Group messages
|
||||
var messageGroups = messageGroupService.GroupMessages(messages);
|
||||
|
||||
// Get mentionables
|
||||
var mentionables = await dataService.GetMentionablesAsync(Options.GetToken(), guild.Id, messages);
|
||||
|
||||
// Create log
|
||||
var log = new ChatLog(guild, channel, Options.After, Options.Before, messageGroups, mentionables);
|
||||
|
||||
// Export
|
||||
exportService.Export(Options.ExportFormat, filePath, log);
|
||||
|
||||
// Print result
|
||||
Console.WriteLine($"Exported chat to [{filePath}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetChannelsVerb : Verb<GetChannelsOptions>
|
||||
{
|
||||
public GetChannelsVerb(GetChannelsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var container = new Container();
|
||||
var dataService = container.Resolve<IDataService>();
|
||||
|
||||
// Get channels
|
||||
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
|
||||
|
||||
// Print result
|
||||
foreach (var channel in channels.Where(c => c.Type.IsEither(ChannelType.GuildTextChat))
|
||||
.OrderBy(c => c.Name))
|
||||
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
|
||||
{
|
||||
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var container = new Container();
|
||||
var dataService = container.Resolve<IDataService>();
|
||||
|
||||
// Get channels
|
||||
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
|
||||
|
||||
// Print result
|
||||
foreach (var channel in channels.OrderBy(c => c.Name))
|
||||
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetGuildsVerb : Verb<GetGuildsOptions>
|
||||
{
|
||||
public GetGuildsVerb(GetGuildsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var container = new Container();
|
||||
var dataService = container.Resolve<IDataService>();
|
||||
|
||||
// Get guilds
|
||||
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
|
||||
|
||||
// Print result
|
||||
foreach (var guild in guilds.OrderBy(g => g.Name))
|
||||
Console.WriteLine($"{guild.Id} | {guild.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("export", HelpText = "Export channel chat log to a file.")]
|
||||
public class ExportChatOptions : TokenOptions
|
||||
{
|
||||
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
|
||||
public string ChannelId { get; set; }
|
||||
|
||||
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
|
||||
public ExportFormat ExportFormat { get; set; }
|
||||
|
||||
[Option('o', "output", Default = null, HelpText = "Output file path.")]
|
||||
public string FilePath { get; set; }
|
||||
|
||||
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
|
||||
public DateTime? After { get; set; }
|
||||
|
||||
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
|
||||
public DateTime? Before { get; set; }
|
||||
|
||||
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
|
||||
public string DateFormat { get; set; }
|
||||
|
||||
[Option("grouplimit", Default = 0, HelpText = "Message group limit.")]
|
||||
public int MessageGroupLimit { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("channels", HelpText = "Get the list of channels in the given guild.")]
|
||||
public class GetChannelsOptions : TokenOptions
|
||||
{
|
||||
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
|
||||
public string GuildId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("dm", HelpText = "Get the list of direct message channels.")]
|
||||
public class GetDirectMessageChannelsOptions : TokenOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("guilds", HelpText = "Get the list of accessible guilds.")]
|
||||
public class GetGuildsOptions : TokenOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
public class TokenOptions
|
||||
{
|
||||
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
|
||||
public string TokenValue { get; set; }
|
||||
|
||||
[Option('b', "bot", Default = false, HelpText = "Whether this authorization token belongs to a bot.")]
|
||||
public bool IsBotToken { get; set; }
|
||||
|
||||
public AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("update", HelpText = "Updates this application to the latest version.")]
|
||||
public class UpdateAppOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class UpdateAppVerb : Verb<UpdateAppOptions>
|
||||
{
|
||||
public UpdateAppVerb(UpdateAppOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get update service
|
||||
var container = new Container();
|
||||
var updateService = container.Resolve<IUpdateService>();
|
||||
|
||||
// TODO: this is configured only for GUI
|
||||
// Get update version
|
||||
var updateVersion = await updateService.CheckPrepareUpdateAsync();
|
||||
|
||||
if (updateVersion != null)
|
||||
{
|
||||
Console.WriteLine($"Updating to version {updateVersion}");
|
||||
|
||||
updateService.NeedRestart = false;
|
||||
updateService.FinalizeUpdate();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("There are no application updates available.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public abstract class Verb<TOptions>
|
||||
{
|
||||
protected TOptions Options { get; }
|
||||
|
||||
protected Verb(TOptions options)
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public abstract Task ExecuteAsync();
|
||||
|
||||
public virtual void Execute() => ExecuteAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
<Version>2.7</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\PlainText.txt" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\Core.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\Shared.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\DarkTheme.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\LightTheme.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Csv.csv" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="mscorlib" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
||||
<PackageReference Include="Onova" Version="2.1.0" />
|
||||
<PackageReference Include="Polly" Version="6.0.1" />
|
||||
<PackageReference Include="Scriban" Version="1.2.1" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exceptions
|
||||
{
|
||||
public class HttpErrorStatusCodeException : Exception
|
||||
{
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public string ReasonPhrase { get; }
|
||||
|
||||
public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase)
|
||||
: base($"Error HTTP status code: {statusCode} - {reasonPhrase}")
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ReasonPhrase = reasonPhrase;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Internal
|
||||
{
|
||||
internal static class Extensions
|
||||
{
|
||||
public static string ToSnowflake(this DateTime dateTime)
|
||||
{
|
||||
const long epoch = 62135596800000;
|
||||
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
|
||||
var value = ((ulong) unixTime - 1420070400000UL) << 22;
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
public static 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
||||
|
||||
public class Attachment
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public int? Width { get; }
|
||||
|
||||
public int? Height { get; }
|
||||
|
||||
public bool IsImage => Width != null;
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public long FileSize { get; }
|
||||
|
||||
public Attachment(string id, int? width, int? height, string url, string fileName, long fileSize)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FileName = fileName;
|
||||
FileSize = fileSize;
|
||||
}
|
||||
|
||||
public override string ToString() => FileName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class AuthToken
|
||||
{
|
||||
public AuthTokenType Type { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public AuthToken(AuthTokenType type, string value)
|
||||
{
|
||||
Type = type;
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public enum AuthTokenType
|
||||
{
|
||||
User,
|
||||
Bot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#channel-object
|
||||
|
||||
public partial class Channel
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string GuildId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Topic { get; }
|
||||
|
||||
public ChannelType Type { get; }
|
||||
|
||||
public Channel(string id, string guildId, string name, string topic, ChannelType type)
|
||||
{
|
||||
Id = id;
|
||||
GuildId = guildId;
|
||||
Name = name;
|
||||
Topic = topic;
|
||||
Type = type;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Channel
|
||||
{
|
||||
public static Channel CreateDeletedChannel(string id) =>
|
||||
new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
|
||||
public enum ChannelType
|
||||
{
|
||||
GuildTextChat,
|
||||
DirectTextChat,
|
||||
GuildVoiceChat,
|
||||
DirectGroupTextChat,
|
||||
Category
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public enum ExportFormat
|
||||
{
|
||||
PlainText,
|
||||
HtmlDark,
|
||||
HtmlLight,
|
||||
Csv
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public static class Extensions
|
||||
{
|
||||
public static string GetFileExtension(this ExportFormat format)
|
||||
{
|
||||
if (format == ExportFormat.PlainText)
|
||||
return "txt";
|
||||
if (format == ExportFormat.HtmlDark)
|
||||
return "html";
|
||||
if (format == ExportFormat.HtmlLight)
|
||||
return "html";
|
||||
if (format == ExportFormat.Csv)
|
||||
return "csv";
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(format));
|
||||
}
|
||||
|
||||
public static string GetDisplayName(this ExportFormat format)
|
||||
{
|
||||
if (format == ExportFormat.PlainText)
|
||||
return "Plain Text";
|
||||
if (format == ExportFormat.HtmlDark)
|
||||
return "HTML (Dark)";
|
||||
if (format == ExportFormat.HtmlLight)
|
||||
return "HTML (Light)";
|
||||
if (format == ExportFormat.Csv)
|
||||
return "Comma Seperated Values (CSV)";
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(format));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/guild#guild-object
|
||||
|
||||
public partial class Guild
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string IconHash { get; }
|
||||
|
||||
public string IconUrl => IconHash.IsNotBlank()
|
||||
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
|
||||
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
|
||||
public Guild(string id, string name, string iconHash)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
IconHash = iconHash;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Guild
|
||||
{
|
||||
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#message-object
|
||||
|
||||
public class Message
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public MessageType Type { get; }
|
||||
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
public DateTime? EditedTimestamp { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
public IReadOnlyList<Attachment> Attachments { get; }
|
||||
|
||||
public IReadOnlyList<Embed> Embeds { get; }
|
||||
|
||||
public IReadOnlyList<Reaction> Reactions { get; }
|
||||
|
||||
public IReadOnlyList<User> MentionedUsers { get; }
|
||||
|
||||
public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
|
||||
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
|
||||
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
|
||||
{
|
||||
Id = id;
|
||||
ChannelId = channelId;
|
||||
Type = type;
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
EditedTimestamp = editedTimestamp;
|
||||
Content = content;
|
||||
Attachments = attachments;
|
||||
Embeds = embeds;
|
||||
Reactions = reactions;
|
||||
MentionedUsers = mentionedUsers;
|
||||
}
|
||||
|
||||
public override string ToString() => Content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class MessageGroup
|
||||
{
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
|
||||
{
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
Messages = messages;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Author.FullName} | {Timestamp} | {Messages.Count} messages";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
Default,
|
||||
RecipientAdd,
|
||||
RecipientRemove,
|
||||
Call,
|
||||
ChannelNameChange,
|
||||
ChannelIconChange,
|
||||
ChannelPinnedMessage,
|
||||
GuildMemberJoin
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||
|
||||
public partial class Role
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public Role(string id, string name)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Role
|
||||
{
|
||||
public static Role CreateDeletedRole(string id) =>
|
||||
new Role(id, "deleted-role");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||
|
||||
public partial class User
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public int Discriminator { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||
|
||||
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
||||
|
||||
public string AvatarHash { get; }
|
||||
|
||||
public bool IsAvatarAnimated =>
|
||||
AvatarHash.IsNotBlank() && AvatarHash.StartsWith("a_", StringComparison.Ordinal);
|
||||
|
||||
public string AvatarUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
// Custom avatar
|
||||
if (AvatarHash.IsNotBlank())
|
||||
{
|
||||
// Animated
|
||||
if (IsAvatarAnimated)
|
||||
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.gif";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png";
|
||||
}
|
||||
|
||||
// Default avatar
|
||||
return $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
|
||||
}
|
||||
}
|
||||
|
||||
public User(string id, int discriminator, string name, string avatarHash)
|
||||
{
|
||||
Id = id;
|
||||
Discriminator = discriminator;
|
||||
Name = name;
|
||||
AvatarHash = avatarHash;
|
||||
}
|
||||
|
||||
public override string ToString() => FullName;
|
||||
}
|
||||
|
||||
public partial class User
|
||||
{
|
||||
public static User CreateUnknownUser(string id) =>
|
||||
new User(id, 0, "Unknown", null);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using DiscordChatExporter.Core.Internal;
|
||||
using Polly;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public partial class DataService : IDataService, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
|
||||
params string[] parameters)
|
||||
{
|
||||
// Create request policy
|
||||
var policy = Policy
|
||||
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
|
||||
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
|
||||
|
||||
// Send request
|
||||
return await policy.ExecuteAsync(async () =>
|
||||
{
|
||||
// Create request
|
||||
const string apiRoot = "https://discordapp.com/api/v6";
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
|
||||
{
|
||||
// Set authorization header
|
||||
request.Headers.Authorization = token.Type == AuthTokenType.Bot
|
||||
? new AuthenticationHeaderValue("Bot", token.Value)
|
||||
: new AuthenticationHeaderValue(token.Value);
|
||||
|
||||
// Add parameters
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
var key = parameter.SubstringUntil("=");
|
||||
var value = parameter.SubstringAfter("=");
|
||||
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
||||
}
|
||||
|
||||
// Get response
|
||||
using (var response = await _httpClient.SendAsync(request))
|
||||
{
|
||||
// Check status code
|
||||
// We throw our own exception here because default one doesn't have status code
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase);
|
||||
|
||||
// Get content
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Parse
|
||||
return JToken.Parse(raw);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "guilds", guildId);
|
||||
var guild = ParseGuild(response);
|
||||
|
||||
return guild;
|
||||
}
|
||||
|
||||
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "channels", channelId);
|
||||
var channel = ParseChannel(response);
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
|
||||
var guilds = response.Select(ParseGuild).ToArray();
|
||||
|
||||
return guilds;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "users", "@me/channels");
|
||||
var channels = response.Select(ParseChannel).ToArray();
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
|
||||
var channels = response.Select(ParseChannel).ToArray();
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
|
||||
var roles = response.Select(ParseRole).ToArray();
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||
{
|
||||
var result = new List<Message>();
|
||||
|
||||
// Report indeterminate progress
|
||||
progress?.Report(-1);
|
||||
|
||||
// Get the snowflakes for the selected range
|
||||
var firstId = from != null ? from.Value.ToSnowflake() : "0";
|
||||
var lastId = to != null ? to.Value.ToSnowflake() : DateTime.MaxValue.ToSnowflake();
|
||||
|
||||
// Get the last message
|
||||
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
||||
"limit=1", $"before={lastId}");
|
||||
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
|
||||
|
||||
// If the last message doesn't exist or it's outside of range - return
|
||||
if (lastMessage == null || lastMessage.Timestamp < from)
|
||||
{
|
||||
progress?.Report(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get other messages
|
||||
var offsetId = firstId;
|
||||
while (true)
|
||||
{
|
||||
// Get message batch
|
||||
response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
||||
"limit=100", $"after={offsetId}");
|
||||
|
||||
// Parse
|
||||
var messages = response
|
||||
.Select(ParseMessage)
|
||||
.Reverse() // reverse because messages appear newest first
|
||||
.ToArray();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
if (!messages.Any())
|
||||
break;
|
||||
|
||||
// Trim messages to range (until last message)
|
||||
var messagesInRange = messages
|
||||
.TakeWhile(m => m.Id != lastMessage.Id && m.Timestamp < lastMessage.Timestamp)
|
||||
.ToArray();
|
||||
|
||||
// Add to result
|
||||
result.AddRange(messagesInRange);
|
||||
|
||||
// Break if messages were trimmed (which means the last message was encountered)
|
||||
if (messagesInRange.Length != messages.Length)
|
||||
break;
|
||||
|
||||
// Report progress (based on the time range of parsed messages compared to total)
|
||||
progress?.Report((result.Last().Timestamp - result.First().Timestamp).TotalSeconds /
|
||||
(lastMessage.Timestamp - result.First().Timestamp).TotalSeconds);
|
||||
|
||||
// Move offset
|
||||
offsetId = result.Last().Id;
|
||||
}
|
||||
|
||||
// Add last message
|
||||
result.Add(lastMessage);
|
||||
|
||||
// Report progress
|
||||
progress?.Report(1);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
||||
IEnumerable<Message> messages)
|
||||
{
|
||||
// Get channels and roles
|
||||
var channels = guildId != Guild.DirectMessages.Id
|
||||
? await GetGuildChannelsAsync(token, guildId)
|
||||
: Array.Empty<Channel>();
|
||||
var roles = guildId != Guild.DirectMessages.Id
|
||||
? await GetGuildRolesAsync(token, guildId)
|
||||
: Array.Empty<Role>();
|
||||
|
||||
// Get users
|
||||
var userMap = new Dictionary<string, User>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
// Author
|
||||
userMap[message.Author.Id] = message.Author;
|
||||
|
||||
// Mentioned users
|
||||
foreach (var mentionedUser in message.MentionedUsers)
|
||||
userMap[mentionedUser.Id] = mentionedUser;
|
||||
}
|
||||
|
||||
var users = userMap.Values.ToArray();
|
||||
|
||||
return new Mentionables(users, channels, roles);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.IO;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Scriban;
|
||||
using Scriban.Runtime;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public partial class ExportService : IExportService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public ExportService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
// Create directory
|
||||
var dirPath = Path.GetDirectoryName(filePath);
|
||||
if (dirPath.IsNotBlank())
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
// Render output
|
||||
using (var output = File.CreateText(filePath))
|
||||
{
|
||||
// Configure output
|
||||
context.PushOutput(new TextWriterOutput(output));
|
||||
|
||||
// Render template
|
||||
template.Render(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IDataService
|
||||
{
|
||||
Task<Guild> GetGuildAsync(AuthToken token, string guildId);
|
||||
|
||||
Task<Channel> GetChannelAsync(AuthToken token, string channelId);
|
||||
|
||||
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
|
||||
|
||||
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
|
||||
|
||||
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
|
||||
|
||||
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
|
||||
|
||||
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
||||
|
||||
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
||||
IEnumerable<Message> messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IExportService
|
||||
{
|
||||
void Export(ExportFormat format, string filePath, ChatLog log);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IMessageGroupService
|
||||
{
|
||||
IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface ISettingsService
|
||||
{
|
||||
bool IsAutoUpdateEnabled { get; set; }
|
||||
|
||||
string DateFormat { get; set; }
|
||||
int MessageGroupLimit { get; set; }
|
||||
|
||||
AuthToken LastToken { get; set; }
|
||||
ExportFormat LastExportFormat { get; set; }
|
||||
|
||||
void Load();
|
||||
void Save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IUpdateService
|
||||
{
|
||||
bool NeedRestart { get; set; }
|
||||
|
||||
Task<Version> CheckPrepareUpdateAsync();
|
||||
|
||||
void FinalizeUpdate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class MessageGroupService : IMessageGroupService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public MessageGroupService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
||||
{
|
||||
var result = new List<MessageGroup>();
|
||||
|
||||
// Group adjacent messages by timestamp and author
|
||||
var groupBuffer = new List<Message>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var groupFirst = groupBuffer.FirstOrDefault();
|
||||
|
||||
// Group break condition
|
||||
var breakCondition =
|
||||
groupFirst != null &&
|
||||
(
|
||||
message.Author.Id != groupFirst.Author.Id ||
|
||||
(message.Timestamp - groupFirst.Timestamp).TotalHours > 1 ||
|
||||
message.Timestamp.Hour != groupFirst.Timestamp.Hour ||
|
||||
groupBuffer.Count >= _settingsService.MessageGroupLimit
|
||||
);
|
||||
|
||||
// If condition is true - flush buffer
|
||||
if (breakCondition)
|
||||
{
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
|
||||
result.Add(group);
|
||||
groupBuffer.Clear();
|
||||
}
|
||||
|
||||
// Add message to buffer
|
||||
groupBuffer.Add(message);
|
||||
}
|
||||
|
||||
// Add what's remaining in buffer
|
||||
if (groupBuffer.Any())
|
||||
{
|
||||
var groupFirst = groupBuffer.First();
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
|
||||
result.Add(group);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Tyrrrz.Settings;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class SettingsService : SettingsManager, ISettingsService
|
||||
{
|
||||
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||
|
||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||
public int MessageGroupLimit { get; set; } = 20;
|
||||
|
||||
public AuthToken LastToken { get; set; }
|
||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
Configuration.StorageSpace = StorageSpace.Instance;
|
||||
Configuration.SubDirectoryPath = "";
|
||||
Configuration.FileName = "Settings.dat";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Onova;
|
||||
using Onova.Services;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class UpdateService : IUpdateService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IUpdateManager _manager;
|
||||
|
||||
private Version _updateVersion;
|
||||
private bool _updateFinalized;
|
||||
|
||||
public bool NeedRestart { get; set; }
|
||||
|
||||
public UpdateService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
|
||||
_manager = new UpdateManager(
|
||||
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
||||
new ZipPackageExtractor());
|
||||
}
|
||||
|
||||
public async Task<Version> CheckPrepareUpdateAsync()
|
||||
{
|
||||
// If auto-update is disabled - don't check for updates
|
||||
if (!_settingsService.IsAutoUpdateEnabled)
|
||||
return null;
|
||||
|
||||
// Cleanup leftover files
|
||||
_manager.Cleanup();
|
||||
|
||||
// Check for updates
|
||||
var check = await _manager.CheckForUpdatesAsync();
|
||||
if (!check.CanUpdate)
|
||||
return null;
|
||||
|
||||
// Prepare the update
|
||||
if (!_manager.IsUpdatePrepared(check.LastVersion))
|
||||
await _manager.PrepareUpdateAsync(check.LastVersion);
|
||||
|
||||
return _updateVersion = check.LastVersion;
|
||||
}
|
||||
|
||||
public void FinalizeUpdate()
|
||||
{
|
||||
// Check if an update is pending
|
||||
if (_updateVersion == null)
|
||||
return;
|
||||
|
||||
// Check if the update has already been finalized
|
||||
if (_updateFinalized)
|
||||
return;
|
||||
|
||||
// Launch the updater
|
||||
_manager.LaunchUpdater(_updateVersion, NeedRestart);
|
||||
_updateFinalized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<Application
|
||||
x:Class="DiscordChatExporter.Gui.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
||||
StartupUri="Views/MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
|
||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
||||
<ResourceDictionary Source="pack://application:,,,/Tyrrrz.WpfExtensions;component/ConvertersDictionary.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Colors -->
|
||||
<Color x:Key="PrimaryColor">#343838</Color>
|
||||
<Color x:Key="PrimaryLightColor">#5E6262</Color>
|
||||
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
|
||||
<Color x:Key="AccentColor">#F9A825</Color>
|
||||
<Color x:Key="AccentDarkColor">#C17900</Color>
|
||||
<Color x:Key="TextColor">#000000</Color>
|
||||
<Color x:Key="InverseTextColor">#FFFFFF</Color>
|
||||
|
||||
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
|
||||
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
|
||||
|
||||
<SolidColorBrush
|
||||
x:Key="PrimaryTextBrush"
|
||||
Opacity="0.87"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="SecondaryTextBrush"
|
||||
Opacity="0.64"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DimTextBrush"
|
||||
Opacity="0.45"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="PrimaryInverseTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="SecondaryInverseTextBrush"
|
||||
Opacity="0.7"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DimInverseTextBrush"
|
||||
Opacity="0.52"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="AccentTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource AccentColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="AccentDarkTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource AccentDarkColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DividerBrush"
|
||||
Opacity="0.12"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="InverseDividerBrush"
|
||||
Opacity="0.12"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
|
||||
<!-- Styles -->
|
||||
<Style TargetType="{x:Type Image}">
|
||||
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
|
||||
<Setter Property="RenderOptions.EdgeMode" Value="Aliased" />
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
|
||||
<Setter Property="Height" Value="2" />
|
||||
<Setter Property="Maximum" Value="1" />
|
||||
<Setter Property="Minimum" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="MaterialDesignFlatActionToggleButton"
|
||||
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
|
||||
TargetType="{x:Type ToggleButton}">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
|
||||
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Converters -->
|
||||
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
|
||||
|
||||
<!-- Container -->
|
||||
<local:Container x:Key="Container" />
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DiscordChatExporter.Gui
|
||||
{
|
||||
public partial class App
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using CommonServiceLocator;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using DiscordChatExporter.Gui.ViewModels;
|
||||
using GalaSoft.MvvmLight.Ioc;
|
||||
|
||||
namespace DiscordChatExporter.Gui
|
||||
{
|
||||
public class Container
|
||||
{
|
||||
public IExportSetupViewModel ExportSetupViewModel => Resolve<IExportSetupViewModel>();
|
||||
public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
|
||||
public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>();
|
||||
|
||||
public Container()
|
||||
{
|
||||
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
|
||||
SimpleIoc.Default.Reset();
|
||||
|
||||
// Services
|
||||
SimpleIoc.Default.Register<IDataService, DataService>();
|
||||
SimpleIoc.Default.Register<IExportService, ExportService>();
|
||||
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
|
||||
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
|
||||
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
|
||||
|
||||
// View models
|
||||
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
|
||||
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
|
||||
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
|
||||
}
|
||||
|
||||
private T Resolve<T>(string key = null)
|
||||
{
|
||||
return ServiceLocator.Current.GetInstance<T>(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Converters
|
||||
{
|
||||
[ValueConversion(typeof(ExportFormat), typeof(string))]
|
||||
public class ExportFormatToStringConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var format = (ExportFormat) value;
|
||||
return format.GetDisplayName();
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{732A67AF-93DE-49DF-B10F-FD74710B7863}</ProjectGuid>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>DiscordChatExporter.Gui</RootNamespace>
|
||||
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<StartupObject />
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="CommonServiceLocator, Version=2.0.3.0, Culture=neutral, PublicKeyToken=489b6accfaf20ef0, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\CommonServiceLocator.2.0.3\lib\net45\CommonServiceLocator.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="GalaSoft.MvvmLight, Version=5.4.1.0, Culture=neutral, PublicKeyToken=e7570ab207bcb616, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="GalaSoft.MvvmLight.Extras, Version=5.4.1.0, Culture=neutral, PublicKeyToken=669f0b5e8f868abf, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Extras.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="GalaSoft.MvvmLight.Platform, Version=5.4.1.0, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="MaterialDesignThemes.Wpf, Version=2.4.0.1044, Culture=neutral, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MaterialDesignThemes.2.4.0.1044\lib\net45\MaterialDesignThemes.Wpf.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Windows.Interactivity, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\System.Windows.Interactivity.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.Xaml">
|
||||
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
||||
</Reference>
|
||||
<Reference Include="Tyrrrz.Extensions, Version=1.5.1.0, Culture=neutral, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Tyrrrz.Extensions.1.5.1\lib\net45\Tyrrrz.Extensions.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Tyrrrz.Settings, Version=1.3.2.0, Culture=neutral, PublicKeyToken=null" />
|
||||
<Reference Include="Tyrrrz.WpfExtensions, Version=1.0.6269.37170, Culture=neutral, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Tyrrrz.WpfExtensions.1.0.5\lib\net45\Tyrrrz.WpfExtensions.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="WindowsBase" />
|
||||
<Reference Include="PresentationCore" />
|
||||
<Reference Include="PresentationFramework" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="App.xaml.cs">
|
||||
<DependentUpon>App.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Converters\ExportFormatToStringConverter.cs" />
|
||||
<Compile Include="Messages\ShowExportSetupMessage.cs" />
|
||||
<Compile Include="Messages\ShowNotificationMessage.cs" />
|
||||
<Compile Include="Messages\ShowSettingsMessage.cs" />
|
||||
<Compile Include="Messages\StartExportMessage.cs" />
|
||||
<Compile Include="ViewModels\ExportSetupViewModel.cs" />
|
||||
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
|
||||
<Compile Include="ViewModels\ISettingsViewModel.cs" />
|
||||
<Compile Include="ViewModels\SettingsViewModel.cs" />
|
||||
<Compile Include="Container.cs" />
|
||||
<Compile Include="ViewModels\IMainViewModel.cs" />
|
||||
<Compile Include="ViewModels\MainViewModel.cs" />
|
||||
<Compile Include="Views\ExportSetupDialog.xaml.cs">
|
||||
<DependentUpon>ExportSetupDialog.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Views\MainWindow.xaml.cs">
|
||||
<DependentUpon>MainWindow.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Views\SettingsDialog.xaml.cs">
|
||||
<DependentUpon>SettingsDialog.xaml</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Properties\AssemblyInfo.cs">
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Include="Properties\Resources.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DesignTime>True</DesignTime>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<EmbeddedResource Include="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<None Include="app.config" />
|
||||
<None Include="packages.config">
|
||||
<SubType>Designer</SubType>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Resource Include="..\favicon.ico" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj">
|
||||
<Project>{707c0cd0-a7e0-4cab-8db9-07a45cb87377}</Project>
|
||||
<Name>DiscordChatExporter.Core</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ApplicationDefinition Include="App.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<SubType>Designer</SubType>
|
||||
</ApplicationDefinition>
|
||||
<Page Include="Views\ExportSetupDialog.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Views\MainWindow.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Views\SettingsDialog.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup />
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Messages
|
||||
{
|
||||
public class ShowExportSetupMessage
|
||||
{
|
||||
public Guild Guild { get; }
|
||||
|
||||
public Channel Channel { get; }
|
||||
|
||||
public ShowExportSetupMessage(Guild guild, Channel channel)
|
||||
{
|
||||
Guild = guild;
|
||||
Channel = channel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Messages
|
||||
{
|
||||
public class ShowNotificationMessage
|
||||
{
|
||||
public string Message { get; }
|
||||
|
||||
public string CallbackCaption { get; }
|
||||
|
||||
public Action Callback { get; }
|
||||
|
||||
public ShowNotificationMessage(string message)
|
||||
{
|
||||
Message = message;
|
||||
}
|
||||
|
||||
public ShowNotificationMessage(string message, string callbackCaption, Action callback)
|
||||
: this(message)
|
||||
{
|
||||
CallbackCaption = callbackCaption;
|
||||
Callback = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DiscordChatExporter.Gui.Messages
|
||||
{
|
||||
public class ShowSettingsMessage
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Messages
|
||||
{
|
||||
public class StartExportMessage
|
||||
{
|
||||
public Channel Channel { get; }
|
||||
|
||||
public string FilePath { get; }
|
||||
|
||||
public ExportFormat Format { get; }
|
||||
|
||||
public DateTime? From { get; }
|
||||
|
||||
public DateTime? To { get; }
|
||||
|
||||
public StartExportMessage(Channel channel, string filePath, ExportFormat format,
|
||||
DateTime? from, DateTime? to)
|
||||
{
|
||||
Channel = channel;
|
||||
FilePath = filePath;
|
||||
Format = format;
|
||||
From = from;
|
||||
To = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: AssemblyTitle("DiscordChatExporter")]
|
||||
[assembly: AssemblyCompany("Tyrrrz")]
|
||||
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
|
||||
[assembly: AssemblyVersion("2.7")]
|
||||
[assembly: AssemblyFileVersion("2.7")]
|
||||
@@ -0,0 +1,63 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace DiscordChatExporter.Gui.Properties {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DiscordChatExporter.Gui.Properties.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using DiscordChatExporter.Gui.Messages;
|
||||
using GalaSoft.MvvmLight;
|
||||
using GalaSoft.MvvmLight.CommandWpf;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
private string _filePath;
|
||||
private ExportFormat _format;
|
||||
private DateTime? _from;
|
||||
private DateTime? _to;
|
||||
|
||||
public Guild Guild { get; private set; }
|
||||
|
||||
public Channel Channel { get; private set; }
|
||||
|
||||
public string FilePath
|
||||
{
|
||||
get => _filePath;
|
||||
set
|
||||
{
|
||||
Set(ref _filePath, value);
|
||||
ExportCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ExportFormat> AvailableFormats =>
|
||||
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
|
||||
|
||||
public ExportFormat SelectedFormat
|
||||
{
|
||||
get => _format;
|
||||
set
|
||||
{
|
||||
Set(ref _format, value);
|
||||
|
||||
// Replace extension in path
|
||||
var ext = value.GetFileExtension();
|
||||
if (FilePath != null)
|
||||
FilePath = Path.ChangeExtension(FilePath, ext);
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime? From
|
||||
{
|
||||
get => _from;
|
||||
set => Set(ref _from, value);
|
||||
}
|
||||
|
||||
public DateTime? To
|
||||
{
|
||||
get => _to;
|
||||
set => Set(ref _to, value);
|
||||
}
|
||||
|
||||
// Commands
|
||||
public RelayCommand ExportCommand { get; }
|
||||
|
||||
public ExportSetupViewModel(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
|
||||
// Commands
|
||||
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
|
||||
|
||||
// Messages
|
||||
MessengerInstance.Register<ShowExportSetupMessage>(this, m =>
|
||||
{
|
||||
Guild = m.Guild;
|
||||
Channel = m.Channel;
|
||||
SelectedFormat = _settingsService.LastExportFormat;
|
||||
FilePath = $"{Guild.Name} - {Channel.Name}.{SelectedFormat.GetFileExtension()}"
|
||||
.Replace(Path.GetInvalidFileNameChars(), '_');
|
||||
From = null;
|
||||
To = null;
|
||||
});
|
||||
}
|
||||
|
||||
private void Export()
|
||||
{
|
||||
// Save format
|
||||
_settingsService.LastExportFormat = SelectedFormat;
|
||||
|
||||
// Clamp 'from' and 'to' values
|
||||
if (From > To)
|
||||
From = To;
|
||||
if (To < From)
|
||||
To = From;
|
||||
|
||||
// Start export
|
||||
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using GalaSoft.MvvmLight.CommandWpf;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public interface IExportSetupViewModel
|
||||
{
|
||||
Guild Guild { get; }
|
||||
Channel Channel { get; }
|
||||
string FilePath { get; set; }
|
||||
IReadOnlyList<ExportFormat> AvailableFormats { get; }
|
||||
ExportFormat SelectedFormat { get; set; }
|
||||
DateTime? From { get; set; }
|
||||
DateTime? To { get; set; }
|
||||
|
||||
RelayCommand ExportCommand { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using GalaSoft.MvvmLight.CommandWpf;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public interface IMainViewModel
|
||||
{
|
||||
bool IsBusy { get; }
|
||||
bool IsDataAvailable { get; }
|
||||
|
||||
bool IsProgressIndeterminate { get; }
|
||||
double Progress { get; }
|
||||
|
||||
bool IsBotToken { get; set; }
|
||||
string TokenValue { get; set; }
|
||||
|
||||
IReadOnlyList<Guild> AvailableGuilds { get; }
|
||||
Guild SelectedGuild { get; set; }
|
||||
IReadOnlyList<Channel> AvailableChannels { get; }
|
||||
|
||||
RelayCommand ViewLoadedCommand { get; }
|
||||
RelayCommand ViewClosedCommand { get; }
|
||||
RelayCommand PullDataCommand { get; }
|
||||
RelayCommand ShowSettingsCommand { get; }
|
||||
RelayCommand ShowAboutCommand { get; }
|
||||
RelayCommand<Channel> ShowExportSetupCommand { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public interface ISettingsViewModel
|
||||
{
|
||||
bool IsAutoUpdateEnabled { get; set; }
|
||||
|
||||
string DateFormat { get; set; }
|
||||
int MessageGroupLimit { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Windows;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using DiscordChatExporter.Gui.Messages;
|
||||
using GalaSoft.MvvmLight;
|
||||
using GalaSoft.MvvmLight.CommandWpf;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public class MainViewModel : ViewModelBase, IMainViewModel
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IUpdateService _updateService;
|
||||
private readonly IDataService _dataService;
|
||||
private readonly IMessageGroupService _messageGroupService;
|
||||
private readonly IExportService _exportService;
|
||||
|
||||
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
|
||||
|
||||
private bool _isBusy;
|
||||
private double _progress;
|
||||
private bool _isBotToken;
|
||||
private string _tokenValue;
|
||||
private IReadOnlyList<Guild> _availableGuilds;
|
||||
private Guild _selectedGuild;
|
||||
private IReadOnlyList<Channel> _availableChannels;
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
Set(ref _isBusy, value);
|
||||
PullDataCommand.RaiseCanExecuteChanged();
|
||||
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
|
||||
|
||||
public bool IsProgressIndeterminate => Progress <= 0;
|
||||
|
||||
public double Progress
|
||||
{
|
||||
get => _progress;
|
||||
private set
|
||||
{
|
||||
Set(ref _progress, value);
|
||||
RaisePropertyChanged(() => IsProgressIndeterminate);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsBotToken
|
||||
{
|
||||
get => _isBotToken;
|
||||
set => Set(ref _isBotToken, value);
|
||||
}
|
||||
|
||||
public string TokenValue
|
||||
{
|
||||
get => _tokenValue;
|
||||
set
|
||||
{
|
||||
// Remove invalid chars
|
||||
value = value?.Trim('"');
|
||||
|
||||
Set(ref _tokenValue, value);
|
||||
PullDataCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Guild> AvailableGuilds
|
||||
{
|
||||
get => _availableGuilds;
|
||||
private set
|
||||
{
|
||||
Set(ref _availableGuilds, value);
|
||||
RaisePropertyChanged(() => IsDataAvailable);
|
||||
}
|
||||
}
|
||||
|
||||
public Guild SelectedGuild
|
||||
{
|
||||
get => _selectedGuild;
|
||||
set
|
||||
{
|
||||
Set(ref _selectedGuild, value);
|
||||
AvailableChannels = value != null ? _guildChannelsMap[value] : Array.Empty<Channel>();
|
||||
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Channel> AvailableChannels
|
||||
{
|
||||
get => _availableChannels;
|
||||
private set => Set(ref _availableChannels, value);
|
||||
}
|
||||
|
||||
public RelayCommand ViewLoadedCommand { get; }
|
||||
public RelayCommand ViewClosedCommand { get; }
|
||||
public RelayCommand PullDataCommand { get; }
|
||||
public RelayCommand ShowSettingsCommand { get; }
|
||||
public RelayCommand ShowAboutCommand { get; }
|
||||
public RelayCommand<Channel> ShowExportSetupCommand { get; }
|
||||
|
||||
public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService,
|
||||
IMessageGroupService messageGroupService, IExportService exportService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_updateService = updateService;
|
||||
_dataService = dataService;
|
||||
_messageGroupService = messageGroupService;
|
||||
_exportService = exportService;
|
||||
|
||||
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||
|
||||
// Commands
|
||||
ViewLoadedCommand = new RelayCommand(ViewLoaded);
|
||||
ViewClosedCommand = new RelayCommand(ViewClosed);
|
||||
PullDataCommand = new RelayCommand(PullData, () => TokenValue.IsNotBlank() && !IsBusy);
|
||||
ShowSettingsCommand = new RelayCommand(ShowSettings);
|
||||
ShowAboutCommand = new RelayCommand(ShowAbout);
|
||||
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
|
||||
|
||||
// Messages
|
||||
MessengerInstance.Register<StartExportMessage>(this,
|
||||
m => Export(m.Channel, m.FilePath, m.Format, m.From, m.To));
|
||||
}
|
||||
|
||||
private async void ViewLoaded()
|
||||
{
|
||||
// Load settings
|
||||
_settingsService.Load();
|
||||
|
||||
// Get last token
|
||||
if (_settingsService.LastToken != null)
|
||||
{
|
||||
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
|
||||
TokenValue = _settingsService.LastToken.Value;
|
||||
}
|
||||
|
||||
// Check and prepare update
|
||||
try
|
||||
{
|
||||
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
|
||||
if (updateVersion != null)
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage(
|
||||
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
|
||||
"INSTALL NOW", () =>
|
||||
{
|
||||
_updateService.NeedRestart = true;
|
||||
Application.Current.Shutdown();
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage("Failed to perform application auto-update"));
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewClosed()
|
||||
{
|
||||
// Save settings
|
||||
_settingsService.Save();
|
||||
|
||||
// Finalize updates if available
|
||||
_updateService.FinalizeUpdate();
|
||||
}
|
||||
|
||||
private async void PullData()
|
||||
{
|
||||
IsBusy = true;
|
||||
|
||||
// Create token
|
||||
var token = new AuthToken(
|
||||
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
|
||||
TokenValue);
|
||||
|
||||
// Save token
|
||||
_settingsService.LastToken = token;
|
||||
|
||||
// Clear existing
|
||||
_guildChannelsMap.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
// Get DM channels
|
||||
{
|
||||
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
|
||||
var guild = Guild.DirectMessages;
|
||||
_guildChannelsMap[guild] = channels.OrderBy(c => c.Name).ToArray();
|
||||
}
|
||||
|
||||
// Get guild channels
|
||||
{
|
||||
var guilds = await _dataService.GetUserGuildsAsync(token);
|
||||
foreach (var guild in guilds)
|
||||
{
|
||||
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
|
||||
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage("Unauthorized – make sure the token is valid"));
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage("Forbidden – account may be locked by 2FA"));
|
||||
}
|
||||
|
||||
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
|
||||
SelectedGuild = AvailableGuilds.FirstOrDefault();
|
||||
IsBusy = false;
|
||||
}
|
||||
|
||||
private void ShowSettings()
|
||||
{
|
||||
MessengerInstance.Send(new ShowSettingsMessage());
|
||||
}
|
||||
|
||||
private void ShowAbout()
|
||||
{
|
||||
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
|
||||
}
|
||||
|
||||
private void ShowExportSetup(Channel channel)
|
||||
{
|
||||
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
|
||||
}
|
||||
|
||||
private async void Export(Channel channel, string filePath, ExportFormat format, DateTime? from, DateTime? to)
|
||||
{
|
||||
IsBusy = true;
|
||||
|
||||
// Get last used token
|
||||
var token = _settingsService.LastToken;
|
||||
|
||||
// Get guild
|
||||
var guild = SelectedGuild;
|
||||
|
||||
// Create progress handler
|
||||
var progressHandler = new Progress<double>(p => Progress = p);
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: extract this to make it reusable across implementations
|
||||
// Get messages
|
||||
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to, progressHandler);
|
||||
|
||||
// Group messages
|
||||
var messageGroups = _messageGroupService.GroupMessages(messages);
|
||||
|
||||
// Get mentionables
|
||||
var mentionables = await _dataService.GetMentionablesAsync(token, guild.Id, messages);
|
||||
|
||||
// Create log
|
||||
var log = new ChatLog(guild, channel, from, to, messageGroups, mentionables);
|
||||
|
||||
// Export
|
||||
_exportService.Export(format, filePath, log);
|
||||
|
||||
// Open
|
||||
Process.Start(filePath);
|
||||
|
||||
// Notify completion
|
||||
MessengerInstance.Send(new ShowNotificationMessage("Export complete"));
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage("This channel doesn't exist"));
|
||||
}
|
||||
|
||||
Progress = 0;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using GalaSoft.MvvmLight;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public class SettingsViewModel : ViewModelBase, ISettingsViewModel
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public bool IsAutoUpdateEnabled
|
||||
{
|
||||
get => _settingsService.IsAutoUpdateEnabled;
|
||||
set => _settingsService.IsAutoUpdateEnabled = value;
|
||||
}
|
||||
|
||||
public string DateFormat
|
||||
{
|
||||
get => _settingsService.DateFormat;
|
||||
set => _settingsService.DateFormat = value;
|
||||
}
|
||||
|
||||
public int MessageGroupLimit
|
||||
{
|
||||
get => _settingsService.MessageGroupLimit;
|
||||
set => _settingsService.MessageGroupLimit = value.ClampMin(0);
|
||||
}
|
||||
|
||||
public SettingsViewModel(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<UserControl
|
||||
x:Class="DiscordChatExporter.Gui.Views.ExportSetupDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
Width="325"
|
||||
DataContext="{Binding ExportSetupViewModel, Source={StaticResource Container}}">
|
||||
<StackPanel>
|
||||
<!-- File path -->
|
||||
<TextBox
|
||||
Margin="16,16,16,8"
|
||||
materialDesign:HintAssist.Hint="Output file"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsReadOnly="True"
|
||||
Text="{Binding FilePath, UpdateSourceTrigger=PropertyChanged}" />
|
||||
|
||||
<!-- Format -->
|
||||
<ComboBox
|
||||
Margin="16,8,16,8"
|
||||
materialDesign:HintAssist.Hint="Export format"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsReadOnly="True"
|
||||
ItemsSource="{Binding AvailableFormats}"
|
||||
SelectedItem="{Binding SelectedFormat}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource ExportFormatToStringConverter}}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!-- Date limits -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<DatePicker
|
||||
x:Name="FromDatePicker"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="16,20,8,8"
|
||||
materialDesign:HintAssist.Hint="From (optional)"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
DisplayDateEnd="{Binding SelectedDate, ElementName=ToDatePicker}"
|
||||
SelectedDate="{Binding From}" />
|
||||
<DatePicker
|
||||
x:Name="ToDatePicker"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Margin="8,20,16,8"
|
||||
materialDesign:HintAssist.Hint="To (optional)"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
DisplayDateStart="{Binding SelectedDate, ElementName=FromDatePicker}"
|
||||
SelectedDate="{Binding To}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Buttons -->
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
||||
<Button
|
||||
x:Name="BrowseButton"
|
||||
Margin="8"
|
||||
Click="BrowseButton_Click"
|
||||
Content="BROWSE"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||
<Button
|
||||
x:Name="ExportButton"
|
||||
Margin="8"
|
||||
Click="ExportButton_Click"
|
||||
Command="{Binding ExportCommand}"
|
||||
Content="EXPORT"
|
||||
IsDefault="True"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||
<Button
|
||||
Margin="8"
|
||||
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
|
||||
Content="CANCEL"
|
||||
IsCancel="True"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Windows;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Gui.ViewModels;
|
||||
using MaterialDesignThemes.Wpf;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Views
|
||||
{
|
||||
public partial class ExportSetupDialog
|
||||
{
|
||||
private IExportSetupViewModel ViewModel => (IExportSetupViewModel)DataContext;
|
||||
|
||||
public ExportSetupDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void BrowseButton_Click(object sender, RoutedEventArgs args)
|
||||
{
|
||||
// Get file extension of the selected format
|
||||
var ext = ViewModel.SelectedFormat.GetFileExtension();
|
||||
|
||||
// Open dialog
|
||||
var sfd = new SaveFileDialog
|
||||
{
|
||||
FileName = ViewModel.FilePath,
|
||||
Filter = $"{ext.ToUpperInvariant()} Files|*.{ext}|All Files|*.*",
|
||||
AddExtension = true,
|
||||
Title = "Select output file"
|
||||
};
|
||||
|
||||
// Assign new file path if dialog was successful
|
||||
if (sfd.ShowDialog() == true)
|
||||
{
|
||||
ViewModel.FilePath = sfd.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
public void ExportButton_Click(object sender, RoutedEventArgs args)
|
||||
{
|
||||
DialogHost.CloseDialogCommand.Execute(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<Window
|
||||
x:Class="DiscordChatExporter.Gui.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
Title="DiscordChatExporter"
|
||||
Width="600"
|
||||
Height="550"
|
||||
Background="{DynamicResource MaterialDesignPaper}"
|
||||
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
|
||||
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
|
||||
FontFamily="{DynamicResource MaterialDesignFont}"
|
||||
Icon="/DiscordChatExporter;component/favicon.ico"
|
||||
SnapsToDevicePixels="True"
|
||||
TextElement.FontSize="13"
|
||||
TextElement.FontWeight="Regular"
|
||||
TextElement.Foreground="{DynamicResource SecondaryTextBrush}"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
TextOptions.TextRenderingMode="Auto"
|
||||
UseLayoutRounding="True"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<i:Interaction.Triggers>
|
||||
<i:EventTrigger EventName="Loaded">
|
||||
<i:InvokeCommandAction Command="{Binding ViewLoadedCommand}" />
|
||||
</i:EventTrigger>
|
||||
<i:EventTrigger EventName="Closed">
|
||||
<i:InvokeCommandAction Command="{Binding ViewClosedCommand}" />
|
||||
</i:EventTrigger>
|
||||
</i:Interaction.Triggers>
|
||||
<materialDesign:DialogHost SnackbarMessageQueue="{Binding ElementName=Snackbar}">
|
||||
<DockPanel>
|
||||
<!-- Toolbar -->
|
||||
<Border
|
||||
Background="{DynamicResource PrimaryHueMidBrush}"
|
||||
DockPanel.Dock="Top"
|
||||
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
|
||||
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<materialDesign:Card
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="6,6,0,6">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Token type -->
|
||||
<ToggleButton
|
||||
Grid.Column="0"
|
||||
Margin="6"
|
||||
IsChecked="{Binding IsBotToken}"
|
||||
Style="{StaticResource MaterialDesignFlatActionToggleButton}"
|
||||
ToolTip="Switch between user token and bot token">
|
||||
<ToggleButton.Content>
|
||||
<materialDesign:PackIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
Kind="Account" />
|
||||
</ToggleButton.Content>
|
||||
<materialDesign:ToggleButtonAssist.OnContent>
|
||||
<materialDesign:PackIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
Kind="Robot" />
|
||||
</materialDesign:ToggleButtonAssist.OnContent>
|
||||
</ToggleButton>
|
||||
|
||||
<!-- Token value -->
|
||||
<TextBox
|
||||
x:Name="TokenValueTextBox"
|
||||
Grid.Column="1"
|
||||
Margin="2,6,6,7"
|
||||
materialDesign:HintAssist.Hint="Token"
|
||||
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
||||
BorderThickness="0"
|
||||
FontSize="16"
|
||||
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
|
||||
|
||||
<!-- Pull data button -->
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Margin="0,6,6,6"
|
||||
Padding="4"
|
||||
Command="{Binding PullDataCommand}"
|
||||
IsDefault="True"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}">
|
||||
<materialDesign:PackIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
Kind="ArrowRight" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</materialDesign:Card>
|
||||
|
||||
<!-- Menu -->
|
||||
<materialDesign:PopupBox
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Foreground="{DynamicResource PrimaryHueMidForegroundBrush}"
|
||||
PlacementMode="LeftAndAlignTopEdges">
|
||||
<StackPanel>
|
||||
<Button Command="{Binding ShowSettingsCommand}" Content="Settings" />
|
||||
<Button Command="{Binding ShowAboutCommand}" Content="About" />
|
||||
</StackPanel>
|
||||
</materialDesign:PopupBox>
|
||||
</Grid>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<ProgressBar
|
||||
Background="Transparent"
|
||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
|
||||
Value="{Binding Progress, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<Grid>
|
||||
<DockPanel
|
||||
Background="{DynamicResource MaterialDesignCardBackground}"
|
||||
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
|
||||
Visibility="{Binding IsDataAvailable, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
|
||||
<!-- Guilds -->
|
||||
<Border
|
||||
BorderBrush="{DynamicResource DividerBrush}"
|
||||
BorderThickness="0,0,1,0"
|
||||
DockPanel.Dock="Left">
|
||||
<ListBox
|
||||
ItemsSource="{Binding AvailableGuilds}"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
||||
SelectedItem="{Binding SelectedGuild}"
|
||||
VirtualizingStackPanel.IsVirtualizing="False">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<materialDesign:TransitioningContent>
|
||||
<materialDesign:TransitioningContent.OpeningEffect>
|
||||
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
|
||||
</materialDesign:TransitioningContent.OpeningEffect>
|
||||
<Grid
|
||||
Margin="-8"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
ToolTip="{Binding Name}">
|
||||
<!-- Guild icon placeholder -->
|
||||
<Ellipse
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="12,4,12,4"
|
||||
Fill="{DynamicResource DividerBrush}" />
|
||||
|
||||
<!-- Guild icon -->
|
||||
<Ellipse
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="12,4,12,4">
|
||||
<Ellipse.Fill>
|
||||
<ImageBrush ImageSource="{Binding IconUrl}" />
|
||||
</Ellipse.Fill>
|
||||
</Ellipse>
|
||||
</Grid>
|
||||
</materialDesign:TransitioningContent>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- Channels -->
|
||||
<Border>
|
||||
<ListBox
|
||||
HorizontalContentAlignment="Stretch"
|
||||
ItemsSource="{Binding AvailableChannels}"
|
||||
VirtualizingStackPanel.IsVirtualizing="False">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<materialDesign:TransitioningContent>
|
||||
<materialDesign:TransitioningContent.OpeningEffect>
|
||||
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
|
||||
</materialDesign:TransitioningContent.OpeningEffect>
|
||||
<StackPanel
|
||||
Margin="-8"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
Orientation="Horizontal">
|
||||
<StackPanel.InputBindings>
|
||||
<MouseBinding
|
||||
Command="{Binding DataContext.ShowExportSetupCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}}"
|
||||
CommandParameter="{Binding}"
|
||||
MouseAction="LeftClick" />
|
||||
</StackPanel.InputBindings>
|
||||
<materialDesign:PackIcon
|
||||
Margin="16,7,0,6"
|
||||
VerticalAlignment="Center"
|
||||
Kind="Pound" />
|
||||
<TextBlock
|
||||
Margin="3,8,8,8"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Text="{Binding Name}" />
|
||||
</StackPanel>
|
||||
</materialDesign:TransitioningContent>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Usage instructions -->
|
||||
<Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||
<!-- User token -->
|
||||
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||
<TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work." />
|
||||
<TextBlock
|
||||
Margin="0,8,0,0"
|
||||
FontSize="16"
|
||||
Text="To obtain it, follow these steps:" />
|
||||
<TextBlock Margin="8,0,0,0" FontSize="14">
|
||||
<Run Text="1. Open the Discord app" />
|
||||
<LineBreak />
|
||||
<Run Text="2. Log in if you haven't" />
|
||||
<LineBreak />
|
||||
<Run Text="3. Press" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
||||
<Run Text="to show developer tools" />
|
||||
<LineBreak />
|
||||
<Run Text="4. Press" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
|
||||
<Run Text="to trigger reload" />
|
||||
<LineBreak />
|
||||
<Run Text="5. Navigate to the" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
||||
<Run Text="tab" />
|
||||
<LineBreak />
|
||||
<Run Text="6. Select" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
|
||||
<Run Text=">" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discordapp.com" />
|
||||
<Run Text="on the left" />
|
||||
<LineBreak />
|
||||
<Run Text="7. Find" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
|
||||
<Run Text="under key and copy the value" />
|
||||
</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" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<materialDesign:Snackbar x:Name="Snackbar" />
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</materialDesign:DialogHost>
|
||||
</Window>
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using DiscordChatExporter.Gui.Messages;
|
||||
using GalaSoft.MvvmLight.Messaging;
|
||||
using MaterialDesignThemes.Wpf;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Views
|
||||
{
|
||||
public partial class MainWindow
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
|
||||
|
||||
Snackbar.MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Notification messages
|
||||
Messenger.Default.Register<ShowNotificationMessage>(this, m =>
|
||||
{
|
||||
if (m.CallbackCaption != null && m.Callback != null)
|
||||
Snackbar.MessageQueue.Enqueue(m.Message, m.CallbackCaption, m.Callback);
|
||||
else
|
||||
Snackbar.MessageQueue.Enqueue(m.Message);
|
||||
});
|
||||
|
||||
// Dialog messages
|
||||
Messenger.Default.Register<ShowExportSetupMessage>(this,
|
||||
m => DialogHost.Show(new ExportSetupDialog()).Forget());
|
||||
Messenger.Default.Register<ShowSettingsMessage>(this,
|
||||
m => DialogHost.Show(new SettingsDialog()).Forget());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<UserControl
|
||||
x:Class="DiscordChatExporter.Gui.Views.SettingsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
Width="250"
|
||||
DataContext="{Binding SettingsViewModel, Source={StaticResource Container}}">
|
||||
<StackPanel>
|
||||
<!-- Date format -->
|
||||
<TextBox
|
||||
Margin="16,16,16,8"
|
||||
materialDesign:HintAssist.Hint="Date format"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
Text="{Binding DateFormat}" />
|
||||
|
||||
<!-- Message group limit -->
|
||||
<TextBox
|
||||
Margin="16,8,16,8"
|
||||
materialDesign:HintAssist.Hint="Message group limit"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
Text="{Binding MessageGroupLimit}" />
|
||||
|
||||
<!-- Auto-updates -->
|
||||
<DockPanel LastChildFill="False">
|
||||
<TextBlock
|
||||
Margin="16,8,16,8"
|
||||
DockPanel.Dock="Left"
|
||||
Text="Auto-updates" />
|
||||
<ToggleButton
|
||||
Margin="16,8,16,8"
|
||||
DockPanel.Dock="Right"
|
||||
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Save button -->
|
||||
<Button
|
||||
Margin="8"
|
||||
HorizontalAlignment="Right"
|
||||
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
|
||||
Content="SAVE"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DiscordChatExporter.Gui.Views
|
||||
{
|
||||
public partial class SettingsDialog
|
||||
{
|
||||
public SettingsDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="CommonServiceLocator" publicKeyToken="489b6accfaf20ef0" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.0.3.0" newVersion="2.0.3.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="CommonServiceLocator" version="2.0.3" targetFramework="net461" />
|
||||
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
|
||||
<package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" />
|
||||
<package id="MvvmLightLibs" version="5.4.1" targetFramework="net461" />
|
||||
<package id="Tyrrrz.Extensions" version="1.5.1" targetFramework="net461" />
|
||||
<package id="Tyrrrz.WpfExtensions" version="1.0.5" targetFramework="net461" />
|
||||
</packages>
|
||||
+23
-7
@@ -1,28 +1,44 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 15
|
||||
VisualStudioVersion = 15.0.26430.13
|
||||
VisualStudioVersion = 15.0.27130.2026
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{4BE915D1-129C-49E2-860E-62045ACA5EAD}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
Changelog.md = Changelog.md
|
||||
License.txt = License.txt
|
||||
Readme.md = Readme.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core", "DiscordChatExporter.Core\DiscordChatExporter.Core.csproj", "{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {51587D08-01E1-4511-AC57-A417D1A9162F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net45</TargetFramework>
|
||||
<AssemblyVersion>1.0.0</AssemblyVersion>
|
||||
<FileVersion>$(AssemblyVersion)</FileVersion>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) 2017 Alexey Golub</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Services\ExportTemplate.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.5.1" />
|
||||
<PackageReference Include="Newtonsoft.json" Version="10.0.3" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class Attachment
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public bool IsImage { get; }
|
||||
|
||||
public Attachment(string id, string url, string fileName, bool isImage)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
FileName = fileName;
|
||||
IsImage = isImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class ChatLog
|
||||
{
|
||||
public string ChannelId { get; }
|
||||
|
||||
public IReadOnlyList<User> Participants { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public ChatLog(string channelId, IEnumerable<Message> messages)
|
||||
{
|
||||
ChannelId = channelId;
|
||||
Messages = messages.ToArray();
|
||||
Participants = Messages.Select(m => m.Author).Distinct(a => a.Name).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class Message
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public DateTime TimeStamp { get; }
|
||||
|
||||
public DateTime? EditedTimeStamp { get; }
|
||||
|
||||
public User Author { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
public IReadOnlyList<Attachment> Attachments { get; }
|
||||
|
||||
public Message(string id, DateTime timeStamp, DateTime? editedTimeStamp, User author, string content, IEnumerable<Attachment> attachments)
|
||||
{
|
||||
Id = id;
|
||||
TimeStamp = timeStamp;
|
||||
EditedTimeStamp = editedTimeStamp;
|
||||
Author = author;
|
||||
Content = content;
|
||||
Attachments = attachments.ToArray();
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class MessageGroup
|
||||
{
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime FirstTimeStamp { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable<Message> messages)
|
||||
{
|
||||
Author = author;
|
||||
FirstTimeStamp = firstTimeStamp;
|
||||
Messages = messages.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class Options
|
||||
{
|
||||
public string Token { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public Options(string token, string channelId)
|
||||
{
|
||||
Token = token;
|
||||
ChannelId = channelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string AvatarHash { get; }
|
||||
|
||||
public string AvatarUrl => AvatarHash.IsNotBlank()
|
||||
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size=256"
|
||||
: "https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.png";
|
||||
|
||||
public User(string id, string name, string avatarHash)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
AvatarHash = avatarHash;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Models;
|
||||
using DiscordChatExporter.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
private static readonly DiscordApiService DiscordApiService = new DiscordApiService();
|
||||
private static readonly ExportService ExportService = new ExportService();
|
||||
|
||||
private static Options GetOptions(string[] args)
|
||||
{
|
||||
// Parse the arguments
|
||||
var argsDic = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string arg in args)
|
||||
{
|
||||
var match = Regex.Match(arg, "/(.*?):\"?(.*?)\"?$");
|
||||
string key = match.Groups[1].Value;
|
||||
string value = match.Groups[2].Value;
|
||||
|
||||
if (key.IsBlank())
|
||||
continue;
|
||||
|
||||
argsDic[key] = value;
|
||||
}
|
||||
|
||||
// Extract required arguments
|
||||
string token = argsDic.GetOrDefault("token");
|
||||
string channelId = argsDic.GetOrDefault("channelId");
|
||||
|
||||
// Verify arguments
|
||||
if (token.IsBlank() || channelId.IsBlank())
|
||||
throw new ArgumentException("Some or all required command line arguments are missing");
|
||||
|
||||
// Create option set
|
||||
return new Options(token, channelId);
|
||||
}
|
||||
|
||||
private static async Task MainAsync(string[] args)
|
||||
{
|
||||
// Parse cmd args
|
||||
var options = GetOptions(args);
|
||||
|
||||
// Get messages
|
||||
Console.WriteLine("Getting messages...");
|
||||
var messages = await DiscordApiService.GetMessagesAsync(options.Token, options.ChannelId);
|
||||
var chatLog = new ChatLog(options.ChannelId, messages);
|
||||
|
||||
// Export
|
||||
Console.WriteLine("Exporting messages...");
|
||||
ExportService.Export($"{options.ChannelId}.html", chatLog);
|
||||
}
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Console.Title = "Discord Chat Exporter";
|
||||
|
||||
MainAsync(args).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Services
|
||||
{
|
||||
public class DiscordApiService
|
||||
{
|
||||
private const string ApiRoot = "https://discordapp.com/api";
|
||||
private readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
private IEnumerable<Message> ParseMessages(string json)
|
||||
{
|
||||
var messagesJson = JArray.Parse(json);
|
||||
foreach (var messageJson in messagesJson)
|
||||
{
|
||||
// Get basic data
|
||||
string id = messageJson.Value<string>("id");
|
||||
var timeStamp = messageJson.Value<DateTime>("timestamp");
|
||||
var editedTimeStamp = messageJson.Value<DateTime?>("edited_timestamp");
|
||||
string content = messageJson.Value<string>("content");
|
||||
|
||||
// Lazy workaround for calls
|
||||
if (messageJson["call"] != null)
|
||||
content = "Started a call.";
|
||||
|
||||
// Get author
|
||||
var authorJson = messageJson["author"];
|
||||
string authorId = authorJson.Value<string>("id");
|
||||
string authorName = authorJson.Value<string>("username");
|
||||
string authorAvatarHash = authorJson.Value<string>("avatar");
|
||||
|
||||
// Get attachment
|
||||
var attachmentsJson = messageJson["attachments"];
|
||||
var attachments = new List<Attachment>();
|
||||
foreach (var attachmentJson in attachmentsJson)
|
||||
{
|
||||
string attachmentId = attachmentJson.Value<string>("id");
|
||||
string attachmentUrl = attachmentJson.Value<string>("url");
|
||||
string attachmentFileName = attachmentJson.Value<string>("filename");
|
||||
bool attachmentIsImage = attachmentJson["width"] != null;
|
||||
|
||||
var attachment = new Attachment(attachmentId, attachmentUrl, attachmentFileName, attachmentIsImage);
|
||||
attachments.Add(attachment);
|
||||
}
|
||||
|
||||
var author = new User(authorId, authorName, authorAvatarHash);
|
||||
var message = new Message(id, timeStamp, editedTimeStamp, author, content, attachments);
|
||||
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Message>> GetMessagesAsync(string token, string channelId)
|
||||
{
|
||||
var result = new List<Message>();
|
||||
|
||||
// We are going backwards from last message to first
|
||||
// ...collecting everything between them in batches
|
||||
string beforeId = null;
|
||||
while (true)
|
||||
{
|
||||
// Form request url
|
||||
string url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
|
||||
if (beforeId.IsNotBlank())
|
||||
url += $"&before={beforeId}";
|
||||
|
||||
// Get response
|
||||
string response = await _httpClient.GetStringAsync(url);
|
||||
|
||||
// Parse
|
||||
var messages = ParseMessages(response);
|
||||
|
||||
// Add messages to list
|
||||
string currentMessageId = null;
|
||||
foreach (var message in messages)
|
||||
{
|
||||
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
|
||||
result.Reverse();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Models;
|
||||
using HtmlAgilityPack;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Services
|
||||
{
|
||||
public class ExportService
|
||||
{
|
||||
private HtmlDocument GetTemplate()
|
||||
{
|
||||
const string templateName = "DiscordChatExporter.Services.ExportTemplate.html";
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using (var stream = assembly.GetManifestResourceStream(templateName))
|
||||
{
|
||||
var doc = new HtmlDocument();
|
||||
doc.Load(stream);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
||||
{
|
||||
var result = new List<MessageGroup>();
|
||||
|
||||
// Group adjacent messages by timestamp and author
|
||||
var groupBuffer = new List<Message>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var groupFirst = groupBuffer.FirstOrDefault();
|
||||
|
||||
// Group break condition
|
||||
bool breakCondition =
|
||||
groupFirst != null &&
|
||||
(
|
||||
message.Author.Id != groupFirst.Author.Id ||
|
||||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
|
||||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour
|
||||
);
|
||||
|
||||
// If condition is true - flush buffer
|
||||
if (breakCondition)
|
||||
{
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
|
||||
result.Add(group);
|
||||
groupBuffer.Clear();
|
||||
}
|
||||
|
||||
// Add message to buffer
|
||||
groupBuffer.Add(message);
|
||||
}
|
||||
|
||||
// Add what's remaining in buffer
|
||||
if (groupBuffer.Any())
|
||||
{
|
||||
var groupFirst = groupBuffer.First();
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
|
||||
result.Add(group);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string FormatMessageContent(string content)
|
||||
{
|
||||
// Encode HTML
|
||||
content = HtmlDocument.HtmlEncode(content);
|
||||
|
||||
// Links from URLs
|
||||
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
|
||||
"<a href=\"$1\">$1</a>");
|
||||
|
||||
// Preformatted multiline
|
||||
content = Regex.Replace(content, "```([^`]*?)```",
|
||||
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
|
||||
|
||||
// Preformatted inline
|
||||
content = Regex.Replace(content, "`([^`]*?)`",
|
||||
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
|
||||
|
||||
// Bold
|
||||
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
|
||||
|
||||
// Italic
|
||||
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
|
||||
|
||||
// Underline
|
||||
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
|
||||
|
||||
// Strike through
|
||||
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
|
||||
|
||||
// New lines
|
||||
content = content.Replace("\n", "</br>");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
public void Export(string filePath, ChatLog chatLog)
|
||||
{
|
||||
var doc = GetTemplate();
|
||||
|
||||
// Info
|
||||
var infoHtml = doc.GetElementbyId("info");
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
|
||||
string participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Participants: <b>{participants}</b></div>"));
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Messages: <b>{chatLog.Messages.Count:N0}</b></div>"));
|
||||
|
||||
// Log
|
||||
var logHtml = doc.GetElementbyId("log");
|
||||
var messageGroups = GroupMessages(chatLog.Messages);
|
||||
foreach (var messageGroup in messageGroups)
|
||||
{
|
||||
// Container
|
||||
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
|
||||
|
||||
// Avatar
|
||||
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-avatar\">" +
|
||||
$"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />" +
|
||||
"</div>"));
|
||||
|
||||
// Body
|
||||
var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-body\"></div>"));
|
||||
|
||||
// Author
|
||||
string authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
|
||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
|
||||
|
||||
// Date
|
||||
string timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
|
||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
|
||||
|
||||
// Individual messages
|
||||
foreach (var message in messageGroup.Messages)
|
||||
{
|
||||
// Content
|
||||
if (message.Content.IsNotBlank())
|
||||
{
|
||||
string content = FormatMessageContent(message.Content);
|
||||
var contentHtml =
|
||||
messageBodyHtml.AppendChild(
|
||||
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
|
||||
|
||||
// Edited timestamp
|
||||
if (message.EditedTimeStamp != null)
|
||||
{
|
||||
contentHtml.AppendChild(
|
||||
HtmlNode.CreateNode(
|
||||
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp:g}\">(edited)</span>"));
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
if (attachment.IsImage)
|
||||
{
|
||||
messageBodyHtml.AppendChild(
|
||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
||||
$"<a href=\"{attachment.Url}\">" +
|
||||
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
|
||||
"</a>" +
|
||||
"</div>"));
|
||||
}
|
||||
else
|
||||
{
|
||||
messageBodyHtml.AppendChild(
|
||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
||||
$"<a href=\"{attachment.Url}\">" +
|
||||
$"Attachment: {attachment.FileName}" +
|
||||
"</a>" +
|
||||
"</div>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.Save(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Discord Chat Log</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
color: #37bcf7;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div.pre, span.pre {
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
|
||||
padding-right: 2px;
|
||||
padding-left: 2px;
|
||||
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
div#info {
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
|
||||
color: #939799;
|
||||
}
|
||||
|
||||
div#log {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
div.msg {
|
||||
display: flex;
|
||||
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
|
||||
border-top: 1px solid #eceeef;
|
||||
}
|
||||
|
||||
div.msg-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
img.msg-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
div.msg-body {
|
||||
margin-left: 15px;
|
||||
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
span.msg-user {
|
||||
font-size: 1.15em;
|
||||
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
span.msg-date {
|
||||
font-size: .8em;
|
||||
font-weight: 200;
|
||||
|
||||
margin-left: 5px;
|
||||
|
||||
color: #b7bcbf;
|
||||
}
|
||||
|
||||
span.msg-edited {
|
||||
font-size: .8em;
|
||||
font-weight: 200;
|
||||
|
||||
margin-left: 5px;
|
||||
|
||||
color: #b7bcbf;
|
||||
}
|
||||
|
||||
div.msg-content {
|
||||
padding-top: 5px;
|
||||
|
||||
color: #939799;
|
||||
}
|
||||
|
||||
div.msg-attachment {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
img.msg-attachment {
|
||||
max-width: 50%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="info"></div>
|
||||
<div id="log"></div>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user