Compare commits

..

58 Commits

Author SHA1 Message Date
Alexey Golub cc2583f96b Update version 2018-04-08 13:03:03 +03:00
Alexey Golub de763f8aaa Try to parse DM group chat names instead of only using participants
Fixes #41
2018-04-08 12:46:55 +03:00
Alexey Golub 7b67cbc385 Improve markdown parsing to make it less volatile
Fixes #18, #20, #40
2018-04-08 12:29:38 +03:00
Alexey Golub 7ffb799136 Update nuget packages 2018-04-01 00:31:58 +03:00
Alexey Golub 5a84fb25e6 Update nuget packages and rework UpdateService 2018-03-20 22:18:09 +02:00
Alexey Golub 9988300942 Let user disable auto-updates 2018-02-25 18:22:55 +02:00
Alexey Golub 4540134a98 Don't crash when update fails 2018-02-25 18:11:00 +02:00
Alexey Golub 656e5a5b0d Remove some views and replace them with snackbar notifications 2018-02-25 17:17:50 +02:00
Alexey Golub 63e3ba8891 Add application icon to CLI 2018-02-25 17:03:08 +02:00
Alexey Golub d7572d9fd3 Update nuget packages 2018-02-25 17:01:52 +02:00
Alexey Golub 63c835df88 Implement auto-update via Onova 2018-02-25 17:00:44 +02:00
Alexey Golub 7bfd645e8e Update appveyor.yml 2018-02-11 12:38:59 +02:00
Alexey Golub 4e5d3cb4ed Add changelog 2018-02-11 12:38:48 +02:00
Alexey Golub cff976e2e4 Update nuget packages 2018-02-10 18:29:31 +02:00
Alexey Golub 0c69fd6e45 Update appveyor.yml 2018-02-10 18:27:04 +02:00
Alexey Golub 909a58b641 Update deploy script 2018-02-10 18:25:49 +02:00
Alexey Golub 2db4ad46cc Remove Costura.Fody 2018-02-10 18:25:34 +02:00
Alexey Golub ffa1023c50 Improve word wrapping in messages
Doesn't break words. Fixes #31.
2018-02-10 18:02:36 +02:00
Alexey Golub f7ef3bbb44 Bump framework version and small cleanup 2018-01-26 18:13:25 +02:00
Alexey Golub bb2b04d2e5 Add IsDefault and IsCancel for all dialog buttons 2018-01-13 18:15:42 +02:00
Alexey Golub 1f36fb608a Improve CLI help output 2018-01-12 23:29:16 +02:00
Alexey Golub b6e93e5770 Update readme 2018-01-12 21:33:32 +02:00
Alexey Golub 8515efe11b Add command line interface and change solution structure (#26) 2018-01-12 20:28:36 +01:00
Oleksii Holub 7da82f9ef4 Fix crash on channels without topic 2018-01-10 18:28:42 +02:00
Alexey Golub 6ccba43807 Update copyright 2017-12-31 14:12:35 +02:00
Oleksii Holub 2ce9e2c77e Code styling 2017-12-22 16:29:50 +02:00
Oleksii Holub a6a54d688f Deploy on tags 2017-12-19 18:18:00 +02:00
Oleksii Holub e0d2f9c1ba Minor comment fixes 2017-12-19 18:17:42 +02:00
Alexey Golub 01f8cf0e32 Skip tags in appveyor 2017-12-12 14:04:29 +02:00
Alexey Golub ebab80fda1 Update appveyor.yml 2017-12-10 22:41:04 +02:00
Alexey Golub c432a75f62 Simplify deployment script 2017-12-10 22:36:16 +02:00
Alexey Golub be4989ea34 Add icon 2017-11-25 19:06:08 +02:00
Oleksii Holub f8dae72411 Refactor 2017-11-09 17:57:32 +02:00
Oleksii Holub ab46361489 Add channel topic to output
Closes #22
2017-11-09 17:55:04 +02:00
Alexey Golub 4a986fe5df Refactor 2017-10-29 13:09:57 +02:00
Alexey Golub e3d2afac87 Update readme 2017-10-27 22:51:07 +03:00
Alexey Golub 0cfcaca6b2 Add appveyor.yml 2017-10-27 22:46:10 +03:00
Alexey Golub 107c9de265 Add message type to message object 2017-10-27 22:42:12 +03:00
Alexey Golub 5d6edfd937 Update version 2017-10-27 22:36:25 +03:00
Alexey Golub 60a5a69aa4 Improve message date filtering, convert from snowflake to get the 'to' message ID 2017-10-27 22:35:04 +03:00
Alexey Golub cc565598d3 Add support for channel mentions formatting
Closes #19
2017-10-27 22:08:53 +03:00
Alexey Golub 6a7000cbc9 Re-order URL formatting in HTML formatter
Slightly helps #18
2017-10-27 21:34:45 +03:00
Alexey Golub 6706ff95f1 Fix user mentions not resolving on bots 2017-10-27 21:07:54 +03:00
Alexey Golub ecc95c9c7f Make the mentions resolving safer 2017-10-27 21:04:10 +03:00
Alexey Golub d4f2049db6 Fix wrapping issues in html export 2017-10-26 23:36:10 +03:00
Alexey Golub 4d5118de76 Add mention support (#17)
* Support for user mentions and incomplete support for role mentions

* Improve formatting a bit

* Implement a hack to get roles
2017-10-26 22:21:44 +02:00
Alexey Golub 171718989a Add workarounds for non-default message types
Fixes #16
2017-10-26 22:14:50 +03:00
Alexey Golub 75e8d59cc3 Wrap text in inline pre 2017-10-25 23:37:18 +03:00
Alexey Golub 1d7264a71b Add support for custom emojis
Closes #14
2017-10-25 23:31:32 +03:00
Alexey Golub 2c0a63352c Simplify regexes 2017-10-25 22:19:25 +03:00
Alexey Golub 42d4d64695 Add underscore italics to markdown formatter
Fixes #15
2017-10-25 22:04:47 +03:00
Alexey Golub 6968f987ce Small readability improvement 2017-10-16 20:24:34 +03:00
Alexey Golub 5ecbfd50b8 Update deployment script 2017-10-16 13:00:58 +03:00
Alexey Golub e59a1ea8b4 Add deployment script 2017-10-16 12:47:55 +03:00
Alexey Golub 540ce7f3c3 Update readme 2017-10-16 00:37:34 +03:00
Alexey Golub a0d261f966 Add fancy badges 2017-10-15 23:58:33 +03:00
Alexey Golub 10446064d5 Ammy formatting 2017-10-07 22:03:43 +03:00
Alexey Golub 6709821324 Cleanup views 2017-10-06 23:27:17 +03:00
92 changed files with 1812 additions and 1075 deletions
+18
View File
@@ -0,0 +1,18 @@
### v2.4 (08-Apr-2018)
- Added channel topic to output.
- Added a command line interface alternative to the current graphical user interface.
- Implemented application auto-update (can be disabled in settings).
- Improved some GUI elements and replaced some dialogs with notifications.
- Fixed a lot of issues with the markdown processor.
- Fixed DM group chats that have a custom name being shown as blank in the list.
### v2.3 (27-Oct-2017)
- Improved message date filtering, it's now marginally faster.
- Fixed underscores not recognized as italics in markdown.
- Added support for custom emojis.
- Added support for user and role mentions.
- Added support for channel mentions.
- Fixed text in pre blocks not being wrapped correctly.
- Added workaround for non-default message types.
+11
View File
@@ -0,0 +1,11 @@
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
# GUI
$files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
# CLI
$files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.Cli.zip" -Force
+24
View File
@@ -0,0 +1,24 @@
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; }
}
}
+37
View File
@@ -0,0 +1,37 @@
using CommonServiceLocator;
using DiscordChatExporter.Cli.ViewModels;
using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight.Ioc;
namespace DiscordChatExporter.Cli
{
public class Container
{
public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
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);
SimpleIoc.Default.Reset();
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
// View models
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
}
public void Cleanup()
{
}
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework>
<Version>2.4</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
<PackageReference Include="FluentCommandLineParser" Version="1.4.3" />
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
</Project>
+107
View File
@@ -0,0 +1,107 @@
using System;
using System.Reflection;
using DiscordChatExporter.Core.Models;
using Fclp;
namespace DiscordChatExporter.Cli
{
public static class Program
{
private static readonly Container Container = new Container();
private static void ShowHelp()
{
var version = Assembly.GetExecutingAssembly().GetName().Version;
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ===");
Console.WriteLine();
Console.WriteLine("[-t] [--token] Discord authorization token.");
Console.WriteLine("[-c] [--channel] Discord channel ID.");
Console.WriteLine("[-f] [--format] Export format (PlainText/HtmlDark/HtmlLight). Optional.");
Console.WriteLine("[-o] [--output] Output file path. Optional.");
Console.WriteLine(" [--datefrom] Limit to messages after this date. Optional.");
Console.WriteLine(" [--dateto] Limit to messages before this date. Optional.");
Console.WriteLine(" [--dateformat] Date format. Optional.");
Console.WriteLine(" [--grouplimit] Message group limit. Optional.");
Console.WriteLine();
Console.WriteLine("# To get authorization token:");
Console.WriteLine(" - Open Discord app");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Press Ctrl+Shift+I");
Console.WriteLine(" - Navigate to Application tab");
Console.WriteLine(" - 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)
{
// Init container
Container.Init();
// Parse options
var options = ParseOptions(args);
// Inject some settings
var settings = Container.SettingsService;
settings.DateFormat = options.DateFormat;
settings.MessageGroupLimit = options.MessageGroupLimit;
// Export
var vm = Container.MainViewModel;
vm.ExportAsync(
options.Token,
options.ChannelId,
options.FilePath,
options.ExportFormat,
options.From,
options.To).GetAwaiter().GetResult();
// Cleanup container
Container.Cleanup();
Console.WriteLine("Export complete.");
}
}
}
@@ -0,0 +1,12 @@
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);
}
}
@@ -0,0 +1,53 @@
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, channelId, from, to);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var log = new ChannelChatLog(guild, channel, messageGroups, messages.Count);
// Export
await _exportService.ExportAsync(format, filePath, log);
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net461</TargetFramework>
<Version>2.4</Version>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="Onova" Version="2.1.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.0" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
using System;
using System.Net;
namespace DiscordChatExporter.Exceptions
namespace DiscordChatExporter.Core.Exceptions
{
public class HttpErrorStatusCodeException : Exception
{
@@ -0,0 +1,22 @@
using System.IO;
using System.Reflection;
using System.Resources;
namespace DiscordChatExporter.Core.Internal
{
internal static class Extensions
{
public static string GetManifestResourceString(this Assembly assembly, string resourceName)
{
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
throw new MissingManifestResourceException($"Could not find resource [{resourceName}].");
using (stream)
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
}
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public class Attachment
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public enum AttachmentType
{
@@ -0,0 +1,37 @@
namespace DiscordChatExporter.Core.Models
{
public partial class Channel
{
public string Id { get; }
public string GuildId { get; }
public string Name { get; }
public string Topic { get; }
public ChannelType Type { get; }
public Channel(string id, string guildId, string name, string topic, ChannelType type)
{
Id = id;
GuildId = guildId;
Name = name;
Topic = topic;
Type = type;
}
public override string ToString()
{
return Name;
}
}
public partial class Channel
{
public static Channel CreateDeletedChannel(string id)
{
return new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
}
}
}
@@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public class ChannelChatLog
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public enum ChannelType
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public enum ExportFormat
{
@@ -1,6 +1,6 @@
using System;
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public static class Extensions
{
@@ -13,7 +13,7 @@ namespace DiscordChatExporter.Models
if (format == ExportFormat.HtmlLight)
return "html";
throw new NotImplementedException();
throw new ArgumentOutOfRangeException(nameof(format));
}
public static string GetDisplayName(this ExportFormat format)
@@ -25,7 +25,7 @@ namespace DiscordChatExporter.Models
if (format == ExportFormat.HtmlLight)
return "HTML (Light)";
throw new NotImplementedException();
throw new ArgumentOutOfRangeException(nameof(format));
}
}
}
@@ -1,8 +1,9 @@
using Tyrrrz.Extensions;
using System.Collections.Generic;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public class Guild
public partial class Guild
{
public string Id { get; }
@@ -14,11 +15,14 @@ namespace DiscordChatExporter.Models
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
public Guild(string id, string name, string iconHash)
public IReadOnlyList<Role> Roles { get; }
public Guild(string id, string name, string iconHash, IReadOnlyList<Role> roles)
{
Id = id;
Name = name;
IconHash = iconHash;
Roles = roles;
}
public override string ToString()
@@ -26,4 +30,9 @@ namespace DiscordChatExporter.Models
return Name;
}
}
public partial class Guild
{
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null, new Role[0]);
}
}
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public class Message
{
public string Id { get; }
public string ChannelId { get; }
public MessageType Type { get; }
public User Author { get; }
public DateTime TimeStamp { get; }
public DateTime? EditedTimeStamp { get; }
public string Content { get; }
public IReadOnlyList<Attachment> Attachments { get; }
public IReadOnlyList<User> MentionedUsers { get; }
public IReadOnlyList<Role> MentionedRoles { get; }
public IReadOnlyList<Channel> MentionedChannels { get; }
public Message(string id, string channelId, MessageType type,
User author, DateTime timeStamp,
DateTime? editedTimeStamp, string content,
IReadOnlyList<Attachment> attachments, IReadOnlyList<User> mentionedUsers,
IReadOnlyList<Role> mentionedRoles, IReadOnlyList<Channel> mentionedChannels)
{
Id = id;
ChannelId = channelId;
Type = type;
Author = author;
TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp;
Content = content;
Attachments = attachments;
MentionedUsers = mentionedUsers;
MentionedRoles = mentionedRoles;
MentionedChannels = mentionedChannels;
}
public override string ToString()
{
return Content;
}
}
}
@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public class MessageGroup
{
@@ -0,0 +1,14 @@
namespace DiscordChatExporter.Core.Models
{
public enum MessageType
{
Default,
RecipientAdd,
RecipientRemove,
Call,
ChannelNameChange,
ChannelIconChange,
ChannelPinnedMessage,
GuildMemberJoin
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace DiscordChatExporter.Core.Models
{
public partial class Role
{
public string Id { get; }
public string Name { get; }
public Role(string id, string name)
{
Id = id;
Name = name;
}
public override string ToString()
{
return Name;
}
}
public partial class Role
{
public static Role CreateDeletedRole(string id)
{
return new Role(id, "deleted-role");
}
}
}
@@ -1,6 +1,6 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Models
namespace DiscordChatExporter.Core.Models
{
public class User
{
@@ -10,11 +10,15 @@ namespace DiscordChatExporter.Models
public string Name { get; }
public string FullName => $"{Name}#{Discriminator:0000}";
public string AvatarHash { get; }
public string DefaultAvatarHash => $"{Discriminator % 5}";
public string AvatarUrl => AvatarHash.IsNotBlank()
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
: $"https://cdn.discordapp.com/embed/avatars/{Discriminator % 5}.png";
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
public User(string id, int discriminator, string name, string avatarHash)
{
@@ -26,7 +30,7 @@ namespace DiscordChatExporter.Models
public override string ToString()
{
return $"{Name}#{Discriminator}";
return FullName;
}
}
}
@@ -10,7 +10,9 @@ a {
text-decoration: none;
}
a:hover { text-decoration: underline; }
a:hover {
text-decoration: underline;
}
div.pre {
background-color: #2F3136;
@@ -18,7 +20,7 @@ div.pre {
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre;
white-space: pre-wrap;
}
span.pre {
@@ -26,7 +28,7 @@ span.pre {
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
white-space: pre;
white-space: pre-wrap;
}
div#info {
@@ -37,7 +39,9 @@ div#info {
max-width: 100%;
}
div#log { max-width: 100%; }
div#log {
max-width: 100%;
}
img.guild-icon {
max-height: 64px;
@@ -51,15 +55,22 @@ div.info-right {
div.guild-name {
color: #FFFFFF;
font-size: 1.4rem;
font-size: 1.4em;
}
div.channel-name {
color: #FFFFFF;
font-size: 1.2rem;
font-size: 1.2em;
}
div.misc { margin-top: 2px; }
div.channel-topic {
margin-top: 2px;
color: #FFFFFF;
}
div.channel-messagecount {
margin-top: 2px;
}
div.msg {
border-top: 1px solid rgba(255, 255, 255, 0.04);
@@ -84,28 +95,30 @@ img.msg-avatar {
div.msg-right {
flex: 1;
margin-left: 20px;
min-width: 50%;
}
span.msg-user {
color: #FFFFFF;
font-size: 1rem;
font-size: 1em;
}
span.msg-date {
color: rgba(255, 255, 255, 0.2);
font-size: .75rem;
font-size: .75em;
margin-left: 5px;
}
span.msg-edited {
color: rgba(255, 255, 255, 0.2);
font-size: .8rem;
font-size: .8em;
margin-left: 5px;
}
div.msg-content {
font-size: .9375rem;
font-size: .9375em;
padding-top: 5px;
word-wrap: break-word;
}
div.msg-attachment {
@@ -116,4 +129,14 @@ div.msg-attachment {
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -10,7 +10,9 @@ a {
text-decoration: none;
}
a:hover { text-decoration: underline; }
a:hover {
text-decoration: underline;
}
div.pre {
background-color: #F9F9F9;
@@ -18,6 +20,7 @@ div.pre {
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
}
span.pre {
@@ -25,6 +28,7 @@ span.pre {
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
white-space: pre-wrap;
}
div#info {
@@ -35,7 +39,9 @@ div#info {
max-width: 100%;
}
div#log { max-width: 100%; }
div#log {
max-width: 100%;
}
img.guild-icon {
max-height: 64px;
@@ -49,15 +55,22 @@ div.info-right {
div.guild-name {
color: #2F3136;
font-size: 1.4rem;
font-size: 1.4em;
}
div.channel-name {
color: #2F3136;
font-size: 1.2rem;
font-size: 1.2em;
}
div.misc { margin-top: 2px; }
div.channel-topic {
margin-top: 2px;
color: #2F3136;
}
div.channel-messagecount {
margin-top: 2px;
}
div.msg {
border-top: 1px solid #ECEEEF;
@@ -82,28 +95,30 @@ img.msg-avatar {
div.msg-right {
flex: 1;
margin-left: 20px;
min-width: 50%;
}
span.msg-user {
color: #2F3136;
font-size: 1rem;
font-size: 1em;
}
span.msg-date {
color: #99AAB5;
font-size: .75rem;
font-size: .75em;
margin-left: 5px;
}
span.msg-edited {
color: #99AAB5;
font-size: .8rem;
font-size: .8em;
margin-left: 5px;
}
div.msg-content {
font-size: .9375rem;
font-size: .9375em;
padding-top: 5px;
word-wrap: break-word;
}
div.msg-attachment {
@@ -114,4 +129,14 @@ div.msg-attachment {
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -0,0 +1,326 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService : IDataService, IDisposable
{
private const string ApiRoot = "https://discordapp.com/api/v6";
private readonly HttpClient _httpClient = new HttpClient();
private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>();
private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>();
private User ParseUser(JToken token)
{
var id = token["id"].Value<string>();
var discriminator = token["discriminator"].Value<int>();
var name = token["username"].Value<string>();
var avatarHash = token["avatar"].Value<string>();
return new User(id, discriminator, name, avatarHash);
}
private Role ParseRole(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
return new Role(id, name);
}
private Guild ParseGuild(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
var iconHash = token["icon"].Value<string>();
var roles = token["roles"].Select(ParseRole).ToArray();
return new Guild(id, name, iconHash, roles);
}
private Channel ParseChannel(JToken token)
{
// Get basic data
var id = token["id"].Value<string>();
var guildId = token["guild_id"]?.Value<string>();
var type = (ChannelType) token["type"].Value<int>();
var topic = token["topic"]?.Value<string>();
// Extract name based on type
string name;
if (type.IsEither(ChannelType.DirectTextChat, ChannelType.DirectGroupTextChat))
{
guildId = Guild.DirectMessages.Id;
// Try to get name if it's set
name = token["name"]?.Value<string>();
// Otherwise use recipients as the name
if (name.IsBlank())
name = token["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
}
else
{
name = token["name"].Value<string>();
}
return new Channel(id, guildId, name, topic, type);
}
private Message ParseMessage(JToken token)
{
// Get basic data
var id = token["id"].Value<string>();
var channelId = token["channel_id"].Value<string>();
var timeStamp = token["timestamp"].Value<DateTime>();
var editedTimeStamp = token["edited_timestamp"]?.Value<DateTime?>();
var content = token["content"].Value<string>();
var type = (MessageType) token["type"].Value<int>();
// Workarounds for non-default types
if (type == MessageType.RecipientAdd)
content = "Added a recipient.";
else if (type == MessageType.RecipientRemove)
content = "Removed a recipient.";
else if (type == MessageType.Call)
content = "Started a call.";
else if (type == MessageType.ChannelNameChange)
content = "Changed the channel name.";
else if (type == MessageType.ChannelIconChange)
content = "Changed the channel icon.";
else if (type == MessageType.ChannelPinnedMessage)
content = "Pinned a message.";
else if (type == MessageType.GuildMemberJoin)
content = "Joined the server.";
// Get author
var author = ParseUser(token["author"]);
// Get attachment
var attachments = new List<Attachment>();
foreach (var attachmentJson in token["attachments"].EmptyIfNull())
{
var attachmentId = attachmentJson["id"].Value<string>();
var attachmentUrl = attachmentJson["url"].Value<string>();
var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image
: AttachmentType.Other;
var attachmentFileName = attachmentJson["filename"].Value<string>();
var attachmentFileSize = attachmentJson["size"].Value<long>();
var attachment = new Attachment(
attachmentId, attachmentType, attachmentUrl,
attachmentFileName, attachmentFileSize);
attachments.Add(attachment);
}
// Get user mentions
var mentionedUsers = token["mentions"].Select(ParseUser).ToArray();
// Get role mentions
var mentionedRoles = token["mention_roles"]
.Values<string>()
.Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(id))
.ToArray();
// Get channel mentions
var mentionedChannels = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(id))
.ToArray();
return new Message(id, channelId, type, author, timeStamp, editedTimeStamp, content, attachments,
mentionedUsers, mentionedRoles, mentionedChannels);
}
private async Task<string> GetStringAsync(string url)
{
using (var response = await _httpClient.GetAsync(url))
{
// Check status code
// We throw our own exception here because default one doesn't have status code
if (!response.IsSuccessStatusCode)
throw new HttpErrorStatusCodeException(response.StatusCode);
// Get content
return await response.Content.ReadAsStringAsync();
}
}
public async Task<Guild> GetGuildAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var guild = ParseGuild(JToken.Parse(content));
// Add roles to cache
foreach (var role in guild.Roles)
_roleCache[role.Id] = role;
return guild;
}
public async Task<Channel> GetChannelAsync(string token, string channelId)
{
// Form request url
var url = $"{ApiRoot}/channels/{channelId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channel = ParseChannel(JToken.Parse(content));
// Add channel to cache
_channelCache[channel.Id] = channel;
return channel;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
// Add channels to cache
foreach (var channel in channels)
_channelCache[channel.Id] = channel;
return channels;
}
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
// Get response
var content = await GetStringAsync(url);
// Parse IDs
var guildIds = JArray.Parse(content).Select(t => t["id"].Value<string>());
// Get full guild infos
var guilds = new List<Guild>();
foreach (var guildId in guildIds)
{
var guild = await GetGuildAsync(token, guildId);
guilds.Add(guild);
}
return guilds;
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to)
{
var result = new List<Message>();
// We are going backwards from last message to first
// collecting everything between them in batches
var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
while (true)
{
// Form request url
var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
if (beforeId.IsNotBlank())
url += $"&before={beforeId}";
// Get response
var content = await GetStringAsync(url);
// Parse
var messages = JArray.Parse(content).Select(ParseMessage);
// Add messages to list
string currentMessageId = null;
foreach (var message in messages)
{
// Break when the message is older than from date
if (from != null && message.TimeStamp < from)
{
currentMessageId = null;
break;
}
// Add message
result.Add(message);
currentMessageId = message.Id;
}
// If no messages - break
if (currentMessageId == null)
break;
// Otherwise offset the next request
beforeId = currentMessageId;
}
// Messages appear newest first, we need to reverse
result.Reverse();
return result;
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_httpClient.Dispose();
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
public partial class DataService
{
private static string DateTimeToSnowflake(DateTime dateTime)
{
const long epoch = 62135596800000;
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
}
}
}
@@ -0,0 +1,208 @@
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentHtml(Message message)
{
// A lot of these regexes were inspired by or taken from MarkdownSharp
var content = message.Content;
// HTML-encode content
content = HtmlEncode(content);
// Encode multiline codeblocks (```text```)
content = Regex.Replace(content,
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
m => $"\x1AM{Base64Encode(m.Groups[1].Value)}\x1AM");
// Encode inline codeblocks (`text`)
content = Regex.Replace(content,
@"`([^`]+)`",
m => $"\x1AI{Base64Encode(m.Groups[1].Value)}\x1AI");
// Encode URLs
content = Regex.Replace(content,
@"((https?|ftp)://[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\]\(\);]*[-a-zA-Z0-9+&@#/%=~_|\[\])])(?=$|\W)",
m => $"\x1AL{Base64Encode(m.Groups[1].Value)}\x1AL");
// Process bold (**text**)
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
// Process underline (__text__)
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
// Process italic (*text* or _text_)
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
// Process strike through (~~text~~)
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
// Decode and process multiline codeblocks
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
m => $"<div class=\"pre\">{Base64Decode(m.Groups[1].Value)}</div>");
// Decode and process inline codeblocks
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre\">{Base64Decode(m.Groups[1].Value)}</span>");
// Decode and process URLs
content = Regex.Replace(content, "\x1AL(.*?)\x1AL",
m => $"<a href=\"{Base64Decode(m.Groups[1].Value)}\">{Base64Decode(m.Groups[1].Value)}</a>");
// New lines
content = content.Replace("\n", "<br />");
// Meta mentions (@everyone)
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
{
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
{
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
{
content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css)
{
// Generation info
await output.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Html start
await output.WriteLineAsync("<!DOCTYPE html>");
await output.WriteLineAsync("<html lang=\"en\">");
// HEAD
await output.WriteLineAsync("<head>");
await output.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
await output.WriteLineAsync("<meta charset=\"utf-8\" />");
await output.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await output.WriteLineAsync($"<style>{css}</style>");
await output.WriteLineAsync("</head>");
// Body start
await output.WriteLineAsync("<body>");
// Guild and channel info
await output.WriteLineAsync("<div id=\"info\">");
await output.WriteLineAsync("<div class=\"info-left\">");
await output.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await output.WriteLineAsync("</div>"); // info-left
await output.WriteLineAsync("<div class=\"info-right\">");
await output.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-topic\">{log.Channel.Topic}</div>");
await output.WriteLineAsync(
$"<div class=\"channel-messagecount\">{log.TotalMessageCount:N0} messages</div>");
await output.WriteLineAsync("</div>"); // info-right
await output.WriteLineAsync("</div>"); // info
// Chat log
await output.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{
await output.WriteLineAsync("<div class=\"msg\">");
await output.WriteLineAsync("<div class=\"msg-left\">");
await output.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
await output.WriteLineAsync("</div>");
await output.WriteLineAsync("<div class=\"msg-right\">");
await output.WriteAsync(
$"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author.FullName)}\">");
await output.WriteAsync(HtmlEncode(group.Author.Name));
await output.WriteLineAsync("</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
await output.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
await output.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContentHtml(message);
await output.WriteAsync(contentFormatted);
// Edited timestamp
if (message.EditedTimeStamp != null)
{
var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
await output.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
await output.WriteLineAsync("</div>"); // msg-content
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.Type == AttachmentType.Image)
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await output.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
else
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
await output.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
}
}
await output.WriteLineAsync("</div>"); // msg-right
await output.WriteLineAsync("</div>"); // msg
}
await output.WriteLineAsync("</div>"); // log
await output.WriteLineAsync("</body>");
await output.WriteLineAsync("</html>");
}
}
}
@@ -0,0 +1,79 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentPlainText(Message message)
{
var content = message.Content;
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private async Task ExportAsPlainTextAsync(ChannelChatLog log, TextWriter output)
{
// Generation info
await output.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter");
await output.WriteLineAsync();
// Guild and channel info
await output.WriteLineAsync('='.Repeat(48));
await output.WriteLineAsync($"Guild: {log.Guild.Name}");
await output.WriteLineAsync($"Channel: {log.Channel.Name}");
await output.WriteLineAsync($"Topic: {log.Channel.Topic}");
await output.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}");
await output.WriteLineAsync('='.Repeat(48));
await output.WriteLineAsync();
// Chat log
foreach (var group in log.MessageGroups)
{
var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat);
await output.WriteLineAsync($"{group.Author.FullName} [{timeStampFormatted}]");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var contentFormatted = FormatMessageContentPlainText(message);
await output.WriteLineAsync(contentFormatted);
}
// Attachments
foreach (var attachment in message.Attachments)
{
await output.WriteLineAsync(attachment.Url);
}
}
await output.WriteLineAsync();
}
}
}
}
@@ -0,0 +1,81 @@
using System;
using System.IO;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService : IExportService
{
private readonly ISettingsService _settingsService;
public ExportService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log)
{
using (var output = File.CreateText(filePath))
{
if (format == ExportFormat.PlainText)
{
await ExportAsPlainTextAsync(log, output);
}
else if (format == ExportFormat.HtmlDark)
{
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css");
await ExportAsHtmlAsync(log, output, css);
}
else if (format == ExportFormat.HtmlLight)
{
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css");
await ExportAsHtmlAsync(log, output, css);
}
else throw new ArgumentOutOfRangeException(nameof(format));
}
}
}
public partial class ExportService
{
private static string Base64Encode(string str)
{
return str.GetBytes().ToBase64();
}
private static string Base64Decode(string str)
{
return str.FromBase64().GetString();
}
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static string FormatFileSize(long fileSize)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
var unit = 0;
while (size >= 1024)
{
size /= 1024;
++unit;
}
return $"{size:0.#} {units[unit]}";
}
}
}
@@ -1,18 +1,22 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Services
namespace DiscordChatExporter.Core.Services
{
public interface IDataService
{
Task<IReadOnlyList<Guild>> GetGuildsAsync(string token);
Task<Guild> GetGuildAsync(string token, string guildId);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
Task<Channel> GetChannelAsync(string token, string channelId);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to);
}
@@ -1,7 +1,7 @@
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Services
namespace DiscordChatExporter.Core.Services
{
public interface IExportService
{
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Services
namespace DiscordChatExporter.Core.Services
{
public interface IMessageGroupService
{
@@ -1,9 +1,11 @@
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Services
namespace DiscordChatExporter.Core.Services
{
public interface ISettingsService
{
bool IsAutoUpdateEnabled { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
@@ -0,0 +1,14 @@
using System;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Services
{
public interface IUpdateService
{
bool NeedRestart { get; set; }
Task<Version> CheckPrepareUpdateAsync();
void FinalizeUpdate();
}
}
@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Services
namespace DiscordChatExporter.Core.Services
{
public class MessageGroupService : IMessageGroupService
{
@@ -1,10 +1,12 @@
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Settings;
namespace DiscordChatExporter.Services
namespace DiscordChatExporter.Core.Services
{
public class SettingsService : SettingsManager, ISettingsService
{
public bool IsAutoUpdateEnabled { get; set; } = true;
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20;
@@ -0,0 +1,63 @@
using System;
using System.Threading.Tasks;
using Onova;
using Onova.Services;
namespace DiscordChatExporter.Core.Services
{
public class UpdateService : IUpdateService
{
private readonly ISettingsService _settingsService;
private readonly IUpdateManager _manager;
private Version _updateVersion;
private bool _updateFinalized;
public bool NeedRestart { get; set; }
public UpdateService(ISettingsService settingsService)
{
_settingsService = settingsService;
_manager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
}
public async Task<Version> CheckPrepareUpdateAsync()
{
// If auto-update is disabled - don't check for updates
if (!_settingsService.IsAutoUpdateEnabled)
return null;
// Cleanup leftover files
_manager.Cleanup();
// Check for updates
var check = await _manager.CheckForUpdatesAsync();
if (!check.CanUpdate)
return null;
// Prepare the update
if (!_manager.IsUpdatePrepared(check.LastVersion))
await _manager.PrepareUpdateAsync(check.LastVersion);
return _updateVersion = check.LastVersion;
}
public void FinalizeUpdate()
{
// Check if an update is pending
if (_updateVersion == null)
return;
// Check if the update has already been finalized
if (_updateFinalized)
return;
// Launch the updater
_manager.LaunchUpdater(_updateVersion, NeedRestart);
_updateFinalized = true;
}
}
}
@@ -1,4 +1,4 @@
Application "DiscordChatExporter.App" {
Application "DiscordChatExporter.Gui.App" {
StartupUri: "Views/MainWindow.g.xaml"
Startup: App_Startup
Exit: App_Exit
@@ -1,9 +1,11 @@
using System.Windows;
namespace DiscordChatExporter
namespace DiscordChatExporter.Gui
{
public partial class App
{
private Container Container => (Container) Resources["Container"];
private void App_Startup(object sender, StartupEventArgs e)
{
Container.Init();
+41
View File
@@ -0,0 +1,41 @@
using CommonServiceLocator;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels;
using GalaSoft.MvvmLight.Ioc;
namespace DiscordChatExporter.Gui
{
public class Container
{
public IExportSetupViewModel ExportSetupViewModel => Resolve<IExportSetupViewModel>();
public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>();
private T Resolve<T>(string key = null)
{
return ServiceLocator.Current.GetInstance<T>(key);
}
public void Init()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
// View models
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
}
public void Cleanup()
{
}
}
}
@@ -6,7 +6,7 @@
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{732A67AF-93DE-49DF-B10F-FD74710B7863}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>DiscordChatExporter</RootNamespace>
<RootNamespace>DiscordChatExporter.Gui</RootNamespace>
<AssemblyName>DiscordChatExporter</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
@@ -35,79 +35,62 @@
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<StartupObject />
</PropertyGroup>
<ItemGroup>
<Reference Include="AmmySidekick, Version=1.0.0.0, Culture=neutral, PublicKeyToken=7c1296d24569a67d, processorArchitecture=MSIL">
<HintPath>..\packages\Ammy.WPF.1.2.87\lib\net40\AmmySidekick.dll</HintPath>
<HintPath>..\packages\Ammy.WPF.1.2.94\lib\net40\AmmySidekick.dll</HintPath>
</Reference>
<Reference Include="GalaSoft.MvvmLight, Version=5.3.0.19026, Culture=neutral, PublicKeyToken=e7570ab207bcb616, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.dll</HintPath>
<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.Extras, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=669f0b5e8f868abf, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Extras.dll</HintPath>
<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.Platform, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
<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.3.1.953, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignThemes.2.3.1.953\lib\net45\MaterialDesignThemes.Wpf.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Practices.ServiceLocation, Version=1.3.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
<HintPath>..\packages\CommonServiceLocator.1.3\lib\portable-net4+sl5+netcore45+wpa81+wp8\Microsoft.Practices.ServiceLocation.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json, Version=10.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
<Reference Include="MaterialDesignThemes.Wpf, Version=2.4.0.1044, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignThemes.2.4.0.1044\lib\net45\MaterialDesignThemes.Wpf.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Windows.Interactivity, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\System.Windows.Interactivity.dll</HintPath>
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\System.Windows.Interactivity.dll</HintPath>
</Reference>
<Reference Include="System.Xaml">
<RequiredTargetFramework>4.0</RequiredTargetFramework>
</Reference>
<Reference Include="System.Xml" />
<Reference Include="Tyrrrz.Extensions, Version=1.4.1.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Extensions.1.4.1\lib\net45\Tyrrrz.Extensions.dll</HintPath>
</Reference>
<Reference Include="Tyrrrz.Settings, Version=1.3.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Settings.1.3.0\lib\net45\Tyrrrz.Settings.dll</HintPath>
<Reference Include="Tyrrrz.Extensions, Version=1.5.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Tyrrrz.Extensions.1.5.0\lib\net45\Tyrrrz.Extensions.dll</HintPath>
</Reference>
<Reference Include="Tyrrrz.Settings, Version=1.3.2.0, Culture=neutral, PublicKeyToken=null" />
<Reference Include="WindowsBase" />
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
</ItemGroup>
<ItemGroup>
<Compile Include="Exceptions\HttpErrorStatusCodeException.cs" />
<Compile Include="Messages\ShowErrorMessage.cs" />
<Compile Include="Messages\ShowExportDoneMessage.cs" />
<Compile Include="Messages\ShowExportSetupMessage.cs" />
<Compile Include="Messages\ShowNotificationMessage.cs" />
<Compile Include="Messages\ShowSettingsMessage.cs" />
<Compile Include="Messages\StartExportMessage.cs" />
<Compile Include="Models\AttachmentType.cs" />
<Compile Include="Models\ChannelChatLog.cs" />
<Compile Include="Models\ChannelType.cs" />
<Compile Include="Models\ExportFormat.cs" />
<Compile Include="Models\Extensions.cs" />
<Compile Include="Services\IMessageGroupService.cs" />
<Compile Include="Services\MessageGroupService.cs" />
<Compile Include="ViewModels\ErrorViewModel.cs" />
<Compile Include="ViewModels\ExportSetupViewModel.cs" />
<Compile Include="ViewModels\IErrorViewModel.cs" />
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
<Compile Include="ViewModels\ISettingsViewModel.cs" />
<Compile Include="ViewModels\IExportDoneViewModel.cs" />
<Compile Include="ViewModels\SettingsViewModel.cs" />
<Compile Include="ViewModels\ExportDoneViewModel.cs" />
<Compile Include="Views\ErrorDialog.ammy.cs">
<DependentUpon>ErrorDialog.ammy</DependentUpon>
</Compile>
<Compile Include="Views\ExportDoneDialog.ammy.cs">
<DependentUpon>ExportDoneDialog.ammy</DependentUpon>
</Compile>
<Compile Include="Views\ExportSetupDialog.ammy.cs">
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
</Compile>
@@ -124,11 +107,6 @@
<Generator>MSBuild:Compile</Generator>
<DependentUpon>ErrorDialog.ammy</DependentUpon>
</Page>
<Page Include="Views\ExportDoneDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>ExportDoneDialog.ammy</DependentUpon>
</Page>
<Page Include="Views\ExportSetupDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
@@ -143,19 +121,7 @@
<DependentUpon>App.ammy</DependentUpon>
</Compile>
<Compile Include="Container.cs" />
<Compile Include="Models\Attachment.cs" />
<Compile Include="Models\Channel.cs" />
<Compile Include="Models\Guild.cs" />
<Compile Include="Models\Message.cs" />
<Compile Include="Models\MessageGroup.cs" />
<Compile Include="Models\User.cs" />
<Compile Include="Program.cs" />
<Compile Include="Services\DataService.cs" />
<Compile Include="Services\ExportService.cs" />
<Compile Include="Services\IDataService.cs" />
<Compile Include="Services\IExportService.cs" />
<Compile Include="Services\ISettingsService.cs" />
<Compile Include="Services\SettingsService.cs" />
<Compile Include="ViewModels\IMainViewModel.cs" />
<Compile Include="ViewModels\MainViewModel.cs" />
<Compile Include="Views\MainWindow.ammy.cs">
@@ -181,29 +147,31 @@
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="App.ammy" />
<None Include="app.config" />
<None Include="lib.ammy" />
<None Include="packages.config">
<SubType>Designer</SubType>
</None>
<None Include="Views\ErrorDialog.ammy" />
<None Include="Views\ExportDoneDialog.ammy" />
<None Include="Views\ExportSetupDialog.ammy" />
<None Include="Views\MainWindow.ammy" />
<None Include="Views\SettingsDialog.ammy" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<Resource Include="..\favicon.ico" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj">
<Project>{707c0cd0-a7e0-4cab-8db9-07a45cb87377}</Project>
<Name>DiscordChatExporter.Core</Name>
</ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\packages\Ammy.1.2.87\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" />
<Import Project="..\packages\Ammy.1.2.94\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.94\build\Ammy.targets')" />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Ammy.1.2.87\build\Ammy.targets'))" />
<Error Condition="!Exists('..\packages\Ammy.1.2.94\build\Ammy.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Ammy.1.2.94\build\Ammy.targets'))" />
</Target>
</Project>
@@ -1,6 +1,6 @@
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Messages
namespace DiscordChatExporter.Gui.Messages
{
public class ShowExportSetupMessage
{
@@ -0,0 +1,25 @@
using System;
namespace DiscordChatExporter.Gui.Messages
{
public class ShowNotificationMessage
{
public string Message { get; }
public string CallbackCaption { get; }
public Action Callback { get; }
public ShowNotificationMessage(string message)
{
Message = message;
}
public ShowNotificationMessage(string message, string callbackCaption, Action callback)
: this(message)
{
CallbackCaption = callbackCaption;
Callback = callback;
}
}
}
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Messages
namespace DiscordChatExporter.Gui.Messages
{
public class ShowSettingsMessage
{
@@ -1,7 +1,7 @@
using System;
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Messages
namespace DiscordChatExporter.Gui.Messages
{
public class StartExportMessage
{
+17
View File
@@ -0,0 +1,17 @@
using System;
using AmmySidekick;
namespace DiscordChatExporter.Gui
{
public static class Program
{
[STAThread]
public static void Main()
{
var app = new App();
app.InitializeComponent();
RuntimeUpdateHandler.Register(app, $"/{Ammy.GetAssemblyName(app)};component/App.g.xaml");
app.Run();
}
}
}
@@ -0,0 +1,7 @@
using System.Reflection;
[assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
[assembly: AssemblyVersion("2.4")]
[assembly: AssemblyFileVersion("2.4")]
@@ -8,10 +8,10 @@
// </auto-generated>
//------------------------------------------------------------------------------
namespace DiscordChatExporter.Properties
{
namespace DiscordChatExporter.Gui.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
@@ -19,51 +19,43 @@ namespace DiscordChatExporter.Properties
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources
{
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources()
{
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager
{
get
{
if ((resourceMan == null))
{
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DiscordChatExporter.Properties.Resources", typeof(Resources).Assembly);
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DiscordChatExporter.Gui.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture
{
get
{
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set
{
set {
resourceCulture = value;
}
}
@@ -2,14 +2,14 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DiscordChatExporter.Messages;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.ViewModels
namespace DiscordChatExporter.Gui.ViewModels
{
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
{
@@ -81,7 +81,7 @@ namespace DiscordChatExporter.ViewModels
Guild = m.Guild;
Channel = m.Channel;
SelectedFormat = _settingsService.LastExportFormat;
FilePath = $"{Guild} - {Channel}.{SelectedFormat.GetFileExtension()}"
FilePath = $"{Guild.Name} - {Channel.Name}.{SelectedFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
From = null;
To = null;
@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.ViewModels
namespace DiscordChatExporter.Gui.ViewModels
{
public interface IExportSetupViewModel
{
@@ -1,8 +1,8 @@
using System.Collections.Generic;
using DiscordChatExporter.Models;
using DiscordChatExporter.Core.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.ViewModels
namespace DiscordChatExporter.Gui.ViewModels
{
public interface IMainViewModel
{
@@ -15,6 +15,8 @@ namespace DiscordChatExporter.ViewModels
Guild SelectedGuild { get; set; }
IReadOnlyList<Channel> AvailableChannels { get; }
RelayCommand ViewLoadedCommand { get; }
RelayCommand ViewClosedCommand { get; }
RelayCommand PullDataCommand { get; }
RelayCommand ShowSettingsCommand { get; }
RelayCommand ShowAboutCommand { get; }
@@ -1,7 +1,9 @@
namespace DiscordChatExporter.ViewModels
namespace DiscordChatExporter.Gui.ViewModels
{
public interface ISettingsViewModel
{
bool IsAutoUpdateEnabled { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
}
@@ -3,19 +3,21 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using DiscordChatExporter.Exceptions;
using DiscordChatExporter.Messages;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
using System.Windows;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.ViewModels
namespace DiscordChatExporter.Gui.ViewModels
{
public class MainViewModel : ViewModelBase, IMainViewModel
{
private readonly ISettingsService _settingsService;
private readonly IUpdateService _updateService;
private readonly IDataService _dataService;
private readonly IMessageGroupService _messageGroupService;
private readonly IExportService _exportService;
@@ -81,15 +83,18 @@ namespace DiscordChatExporter.ViewModels
private set => Set(ref _availableChannels, value);
}
public RelayCommand ViewLoadedCommand { get; }
public RelayCommand ViewClosedCommand { get; }
public RelayCommand PullDataCommand { get; }
public RelayCommand ShowSettingsCommand { get; }
public RelayCommand ShowAboutCommand { get; }
public RelayCommand<Channel> ShowExportSetupCommand { get; }
public MainViewModel(ISettingsService settingsService, IDataService dataService,
public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService,
IMessageGroupService messageGroupService, IExportService exportService)
{
_settingsService = settingsService;
_updateService = updateService;
_dataService = dataService;
_messageGroupService = messageGroupService;
_exportService = exportService;
@@ -97,19 +102,54 @@ namespace DiscordChatExporter.ViewModels
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
// Commands
ViewLoadedCommand = new RelayCommand(ViewLoaded);
ViewClosedCommand = new RelayCommand(ViewClosed);
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout);
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
// Messages
MessengerInstance.Register<StartExportMessage>(this, m =>
{
Export(m.Channel, m.FilePath, m.Format, m.From, m.To);
});
MessengerInstance.Register<StartExportMessage>(this,
m => Export(m.Channel, m.FilePath, m.Format, m.From, m.To));
}
// Defaults
_token = _settingsService.LastToken;
private async void ViewLoaded()
{
// Load settings
_settingsService.Load();
// Set last token
Token = _settingsService.LastToken;
// Check and prepare update
try
{
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
MessengerInstance.Send(new ShowNotificationMessage(
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.NeedRestart = true;
Application.Current.Shutdown();
}));
}
}
catch
{
MessengerInstance.Send(new ShowNotificationMessage("Failed to perform application auto-update"));
}
}
private void ViewClosed()
{
// Save settings
_settingsService.Save();
// Finalize updates if available
_updateService.FinalizeUpdate();
}
private async void PullData()
@@ -130,13 +170,13 @@ namespace DiscordChatExporter.ViewModels
// Get DM channels
{
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
var guild = new Guild("@me", "Direct Messages", null);
var guild = Guild.DirectMessages;
_guildChannelsMap[guild] = channels.ToArray();
}
// Get guild channels
{
var guilds = await _dataService.GetGuildsAsync(token);
var guilds = await _dataService.GetUserGuildsAsync(token);
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
@@ -146,13 +186,11 @@ namespace DiscordChatExporter.ViewModels
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
const string message = "Unauthorized to perform request. Make sure token is valid.";
MessengerInstance.Send(new ShowErrorMessage(message));
MessengerInstance.Send(new ShowNotificationMessage("Unauthorized make sure the token is valid"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
const string message = "Forbidden to perform request. The account may be locked by 2FA.";
MessengerInstance.Send(new ShowErrorMessage(message));
MessengerInstance.Send(new ShowNotificationMessage("Forbidden account may be locked by 2FA"));
}
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
@@ -197,12 +235,12 @@ namespace DiscordChatExporter.ViewModels
await _exportService.ExportAsync(format, filePath, log);
// Notify completion
MessengerInstance.Send(new ShowExportDoneMessage(filePath));
MessengerInstance.Send(new ShowNotificationMessage("Export complete",
"OPEN", () => Process.Start(filePath)));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
const string message = "Forbidden to view messages in that channel.";
MessengerInstance.Send(new ShowErrorMessage(message));
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
}
IsBusy = false;
@@ -1,13 +1,19 @@
using DiscordChatExporter.Services;
using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.ViewModels
namespace DiscordChatExporter.Gui.ViewModels
{
public class SettingsViewModel : ViewModelBase, ISettingsViewModel
{
private readonly ISettingsService _settingsService;
public bool IsAutoUpdateEnabled
{
get => _settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value;
}
public string DateFormat
{
get => _settingsService.DateFormat;
@@ -0,0 +1,27 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Gui.Views.ErrorDialog" {
DataContext: bind ErrorViewModel from $resource Container
Width: 250
StackPanel {
// Message
TextBlock {
Margin: 16
FontSize: 16
TextWrapping: WrapWithOverflow
Text: bind Message
}
// OK
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "OK"
HorizontalAlignment: Right
IsDefault: true
IsCancel: true
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Views
namespace DiscordChatExporter.Gui.Views
{
public partial class ErrorDialog
{
@@ -1,14 +1,14 @@
using DiscordChatExporter.Models
using DiscordChatExporter.Core.Models
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
UserControl "DiscordChatExporter.Gui.Views.ExportSetupDialog" {
DataContext: bind ExportSetupViewModel from $resource Container
Width: 325
StackPanel {
// File path
TextBox {
Margin: "16 16 16 8"
Margin: [16, 16, 16, 8]
HintAssist.Hint: "Output file"
HintAssist.IsFloating: true
IsReadOnly: true
@@ -18,18 +18,19 @@ UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
// Format
ComboBox {
Margin: "16 8 16 8"
Margin: [16, 8, 16, 8]
HintAssist.Hint: "Export format"
HintAssist.IsFloating: true
IsReadOnly: true
ItemsSource: bind AvailableFormats
SelectedItem: bind SelectedFormat
ItemTemplate: DataTemplate {
TextBlock {
Text: bind
convert (ExportFormat f) => Extensions.GetDisplayName(f)
}
}
SelectedItem: bind SelectedFormat
}
// Date range
@@ -37,16 +38,16 @@ UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
#TwoColumns("*", "*")
DatePicker {
Grid.Column: 0
Margin: "16 20 8 8"
#Cell(0, 0)
Margin: [16, 20, 8, 8]
HintAssist.Hint: "From (optional)"
HintAssist.IsFloating: true
SelectedDate: bind From
}
DatePicker {
Grid.Column: 1
Margin: "8 20 16 8"
#Cell(0, 1)
Margin: [8, 20, 16, 8]
HintAssist.Hint: "To (optional)"
HintAssist.IsFloating: true
SelectedDate: bind To
@@ -59,26 +60,28 @@ UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
// Browse
Button "BrowseButton" {
Margin: 8
Click: BrowseButton_Click
Content: "BROWSE"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
// Export
Button "ExportButton" {
Margin: 8
Click: ExportButton_Click
Command: bind ExportCommand
Content: "EXPORT"
Margin: 8
IsDefault: true
Style: resource dyn "MaterialDesignFlatButton"
}
// Cancel
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "CANCEL"
Margin: 8
IsCancel: true
Style: resource dyn "MaterialDesignFlatButton"
}
}
@@ -1,10 +1,10 @@
using System.Windows;
using DiscordChatExporter.Models;
using DiscordChatExporter.ViewModels;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Gui.ViewModels;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
namespace DiscordChatExporter.Views
namespace DiscordChatExporter.Gui.Views
{
public partial class ExportSetupDialog
{
@@ -1,7 +1,8 @@
using MaterialDesignThemes.Wpf
using System.Windows.Interactivity;
using MaterialDesignThemes.Wpf
using MaterialDesignThemes.Wpf.Transitions
Window "DiscordChatExporter.Views.MainWindow" {
Window "DiscordChatExporter.Gui.Views.MainWindow" {
Title: "DiscordChatExporter"
Width: 600
Height: 550
@@ -9,6 +10,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
DataContext: bind MainViewModel from $resource Container
FocusManager.FocusedElement: bind from "TokenTextBox"
FontFamily: resource dyn "MaterialDesignFont"
Icon: "/DiscordChatExporter;component/favicon.ico"
SnapsToDevicePixels: true
TextElement.FontSize: 13
TextElement.FontWeight: Regular
@@ -18,47 +20,59 @@ Window "DiscordChatExporter.Views.MainWindow" {
UseLayoutRounding: true
WindowStartupLocation: CenterScreen
DialogHost {
DockPanel {
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
Interaction.Triggers: [
Interactivity.EventTrigger {
EventName: "Loaded"
InvokeCommandAction { Command: bind ViewLoadedCommand }
},
Interactivity.EventTrigger {
EventName: "Closed"
InvokeCommandAction { Command: bind ViewClosedCommand }
}
]
DialogHost {
SnackbarMessageQueue: bind MessageQueue from "Snackbar"
DockPanel {
// Toolbar
Border {
DockPanel.Dock: Top
Background: resource dyn "PrimaryHueMidBrush"
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
TextElement.Foreground: resource dyn "SecondaryInverseTextBrush"
StackPanel {
Grid {
#TwoColumns("*", "Auto")
Card {
Grid.Column: 0
Margin: "6 6 0 6"
#Cell(0, 0)
Margin: [6, 6, 0, 6]
Grid {
#TwoColumns("*", "Auto")
// Token
TextBox "TokenTextBox" {
Grid.Column: 0
#Cell(0, 0)
Margin: 6
BorderThickness: 0
HintAssist.Hint: "Token"
KeyDown: TokenTextBox_KeyDown
FontSize: 16
Text: bind Token
set [ UpdateSourceTrigger: PropertyChanged ]
TextFieldAssist.DecorationVisibility: Hidden
TextFieldAssist.TextBoxViewMargin: "0 0 2 0"
TextFieldAssist.TextBoxViewMargin: [0, 0, 2, 0]
}
// Submit
Button {
Grid.Column: 1
Margin: "0 6 6 6"
#Cell(0, 1)
Margin: [0, 6, 6, 6]
Padding: 4
Command: bind PullDataCommand
IsDefault: true
Style: resource dyn "MaterialDesignFlatButton"
PackIcon {
@@ -72,7 +86,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
// Popup menu
PopupBox {
Grid.Column: 1
#Cell(0, 1)
Foreground: resource dyn "PrimaryHueMidForegroundBrush"
PlacementMode: LeftAndAlignTopEdges
@@ -103,6 +117,8 @@ Window "DiscordChatExporter.Views.MainWindow" {
Grid {
DockPanel {
Background: resource dyn "MaterialDesignCardBackground"
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
Visibility: bind IsDataAvailable
convert (bool b) => b ? Visibility.Visible : Visibility.Hidden
@@ -122,7 +138,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
TransitioningContent {
OpeningEffect: TransitionEffect {
Duration: "0:0:0.3"
Kind: SlideInFromRight
Kind: SlideInFromLeft
}
Border {
@@ -131,9 +147,11 @@ Window "DiscordChatExporter.Views.MainWindow" {
Cursor: CursorType.Hand
Image {
Margin: "12 4 12 4"
Margin: [12, 4, 12, 4]
Width: 48
Height: 48
Source: bind IconUrl
ToolTip: bind Name
OpacityMask: RadialGradientBrush {
GradientStops: [
GradientStop { Color: "#FF000000", Offset: 0 }
@@ -141,8 +159,6 @@ Window "DiscordChatExporter.Views.MainWindow" {
GradientStop { Color: "#00000000", Offset: 1 }
]
}
Source: bind IconUrl
ToolTip: bind Name
}
}
}
@@ -177,12 +193,12 @@ Window "DiscordChatExporter.Views.MainWindow" {
]
PackIcon {
Margin: "16 7 0 6"
Margin: [16, 7, 0, 6]
Kind: PackIconKind.Pound
VerticalAlignment: Center
}
TextBlock {
Margin: "3 8 8 8"
Margin: [3, 8, 8, 8]
FontSize: 14
Text: bind Name
VerticalAlignment: Center
@@ -196,7 +212,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
// Content placeholder
StackPanel {
Margin: "32 32 8 8"
Margin: [32, 32, 8, 8]
Visibility: bind IsDataAvailable
convert (bool b) => b ? Visibility.Hidden : Visibility.Visible
@@ -206,13 +222,13 @@ Window "DiscordChatExporter.Views.MainWindow" {
}
TextBlock {
Margin: "0 8 0 0"
Margin: [0, 8, 0, 0]
FontSize: 16
Text: "To obtain it, follow these steps:"
}
TextBlock {
Margin: "8 0 0 0"
Margin: [8, 0, 0, 0]
FontSize: 14
Run {
@@ -264,6 +280,10 @@ Window "DiscordChatExporter.Views.MainWindow" {
}
}
}
// Snackbar
Snackbar "Snackbar" {
}
}
}
}
@@ -0,0 +1,35 @@
using System;
using System.Reflection;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight.Messaging;
using MaterialDesignThemes.Wpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.Views
{
public partial class MainWindow
{
public MainWindow()
{
InitializeComponent();
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
Snackbar.MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
// Notification messages
Messenger.Default.Register<ShowNotificationMessage>(this, m =>
{
if (m.CallbackCaption != null && m.Callback != null)
Snackbar.MessageQueue.Enqueue(m.Message, m.CallbackCaption, m.Callback);
else
Snackbar.MessageQueue.Enqueue(m.Message);
});
// Dialog messages
Messenger.Default.Register<ShowExportSetupMessage>(this,
m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this,
m => DialogHost.Show(new SettingsDialog()).Forget());
}
}
}
@@ -1,13 +1,13 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.SettingsDialog" {
UserControl "DiscordChatExporter.Gui.Views.SettingsDialog" {
DataContext: bind SettingsViewModel from $resource Container
Width: 250
StackPanel {
// Date format
TextBox {
Margin: "16 16 16 8"
Margin: [16, 16, 16, 8]
HintAssist.Hint: "Date format"
HintAssist.IsFloating: true
Text: bind DateFormat
@@ -15,18 +15,37 @@ UserControl "DiscordChatExporter.Views.SettingsDialog" {
// Group limit
TextBox {
Margin: "16 8 16 8"
Margin: [16, 8, 16, 8]
HintAssist.Hint: "Message group limit"
HintAssist.IsFloating: true
Text: bind MessageGroupLimit
}
// Auto-update
DockPanel {
LastChildFill: false
TextBlock {
Margin: [16, 8, 16, 8]
DockPanel.Dock: Left
Text: "Auto-updates"
}
ToggleButton {
Margin: [16, 8, 16, 8]
DockPanel.Dock: Right
IsChecked: bind IsAutoUpdateEnabled
}
}
// Save
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "SAVE"
HorizontalAlignment: Right
IsDefault: true
IsCancel: true
Style: resource dyn "MaterialDesignFlatButton"
}
}
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Views
namespace DiscordChatExporter.Gui.Views
{
public partial class SettingsDialog
{
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="CommonServiceLocator" publicKeyToken="489b6accfaf20ef0" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.0.3.0" newVersion="2.0.3.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Ammy" version="1.2.94" targetFramework="net461" />
<package id="Ammy.WPF" version="1.2.94" targetFramework="net461" />
<package id="CommonServiceLocator" version="2.0.3" targetFramework="net461" />
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.4.1" targetFramework="net461" />
<package id="Tyrrrz.Extensions" version="1.5.0" targetFramework="net461" />
</packages>
+15 -2
View File
@@ -1,15 +1,20 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26730.15
VisualStudioVersion = 15.0.27130.2026
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
ProjectSection(SolutionItems) = preProject
Changelog.md = Changelog.md
License.txt = License.txt
Readme.md = Readme.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core", "DiscordChatExporter.Core\DiscordChatExporter.Core.csproj", "{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -21,6 +26,14 @@ Global
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.ActiveCfg = Release|Any CPU
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.Build.0 = Release|Any CPU
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.Build.0 = Debug|Any CPU
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.ActiveCfg = Release|Any CPU
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.Build.0 = Release|Any CPU
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
</startup>
</configuration>
-43
View File
@@ -1,43 +0,0 @@
using DiscordChatExporter.Services;
using DiscordChatExporter.ViewModels;
using GalaSoft.MvvmLight.Ioc;
using Microsoft.Practices.ServiceLocation;
namespace DiscordChatExporter
{
public class Container
{
public static void Init()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
// Settings
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
ServiceLocator.Current.GetInstance<ISettingsService>().Load();
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
// View models
SimpleIoc.Default.Register<IErrorViewModel, ErrorViewModel>(true);
SimpleIoc.Default.Register<IExportDoneViewModel, ExportDoneViewModel>(true);
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
}
public static void Cleanup()
{
// Settings
ServiceLocator.Current.GetInstance<ISettingsService>().Save();
}
public IErrorViewModel ErrorViewModel => ServiceLocator.Current.GetInstance<IErrorViewModel>();
public IExportDoneViewModel ExportDoneViewModel => ServiceLocator.Current.GetInstance<IExportDoneViewModel>();
public IExportSetupViewModel ExportSetupViewModel => ServiceLocator.Current.GetInstance<IExportSetupViewModel>();
public IMainViewModel MainViewModel => ServiceLocator.Current.GetInstance<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance<ISettingsViewModel>();
}
}
@@ -1,12 +0,0 @@
namespace DiscordChatExporter.Messages
{
public class ShowErrorMessage
{
public string Message { get; }
public ShowErrorMessage(string message)
{
Message = message;
}
}
}
@@ -1,12 +0,0 @@
namespace DiscordChatExporter.Messages
{
public class ShowExportDoneMessage
{
public string FilePath { get; }
public ShowExportDoneMessage(string filePath)
{
FilePath = filePath;
}
}
}
-23
View File
@@ -1,23 +0,0 @@
namespace DiscordChatExporter.Models
{
public class Channel
{
public string Id { get; }
public string Name { get; }
public ChannelType Type { get; }
public Channel(string id, string name, ChannelType type)
{
Id = id;
Name = name;
Type = type;
}
public override string ToString()
{
return Name;
}
}
}
-37
View File
@@ -1,37 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Models
{
public class Message
{
public string Id { get; }
public User Author { get; }
public DateTime TimeStamp { get; }
public DateTime? EditedTimeStamp { get; }
public string Content { get; }
public IReadOnlyList<Attachment> Attachments { get; }
public Message(string id, User author,
DateTime timeStamp, DateTime? editedTimeStamp,
string content, IReadOnlyList<Attachment> attachments)
{
Id = id;
Author = author;
TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp;
Content = content;
Attachments = attachments;
}
public override string ToString()
{
return Content;
}
}
}
-36
View File
@@ -1,36 +0,0 @@
using System;
using System.IO;
using System.Reflection;
using System.Resources;
using AmmySidekick;
namespace DiscordChatExporter
{
public static class Program
{
[STAThread]
public static void Main()
{
var app = new App();
app.InitializeComponent();
RuntimeUpdateHandler.Register(app, $"/{Ammy.GetAssemblyName(app)};component/App.g.xaml");
app.Run();
}
public static string GetResourceString(string resourcePath)
{
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream == null)
throw new MissingManifestResourceException("Could not find resource");
using (stream)
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
}
@@ -1,7 +0,0 @@
using System.Reflection;
[assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017 Alexey Golub")]
[assembly: AssemblyVersion("2.2.1")]
[assembly: AssemblyFileVersion("2.2.1")]
-220
View File
@@ -1,220 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using DiscordChatExporter.Exceptions;
using DiscordChatExporter.Models;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
{
public partial class DataService : IDataService, IDisposable
{
private const string ApiRoot = "https://discordapp.com/api/v6";
private readonly HttpClient _httpClient = new HttpClient();
private async Task<string> GetStringAsync(string url)
{
using (var response = await _httpClient.GetAsync(url))
{
// Check status code
// We throw our own exception here because default one doesn't have status code
if (!response.IsSuccessStatusCode)
throw new HttpErrorStatusCodeException(response.StatusCode);
// Get content
return await response.Content.ReadAsStringAsync();
}
}
public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
// Get response
var content = await GetStringAsync(url);
// Parse
var guilds = JArray.Parse(content).Select(ParseGuild).ToArray();
return guilds;
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to)
{
var result = new List<Message>();
// We are going backwards from last message to first
// collecting everything between them in batches
string beforeId = null;
while (true)
{
// Form request url
var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
if (beforeId.IsNotBlank())
url += $"&before={beforeId}";
// Get response
var content = await GetStringAsync(url);
// Parse
var messages = JArray.Parse(content).Select(ParseMessage);
// Add messages to list
var currentMessageId = default(string);
foreach (var message in messages)
{
result.Add(message);
currentMessageId = message.Id;
}
// If no messages - break
if (currentMessageId == null)
break;
// If last message is older than from date - break
if (from != null && result.Last().TimeStamp < from)
break;
// Otherwise offset the next request
beforeId = currentMessageId;
}
// Messages appear newest first, we need to reverse
result.Reverse();
// Filter
if (from != null)
result.RemoveAll(m => m.TimeStamp < from);
if (to != null)
result.RemoveAll(m => m.TimeStamp > to);
return result;
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_httpClient.Dispose();
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
public partial class DataService
{
private static User ParseUser(JToken token)
{
var id = token.Value<string>("id");
var discriminator = token.Value<int>("discriminator");
var name = token.Value<string>("username");
var avatarHash = token.Value<string>("avatar");
return new User(id, discriminator, name, avatarHash);
}
private static Guild ParseGuild(JToken token)
{
var id = token.Value<string>("id");
var name = token.Value<string>("name");
var iconHash = token.Value<string>("icon");
return new Guild(id, name, iconHash);
}
private static Channel ParseChannel(JToken token)
{
// Get basic data
var id = token.Value<string>("id");
var type = (ChannelType) token.Value<int>("type");
// Extract name based on type
string name;
if (type.IsEither(ChannelType.DirectTextChat, ChannelType.DirectGroupTextChat))
{
var recipients = token["recipients"].Select(ParseUser);
name = recipients.Select(r => r.Name).JoinToString(", ");
}
else
{
name = token.Value<string>("name");
}
return new Channel(id, name, type);
}
private static Message ParseMessage(JToken token)
{
// Get basic data
var id = token.Value<string>("id");
var timeStamp = token.Value<DateTime>("timestamp");
var editedTimeStamp = token.Value<DateTime?>("edited_timestamp");
var content = token.Value<string>("content");
// Lazy workaround for calls
if (token["call"] != null)
content = "Started a call.";
// Get author
var author = ParseUser(token["author"]);
// Get attachment
var attachments = new List<Attachment>();
foreach (var attachmentJson in token["attachments"].EmptyIfNull())
{
var attachmentId = attachmentJson.Value<string>("id");
var attachmentUrl = attachmentJson.Value<string>("url");
var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image
: AttachmentType.Other;
var attachmentFileName = attachmentJson.Value<string>("filename");
var attachmentFileSize = attachmentJson.Value<long>("size");
var attachment = new Attachment(
attachmentId, attachmentType, attachmentUrl,
attachmentFileName, attachmentFileSize);
attachments.Add(attachment);
}
return new Message(id, author, timeStamp, editedTimeStamp, content, attachments);
}
}
}
@@ -1,253 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
{
public partial class ExportService : IExportService
{
private readonly ISettingsService _settingsService;
public ExportService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
private async Task ExportAsTextAsync(string filePath, ChannelChatLog log)
{
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
{
// Generation info
await writer.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter");
await writer.WriteLineAsync();
// Guild and channel info
await writer.WriteLineAsync('='.Repeat(48));
await writer.WriteLineAsync($"Guild: {log.Guild}");
await writer.WriteLineAsync($"Channel: {log.Channel}");
await writer.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}");
await writer.WriteLineAsync('='.Repeat(48));
await writer.WriteLineAsync();
// Chat log
foreach (var group in log.MessageGroups)
{
var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat);
await writer.WriteLineAsync($"{group.Author} [{timeStampFormatted}]");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var contentFormatted = message.Content.Replace("\n", Environment.NewLine);
await writer.WriteLineAsync(contentFormatted);
}
// Attachments
foreach (var attachment in message.Attachments)
{
await writer.WriteLineAsync(attachment.Url);
}
}
await writer.WriteLineAsync();
}
}
}
private async Task ExportAsHtmlAsync(string filePath, ChannelChatLog log, string css)
{
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
{
// Generation info
await writer.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Html start
await writer.WriteLineAsync("<!DOCTYPE html>");
await writer.WriteLineAsync("<html lang=\"en\">");
// HEAD
await writer.WriteLineAsync("<head>");
await writer.WriteLineAsync($"<title>{log.Guild} - {log.Channel}</title>");
await writer.WriteLineAsync("<meta charset=\"utf-8\" />");
await writer.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await writer.WriteLineAsync($"<style>{css}</style>");
await writer.WriteLineAsync("</head>");
// Body start
await writer.WriteLineAsync("<body>");
// Guild and channel info
await writer.WriteLineAsync("<div id=\"info\">");
await writer.WriteLineAsync("<div class=\"info-left\">");
await writer.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await writer.WriteLineAsync("</div>"); // info-left
await writer.WriteLineAsync("<div class=\"info-right\">");
await writer.WriteLineAsync($"<div class=\"guild-name\">{log.Guild}</div>");
await writer.WriteLineAsync($"<div class=\"channel-name\">{log.Channel}</div>");
await writer.WriteLineAsync($"<div class=\"misc\">{log.TotalMessageCount:N0} messages</div>");
await writer.WriteLineAsync("</div>"); // info-right
await writer.WriteLineAsync("</div>"); // info
// Chat log
await writer.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{
await writer.WriteLineAsync("<div class=\"msg\">");
await writer.WriteLineAsync("<div class=\"msg-left\">");
await writer.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
await writer.WriteLineAsync("</div>");
await writer.WriteLineAsync("<div class=\"msg-right\">");
await writer.WriteAsync($"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author)}\">");
await writer.WriteAsync(HtmlEncode(group.Author.Name));
await writer.WriteLineAsync("</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
await writer.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
await writer.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContentHtml(message.Content);
await writer.WriteAsync(contentFormatted);
// Edited timestamp
if (message.EditedTimeStamp != null)
{
var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
await writer.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
await writer.WriteLineAsync("</div>"); // msg-content
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.Type == AttachmentType.Image)
{
await writer.WriteLineAsync("<div class=\"msg-attachment\">");
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await writer.WriteLineAsync(
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await writer.WriteLineAsync("</a>");
await writer.WriteLineAsync("</div>");
}
else
{
await writer.WriteLineAsync("<div class=\"msg-attachment\">");
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
await writer.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
await writer.WriteLineAsync("</a>");
await writer.WriteLineAsync("</div>");
}
}
}
await writer.WriteLineAsync("</div>"); // msg-right
await writer.WriteLineAsync("</div>"); // msg
}
await writer.WriteLineAsync("</div>"); // log
await writer.WriteLineAsync("</body>");
await writer.WriteLineAsync("</html>");
}
}
public Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log)
{
if (format == ExportFormat.PlainText)
{
return ExportAsTextAsync(filePath, log);
}
if (format == ExportFormat.HtmlDark)
{
var css = Program.GetResourceString("DiscordChatExporter.Resources.ExportService.DarkTheme.css");
return ExportAsHtmlAsync(filePath, log, css);
}
if (format == ExportFormat.HtmlLight)
{
var css = Program.GetResourceString("DiscordChatExporter.Resources.ExportService.LightTheme.css");
return ExportAsHtmlAsync(filePath, log, css);
}
throw new NotImplementedException();
}
}
public partial class ExportService
{
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static string HtmlEncode(object obj)
{
return WebUtility.HtmlEncode(obj.ToString());
}
private static string FormatFileSize(long fileSize)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
var unit = 0;
while (size >= 1024)
{
size /= 1024;
++unit;
}
return $"{size:0.#} {units[unit]}";
}
private static string FormatMessageContentHtml(string content)
{
// Encode HTML
content = HtmlEncode(content);
// Preformatted div
content = Regex.Replace(content, "```+(?:[^`]*?\\n)?([^`]+)\\n?```+",
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
// Preformatted span
content = Regex.Replace(content, "`([^`]+)`",
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
// Links from URLs
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
"<a href=\"$1\">$1</a>");
// Bold
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
// Italic
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
// Underline
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
// Strike through
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
// New lines
content = content.Replace("\n", "<br />");
return content;
}
}
}
@@ -1,19 +0,0 @@
using DiscordChatExporter.Messages;
using GalaSoft.MvvmLight;
namespace DiscordChatExporter.ViewModels
{
public class ErrorViewModel : ViewModelBase, IErrorViewModel
{
public string Message { get; private set; }
public ErrorViewModel()
{
// Messages
MessengerInstance.Register<ShowErrorMessage>(this, m =>
{
Message = m.Message;
});
}
}
}
@@ -1,32 +0,0 @@
using System.Diagnostics;
using DiscordChatExporter.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.ViewModels
{
public class ExportDoneViewModel : ViewModelBase, IExportDoneViewModel
{
private string _filePath;
// Commands
public RelayCommand OpenCommand { get; }
public ExportDoneViewModel()
{
// Commands
OpenCommand = new RelayCommand(Open);
// Messages
MessengerInstance.Register<ShowExportDoneMessage>(this, m =>
{
_filePath = m.FilePath;
});
}
private void Open()
{
Process.Start(_filePath);
}
}
}
@@ -1,7 +0,0 @@
namespace DiscordChatExporter.ViewModels
{
public interface IErrorViewModel
{
string Message { get; }
}
}
@@ -1,9 +0,0 @@
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.ViewModels
{
public interface IExportDoneViewModel
{
RelayCommand OpenCommand { get; }
}
}
@@ -1,25 +0,0 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.ErrorDialog" {
DataContext: bind ErrorViewModel from $resource Container
Width: 250
StackPanel {
// Message
TextBlock {
Margin: 16
FontSize: 16
TextWrapping: WrapWithOverflow
Text: bind Message
}
// OK
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "OK"
HorizontalAlignment: Right
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
@@ -1,38 +0,0 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.ExportDoneDialog" {
DataContext: bind ExportDoneViewModel from $resource Container
Width: 250
StackPanel {
// Message
TextBlock {
Margin: 16
FontSize: 16
TextWrapping: WrapWithOverflow
Text: "Finished exporting chat log"
}
// Buttons
@StackPanelHorizontal {
HorizontalAlignment: Right
// Open
Button "OpenButton" {
Click: OpenButton_Click
Command: bind OpenCommand
Content: "OPEN IT"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
// Dismiss
Button {
Command: DialogHost.CloseDialogCommand
Content: "DISMISS"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
}
@@ -1,18 +0,0 @@
using System.Windows;
using MaterialDesignThemes.Wpf;
namespace DiscordChatExporter.Views
{
public partial class ExportDoneDialog
{
public ExportDoneDialog()
{
InitializeComponent();
}
public void OpenButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
}
}
@@ -1,35 +0,0 @@
using System.Reflection;
using System.Windows.Input;
using DiscordChatExporter.Messages;
using DiscordChatExporter.ViewModels;
using GalaSoft.MvvmLight.Messaging;
using MaterialDesignThemes.Wpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Views
{
public partial class MainWindow
{
private IMainViewModel ViewModel => (IMainViewModel) DataContext;
public MainWindow()
{
InitializeComponent();
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
Messenger.Default.Register<ShowErrorMessage>(this, m => DialogHost.Show(new ErrorDialog()).Forget());
Messenger.Default.Register<ShowExportDoneMessage>(this, m => DialogHost.Show(new ExportDoneDialog()).Forget());
Messenger.Default.Register<ShowExportSetupMessage>(this, m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this, m => DialogHost.Show(new SettingsDialog()).Forget());
}
public void TokenTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
// Execute command
ViewModel.PullDataCommand.Execute(null);
}
}
}
}
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Ammy" version="1.2.87" targetFramework="net461" />
<package id="Ammy.WPF" version="1.2.87" targetFramework="net461" />
<package id="CommonServiceLocator" version="1.3" targetFramework="net461" />
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.3.1.953" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.3.0.0" targetFramework="net461" />
<package id="Newtonsoft.Json" version="10.0.3" targetFramework="net461" />
<package id="Tyrrrz.Extensions" version="1.4.1" targetFramework="net461" />
<package id="Tyrrrz.Settings" version="1.3.0" targetFramework="net461" />
</packages>
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017 Alexey Golub
Copyright (c) 2017-2018 Alexey Golub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+13 -3
View File
@@ -1,5 +1,9 @@
# DiscordChatExporter
[![Build](https://img.shields.io/appveyor/ci/Tyrrrz/DiscordChatExporter/master.svg)](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
[![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)
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.
## Screenshots
@@ -9,21 +13,25 @@ DiscordChatExporter can be used to export message history from a [Discord](https
## Download
- [See releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
## Features
- Intuitive GUI that displays available guilds and channels
- CLI as additional alternative to GUI
- Date ranges to limit messages
- Groups messages by author and time
- Export to a plain text file
- Export to an HTML file
- Exports to a plain text file
- Exports to an HTML file
- Dark and light themes
- User avatars
- Inline image attachments
- Full markdown support
- Automatic links
- Styled similarly to the app
- Renders custom emojis
- Resolves user, role and channel mentions
## Libraries used
@@ -31,5 +39,7 @@ DiscordChatExporter can be used to export message history from a [Discord](https
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [Onova](https://github.com/Tyrrrz/Onova)
- [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser)
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)
+29
View File
@@ -0,0 +1,29 @@
version: '{build}'
image: Visual Studio 2017
configuration: Release
before_build:
- ps: nuget restore
build:
verbosity: minimal
after_build:
- ps: Deploy\Prepare.ps1
artifacts:
- path: Deploy\bin\DiscordChatExporter.zip
name: DiscordChatExporter.zip
- path: Deploy\bin\DiscordChatExporter.Cli.zip
name: DiscordChatExporter.Cli.zip
deploy:
- provider: GitHub
auth_token:
secure: sjQHWRw29AMiVMn3MtidtWnAzAf1mJ+mkJ/7h1B9TIAHhkFrqwMK7LtXV+uNJ9AO
artifact: DiscordChatExporter.zip,DiscordChatExporter.Cli.zip
description: '[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)'
on:
branch: master
appveyor_repo_tag: true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB