Compare commits

...

34 Commits

Author SHA1 Message Date
Alexey Golub b518fb2f59 Update version 2018-11-03 23:51:19 +02:00
Alexey Golub 94165dcd8d Don't render the template in memory to avoid out of memory exceptions
Fixes #88
2018-11-03 23:04:26 +02:00
Alexey Golub d6507598fa Fix not being able to reset partition limit in GUI 2018-11-03 23:01:58 +02:00
Alexey Golub a0359b1e43 Split output file into multiple partitions (#116) 2018-11-03 22:53:20 +02:00
Oleksii Holub aa53cecd4e Don't open exported file on completion
Fixes #85
2018-11-02 17:12:17 +02:00
Oleksii Holub 4144911208 Remove autoupdate feature from CLI entirely 2018-11-02 16:24:06 +02:00
Oleksii Holub d28e81f8dc Don't replace new lines in csv
Part of #102
2018-11-01 18:33:29 +02:00
Oleksii Holub 761cb032d7 Don't group messages in PlainText export
Part of #102
2018-11-01 18:27:41 +02:00
Oleksii Holub 47f0561c71 Refactor message grouping from data layer to render layer 2018-11-01 18:16:23 +02:00
Oleksii Holub 23116b776b Show unhandled exceptions to users 2018-11-01 14:43:21 +02:00
Oleksii Holub ba27a9f909 Rename ExportService.Export to ExportChatLog for consistency 2018-10-31 17:54:13 +02:00
Oleksii Holub 52684c264f Change order of parameters in ExportService.Export 2018-10-31 17:35:28 +02:00
Oleksii Holub bcad5b4ed1 Set ChatLog.TotalMessageCount from ChatLogService 2018-10-31 17:34:57 +02:00
Alexey Golub 95a4217ab3 Refactor resolving chat log to a separate service to allow reusability across CLI and GUI 2018-10-30 22:52:28 +02:00
Alexey Golub 4e8fb80ac5 Use PackageReference 2018-10-28 15:25:22 +02:00
Alexey Golub 7ae560dde0 Fix changelog 2018-09-14 00:36:31 +03:00
Alexey Golub 97a36028b9 Update version 2018-09-14 00:34:18 +03:00
Alexey Golub f0d99676a3 Disable UpdateAppOptions in CLI 2018-09-14 00:25:21 +03:00
Alexey Golub 8e38adae7e Update instructions to obtain token
Fixes #76
2018-09-14 00:23:23 +03:00
Alexey Golub d95bc37592 Link to dockerhub from readme 2018-09-13 23:32:09 +03:00
Hyeon Kim 3f6354053c Dockerize the application (#92) 2018-09-13 22:50:15 +03:00
Alexey Golub a9bab60ba6 Update instructions to obtain token 2018-08-14 00:49:32 +03:00
Alexey Golub 614bd8590d Generate file name if given output path CLI argument is a directory
Closes #67
2018-08-13 23:25:06 +03:00
Alexey Golub bd9dc6455f Refactor CLI (#81) 2018-08-13 22:49:13 +03:00
Alexey Golub 0faa427970 Invert if condition in authentication header 2018-08-03 00:13:44 +03:00
Alexey Golub dd9da4831e Change how user token is sent in requests 2018-08-03 00:00:51 +03:00
Oleksii Holub 0d816cee5d Update readme 2018-07-26 18:25:47 +03:00
Oleksii Holub f63ea41188 Update version 2018-07-25 11:53:05 +03:00
Alexey Golub 3572a21aad Allow using bot token instead of user token (#70) 2018-07-24 22:47:45 +03:00
Alexey Golub 37ee0b8be3 Cleanup templates and change how model is referenced 2018-07-03 22:58:46 +03:00
trainboy2019 ed146bac22 Add support for animated avatars (#66) 2018-07-02 21:12:32 +03:00
Alexey Golub b9c449709f Update version 2018-07-01 21:12:06 +03:00
Alexey Golub 25207c5093 Improve the templates a bit 2018-07-01 21:04:03 +03:00
Alexey Golub 4fbec95208 Fix CSV and PlainText outputs not rendering timestamps correctly
Fixes #65
2018-07-01 16:53:33 +03:00
53 changed files with 1003 additions and 656 deletions
+27
View File
@@ -1,3 +1,30 @@
### v2.8 (03-Nov-2018)
- Added support for partitioning which lets you split the output into multiple files by setting the partition message limit (`-p` parameter for CLI).
- Exported file will no longer open automatically on completion.
- Reduced amount of memory used during exporting.
- Disabled message grouping in PlainText export.
- Improved encoding of newlines in CSV export.
- Improved error messages in the GUI app during crashes.
### 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), `dm` (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, the file name will be automatically generated if the provided output file path is a directory.
### 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) ### v2.5 (28-Jun-2018)
- Added support for embeds and webhooks (HTML only). - Added support for embeds and webhooks (HTML only).
+1 -1
View File
@@ -8,4 +8,4 @@ $files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporte
# CLI # CLI
$files = @() $files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config" $files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.Cli.zip" -Force $files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
-24
View File
@@ -1,24 +0,0 @@
using System;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli
{
public class CliOptions
{
public string Token { get; set; }
public string ChannelId { get; set; }
public ExportFormat ExportFormat { get; set; }
public string FilePath { get; set; }
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public string DateFormat { get; set; }
public int MessageGroupLimit { get; set; }
}
}
+4 -15
View File
@@ -1,5 +1,4 @@
using CommonServiceLocator; using CommonServiceLocator;
using DiscordChatExporter.Cli.ViewModels;
using DiscordChatExporter.Core.Services; using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight.Ioc; using GalaSoft.MvvmLight.Ioc;
@@ -7,15 +6,7 @@ namespace DiscordChatExporter.Cli
{ {
public class Container public class Container
{ {
public IMainViewModel MainViewModel => Resolve<IMainViewModel>(); public Container()
public ISettingsService SettingsService => Resolve<ISettingsService>();
private T Resolve<T>(string key = null)
{
return ServiceLocator.Current.GetInstance<T>(key);
}
public void Init()
{ {
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default); ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset(); SimpleIoc.Default.Reset();
@@ -23,15 +14,13 @@ namespace DiscordChatExporter.Cli
// Services // Services
SimpleIoc.Default.Register<IDataService, DataService>(); SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>(); SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>(); SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
// View models
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
} }
public void Cleanup() public T Resolve<T>(string key = null)
{ {
return ServiceLocator.Current.GetInstance<T>(key);
} }
} }
} }
@@ -3,15 +3,15 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework> <TargetFramework>net461</TargetFramework>
<Version>2.5</Version> <Version>2.8</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright> <Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.2.1" />
<PackageReference Include="CommonServiceLocator" Version="2.0.3" /> <PackageReference Include="CommonServiceLocator" Version="2.0.3" />
<PackageReference Include="FluentCommandLineParser" Version="1.4.3" />
<PackageReference Include="MvvmLightLibs" Version="5.4.1" /> <PackageReference Include="MvvmLightLibs" Version="5.4.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
</ItemGroup> </ItemGroup>
+50 -90
View File
@@ -1,111 +1,71 @@
using System; using System;
using System.Reflection; using System.Linq;
using DiscordChatExporter.Core.Models; using CommandLine;
using Fclp; using DiscordChatExporter.Cli.Verbs;
using Tyrrrz.Extensions; using DiscordChatExporter.Cli.Verbs.Options;
namespace DiscordChatExporter.Cli namespace DiscordChatExporter.Cli
{ {
public static class Program public static class Program
{ {
private static readonly Container Container = new Container(); private static void PrintTokenHelp()
private static void ShowHelp()
{ {
var version = Assembly.GetExecutingAssembly().GetName().Version; Console.WriteLine("# To get user token:");
var availableFormats = Enum.GetNames(typeof(ExportFormat)); Console.WriteLine(" 1. Open Discord app");
Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ==="); 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();
Console.WriteLine("[-t] [--token] Discord authorization token."); Console.WriteLine("# To get bot token:");
Console.WriteLine("[-c] [--channel] Discord channel ID."); Console.WriteLine(" 1. Go to Discord developer portal");
Console.WriteLine("[-f] [--format] Export format. Optional."); Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine("[-o] [--output] Output file path. Optional."); Console.WriteLine(" 3. Open your application's settings");
Console.WriteLine(" [--datefrom] Limit to messages after this date. Optional."); Console.WriteLine(" 4. Navigate to the Bot section on the left");
Console.WriteLine(" [--dateto] Limit to messages before this date. Optional."); Console.WriteLine(" 5. Under Token click Copy");
Console.WriteLine(" [--dateformat] Date format. Optional.");
Console.WriteLine(" [--grouplimit] Message group limit. Optional.");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine($"Available export formats: {availableFormats.JoinToString(", ")}"); Console.WriteLine("# To get guild or channel ID:");
Console.WriteLine(); Console.WriteLine(" 1. Open Discord app");
Console.WriteLine("# To get authorization token:"); Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine(" - Open Discord app"); Console.WriteLine(" 3. Open Settings");
Console.WriteLine(" - Log in if you haven't"); Console.WriteLine(" 4. Go to Appearance section");
Console.WriteLine(" - Press Ctrl+Shift+I"); Console.WriteLine(" 5. Enable Developer Mode");
Console.WriteLine(" - Navigate to Application tab"); Console.WriteLine(" 6. Right click on the desired guild or channel and click Copy ID");
Console.WriteLine(" - Expand Storage > Local Storage > https://discordapp.com");
Console.WriteLine(" - Find \"token\" under key and copy the value");
Console.WriteLine();
Console.WriteLine("# To get channel ID:");
Console.WriteLine(" - Open Discord app");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Go to any channel you want to export");
Console.WriteLine(" - Press Ctrl+Shift+I");
Console.WriteLine(" - Navigate to Console tab");
Console.WriteLine(" - Type \"document.URL\" and press Enter");
Console.WriteLine(" - Copy the long sequence of numbers after last slash");
}
private static CliOptions ParseOptions(string[] args)
{
var argsParser = new FluentCommandLineParser<CliOptions>();
var settings = Container.SettingsService;
argsParser.Setup(o => o.Token).As('t', "token").Required();
argsParser.Setup(o => o.ChannelId).As('c', "channel").Required();
argsParser.Setup(o => o.ExportFormat).As('f', "format").SetDefault(ExportFormat.HtmlDark);
argsParser.Setup(o => o.FilePath).As('o', "output").SetDefault(null);
argsParser.Setup(o => o.From).As("datefrom").SetDefault(null);
argsParser.Setup(o => o.To).As("dateto").SetDefault(null);
argsParser.Setup(o => o.DateFormat).As("dateformat").SetDefault(settings.DateFormat);
argsParser.Setup(o => o.MessageGroupLimit).As("grouplimit").SetDefault(settings.MessageGroupLimit);
var parsed = argsParser.Parse(args);
// Show help if no arguments
if (parsed.EmptyArgs)
{
ShowHelp();
Environment.Exit(0);
}
// Show error if there are any
else if (parsed.HasErrors)
{
Console.Error.Write(parsed.ErrorText);
Environment.Exit(-1);
}
return argsParser.Object;
} }
public static void Main(string[] args) public static void Main(string[] args)
{ {
// Init container // Get all verb types
Container.Init(); var verbTypes = new[]
{
typeof(ExportChatOptions),
typeof(GetChannelsOptions),
typeof(GetDirectMessageChannelsOptions),
typeof(GetGuildsOptions)
};
// Parse options // Parse command line arguments
var options = ParseOptions(args); var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
// Inject some settings // Execute commands
var settings = Container.SettingsService; parsedArgs.WithParsed<ExportChatOptions>(o => new ExportChatVerb(o).Execute());
settings.DateFormat = options.DateFormat; parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
settings.MessageGroupLimit = options.MessageGroupLimit; parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
// Export // Show token help if help requested or no verb specified
var vm = Container.MainViewModel; parsedArgs.WithNotParsed(errs =>
vm.ExportAsync( {
options.Token, var err = errs.First();
options.ChannelId,
options.FilePath,
options.ExportFormat,
options.From,
options.To).GetAwaiter().GetResult();
// Cleanup container if (err.Tag == ErrorType.NoVerbSelectedError)
Container.Cleanup(); PrintTokenHelp();
Console.WriteLine("Export complete."); if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
PrintTokenHelp();
});
} }
} }
} }
@@ -0,0 +1,51 @@
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 exportService = container.Resolve<IExportService>();
// Configure settings
if (Options.DateFormat.IsNotBlank())
settingsService.DateFormat = Options.DateFormat;
if (Options.MessageGroupLimit > 0)
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
Options.After, Options.Before);
// Generate file path if not set
var filePath = Options.FilePath;
if (filePath == null || filePath.EndsWith("/") || filePath.EndsWith("\\"))
{
filePath += $"{chatLog.Guild.Name} - {chatLog.Channel.Name}.{Options.ExportFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
}
// Export
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
// 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,34 @@
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('p', "partition", Default = null, HelpText = "Split output into partitions limited to this number of messages.")]
public int? PartitionLimit { 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);
}
}
+18
View File
@@ -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();
}
}
@@ -1,12 +0,0 @@
using System;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.ViewModels
{
public interface IMainViewModel
{
Task ExportAsync(string token, string channelId, string filePath, ExportFormat format, DateTime? from,
DateTime? to);
}
}
@@ -1,56 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.ViewModels
{
public class MainViewModel : IMainViewModel
{
private readonly IDataService _dataService;
private readonly IMessageGroupService _messageGroupService;
private readonly IExportService _exportService;
public MainViewModel(IDataService dataService, IMessageGroupService messageGroupService,
IExportService exportService)
{
_dataService = dataService;
_messageGroupService = messageGroupService;
_exportService = exportService;
}
public async Task ExportAsync(string token, string channelId, string filePath, ExportFormat format,
DateTime? from, DateTime? to)
{
// Get channel and guild
var channel = await _dataService.GetChannelAsync(token, channelId);
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await _dataService.GetGuildAsync(token, channel.GuildId);
// Generate file path if not set
if (filePath.IsBlank())
{
filePath = $"{guild.Name} - {channel.Name}.{format.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
}
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to);
// 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);
}
}
}
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net461</TargetFramework> <TargetFramework>net461</TargetFramework>
<Version>2.5</Version> <Version>2.8</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -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
}
}
+3 -6
View File
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
@@ -14,20 +13,18 @@ namespace DiscordChatExporter.Core.Models
public DateTime? To { get; } public DateTime? To { get; }
public IReadOnlyList<MessageGroup> MessageGroups { get; } public IReadOnlyList<Message> Messages { get; }
public int TotalMessageCount => MessageGroups.Sum(g => g.Messages.Count);
public Mentionables Mentionables { get; } public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to, public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
IReadOnlyList<MessageGroup> messageGroups, Mentionables mentionables) IReadOnlyList<Message> messages, Mentionables mentionables)
{ {
Guild = guild; Guild = guild;
Channel = channel; Channel = channel;
From = from; From = from;
To = to; To = to;
MessageGroups = messageGroups; Messages = messages;
Mentionables = mentionables; Mentionables = mentionables;
} }
@@ -1,4 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
@@ -31,5 +33,45 @@ namespace DiscordChatExporter.Core.Models
throw new ArgumentOutOfRangeException(nameof(format)); throw new ArgumentOutOfRangeException(nameof(format));
} }
public static IReadOnlyList<ChatLog> SplitIntoPartitions(this ChatLog chatLog, int partitionLimit)
{
// If chat log has fewer messages than the limit - just return chat log in a list
if (chatLog.Messages.Count <= partitionLimit)
return new[] {chatLog};
var result = new List<ChatLog>();
// Loop through messages
var buffer = new List<Message>();
foreach (var message in chatLog.Messages)
{
// Add message to buffer
buffer.Add(message);
// If reached the limit - split and reset buffer
if (buffer.Count >= partitionLimit)
{
// Add to result
var chatLogPartition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, buffer,
chatLog.Mentionables);
result.Add(chatLogPartition);
// Reset the buffer instead of clearing to avoid mutations on existing references
buffer = new List<Message>();
}
}
// Add what's remaining in buffer
if (buffer.Any())
{
// Add to result
var chatLogPartition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, buffer,
chatLog.Mentionables);
result.Add(chatLogPartition);
}
return result;
}
} }
} }
@@ -1,23 +0,0 @@
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";
}
}
+26 -6
View File
@@ -1,4 +1,5 @@
using Tyrrrz.Extensions; using System;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
@@ -14,13 +15,32 @@ namespace DiscordChatExporter.Core.Models
public string FullName => $"{Name}#{Discriminator:0000}"; public string FullName => $"{Name}#{Discriminator:0000}";
public string AvatarHash { get; }
public string DefaultAvatarHash => $"{Discriminator % 5}"; public string DefaultAvatarHash => $"{Discriminator % 5}";
public string AvatarUrl => AvatarHash.IsNotBlank() public string AvatarHash { get; }
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png"; public bool IsAvatarAnimated =>
AvatarHash.IsNotBlank() && AvatarHash.StartsWith("a_", StringComparison.Ordinal);
public string AvatarUrl
{
get
{
// Custom avatar
if (AvatarHash.IsNotBlank())
{
// Animated
if (IsAvatarAnimated)
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.gif";
// Non-animated
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png";
}
// Default avatar
return $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
}
}
public User(string id, int discriminator, string name, string avatarHash) public User(string id, int discriminator, string name, string avatarHash)
{ {
@@ -1,13 +1,10 @@
Author;Date;Content;Attachments; Author;Date;Content;Attachments;
{{~ for message in Model.Messages -}}
{{- }}"{{ message.Author.FullName }}";
{{- for group in MessageGroups -}} {{- }}"{{ message.Timestamp | FormatDate }}";
{{- for message in group.Messages -}}
{{- message.Author.FullName }};
{{- message.TimeStamp | FormatDate }}; {{- }}"{{ message.Content | FormatContent }}";
{{- message.Content | FormatContent }}; {{- }}"{{ message.Attachments | array.map "Url" | array.join "," }}";
{{~ end -}}
{{- message.Attachments | array.map "Url" | array.join "," }};
{{~ end -}}
{{- end -}}
Can't render this file because it contains an unexpected character in line 11 and column 41.
@@ -2,208 +2,214 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ Guild.Name | HtmlEncode }} - {{ Channel.Name | HtmlEncode }}</title> <title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<style>{{ StyleSheet }}</style> <style>
{{ StyleSheet }}
</style>
</head> </head>
<body> <body>
{{~ # Info }} {{~ # Info ~}}
<div class="info"> <div class="info">
<div class="info__guild-icon-container"> <div class="info__guild-icon-container">
<img class="info__guild-icon" src="{{ Guild.IconUrl }}" /> <img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
</div> </div>
<div class="info__metadata"> <div class="info__metadata">
<div class="info__guild-name">{{ Guild.Name | HtmlEncode }}</div> <div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Channel.Name | HtmlEncode }}</div> <div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
{{ if Channel.Topic }} {{~ if Model.Channel.Topic ~}}
<div class="info__channel-topic">{{ Channel.Topic | HtmlEncode }}</div> <div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
{{ end }} {{~ end ~}}
<div class="info__channel-message-count">{{ TotalMessageCount | Format "N0" }} messages</div> <div class="info__channel-message-count">{{ Model.Messages | array.size | Format "N0" }} messages</div>
{{ if From || To }} {{~ if Model.From || Model.To ~}}
<div class="info__channel-date-range"> <div class="info__channel-date-range">
{{ if From && To }} {{~ if Model.From && Model.To ~}}
Between {{ From | FormatDate | HtmlEncode }} and {{ To | FormatDate | HtmlEncode }} Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
{{ else if From }} {{~ else if Model.From ~}}
After {{ From | FormatDate | HtmlEncode }} After {{ Model.From | FormatDate | html.escape }}
{{ else if To }} {{~ else if Model.To ~}}
Before {{ To | FormatDate | HtmlEncode }} Before {{ Model.To | FormatDate | html.escape }}
{{ end }} {{~ end ~}}
</div> </div>
{{ end }} {{~ end ~}}
</div> </div>
</div> </div>
{{~ # Log }} {{~ # Log ~}}
<div class="chatlog"> <div class="chatlog">
{{ for group in MessageGroups }} {{~ for group in Model.Messages | GroupMessages ~}}
<div class="chatlog__message-group"> <div class="chatlog__message-group">
{{~ # Avatar }} {{~ # Avatar ~}}
<div class="chatlog__author-avatar-container"> <div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" /> <img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
</div> </div>
<div class="chatlog__messages"> <div class="chatlog__messages">
{{~ # Author name and timestamp }} {{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ group.Author.FullName | HtmlEncode }}">{{ group.Author.Name | HtmlEncode }}</span> <span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}">{{ group.Author.Name | html.escape }}</span>
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | HtmlEncode }}</span> <span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages }} {{~ # Messages ~}}
{{ for message in group.Messages }} {{~ for message in group.Messages ~}}
{{~ # Content }} {{~ # Content ~}}
{{ if message.Content }} {{~ if message.Content ~}}
<div class="chatlog__content"> <div class="chatlog__content">
{{ message.Content | FormatContent }} {{ message.Content | FormatContent }}
{{~ # Edited timestamp }} {{~ # Edited timestamp ~}}
{{ if message.EditedTimestamp }} {{~ if message.EditedTimestamp ~}}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | HtmlEncode }}">(edited)</span> <span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
{{ end }} {{~ end ~}}
</div> </div>
{{ end }} {{~ end ~}}
{{~ # Attachments }} {{~ # Attachments ~}}
{{ for attachment in message.Attachments }} {{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment"> <div class="chatlog__attachment">
<a href="{{ attachment.Url }}"> <a href="{{ attachment.Url }}">
{{~ # Image }} {{ # Image }}
{{ if attachment.IsImage }} {{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" /> <img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image }} {{~ # Non-image ~}}
{{ else }} {{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }}) Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
{{ end }} {{~ end ~}}
</a> </a>
</div> </div>
{{ end }} {{~ end ~}}
{{~ # Embeds }} {{~ # Embeds ~}}
{{ for embed in message.Embeds }} {{~ for embed in message.Embeds ~}}
<div class="chatlog__embed"> <div class="chatlog__embed">
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color | FormatColor }})"></div> <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-container">
<div class="chatlog__embed-content"> <div class="chatlog__embed-content">
<div class="chatlog__embed-text"> <div class="chatlog__embed-text">
{{~ # Author }} {{~ # Author ~}}
{{ if embed.Author }} {{~ if embed.Author ~}}
<div class="chatlog__embed-author"> <div class="chatlog__embed-author">
{{ if embed.Author.IconUrl }} {{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" /> <img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
{{ end }} {{~ end ~}}
{{ if embed.Author.Name }} {{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name"> <span class="chatlog__embed-author-name">
{{ if embed.Author.Url }} {{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | HtmlEncode }}</a> <a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{ else }} {{~ else ~}}
{{ embed.Author.Name | HtmlEncode }} {{ embed.Author.Name | html.escape }}
{{ end }} {{~ end ~}}
</span> </span>
{{ end }} {{~ end ~}}
</div> </div>
{{ end }} {{~ end ~}}
{{~ # Title }} {{~ # Title ~}}
{{ if embed.Title }} {{~ if embed.Title ~}}
<div class="chatlog__embed-title"> <div class="chatlog__embed-title">
{{ if embed.Url }} {{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a> <a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
{{ else }} {{~ else ~}}
{{ embed.Title | FormatContent }} {{ embed.Title | FormatContent }}
{{ end }} {{~ end ~}}
</div> </div>
{{ end }} {{~ end ~}}
{{~ # Description }} {{~ # Description ~}}
{{ if embed.Description }} {{~ if embed.Description ~}}
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div> <div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
{{ end }} {{~ end ~}}
{{~ # Fields }} {{~ # Fields ~}}
<div class="chatlog__embed-fields"> {{~ if embed.Fields | array.size > 0 ~}}
{{ for field in embed.Fields }} <div class="chatlog__embed-fields">
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}"> {{~ for field in embed.Fields ~}}
{{ if field.Name }} <div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div> {{~ if field.Name ~}}
{{ end }} <div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
{{ if field.Value }} {{~ end ~}}
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div> {{~ if field.Value ~}}
{{ end }} <div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
</div> {{~ end ~}}
{{ end }} </div>
</div> {{~ end ~}}
</div>
{{~ end ~}}
</div> </div>
{{~ # Thumbnail }} {{~ # Thumbnail ~}}
{{ if embed.Thumbnail }} {{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container"> <div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}"> <a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" /> <img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
</a> </a>
</div> </div>
{{ end }} {{~ end ~}}
</div> </div>
{{~ # Image }} {{~ # Image ~}}
{{ if embed.Image }} {{~ if embed.Image ~}}
<div class="chatlog__embed-image-container"> <div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}"> <a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" /> <img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
</a> </a>
</div> </div>
{{ end }} {{~ end ~}}
{{~ # Footer }} {{~ # Footer ~}}
{{ if embed.Footer || embed.Timestamp }} {{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer"> <div class="chatlog__embed-footer">
{{ if embed.Footer }} {{~ if embed.Footer ~}}
{{ if embed.Footer.Text && embed.Footer.IconUrl }} {{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" /> <img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
{{ end }} {{~ end ~}}
{{ end }} {{~ end ~}}
<span class="chatlog__embed-footer-text"> <span class="chatlog__embed-footer-text">
{{ if embed.Footer }} {{~ if embed.Footer ~}}
{{ if embed.Footer.Text }} {{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | HtmlEncode }} {{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }} {{ if embed.Timestamp }} • {{ end }}
{{ end }} {{~ end ~}}
{{ end }} {{~ end ~}}
{{ if embed.Timestamp }} {{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | HtmlEncode }} {{ embed.Timestamp | FormatDate | html.escape }}
{{ end }} {{~ end ~}}
</span> </span>
</div> </div>
{{ end }} {{~ end ~}}
</div> </div>
</div> </div>
{{ end }} {{~ end ~}}
{{~ # Reactions }} {{~ # Reactions ~}}
<div class="chatlog__reactions"> {{~ if message.Reactions | array.size > 0 ~}}
{{ for reaction in message.Reactions }} <div class="chatlog__reactions">
<div class="chatlog__reaction"> {{~ for reaction in message.Reactions ~}}
<span class="chatlog__reaction-emoji"> <div class="chatlog__reaction">
{{~ # Custom emoji }} <span class="chatlog__reaction-emoji">
{{ if reaction.EmojiId }} {{~ # Custom emoji ~}}
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" /> {{~ if reaction.EmojiId ~}}
{{~ # Default emoji }} <img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
{{ else }} {{~ # Default emoji ~}}
{{ reaction.EmojiName }} {{~ else ~}}
{{ end }} {{ reaction.EmojiName }}
</span> {{~ end ~}}
<span class="chatlog__reaction-count">{{ reaction.Count }}</span> </span>
</div> <span class="chatlog__reaction-count">{{ reaction.Count }}</span>
{{ end }} </div>
</div> {{~ end ~}}
{{ end }} </div>
{{~ end ~}}
{{~ end ~}}
</div> </div>
</div> </div>
{{ end }} {{~ end ~}}
</div> </div>
</body> </body>
@@ -1,18 +1,21 @@
============================================================== {{~ # Info ~}}
Guild: {{ Guild.Name }} ==============================================================
Channel: {{ Channel.Name }} Guild: {{ Model.Guild.Name }}
Topic: {{ Channel.Topic }} Channel: {{ Model.Channel.Name }}
Messages: {{ TotalMessageCount | Format "N0" }} Topic: {{ Model.Channel.Topic }}
Range: {{ if From }}{{ From | FormatDate }} {{ end }}{{ if From || To }}->{{ end }}{{ if To }} {{ To | FormatDate }}{{ end }} Messages: {{ Model.Messages | array.size | Format "N0" }}
Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model.From || Model.To }}->{{ end }}{{ if Model.To }} {{ Model.To | FormatDate }}{{ end }}
============================================================== ==============================================================
{{~ for group in MessageGroups ~}} {{~ # Log ~}}
{{~ group.Author.FullName }} [{{ group.TimeStamp | FormatDate }}] {{~ for message in Model.Messages ~}}
{{~ for message in group.Messages ~}} {{~ # Author name and timestamp ~}}
{{~ message.Content | FormatContent }} {{~ }}[{{ message.Timestamp | FormatDate }}] {{ message.Author.FullName }}
{{~ for attachment in message.Attachments ~}} {{~ # Content ~}}
{{~ attachment.Url }} {{~ message.Content | FormatContent }}
{{~ end ~}} {{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}} {{~ end ~}}
{{~ end ~}} {{~ end ~}}
@@ -2,12 +2,14 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using DiscordChatExporter.Core.Internal; using DiscordChatExporter.Core.Internal;
using Polly; using Polly;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
@@ -15,17 +17,9 @@ namespace DiscordChatExporter.Core.Services
{ {
private readonly HttpClient _httpClient = new HttpClient(); private readonly HttpClient _httpClient = new HttpClient();
private async Task<JToken> GetApiResponseAsync(string token, string resource, string endpoint, private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
params string[] parameters) params string[] parameters)
{ {
// Format URL
const string apiRoot = "https://discordapp.com/api/v6";
var url = $"{apiRoot}/{resource}/{endpoint}?token={token}";
// Add parameters
foreach (var parameter in parameters)
url += $"&{parameter}";
// Create request policy // Create request policy
var policy = Policy var policy = Policy
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429) .Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
@@ -34,31 +28,54 @@ namespace DiscordChatExporter.Core.Services
// Send request // Send request
return await policy.ExecuteAsync(async () => return await policy.ExecuteAsync(async () =>
{ {
using (var response = await _httpClient.GetAsync(url)) // Create request
const string apiRoot = "https://discordapp.com/api/v6";
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
{ {
// Check status code // Set authorization header
// We throw our own exception here because default one doesn't have status code request.Headers.Authorization = token.Type == AuthTokenType.Bot
if (!response.IsSuccessStatusCode) ? new AuthenticationHeaderValue("Bot", token.Value)
throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase); : new AuthenticationHeaderValue(token.Value);
// Get content // Add parameters
var raw = await response.Content.ReadAsStringAsync(); foreach (var parameter in parameters)
{
var key = parameter.SubstringUntil("=");
var value = parameter.SubstringAfter("=");
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
}
// Parse // Get response
return JToken.Parse(raw); 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(string token, string guildId) public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
{ {
// Special case for direct messages pseudo-guild
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
var response = await GetApiResponseAsync(token, "guilds", guildId); var response = await GetApiResponseAsync(token, "guilds", guildId);
var guild = ParseGuild(response); var guild = ParseGuild(response);
return guild; return guild;
} }
public async Task<Channel> GetChannelAsync(string token, string channelId) public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
{ {
var response = await GetApiResponseAsync(token, "channels", channelId); var response = await GetApiResponseAsync(token, "channels", channelId);
var channel = ParseChannel(response); var channel = ParseChannel(response);
@@ -66,7 +83,7 @@ namespace DiscordChatExporter.Core.Services
return channel; return channel;
} }
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token) public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
{ {
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100"); var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
var guilds = response.Select(ParseGuild).ToArray(); var guilds = response.Select(ParseGuild).ToArray();
@@ -74,7 +91,7 @@ namespace DiscordChatExporter.Core.Services
return guilds; return guilds;
} }
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token) public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
{ {
var response = await GetApiResponseAsync(token, "users", "@me/channels"); var response = await GetApiResponseAsync(token, "users", "@me/channels");
var channels = response.Select(ParseChannel).ToArray(); var channels = response.Select(ParseChannel).ToArray();
@@ -82,7 +99,7 @@ namespace DiscordChatExporter.Core.Services
return channels; return channels;
} }
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId) public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
{ {
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels"); var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
var channels = response.Select(ParseChannel).ToArray(); var channels = response.Select(ParseChannel).ToArray();
@@ -90,7 +107,7 @@ namespace DiscordChatExporter.Core.Services
return channels; return channels;
} }
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId) public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
{ {
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles"); var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
var roles = response.Select(ParseRole).ToArray(); var roles = response.Select(ParseRole).ToArray();
@@ -98,7 +115,7 @@ namespace DiscordChatExporter.Core.Services
return roles; return roles;
} }
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId, public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null) DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{ {
var result = new List<Message>(); var result = new List<Message>();
@@ -169,7 +186,7 @@ namespace DiscordChatExporter.Core.Services
return result; return result;
} }
public async Task<Mentionables> GetMentionablesAsync(string token, string guildId, public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages) IEnumerable<Message> messages)
{ {
// Get channels and roles // Get channels and roles
@@ -197,6 +214,33 @@ namespace DiscordChatExporter.Core.Services
return new Mentionables(users, channels, roles); return new Mentionables(users, channels, roles);
} }
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
// Get messages
var messages = await GetChannelMessagesAsync(token, channel.Id, from, to, progress);
// Get mentionables
var mentionables = await GetMentionablesAsync(token, guild.Id, messages);
return new ChatLog(guild, channel, from, to, messages, mentionables);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
// Get channel
var channel = await GetChannelAsync(token, channelId);
// Get guild
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await GetGuildAsync(token, channel.GuildId);
// Get the chat log
return await GetChatLogAsync(token, guild, channel, from, to, progress);
}
public void Dispose() public void Dispose()
{ {
_httpClient.Dispose(); _httpClient.Dispose();
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private class MessageGroup
{
public User Author { get; }
public DateTime Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
{
Author = author;
Timestamp = timestamp;
Messages = messages;
}
}
}
}
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
@@ -18,18 +19,61 @@ namespace DiscordChatExporter.Core.Services
private readonly ExportFormat _format; private readonly ExportFormat _format;
private readonly ChatLog _log; private readonly ChatLog _log;
private readonly string _dateFormat; private readonly string _dateFormat;
private readonly int _messageGroupLimit;
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat) public TemplateModel(ExportFormat format, ChatLog log, string dateFormat, int messageGroupLimit)
{ {
_format = format; _format = format;
_log = log; _log = log;
_dateFormat = dateFormat; _dateFormat = dateFormat;
_messageGroupLimit = messageGroupLimit;
}
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{
// Group adjacent messages by timestamp and author
var buffer = new List<Message>();
foreach (var message in messages)
{
var groupFirst = buffer.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 ||
buffer.Count >= _messageGroupLimit
);
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, buffer);
// Reset the buffer instead of clearing to avoid mutations on existing references
buffer = new List<Message>();
yield return group;
}
// Add message to buffer
buffer.Add(message);
}
// Add what's remaining in buffer
if (buffer.Any())
{
var groupFirst = buffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, buffer);
yield return group;
}
} }
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str); private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
private string HtmlDecode(string str) => WebUtility.HtmlDecode(str);
private string Format(IFormattable obj, string format) => private string Format(IFormattable obj, string format) =>
obj.ToString(format, CultureInfo.InvariantCulture); obj.ToString(format, CultureInfo.InvariantCulture);
@@ -231,9 +275,6 @@ namespace DiscordChatExporter.Core.Services
private string FormatContentCsv(string content) private string FormatContentCsv(string content)
{ {
// New lines
content = content.Replace("\n", ", ");
// Escape quotes // Escape quotes
content = content.Replace("\"", "\"\""); content = content.Replace("\"", "\"\"");
@@ -309,11 +350,10 @@ namespace DiscordChatExporter.Core.Services
var scriptObject = new ScriptObject(); var scriptObject = new ScriptObject();
// Import chat log // Import chat log
scriptObject.Import(_log, TemplateMemberFilter, TemplateMemberRenamer); scriptObject.SetValue("Model", _log, true);
// Import functions // Import functions
scriptObject.Import(nameof(HtmlEncode), new Func<string, string>(HtmlEncode)); scriptObject.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
scriptObject.Import(nameof(HtmlDecode), new Func<string, string>(HtmlDecode));
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format)); scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate)); scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize)); scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
@@ -1,15 +1,14 @@
using System.IO; using System.Collections.Generic;
using System.IO;
using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Models;
using Scriban; using Scriban;
using Scriban.Runtime; using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Core.Services
{ {
public partial class ExportService : IExportService public partial class ExportService : IExportService
{ {
private static readonly MemberRenamerDelegate TemplateMemberRenamer = m => m.Name;
private static readonly MemberFilterDelegate TemplateMemberFilter = m => true;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
public ExportService(ISettingsService settingsService) public ExportService(ISettingsService settingsService)
@@ -17,7 +16,7 @@ namespace DiscordChatExporter.Core.Services
_settingsService = settingsService; _settingsService = settingsService;
} }
public void Export(ExportFormat format, string filePath, ChatLog log) private void ExportChatLogSingle(ChatLog chatLog, string filePath, ExportFormat format)
{ {
// Create template loader // Create template loader
var loader = new TemplateLoader(); var loader = new TemplateLoader();
@@ -30,22 +29,73 @@ namespace DiscordChatExporter.Core.Services
var context = new TemplateContext var context = new TemplateContext
{ {
TemplateLoader = loader, TemplateLoader = loader,
MemberRenamer = TemplateMemberRenamer, MemberRenamer = m => m.Name,
MemberFilter = TemplateMemberFilter MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
}; };
// Create template model // Create template model
var templateModel = new TemplateModel(format, log, _settingsService.DateFormat); var templateModel = new TemplateModel(format, chatLog,
_settingsService.DateFormat, _settingsService.MessageGroupLimit);
context.PushGlobal(templateModel.GetScriptObject()); context.PushGlobal(templateModel.GetScriptObject());
// Create directory
var dirPath = Path.GetDirectoryName(filePath);
if (dirPath.IsNotBlank())
Directory.CreateDirectory(dirPath);
// Render output // Render output
using (var output = File.CreateText(filePath)) using (var output = File.CreateText(filePath))
{ {
// Configure output // Configure output
context.PushOutput(new TextWriterOutput(output)); context.PushOutput(new TextWriterOutput(output));
// Render template // Render output
template.Render(context); context.Evaluate(template.Page);
}
}
private void ExportChatLogPartitions(IReadOnlyList<ChatLog> partitions, string filePath, ExportFormat format)
{
// Split file path into components
var dirPath = Path.GetDirectoryName(filePath);
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
var fileExt = Path.GetExtension(filePath);
// Export each partition separately
var partitionNumber = 1;
foreach (var partition in partitions)
{
// Compose new file name
var partitionFilePath = $"{fileNameWithoutExt}-{partitionNumber}{fileExt}";
// Compose full file path
if (dirPath.IsNotBlank())
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
// Export
ExportChatLogSingle(partition, partitionFilePath, format);
// Increment partition number
partitionNumber++;
}
}
public void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
int? partitionLimit = null)
{
// If partitioning is disabled or there are fewer messages in chat log than the limit - process it without partitioning
if (partitionLimit == null || chatLog.Messages.Count <= partitionLimit)
{
ExportChatLogSingle(chatLog, filePath, format);
}
// Otherwise split into partitions and export separately
else
{
var partitions = chatLog.SplitIntoPartitions(partitionLimit.Value);
ExportChatLogPartitions(partitions, filePath, format);
} }
} }
} }
@@ -7,22 +7,28 @@ namespace DiscordChatExporter.Core.Services
{ {
public interface IDataService public interface IDataService
{ {
Task<Guild> GetGuildAsync(string token, string guildId); Task<Guild> GetGuildAsync(AuthToken token, string guildId);
Task<Channel> GetChannelAsync(string token, string channelId); Task<Channel> GetChannelAsync(AuthToken token, string channelId);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token); Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token); Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId); Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId); Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId, Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null); DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<Mentionables> GetMentionablesAsync(string token, string guildId, Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages); IEnumerable<Message> messages);
Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
} }
} }
@@ -4,6 +4,7 @@ namespace DiscordChatExporter.Core.Services
{ {
public interface IExportService public interface IExportService
{ {
void Export(ExportFormat format, string filePath, ChatLog log); void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
int? partitionLimit = null);
} }
} }
@@ -1,10 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IMessageGroupService
{
IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages);
}
}
@@ -9,8 +9,9 @@ namespace DiscordChatExporter.Core.Services
string DateFormat { get; set; } string DateFormat { get; set; }
int MessageGroupLimit { get; set; } int MessageGroupLimit { get; set; }
string LastToken { get; set; } AuthToken LastToken { get; set; }
ExportFormat LastExportFormat { get; set; } ExportFormat LastExportFormat { get; set; }
int? LastPartitionLimit { get; set; }
void Load(); void Load();
void Save(); void Save();
@@ -1,59 +0,0 @@
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;
}
}
}
@@ -10,8 +10,9 @@ namespace DiscordChatExporter.Core.Services
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt"; public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20; public int MessageGroupLimit { get; set; } = 20;
public string LastToken { get; set; } public AuthToken LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public int? LastPartitionLimit { get; set; }
public SettingsService() public SettingsService()
{ {
+16 -2
View File
@@ -4,8 +4,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:local="clr-namespace:DiscordChatExporter.Gui" xmlns:local="clr-namespace:DiscordChatExporter.Gui"
Exit="App_Exit" DispatcherUnhandledException="App_OnDispatcherUnhandledException"
Startup="App_Startup"
StartupUri="Views/MainWindow.xaml"> StartupUri="Views/MainWindow.xaml">
<Application.Resources> <Application.Resources>
<ResourceDictionary> <ResourceDictionary>
@@ -96,6 +95,21 @@
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style> </Style>
<Style
x:Key="MaterialDesignFlatActionToggleButton"
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
TargetType="{x:Type ToggleButton}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
</Trigger>
</Style.Triggers>
</Style>
<!-- Converters --> <!-- Converters -->
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" /> <converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
+3 -9
View File
@@ -1,19 +1,13 @@
using System.Windows; using System.Windows;
using System.Windows.Threading;
namespace DiscordChatExporter.Gui namespace DiscordChatExporter.Gui
{ {
public partial class App public partial class App
{ {
private Container Container => (Container) Resources["Container"]; private void App_OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs args)
private void App_Startup(object sender, StartupEventArgs e)
{ {
Container.Init(); MessageBox.Show(args.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error);
}
private void App_Exit(object sender, ExitEventArgs e)
{
Container.Cleanup();
} }
} }
} }
+3 -8
View File
@@ -11,12 +11,7 @@ namespace DiscordChatExporter.Gui
public IMainViewModel MainViewModel => Resolve<IMainViewModel>(); public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>(); public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>();
private T Resolve<T>(string key = null) public Container()
{
return ServiceLocator.Current.GetInstance<T>(key);
}
public void Init()
{ {
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default); ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset(); SimpleIoc.Default.Reset();
@@ -24,7 +19,6 @@ namespace DiscordChatExporter.Gui
// Services // Services
SimpleIoc.Default.Register<IDataService, DataService>(); SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>(); SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>(); SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>(); SimpleIoc.Default.Register<IUpdateService, UpdateService>();
@@ -34,8 +28,9 @@ namespace DiscordChatExporter.Gui
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true); SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
} }
public void Cleanup() private T Resolve<T>(string key = null)
{ {
return ServiceLocator.Current.GetInstance<T>(key);
} }
} }
} }
@@ -42,39 +42,12 @@
<StartupObject /> <StartupObject />
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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" />
<Reference Include="System.Core" /> <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"> <Reference Include="System.Xaml">
<RequiredTargetFramework>4.0</RequiredTargetFramework> <RequiredTargetFramework>4.0</RequiredTargetFramework>
</Reference> </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.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="WindowsBase" />
<Reference Include="PresentationCore" /> <Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" /> <Reference Include="PresentationFramework" />
@@ -119,9 +92,6 @@
<LastGenOutput>Resources.Designer.cs</LastGenOutput> <LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource> </EmbeddedResource>
<None Include="app.config" /> <None Include="app.config" />
<None Include="packages.config">
<SubType>Designer</SubType>
</None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Resource Include="..\favicon.ico" /> <Resource Include="..\favicon.ico" />
@@ -150,6 +120,25 @@
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</Page> </Page>
</ItemGroup> </ItemGroup>
<ItemGroup /> <ItemGroup>
<PackageReference Include="CommonServiceLocator">
<Version>2.0.3</Version>
</PackageReference>
<PackageReference Include="MaterialDesignColors">
<Version>1.1.3</Version>
</PackageReference>
<PackageReference Include="MaterialDesignThemes">
<Version>2.4.0.1044</Version>
</PackageReference>
<PackageReference Include="MvvmLightLibs">
<Version>5.4.1</Version>
</PackageReference>
<PackageReference Include="Tyrrrz.Extensions">
<Version>1.5.1</Version>
</PackageReference>
<PackageReference Include="Tyrrrz.WpfExtensions">
<Version>1.0.5</Version>
</PackageReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project> </Project>
@@ -15,14 +15,17 @@ namespace DiscordChatExporter.Gui.Messages
public DateTime? To { get; } public DateTime? To { get; }
public int? PartitionLimit { get; }
public StartExportMessage(Channel channel, string filePath, ExportFormat format, public StartExportMessage(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to) DateTime? from, DateTime? to, int? partitionLimit)
{ {
Channel = channel; Channel = channel;
FilePath = filePath; FilePath = filePath;
Format = format; Format = format;
From = from; From = from;
To = to; To = to;
PartitionLimit = partitionLimit;
} }
} }
} }
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")] [assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")] [assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")] [assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
[assembly: AssemblyVersion("2.5")] [assembly: AssemblyVersion("2.8")]
[assembly: AssemblyFileVersion("2.5")] [assembly: AssemblyFileVersion("2.8")]
@@ -19,6 +19,7 @@ namespace DiscordChatExporter.Gui.ViewModels
private ExportFormat _format; private ExportFormat _format;
private DateTime? _from; private DateTime? _from;
private DateTime? _to; private DateTime? _to;
private int? _partitionLimit;
public Guild Guild { get; private set; } public Guild Guild { get; private set; }
@@ -34,7 +35,8 @@ namespace DiscordChatExporter.Gui.ViewModels
} }
} }
public IReadOnlyList<ExportFormat> AvailableFormats { get; } public IReadOnlyList<ExportFormat> AvailableFormats =>
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public ExportFormat SelectedFormat public ExportFormat SelectedFormat
{ {
@@ -62,6 +64,12 @@ namespace DiscordChatExporter.Gui.ViewModels
set => Set(ref _to, value); set => Set(ref _to, value);
} }
public int? PartitionLimit
{
get => _partitionLimit;
set => Set(ref _partitionLimit, value);
}
// Commands // Commands
public RelayCommand ExportCommand { get; } public RelayCommand ExportCommand { get; }
@@ -69,9 +77,6 @@ namespace DiscordChatExporter.Gui.ViewModels
{ {
_settingsService = settingsService; _settingsService = settingsService;
// Defaults
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
// Commands // Commands
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank()); ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
@@ -85,13 +90,15 @@ namespace DiscordChatExporter.Gui.ViewModels
.Replace(Path.GetInvalidFileNameChars(), '_'); .Replace(Path.GetInvalidFileNameChars(), '_');
From = null; From = null;
To = null; To = null;
PartitionLimit = _settingsService.LastPartitionLimit;
}); });
} }
private void Export() private void Export()
{ {
// Save format // Persist preferences
_settingsService.LastExportFormat = SelectedFormat; _settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimit = PartitionLimit;
// Clamp 'from' and 'to' values // Clamp 'from' and 'to' values
if (From > To) if (From > To)
@@ -100,7 +107,7 @@ namespace DiscordChatExporter.Gui.ViewModels
To = From; To = From;
// Start export // Start export
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To)); MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To, PartitionLimit));
} }
} }
} }
@@ -14,6 +14,7 @@ namespace DiscordChatExporter.Gui.ViewModels
ExportFormat SelectedFormat { get; set; } ExportFormat SelectedFormat { get; set; }
DateTime? From { get; set; } DateTime? From { get; set; }
DateTime? To { get; set; } DateTime? To { get; set; }
int? PartitionLimit { get; set; }
RelayCommand ExportCommand { get; } RelayCommand ExportCommand { get; }
} }
@@ -12,7 +12,8 @@ namespace DiscordChatExporter.Gui.ViewModels
bool IsProgressIndeterminate { get; } bool IsProgressIndeterminate { get; }
double Progress { get; } double Progress { get; }
string Token { get; set; } bool IsBotToken { get; set; }
string TokenValue { get; set; }
IReadOnlyList<Guild> AvailableGuilds { get; } IReadOnlyList<Guild> AvailableGuilds { get; }
Guild SelectedGuild { get; set; } Guild SelectedGuild { get; set; }
@@ -19,14 +19,14 @@ namespace DiscordChatExporter.Gui.ViewModels
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly IUpdateService _updateService; private readonly IUpdateService _updateService;
private readonly IDataService _dataService; private readonly IDataService _dataService;
private readonly IMessageGroupService _messageGroupService;
private readonly IExportService _exportService; private readonly IExportService _exportService;
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap; private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy; private bool _isBusy;
private double _progress; private double _progress;
private string _token; private bool _isBotToken;
private string _tokenValue;
private IReadOnlyList<Guild> _availableGuilds; private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild; private Guild _selectedGuild;
private IReadOnlyList<Channel> _availableChannels; private IReadOnlyList<Channel> _availableChannels;
@@ -56,15 +56,21 @@ namespace DiscordChatExporter.Gui.ViewModels
} }
} }
public string Token public bool IsBotToken
{ {
get => _token; get => _isBotToken;
set => Set(ref _isBotToken, value);
}
public string TokenValue
{
get => _tokenValue;
set set
{ {
// Remove invalid chars // Remove invalid chars
value = value?.Trim('"'); value = value?.Trim('"');
Set(ref _token, value); Set(ref _tokenValue, value);
PullDataCommand.RaiseCanExecuteChanged(); PullDataCommand.RaiseCanExecuteChanged();
} }
} }
@@ -104,12 +110,11 @@ namespace DiscordChatExporter.Gui.ViewModels
public RelayCommand<Channel> ShowExportSetupCommand { get; } public RelayCommand<Channel> ShowExportSetupCommand { get; }
public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService, public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService,
IMessageGroupService messageGroupService, IExportService exportService) IExportService exportService)
{ {
_settingsService = settingsService; _settingsService = settingsService;
_updateService = updateService; _updateService = updateService;
_dataService = dataService; _dataService = dataService;
_messageGroupService = messageGroupService;
_exportService = exportService; _exportService = exportService;
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>(); _guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
@@ -117,14 +122,14 @@ namespace DiscordChatExporter.Gui.ViewModels
// Commands // Commands
ViewLoadedCommand = new RelayCommand(ViewLoaded); ViewLoadedCommand = new RelayCommand(ViewLoaded);
ViewClosedCommand = new RelayCommand(ViewClosed); ViewClosedCommand = new RelayCommand(ViewClosed);
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy); PullDataCommand = new RelayCommand(PullData, () => TokenValue.IsNotBlank() && !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings); ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout); ShowAboutCommand = new RelayCommand(ShowAbout);
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy); ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
// Messages // Messages
MessengerInstance.Register<StartExportMessage>(this, MessengerInstance.Register<StartExportMessage>(this,
m => Export(m.Channel, m.FilePath, m.Format, m.From, m.To)); m => Export(m.Channel, m.FilePath, m.Format, m.From, m.To, m.PartitionLimit));
} }
private async void ViewLoaded() private async void ViewLoaded()
@@ -132,8 +137,12 @@ namespace DiscordChatExporter.Gui.ViewModels
// Load settings // Load settings
_settingsService.Load(); _settingsService.Load();
// Set last token // Get last token
Token = _settingsService.LastToken; if (_settingsService.LastToken != null)
{
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
TokenValue = _settingsService.LastToken.Value;
}
// Check and prepare update // Check and prepare update
try try
@@ -169,8 +178,10 @@ namespace DiscordChatExporter.Gui.ViewModels
{ {
IsBusy = true; IsBusy = true;
// Copy token so it doesn't get mutated // Create token
var token = Token; var token = new AuthToken(
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
TokenValue);
// Save token // Save token
_settingsService.LastToken = token; _settingsService.LastToken = token;
@@ -228,7 +239,8 @@ namespace DiscordChatExporter.Gui.ViewModels
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel)); MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
} }
private async void Export(Channel channel, string filePath, ExportFormat format, DateTime? from, DateTime? to) private async void Export(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to, int? partitionLimit)
{ {
IsBusy = true; IsBusy = true;
@@ -243,23 +255,11 @@ namespace DiscordChatExporter.Gui.ViewModels
try try
{ {
// Get messages // Get chat log
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to, progressHandler); var chatLog = await _dataService.GetChatLogAsync(token, guild, channel, 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 // Export
_exportService.Export(format, filePath, log); _exportService.ExportChatLog(chatLog, filePath, format, partitionLimit);
// Open
Process.Start(filePath);
// Notify completion // Notify completion
MessengerInstance.Send(new ShowNotificationMessage("Export complete")); MessengerInstance.Send(new ShowNotificationMessage("Export complete"));
@@ -55,6 +55,13 @@
SelectedDate="{Binding To}" /> SelectedDate="{Binding To}" />
</Grid> </Grid>
<!-- Partitioning -->
<TextBox
Margin="16,8,16,8"
materialDesign:HintAssist.Hint="Messages per partition (optional)"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding PartitionLimit, TargetNullValue=''}" />
<!-- Buttons --> <!-- Buttons -->
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal"> <StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button <Button
+92 -38
View File
@@ -9,7 +9,7 @@
Height="550" Height="550"
Background="{DynamicResource MaterialDesignPaper}" Background="{DynamicResource MaterialDesignPaper}"
DataContext="{Binding MainViewModel, Source={StaticResource Container}}" DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
FocusManager.FocusedElement="{Binding ElementName=TokenTextBox}" FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
FontFamily="{DynamicResource MaterialDesignFont}" FontFamily="{DynamicResource MaterialDesignFont}"
Icon="/DiscordChatExporter;component/favicon.ico" Icon="/DiscordChatExporter;component/favicon.ico"
SnapsToDevicePixels="True" SnapsToDevicePixels="True"
@@ -48,27 +48,47 @@
Margin="6,6,0,6"> Margin="6,6,0,6">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Token --> <!-- Token type -->
<TextBox <ToggleButton
x:Name="TokenTextBox"
Grid.Row="0"
Grid.Column="0" Grid.Column="0"
Margin="6" Margin="6"
IsChecked="{Binding IsBotToken}"
Style="{StaticResource MaterialDesignFlatActionToggleButton}"
ToolTip="Switch between user token and bot token">
<ToggleButton.Content>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Account" />
</ToggleButton.Content>
<materialDesign:ToggleButtonAssist.OnContent>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Robot" />
</materialDesign:ToggleButtonAssist.OnContent>
</ToggleButton>
<!-- Token value -->
<TextBox
x:Name="TokenValueTextBox"
Grid.Column="1"
Margin="2,6,6,7"
materialDesign:HintAssist.Hint="Token" materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden" materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0" materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0" BorderThickness="0"
FontSize="16" FontSize="16"
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" /> Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button --> <!-- Pull data button -->
<Button <Button
Grid.Row="0" Grid.Column="2"
Grid.Column="1"
Margin="0,6,6,6" Margin="0,6,6,6"
Padding="4" Padding="4"
Command="{Binding PullDataCommand}" Command="{Binding PullDataCommand}"
@@ -99,8 +119,8 @@
<ProgressBar <ProgressBar
Background="Transparent" Background="Transparent"
IsIndeterminate="{Binding IsProgressIndeterminate}" IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding Progress, Mode=OneWay}" Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}" /> Value="{Binding Progress, Mode=OneWay}" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -196,34 +216,68 @@
</DockPanel> </DockPanel>
<!-- Usage instructions --> <!-- Usage instructions -->
<StackPanel Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}"> <Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your authorization token to work." /> <!-- User token -->
<TextBlock <StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}">
Margin="0,8,0,0" <TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work." />
FontSize="16" <TextBlock
Text="To obtain it, follow these steps:" /> Margin="0,8,0,0"
<TextBlock Margin="8,0,0,0" FontSize="14"> FontSize="16"
<Run Text="1. Open the Discord app" /> Text="To obtain it, follow these steps:" />
<LineBreak /> <TextBlock Margin="8,0,0,0" FontSize="14">
<Run Text="2. Log in if you haven't" /> <Run Text="1. Open the Discord app" />
<LineBreak /> <LineBreak />
<Run Text="3. Press" /> <Run Text="2. Log in if you haven't" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" /> <LineBreak />
<LineBreak /> <Run Text="3. Press" />
<Run Text="4. Navigate to" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" /> <Run Text="to show developer tools" />
<Run Text="tab" /> <LineBreak />
<LineBreak /> <Run Text="4. Press" />
<Run Text="5. Expand" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage &gt; Local Storage &gt; https://discordapp.com" /> <Run Text="to trigger reload" />
<LineBreak /> <LineBreak />
<Run Text="6. Find" /> <Run Text="5. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="&quot;token&quot;" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
<Run Text="under key and copy the value" /> <Run Text="tab" />
<LineBreak /> <LineBreak />
<Run Text="7. Paste the value in the textbox above" /> <Run Text="6. Select" />
</TextBlock> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
</StackPanel> <Run Text="&gt;" />
<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" /> <materialDesign:Snackbar x:Name="Snackbar" />
</Grid> </Grid>
</DockPanel> </DockPanel>
-9
View File
@@ -1,9 +0,0 @@
<?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>
+12
View File
@@ -0,0 +1,12 @@
FROM mono:5
WORKDIR /root/build
COPY DiscordChatExporter.sln favicon.ico ./
COPY DiscordChatExporter.Core DiscordChatExporter.Core
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
RUN msbuild ./DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj /t:Restore
RUN msbuild ./DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj /p:Configuration=Release
FROM mono:5
COPY --from=0 /root/build/DiscordChatExporter.Cli/bin/Release/net461 /root/bin
WORKDIR /a
ENTRYPOINT ["mono", "/root/bin/DiscordChatExporter.Cli.exe"]
+8 -18
View File
@@ -4,7 +4,7 @@
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. The tool also lets you select from/to dates to limit the exported messages. There are options to configure the output, such as date format, color theme, message grouping limit, etc. DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, embeds, attachments, mentions, reactions and other features. It works with both user and bot tokens, supports multiple output formats and allows you to trim messages by dates.
## Screenshots ## Screenshots
@@ -15,25 +15,15 @@ DiscordChatExporter can be used to export message history from a [Discord](https
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases) - [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter) - [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
- [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter` (only CLI version)
## Features ## Features
- Intuitive GUI that displays available guilds and channels - Graphical and command line interfaces
- CLI as additional alternative to GUI - Supports both user tokens and bot tokens
- Date ranges to limit messages - Allows retrieving messages in specified date range
- Groups messages by author and time - Multiple export formats: HTML (dark/light), TXT and CSV
- Exports to a plain text file - Renders all message features including: markdown, attachments, embeds, emojis, mentions, etc
- Exports to an HTML file
- Dark and light themes
- User avatars
- Inline image attachments
- Embeds and webhooks
- Full markdown support
- Automatic links
- Styled similarly to the app
- Exports to a CSV file
- Renders custom emojis
- Resolves user, role and channel mentions
## Libraries used ## Libraries used
@@ -43,7 +33,7 @@ DiscordChatExporter can be used to export message history from a [Discord](https
- [Scriban](https://github.com/lunet-io/scriban) - [Scriban](https://github.com/lunet-io/scriban)
- [Polly](https://github.com/App-vNext/Polly) - [Polly](https://github.com/App-vNext/Polly)
- [Onova](https://github.com/Tyrrrz/Onova) - [Onova](https://github.com/Tyrrrz/Onova)
- [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser) - [CommandLineParser](https://github.com/commandlineparser/commandline)
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions) - [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
- [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions) - [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions)
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings) - [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)