Compare commits

...

35 Commits

Author SHA1 Message Date
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
Alexey Golub 705e60c76d Update version 2017-10-02 19:24:12 +03:00
Alexey Golub 8ac8c16db3 Disable whole UI when busy 2017-10-02 19:16:06 +03:00
Alexey Golub 1850caba71 Add 'optional' suffix for from/to date pickers in ExportSetupDialog 2017-10-02 19:12:00 +03:00
Alexey Golub 6ccaf3f106 Fix settings not loading early enough 2017-10-02 19:09:35 +03:00
Alexey Golub d7345e91d3 Improve UX of ExportSetupDialog
Closes #11
2017-10-02 18:59:00 +03:00
Alexey Golub e5e9d4c9ff Tidy up 2017-10-01 12:18:54 +03:00
Alexey Golub b280f8b736 Add AM/PM to default date format as it was previously missing it 2017-10-01 11:27:21 +03:00
Alexey Golub ca452d2421 Update readme 2017-10-01 01:56:42 +03:00
Alexey Golub 9e7d4033e0 Update version 2017-10-01 01:55:04 +03:00
Alexey Golub 263a606bff Improve error messages 2017-10-01 01:49:39 +03:00
Alexey Golub 1e853f11b0 Fix styling for pre 2017-10-01 01:49:26 +03:00
Alexey Golub 77d0956f5d Redesign file picker in ExportSetupDialog 2017-10-01 00:32:10 +03:00
Alexey Golub d251e8ecf6 Update readme 2017-09-30 23:14:32 +03:00
Alexey Golub 01f0e6952d Add from/to date pickers
Fixes #6
2017-09-30 22:57:52 +03:00
Alexey Golub 28a67ab60d Add export dialog and implement export as plain text 2017-09-30 22:22:28 +03:00
41 changed files with 929 additions and 272 deletions
+4 -1
View File
@@ -261,4 +261,7 @@ __pycache__/
*.pyc *.pyc
# Ammy auto-generated XAML # Ammy auto-generated XAML
*.g.xaml *.g.xaml
# Deploy output
Deploy/Output/
+22
View File
@@ -0,0 +1,22 @@
$path = "$PSScriptRoot\..\DiscordChatExporter\bin\Release\*"
$include = "*.exe", "*.dll", "*.config"
$outputDir = "$PSScriptRoot\Output"
$outputFile = "DiscordChatExporter.zip"
# Create output directory
if (-Not (Test-Path $outputDir))
{
New-Item $outputDir -ItemType Directory
}
# Delete output if already exists
if (Test-Path("$outputDir\$outputFile"))
{
Remove-Item -Path "$outputDir\$outputFile"
}
# Get files
$files = Get-ChildItem -Path $path -Include $include
# Pack into archive
$files | Compress-Archive -DestinationPath "$outputDir\$outputFile"
+9 -7
View File
@@ -11,31 +11,33 @@ namespace DiscordChatExporter
{ {
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default); ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
// Settings
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
ServiceLocator.Current.GetInstance<ISettingsService>().Load();
// Services // Services
SimpleIoc.Default.Register<IDataService, DataService>(); SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>(); SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>(); SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
// View models // View models
SimpleIoc.Default.Register<IErrorViewModel, ErrorViewModel>(true); 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<IMainViewModel, MainViewModel>(true);
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true); SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
SimpleIoc.Default.Register<IExportDoneViewModel, ExportDoneViewModel>(true);
// Load settings
ServiceLocator.Current.GetInstance<ISettingsService>().Load();
} }
public static void Cleanup() public static void Cleanup()
{ {
// Save settings // Settings
ServiceLocator.Current.GetInstance<ISettingsService>().Save(); ServiceLocator.Current.GetInstance<ISettingsService>().Save();
} }
public IErrorViewModel ErrorViewModel => ServiceLocator.Current.GetInstance<IErrorViewModel>(); 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 IMainViewModel MainViewModel => ServiceLocator.Current.GetInstance<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance<ISettingsViewModel>(); public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance<ISettingsViewModel>();
public IExportDoneViewModel ExportDoneViewModel => ServiceLocator.Current.GetInstance<IExportDoneViewModel>();
} }
} }
+17 -1
View File
@@ -84,14 +84,22 @@
<Compile Include="Exceptions\HttpErrorStatusCodeException.cs" /> <Compile Include="Exceptions\HttpErrorStatusCodeException.cs" />
<Compile Include="Messages\ShowErrorMessage.cs" /> <Compile Include="Messages\ShowErrorMessage.cs" />
<Compile Include="Messages\ShowExportDoneMessage.cs" /> <Compile Include="Messages\ShowExportDoneMessage.cs" />
<Compile Include="Messages\ShowExportSetupMessage.cs" />
<Compile Include="Messages\ShowSettingsMessage.cs" /> <Compile Include="Messages\ShowSettingsMessage.cs" />
<Compile Include="Messages\StartExportMessage.cs" />
<Compile Include="Models\AttachmentType.cs" /> <Compile Include="Models\AttachmentType.cs" />
<Compile Include="Models\ChannelChatLog.cs" /> <Compile Include="Models\ChannelChatLog.cs" />
<Compile Include="Models\ChannelType.cs" /> <Compile Include="Models\ChannelType.cs" />
<Compile Include="Models\ExportFormat.cs" />
<Compile Include="Models\Extensions.cs" />
<Compile Include="Models\Role.cs" />
<Compile Include="Models\MessageType.cs" />
<Compile Include="Services\IMessageGroupService.cs" /> <Compile Include="Services\IMessageGroupService.cs" />
<Compile Include="Services\MessageGroupService.cs" /> <Compile Include="Services\MessageGroupService.cs" />
<Compile Include="ViewModels\ErrorViewModel.cs" /> <Compile Include="ViewModels\ErrorViewModel.cs" />
<Compile Include="ViewModels\ExportSetupViewModel.cs" />
<Compile Include="ViewModels\IErrorViewModel.cs" /> <Compile Include="ViewModels\IErrorViewModel.cs" />
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
<Compile Include="ViewModels\ISettingsViewModel.cs" /> <Compile Include="ViewModels\ISettingsViewModel.cs" />
<Compile Include="ViewModels\IExportDoneViewModel.cs" /> <Compile Include="ViewModels\IExportDoneViewModel.cs" />
<Compile Include="ViewModels\SettingsViewModel.cs" /> <Compile Include="ViewModels\SettingsViewModel.cs" />
@@ -102,6 +110,9 @@
<Compile Include="Views\ExportDoneDialog.ammy.cs"> <Compile Include="Views\ExportDoneDialog.ammy.cs">
<DependentUpon>ExportDoneDialog.ammy</DependentUpon> <DependentUpon>ExportDoneDialog.ammy</DependentUpon>
</Compile> </Compile>
<Compile Include="Views\ExportSetupDialog.ammy.cs">
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
</Compile>
<Compile Include="Views\SettingsDialog.ammy.cs"> <Compile Include="Views\SettingsDialog.ammy.cs">
<DependentUpon>SettingsDialog.ammy</DependentUpon> <DependentUpon>SettingsDialog.ammy</DependentUpon>
</Compile> </Compile>
@@ -120,6 +131,11 @@
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
<DependentUpon>ExportDoneDialog.ammy</DependentUpon> <DependentUpon>ExportDoneDialog.ammy</DependentUpon>
</Page> </Page>
<Page Include="Views\ExportSetupDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
</Page>
<Page Include="Views\MainWindow.g.xaml"> <Page Include="Views\MainWindow.g.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
@@ -134,7 +150,6 @@
<Compile Include="Models\Guild.cs" /> <Compile Include="Models\Guild.cs" />
<Compile Include="Models\Message.cs" /> <Compile Include="Models\Message.cs" />
<Compile Include="Models\MessageGroup.cs" /> <Compile Include="Models\MessageGroup.cs" />
<Compile Include="Models\Theme.cs" />
<Compile Include="Models\User.cs" /> <Compile Include="Models\User.cs" />
<Compile Include="Program.cs" /> <Compile Include="Program.cs" />
<Compile Include="Services\DataService.cs" /> <Compile Include="Services\DataService.cs" />
@@ -174,6 +189,7 @@
</None> </None>
<None Include="Views\ErrorDialog.ammy" /> <None Include="Views\ErrorDialog.ammy" />
<None Include="Views\ExportDoneDialog.ammy" /> <None Include="Views\ExportDoneDialog.ammy" />
<None Include="Views\ExportSetupDialog.ammy" />
<None Include="Views\MainWindow.ammy" /> <None Include="Views\MainWindow.ammy" />
<None Include="Views\SettingsDialog.ammy" /> <None Include="Views\SettingsDialog.ammy" />
</ItemGroup> </ItemGroup>
@@ -0,0 +1,17 @@
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Messages
{
public class ShowExportSetupMessage
{
public Guild Guild { get; }
public Channel Channel { get; }
public ShowExportSetupMessage(Guild guild, Channel channel)
{
Guild = guild;
Channel = channel;
}
}
}
@@ -0,0 +1,28 @@
using System;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Messages
{
public class StartExportMessage
{
public Channel Channel { get; }
public string FilePath { get; }
public ExportFormat Format { get; }
public DateTime? From { get; }
public DateTime? To { get; }
public StartExportMessage(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to)
{
Channel = channel;
FilePath = filePath;
Format = format;
From = from;
To = to;
}
}
}
@@ -0,0 +1,9 @@
namespace DiscordChatExporter.Models
{
public enum ExportFormat
{
PlainText,
HtmlDark,
HtmlLight
}
}
+31
View File
@@ -0,0 +1,31 @@
using System;
namespace DiscordChatExporter.Models
{
public static class Extensions
{
public static string GetFileExtension(this ExportFormat format)
{
if (format == ExportFormat.PlainText)
return "txt";
if (format == ExportFormat.HtmlDark)
return "html";
if (format == ExportFormat.HtmlLight)
return "html";
throw new NotImplementedException();
}
public static string GetDisplayName(this ExportFormat format)
{
if (format == ExportFormat.PlainText)
return "Plain Text";
if (format == ExportFormat.HtmlDark)
return "HTML (Dark)";
if (format == ExportFormat.HtmlLight)
return "HTML (Light)";
throw new NotImplementedException();
}
}
}
+12 -1
View File
@@ -17,9 +17,17 @@ namespace DiscordChatExporter.Models
public IReadOnlyList<Attachment> Attachments { 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, User author, public Message(string id, User author,
DateTime timeStamp, DateTime? editedTimeStamp, DateTime timeStamp, DateTime? editedTimeStamp,
string content, IReadOnlyList<Attachment> attachments) string content, IReadOnlyList<Attachment> attachments,
IReadOnlyList<User> mentionedUsers, IReadOnlyList<Role> mentionedRoles,
IReadOnlyList<Channel> mentionedChannels)
{ {
Id = id; Id = id;
Author = author; Author = author;
@@ -27,6 +35,9 @@ namespace DiscordChatExporter.Models
EditedTimeStamp = editedTimeStamp; EditedTimeStamp = editedTimeStamp;
Content = content; Content = content;
Attachments = attachments; Attachments = attachments;
MentionedUsers = mentionedUsers;
MentionedRoles = mentionedRoles;
MentionedChannels = mentionedChannels;
} }
public override string ToString() public override string ToString()
+14
View File
@@ -0,0 +1,14 @@
namespace DiscordChatExporter.Models
{
public enum MessageType
{
Default,
RecipientAdd,
RecipientRemove,
Call,
ChannelNameChange,
ChannelIconChange,
ChannelPinnedMessage,
GuildMemberJoin
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace DiscordChatExporter.Models
{
public 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;
}
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace DiscordChatExporter.Models
{
public enum Theme
{
Dark,
Light
}
}
+17
View File
@@ -1,4 +1,7 @@
using System; using System;
using System.IO;
using System.Reflection;
using System.Resources;
using AmmySidekick; using AmmySidekick;
namespace DiscordChatExporter namespace DiscordChatExporter
@@ -15,5 +18,19 @@ namespace DiscordChatExporter
app.Run(); 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();
}
}
} }
} }
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")] [assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")] [assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017 Alexey Golub")] [assembly: AssemblyCopyright("Copyright (c) 2017 Alexey Golub")]
[assembly: AssemblyVersion("2.1")] [assembly: AssemblyVersion("2.3")]
[assembly: AssemblyFileVersion("2.1")] [assembly: AssemblyFileVersion("2.3")]
@@ -14,8 +14,11 @@ a:hover { text-decoration: underline; }
div.pre { div.pre {
background-color: #2F3136; background-color: #2F3136;
color: rgb(131, 148, 150);
font-family: Consolas, Courier New, Courier, Monospace; font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px; padding: 8px;
white-space: pre-wrap;
} }
span.pre { span.pre {
@@ -23,6 +26,7 @@ span.pre {
font-family: Consolas, Courier New, Courier, Monospace; font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px; padding-left: 2px;
padding-right: 2px; padding-right: 2px;
white-space: pre-wrap;
} }
div#info { div#info {
@@ -102,6 +106,7 @@ span.msg-edited {
div.msg-content { div.msg-content {
font-size: .9375rem; font-size: .9375rem;
padding-top: 5px; padding-top: 5px;
word-break: break-all;
} }
div.msg-attachment { div.msg-attachment {
@@ -112,4 +117,14 @@ div.msg-attachment {
img.msg-attachment { img.msg-attachment {
max-height: 500px; max-height: 500px;
max-width: 50%; max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
} }
@@ -14,8 +14,11 @@ a:hover { text-decoration: underline; }
div.pre { div.pre {
background-color: #F9F9F9; background-color: #F9F9F9;
color: rgb(101, 123, 131);
font-family: Consolas, Courier New, Courier, Monospace; font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px; padding: 8px;
white-space: pre-wrap;
} }
span.pre { span.pre {
@@ -23,6 +26,7 @@ span.pre {
font-family: Consolas, Courier New, Courier, Monospace; font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px; padding-left: 2px;
padding-right: 2px; padding-right: 2px;
white-space: pre-wrap;
} }
div#info { div#info {
@@ -102,6 +106,7 @@ span.msg-edited {
div.msg-content { div.msg-content {
font-size: .9375rem; font-size: .9375rem;
padding-top: 5px; padding-top: 5px;
word-break: break-all;
} }
div.msg-attachment { div.msg-attachment {
@@ -112,4 +117,14 @@ div.msg-attachment {
img.msg-attachment { img.msg-attachment {
max-height: 500px; max-height: 500px;
max-width: 50%; max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
} }
+100 -12
View File
@@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Exceptions; using DiscordChatExporter.Exceptions;
using DiscordChatExporter.Models; using DiscordChatExporter.Models;
@@ -13,7 +14,10 @@ namespace DiscordChatExporter.Services
public partial class DataService : IDataService, IDisposable public partial class DataService : IDataService, IDisposable
{ {
private const string ApiRoot = "https://discordapp.com/api/v6"; private const string ApiRoot = "https://discordapp.com/api/v6";
private readonly HttpClient _httpClient = new HttpClient(); private readonly HttpClient _httpClient = new HttpClient();
private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>();
private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>();
private async Task<string> GetStringAsync(string url) private async Task<string> GetStringAsync(string url)
{ {
@@ -29,6 +33,20 @@ namespace DiscordChatExporter.Services
} }
} }
private async Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var roles = JToken.Parse(content)["roles"].Select(ParseRole).ToArray();
return roles;
}
public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token) public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token)
{ {
// Form request url // Form request url
@@ -40,6 +58,14 @@ namespace DiscordChatExporter.Services
// Parse // Parse
var guilds = JArray.Parse(content).Select(ParseGuild).ToArray(); var guilds = JArray.Parse(content).Select(ParseGuild).ToArray();
// HACK: also get roles for all of them
foreach (var guild in guilds)
{
var roles = await GetGuildRolesAsync(token, guild.Id);
foreach (var role in roles)
_roleCache[role.Id] = role;
}
return guilds; return guilds;
} }
@@ -68,16 +94,21 @@ namespace DiscordChatExporter.Services
// Parse // Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray(); var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
// Cache
foreach (var channel in channels)
_channelCache[channel.Id] = channel;
return channels; return channels;
} }
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId) public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to)
{ {
var result = new List<Message>(); var result = new List<Message>();
// We are going backwards from last message to first // We are going backwards from last message to first
// collecting everything between them in batches // collecting everything between them in batches
string beforeId = null; var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
while (true) while (true)
{ {
// Form request url // Form request url
@@ -89,18 +120,27 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url); var content = await GetStringAsync(url);
// Parse // Parse
var messages = JArray.Parse(content).Select(ParseMessage); var messages = JArray.Parse(content).Select(j => ParseMessage(j, _roleCache, _channelCache));
// Add messages to list // Add messages to list
var currentMessageId = default(string); string currentMessageId = null;
foreach (var message in messages) 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); result.Add(message);
currentMessageId = message.Id; currentMessageId = message.Id;
} }
// If no messages - break // If no messages - break
if (currentMessageId == null) break; if (currentMessageId == null)
break;
// Otherwise offset the next request // Otherwise offset the next request
beforeId = currentMessageId; beforeId = currentMessageId;
@@ -129,6 +169,23 @@ namespace DiscordChatExporter.Services
public partial class DataService 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();
}
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 User ParseUser(JToken token) private static User ParseUser(JToken token)
{ {
var id = token.Value<string>("id"); var id = token.Value<string>("id");
@@ -139,13 +196,12 @@ namespace DiscordChatExporter.Services
return new User(id, discriminator, name, avatarHash); return new User(id, discriminator, name, avatarHash);
} }
private static Guild ParseGuild(JToken token) private static Role ParseRole(JToken token)
{ {
var id = token.Value<string>("id"); var id = token.Value<string>("id");
var name = token.Value<string>("name"); var name = token.Value<string>("name");
var iconHash = token.Value<string>("icon");
return new Guild(id, name, iconHash); return new Role(id, name);
} }
private static Channel ParseChannel(JToken token) private static Channel ParseChannel(JToken token)
@@ -169,17 +225,31 @@ namespace DiscordChatExporter.Services
return new Channel(id, name, type); return new Channel(id, name, type);
} }
private static Message ParseMessage(JToken token) private static Message ParseMessage(JToken token,
IDictionary<string, Role> roles, IDictionary<string, Channel> channels)
{ {
// Get basic data // Get basic data
var id = token.Value<string>("id"); var id = token.Value<string>("id");
var timeStamp = token.Value<DateTime>("timestamp"); var timeStamp = token.Value<DateTime>("timestamp");
var editedTimeStamp = token.Value<DateTime?>("edited_timestamp"); var editedTimeStamp = token.Value<DateTime?>("edited_timestamp");
var content = token.Value<string>("content"); var content = token.Value<string>("content");
var type = (MessageType) token.Value<int>("type");
// Lazy workaround for calls // Workarounds for non-default types
if (token["call"] != null) 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."; 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 // Get author
var author = ParseUser(token["author"]); var author = ParseUser(token["author"]);
@@ -202,7 +272,25 @@ namespace DiscordChatExporter.Services
attachments.Add(attachment); attachments.Add(attachment);
} }
return new Message(id, author, timeStamp, editedTimeStamp, content, attachments); // Get user mentions
var mentionedUsers = token["mentions"].Select(ParseUser).ToArray();
// Get role mentions
var mentionedRoles = token["mention_roles"]
.Values<string>()
.Select(i => roles.GetOrDefault(i) ?? new Role(i, "deleted-role"))
.ToArray();
// Get channel mentions
var mentionedChanenls = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.Select(i => channels.GetOrDefault(i) ?? new Channel(i, "deleted-channel", ChannelType.GuildTextChat))
.ToArray();
return new Message(id, author, timeStamp, editedTimeStamp, content, attachments,
mentionedUsers, mentionedRoles, mentionedChanenls);
} }
} }
} }
+164 -48
View File
@@ -1,7 +1,6 @@
using System.IO; using System;
using System.IO;
using System.Net; using System.Net;
using System.Reflection;
using System.Resources;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -19,12 +18,53 @@ namespace DiscordChatExporter.Services
_settingsService = settingsService; _settingsService = settingsService;
} }
public async Task ExportAsync(string filePath, ChannelChatLog log, Theme theme) private async Task ExportAsTextAsync(string filePath, ChannelChatLog log)
{ {
var themeCss = GetThemeCss(theme); using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
var dateFormat = _settingsService.DateFormat; {
// Generation info
await writer.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter");
await writer.WriteLineAsync();
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128*1024)) // 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 = FormatMessageContentText(message);
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 // Generation info
await writer.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->"); await writer.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
@@ -35,10 +75,10 @@ namespace DiscordChatExporter.Services
// HEAD // HEAD
await writer.WriteLineAsync("<head>"); await writer.WriteLineAsync("<head>");
await writer.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>"); await writer.WriteLineAsync($"<title>{log.Guild} - {log.Channel}</title>");
await writer.WriteLineAsync("<meta charset=\"utf-8\" />"); await writer.WriteLineAsync("<meta charset=\"utf-8\" />");
await writer.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />"); await writer.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await writer.WriteLineAsync($"<style>{themeCss}</style>"); await writer.WriteLineAsync($"<style>{css}</style>");
await writer.WriteLineAsync("</head>"); await writer.WriteLineAsync("</head>");
// Body start // Body start
@@ -50,8 +90,8 @@ namespace DiscordChatExporter.Services
await writer.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />"); await writer.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await writer.WriteLineAsync("</div>"); // info-left await writer.WriteLineAsync("</div>"); // info-left
await writer.WriteLineAsync("<div class=\"info-right\">"); await writer.WriteLineAsync("<div class=\"info-right\">");
await writer.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>"); await writer.WriteLineAsync($"<div class=\"guild-name\">{log.Guild}</div>");
await writer.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</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 class=\"misc\">{log.TotalMessageCount:N0} messages</div>");
await writer.WriteLineAsync("</div>"); // info-right await writer.WriteLineAsync("</div>"); // info-right
await writer.WriteLineAsync("</div>"); // info await writer.WriteLineAsync("</div>"); // info
@@ -66,25 +106,27 @@ namespace DiscordChatExporter.Services
await writer.WriteLineAsync("</div>"); await writer.WriteLineAsync("</div>");
await writer.WriteLineAsync("<div class=\"msg-right\">"); await writer.WriteLineAsync("<div class=\"msg-right\">");
await writer.WriteLineAsync($"<span class=\"msg-user\">{HtmlEncode(group.Author.Name)}</span>"); await writer.WriteAsync($"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author)}\">");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(dateFormat)); 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>"); await writer.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Message // Messages
foreach (var message in group.Messages) foreach (var message in group.Messages)
{ {
// Content // Content
if (message.Content.IsNotBlank()) if (message.Content.IsNotBlank())
{ {
await writer.WriteLineAsync("<div class=\"msg-content\">"); await writer.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContent(message.Content); var contentFormatted = FormatMessageContentHtml(message);
await writer.WriteAsync(contentFormatted); await writer.WriteAsync(contentFormatted);
// Edited timestamp // Edited timestamp
if (message.EditedTimeStamp != null) if (message.EditedTimeStamp != null)
{ {
var editedTimeStampFormatted = var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(dateFormat)); HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
await writer.WriteAsync( await writer.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>"); $"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
} }
@@ -99,7 +141,8 @@ namespace DiscordChatExporter.Services
{ {
await writer.WriteLineAsync("<div class=\"msg-attachment\">"); await writer.WriteLineAsync("<div class=\"msg-attachment\">");
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">"); await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await writer.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />"); await writer.WriteLineAsync(
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await writer.WriteLineAsync("</a>"); await writer.WriteLineAsync("</a>");
await writer.WriteLineAsync("</div>"); await writer.WriteLineAsync("</div>");
} }
@@ -123,31 +166,40 @@ namespace DiscordChatExporter.Services
await writer.WriteLineAsync("</html>"); 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 public partial class ExportService
{ {
private static string GetThemeCss(Theme theme)
{
var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css";
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream == null)
throw new MissingManifestResourceException("Could not find style resource");
using (stream)
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
private static string HtmlEncode(string str) private static string HtmlEncode(string str)
{ {
return WebUtility.HtmlEncode(str); return WebUtility.HtmlEncode(str);
} }
private static string HtmlEncode(object obj)
{
return WebUtility.HtmlEncode(obj.ToString());
}
private static string FormatFileSize(long fileSize) private static string FormatFileSize(long fileSize)
{ {
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}; string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
@@ -163,38 +215,102 @@ namespace DiscordChatExporter.Services
return $"{size:0.#} {units[unit]}"; return $"{size:0.#} {units[unit]}";
} }
private static string FormatMessageContent(string content) public static string FormatMessageContentText(Message message)
{ {
var content = message.Content;
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@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 static string FormatMessageContentHtml(Message message)
{
var content = message.Content;
// Encode HTML // Encode HTML
content = HtmlEncode(content); content = HtmlEncode(content);
// Links from URLs // Pre multiline (```text```)
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)", content = Regex.Replace(content, "```+(?:[^`]*?\\n)?([^`]+)\\n?```+", "<div class=\"pre\">$1</div>");
"<a href=\"$1\">$1</a>");
// Preformatted multiline // Pre inline (`text`)
content = Regex.Replace(content, "```([^`]*?)```", content = Regex.Replace(content, "`([^`]+)`", "<span class=\"pre\">$1</span>");
m => "<div class=\"pre\">" + m.Groups[1].Value.Trim('\n') + "</div>");
// Preformatted inline // Bold (**text**)
content = Regex.Replace(content, "`([^`]*?)`",
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
// Bold
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>"); content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
// Italic // Italic (*text*)
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>"); content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
// Underline // Underline (__text__)
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>"); content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
// Strike through // Italic (_text_)
content = Regex.Replace(content, "_([^_]*?)_", "<i>$1</i>");
// Strike through (~~text~~)
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>"); content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
// New lines // New lines
content = content.Replace("\n", "<br />"); content = content.Replace("\n", "<br />");
// URL links
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s<>]*)", "<a href=\"$1\">$1</a>");
// 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>)
foreach (var mentionedUser in message.MentionedUsers)
{
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser)}\">" +
$"@{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; return content;
} }
} }
+4 -2
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Models; using DiscordChatExporter.Models;
@@ -12,6 +13,7 @@ namespace DiscordChatExporter.Services
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId); Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId); Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to);
} }
} }
@@ -5,6 +5,6 @@ namespace DiscordChatExporter.Services
{ {
public interface IExportService public interface IExportService
{ {
Task ExportAsync(string filePath, ChannelChatLog log, Theme theme); Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log);
} }
} }
@@ -4,11 +4,12 @@ namespace DiscordChatExporter.Services
{ {
public interface ISettingsService public interface ISettingsService
{ {
string Token { get; set; }
Theme Theme { get; set; }
string DateFormat { get; set; } string DateFormat { get; set; }
int MessageGroupLimit { get; set; } int MessageGroupLimit { get; set; }
string LastToken { get; set; }
ExportFormat LastExportFormat { get; set; }
void Load(); void Load();
void Save(); void Save();
} }
@@ -15,7 +15,6 @@ namespace DiscordChatExporter.Services
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages) public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
{ {
var groupLimit = _settingsService.MessageGroupLimit;
var result = new List<MessageGroup>(); var result = new List<MessageGroup>();
// Group adjacent messages by timestamp and author // Group adjacent messages by timestamp and author
@@ -31,7 +30,7 @@ namespace DiscordChatExporter.Services
message.Author.Id != groupFirst.Author.Id || message.Author.Id != groupFirst.Author.Id ||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 || (message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour || message.TimeStamp.Hour != groupFirst.TimeStamp.Hour ||
groupBuffer.Count >= groupLimit groupBuffer.Count >= _settingsService.MessageGroupLimit
); );
// If condition is true - flush buffer // If condition is true - flush buffer
@@ -5,11 +5,12 @@ namespace DiscordChatExporter.Services
{ {
public class SettingsService : SettingsManager, ISettingsService public class SettingsService : SettingsManager, ISettingsService
{ {
public string Token { get; set; } public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public Theme Theme { get; set; }
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm";
public int MessageGroupLimit { get; set; } = 20; public int MessageGroupLimit { get; set; } = 20;
public string LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public SettingsService() public SettingsService()
{ {
Configuration.StorageSpace = StorageSpace.Instance; Configuration.StorageSpace = StorageSpace.Instance;
@@ -9,7 +9,11 @@ namespace DiscordChatExporter.ViewModels
public ErrorViewModel() public ErrorViewModel()
{ {
MessengerInstance.Register<ShowErrorMessage>(this, m => Message = m.Message); // Messages
MessengerInstance.Register<ShowErrorMessage>(this, m =>
{
Message = m.Message;
});
} }
} }
} }
@@ -14,10 +14,14 @@ namespace DiscordChatExporter.ViewModels
public ExportDoneViewModel() public ExportDoneViewModel()
{ {
MessengerInstance.Register<ShowExportDoneMessage>(this, m => _filePath = m.FilePath);
// Commands // Commands
OpenCommand = new RelayCommand(Open); OpenCommand = new RelayCommand(Open);
// Messages
MessengerInstance.Register<ShowExportDoneMessage>(this, m =>
{
_filePath = m.FilePath;
});
} }
private void Open() private void Open()
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DiscordChatExporter.Messages;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.ViewModels
{
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
{
private readonly ISettingsService _settingsService;
private string _filePath;
private ExportFormat _format;
private DateTime? _from;
private DateTime? _to;
public Guild Guild { get; private set; }
public Channel Channel { get; private set; }
public string FilePath
{
get => _filePath;
set
{
Set(ref _filePath, value);
ExportCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<ExportFormat> AvailableFormats { get; }
public ExportFormat SelectedFormat
{
get => _format;
set
{
Set(ref _format, value);
// Replace extension in path
var newExt = value.GetFileExtension();
if (FilePath != null && !FilePath.EndsWith(newExt))
FilePath = FilePath.SubstringUntilLast(".") + "." + newExt;
}
}
public DateTime? From
{
get => _from;
set => Set(ref _from, value);
}
public DateTime? To
{
get => _to;
set => Set(ref _to, value);
}
// Commands
public RelayCommand ExportCommand { get; }
public ExportSetupViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
// Defaults
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
// Commands
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
// Messages
MessengerInstance.Register<ShowExportSetupMessage>(this, m =>
{
Guild = m.Guild;
Channel = m.Channel;
SelectedFormat = _settingsService.LastExportFormat;
FilePath = $"{Guild} - {Channel}.{SelectedFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
From = null;
To = null;
});
}
private void Export()
{
// Save format
_settingsService.LastExportFormat = SelectedFormat;
// Start export
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
}
}
}
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.ViewModels
{
public interface IExportSetupViewModel
{
Guild Guild { get; }
Channel Channel { get; }
string FilePath { get; set; }
IReadOnlyList<ExportFormat> AvailableFormats { get; }
ExportFormat SelectedFormat { get; set; }
DateTime? From { get; set; }
DateTime? To { get; set; }
RelayCommand ExportCommand { get; }
}
}
@@ -16,8 +16,8 @@ namespace DiscordChatExporter.ViewModels
IReadOnlyList<Channel> AvailableChannels { get; } IReadOnlyList<Channel> AvailableChannels { get; }
RelayCommand PullDataCommand { get; } RelayCommand PullDataCommand { get; }
RelayCommand<Channel> ExportChannelCommand { get; }
RelayCommand ShowSettingsCommand { get; } RelayCommand ShowSettingsCommand { get; }
RelayCommand ShowAboutCommand { get; } RelayCommand ShowAboutCommand { get; }
RelayCommand<Channel> ShowExportSetupCommand { get; }
} }
} }
@@ -1,12 +1,7 @@
using System.Collections.Generic; namespace DiscordChatExporter.ViewModels
using DiscordChatExporter.Models;
namespace DiscordChatExporter.ViewModels
{ {
public interface ISettingsViewModel public interface ISettingsViewModel
{ {
IReadOnlyList<Theme> AvailableThemes { get; }
Theme Theme { get; set; }
string DateFormat { get; set; } string DateFormat { get; set; }
int MessageGroupLimit { get; set; } int MessageGroupLimit { get; set; }
} }
+71 -64
View File
@@ -1,6 +1,6 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using DiscordChatExporter.Exceptions; using DiscordChatExporter.Exceptions;
@@ -9,7 +9,6 @@ using DiscordChatExporter.Models;
using DiscordChatExporter.Services; using DiscordChatExporter.Services;
using GalaSoft.MvvmLight; using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf; using GalaSoft.MvvmLight.CommandWpf;
using Microsoft.Win32;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.ViewModels namespace DiscordChatExporter.ViewModels
@@ -24,10 +23,10 @@ namespace DiscordChatExporter.ViewModels
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap; private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy; private bool _isBusy;
private string _token;
private IReadOnlyList<Guild> _availableGuilds; private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild; private Guild _selectedGuild;
private IReadOnlyList<Channel> _availableChannels; private IReadOnlyList<Channel> _availableChannels;
private string _cachedToken;
public bool IsBusy public bool IsBusy
{ {
@@ -36,7 +35,7 @@ namespace DiscordChatExporter.ViewModels
{ {
Set(ref _isBusy, value); Set(ref _isBusy, value);
PullDataCommand.RaiseCanExecuteChanged(); PullDataCommand.RaiseCanExecuteChanged();
ExportChannelCommand.RaiseCanExecuteChanged(); ShowExportSetupCommand.RaiseCanExecuteChanged();
} }
} }
@@ -44,13 +43,13 @@ namespace DiscordChatExporter.ViewModels
public string Token public string Token
{ {
get => _settingsService.Token; get => _token;
set set
{ {
// Remove invalid chars // Remove invalid chars
value = value?.Trim('"'); value = value?.Trim('"');
_settingsService.Token = value; Set(ref _token, value);
PullDataCommand.RaiseCanExecuteChanged(); PullDataCommand.RaiseCanExecuteChanged();
} }
} }
@@ -72,7 +71,7 @@ namespace DiscordChatExporter.ViewModels
{ {
Set(ref _selectedGuild, value); Set(ref _selectedGuild, value);
AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0]; AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0];
ExportChannelCommand.RaiseCanExecuteChanged(); ShowExportSetupCommand.RaiseCanExecuteChanged();
} }
} }
@@ -83,9 +82,9 @@ namespace DiscordChatExporter.ViewModels
} }
public RelayCommand PullDataCommand { get; } public RelayCommand PullDataCommand { get; }
public RelayCommand<Channel> ExportChannelCommand { get; }
public RelayCommand ShowSettingsCommand { get; } public RelayCommand ShowSettingsCommand { get; }
public RelayCommand ShowAboutCommand { get; } public RelayCommand ShowAboutCommand { get; }
public RelayCommand<Channel> ShowExportSetupCommand { get; }
public MainViewModel(ISettingsService settingsService, IDataService dataService, public MainViewModel(ISettingsService settingsService, IDataService dataService,
IMessageGroupService messageGroupService, IExportService exportService) IMessageGroupService messageGroupService, IExportService exportService)
@@ -99,15 +98,29 @@ namespace DiscordChatExporter.ViewModels
// Commands // Commands
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy); PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
ExportChannelCommand = new RelayCommand<Channel>(ExportChannel, _ => !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings); ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout); 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);
});
// Defaults
_token = _settingsService.LastToken;
} }
private async void PullData() private async void PullData()
{ {
IsBusy = true; IsBusy = true;
_cachedToken = Token;
// Copy token so it doesn't get mutated
var token = Token;
// Save token
_settingsService.LastToken = token;
// Clear existing // Clear existing
_guildChannelsMap.Clear(); _guildChannelsMap.Clear();
@@ -116,24 +129,29 @@ namespace DiscordChatExporter.ViewModels
{ {
// Get DM channels // Get DM channels
{ {
var channels = await _dataService.GetDirectMessageChannelsAsync(_cachedToken); var channels = await _dataService.GetDirectMessageChannelsAsync(token);
var guild = new Guild("@me", "Direct Messages", null); var guild = new Guild("@me", "Direct Messages", null);
_guildChannelsMap[guild] = channels.ToArray(); _guildChannelsMap[guild] = channels.ToArray();
} }
// Get guild channels // Get guild channels
{ {
var guilds = await _dataService.GetGuildsAsync(_cachedToken); var guilds = await _dataService.GetGuildsAsync(token);
foreach (var guild in guilds) foreach (var guild in guilds)
{ {
var channels = await _dataService.GetGuildChannelsAsync(_cachedToken, guild.Id); var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray(); _guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
} }
} }
} }
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{ {
const string message = "Could not authorize using the given token. Make sure it's valid."; const string message = "Unauthorized to perform request. Make sure token is valid.";
MessengerInstance.Send(new ShowErrorMessage(message));
}
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 ShowErrorMessage(message));
} }
@@ -142,55 +160,6 @@ namespace DiscordChatExporter.ViewModels
IsBusy = false; IsBusy = false;
} }
private async void ExportChannel(Channel channel)
{
IsBusy = true;
// Get safe file names
var safeGuildName = SelectedGuild.Name.Replace(Path.GetInvalidFileNameChars(), '_');
var safeChannelName = channel.Name.Replace(Path.GetInvalidFileNameChars(), '_');
// Ask for path
var sfd = new SaveFileDialog
{
FileName = $"{safeGuildName} - {safeChannelName}.html",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
AddExtension = true
};
if (sfd.ShowDialog() != true)
{
IsBusy = false;
return;
}
// Export
try
{
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(_cachedToken, channel.Id);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
// Export
await _exportService.ExportAsync(sfd.FileName, chatLog, _settingsService.Theme);
// Show dialog
MessengerInstance.Send(new ShowExportDoneMessage(sfd.FileName));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
const string message = "You don't have access to the messages in that channel.";
MessengerInstance.Send(new ShowErrorMessage(message));
}
IsBusy = false;
}
private void ShowSettings() private void ShowSettings()
{ {
MessengerInstance.Send(new ShowSettingsMessage()); MessengerInstance.Send(new ShowSettingsMessage());
@@ -200,5 +169,43 @@ namespace DiscordChatExporter.ViewModels
{ {
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter"); Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
} }
private void ShowExportSetup(Channel channel)
{
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
}
private async void Export(Channel channel, string filePath, ExportFormat format, DateTime? from, DateTime? to)
{
IsBusy = true;
// Get last used token
var token = _settingsService.LastToken;
try
{
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var log = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
// Export
await _exportService.ExportAsync(format, filePath, log);
// Notify completion
MessengerInstance.Send(new ShowExportDoneMessage(filePath));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
const string message = "Forbidden to view messages in that channel.";
MessengerInstance.Send(new ShowErrorMessage(message));
}
IsBusy = false;
}
} }
} }
@@ -1,8 +1,4 @@
using System; using DiscordChatExporter.Services;
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
using GalaSoft.MvvmLight; using GalaSoft.MvvmLight;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
@@ -12,14 +8,6 @@ namespace DiscordChatExporter.ViewModels
{ {
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
public IReadOnlyList<Theme> AvailableThemes { get; }
public Theme Theme
{
get => _settingsService.Theme;
set => _settingsService.Theme = value;
}
public string DateFormat public string DateFormat
{ {
get => _settingsService.DateFormat; get => _settingsService.DateFormat;
@@ -35,9 +23,6 @@ namespace DiscordChatExporter.ViewModels
public SettingsViewModel(ISettingsService settingsService) public SettingsViewModel(ISettingsService settingsService)
{ {
_settingsService = settingsService; _settingsService = settingsService;
// Defaults
AvailableThemes = Enum.GetValues(typeof(Theme)).Cast<Theme>().ToArray();
} }
} }
} }
+18 -18
View File
@@ -2,24 +2,24 @@
UserControl "DiscordChatExporter.Views.ErrorDialog" { UserControl "DiscordChatExporter.Views.ErrorDialog" {
DataContext: bind ErrorViewModel from $resource Container DataContext: bind ErrorViewModel from $resource Container
Width: 250 Width: 250
StackPanel { StackPanel {
// Message // Message
TextBlock { TextBlock {
Margin: 16 Margin: 16
FontSize: 16 FontSize: 16
TextWrapping: WrapWithOverflow TextWrapping: WrapWithOverflow
Text: bind Message Text: bind Message
}
// OK
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "OK"
HorizontalAlignment: Right
Style: resource dyn "MaterialDesignFlatButton"
}
} }
// OK
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "OK"
HorizontalAlignment: Right
Style: resource dyn "MaterialDesignFlatButton"
}
}
} }
@@ -1,10 +1,4 @@
using System; namespace DiscordChatExporter.Views
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DiscordChatExporter.Views
{ {
public partial class ErrorDialog public partial class ErrorDialog
{ {
@@ -13,4 +7,4 @@ namespace DiscordChatExporter.Views
InitializeComponent(); InitializeComponent();
} }
} }
} }
@@ -18,18 +18,19 @@ UserControl "DiscordChatExporter.Views.ExportDoneDialog" {
HorizontalAlignment: Right HorizontalAlignment: Right
// Open // Open
Button { Button "OpenButton" {
Margin: 8
Click: OpenButton_Click
Command: bind OpenCommand Command: bind OpenCommand
Content: "OPEN IT" Content: "OPEN IT"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton" Style: resource dyn "MaterialDesignFlatButton"
} }
// Dismiss // Dismiss
Button { Button {
Margin: 8
Command: DialogHost.CloseDialogCommand Command: DialogHost.CloseDialogCommand
Content: "DISMISS" Content: "DISMISS"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton" Style: resource dyn "MaterialDesignFlatButton"
} }
} }
@@ -1,8 +1,5 @@
using System; using System.Windows;
using System.Collections.Generic; using MaterialDesignThemes.Wpf;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DiscordChatExporter.Views namespace DiscordChatExporter.Views
{ {
@@ -12,5 +9,10 @@ namespace DiscordChatExporter.Views
{ {
InitializeComponent(); InitializeComponent();
} }
public void OpenButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
} }
} }
@@ -0,0 +1,88 @@
using DiscordChatExporter.Models
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
DataContext: bind ExportSetupViewModel from $resource Container
Width: 325
StackPanel {
// File path
TextBox {
Margin: [16, 16, 16, 8]
HintAssist.Hint: "Output file"
HintAssist.IsFloating: true
IsReadOnly: true
Text: bind FilePath
set [ UpdateSourceTrigger: PropertyChanged ]
}
// Format
ComboBox {
Margin: [16, 8, 16, 8]
HintAssist.Hint: "Export format"
HintAssist.IsFloating: true
IsReadOnly: true
ItemsSource: bind AvailableFormats
SelectedItem: bind SelectedFormat
ItemTemplate: DataTemplate {
TextBlock {
Text: bind
convert (ExportFormat f) => Extensions.GetDisplayName(f)
}
}
}
// Date range
Grid {
#TwoColumns("*", "*")
DatePicker {
#Cell(0, 0)
Margin: [16, 20, 8, 8]
HintAssist.Hint: "From (optional)"
HintAssist.IsFloating: true
SelectedDate: bind From
}
DatePicker {
#Cell(0, 1)
Margin: [8, 20, 16, 8]
HintAssist.Hint: "To (optional)"
HintAssist.IsFloating: true
SelectedDate: bind To
}
}
// Buttons
@StackPanelHorizontal {
HorizontalAlignment: Right
// Browse
Button "BrowseButton" {
Margin: 8
Click: BrowseButton_Click
Content: "BROWSE"
Style: resource dyn "MaterialDesignFlatButton"
}
// Export
Button "ExportButton" {
Margin: 8
Click: ExportButton_Click
Command: bind ExportCommand
Content: "EXPORT"
IsDefault: true
Style: resource dyn "MaterialDesignFlatButton"
}
// Cancel
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "CANCEL"
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
}
@@ -0,0 +1,44 @@
using System.Windows;
using DiscordChatExporter.Models;
using DiscordChatExporter.ViewModels;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
namespace DiscordChatExporter.Views
{
public partial class ExportSetupDialog
{
private IExportSetupViewModel ViewModel => (IExportSetupViewModel) DataContext;
public ExportSetupDialog()
{
InitializeComponent();
}
public void BrowseButton_Click(object sender, RoutedEventArgs args)
{
// Get file extension of the selected format
var ext = ViewModel.SelectedFormat.GetFileExtension();
// Open dialog
var sfd = new SaveFileDialog
{
FileName = ViewModel.FilePath,
Filter = $"{ext.ToUpperInvariant()} Files|*.{ext}|All Files|*.*",
AddExtension = true,
Title = "Select output file"
};
// Assign new file path if dialog was successful
if (sfd.ShowDialog() == true)
{
ViewModel.FilePath = sfd.FileName;
}
}
public void ExportButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
}
}
+22 -19
View File
@@ -20,6 +20,9 @@ Window "DiscordChatExporter.Views.MainWindow" {
DialogHost { DialogHost {
DockPanel { DockPanel {
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
// Toolbar // Toolbar
Border { Border {
DockPanel.Dock: Top DockPanel.Dock: Top
@@ -30,30 +33,32 @@ Window "DiscordChatExporter.Views.MainWindow" {
#TwoColumns("*", "Auto") #TwoColumns("*", "Auto")
Card { Card {
Grid.Column: 0 #Cell(0, 0)
Margin: "6 6 0 6" Margin: [6, 6, 0, 6]
Grid { Grid {
#TwoColumns("*", "Auto") #TwoColumns("*", "Auto")
// Token // Token
TextBox "TokenTextBox" { TextBox "TokenTextBox" {
Grid.Column: 0 #Cell(0, 0)
Margin: 6 Margin: 6
BorderThickness: 0 BorderThickness: 0
HintAssist.Hint: "Token" HintAssist.Hint: "Token"
KeyDown: TokenTextBox_KeyDown
FontSize: 16 FontSize: 16
Text: bind Token Text: bind Token
set [ UpdateSourceTrigger: PropertyChanged ] set [ UpdateSourceTrigger: PropertyChanged ]
TextFieldAssist.DecorationVisibility: Hidden
TextFieldAssist.TextBoxViewMargin: [0, 0, 2, 0]
} }
// Submit // Submit
Button { Button {
Grid.Column: 1 #Cell(0, 1)
Margin: "0 6 6 6" Margin: [0, 6, 6, 6]
Padding: 4 Padding: 4
Command: bind PullDataCommand Command: bind PullDataCommand
IsDefault: true
Style: resource dyn "MaterialDesignFlatButton" Style: resource dyn "MaterialDesignFlatButton"
PackIcon { PackIcon {
@@ -67,7 +72,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
// Popup menu // Popup menu
PopupBox { PopupBox {
Grid.Column: 1 #Cell(0, 1)
Foreground: resource dyn "PrimaryHueMidForegroundBrush" Foreground: resource dyn "PrimaryHueMidForegroundBrush"
PlacementMode: LeftAndAlignTopEdges PlacementMode: LeftAndAlignTopEdges
@@ -98,8 +103,6 @@ Window "DiscordChatExporter.Views.MainWindow" {
Grid { Grid {
DockPanel { DockPanel {
Background: resource dyn "MaterialDesignCardBackground" Background: resource dyn "MaterialDesignCardBackground"
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
Visibility: bind IsDataAvailable Visibility: bind IsDataAvailable
convert (bool b) => b ? Visibility.Visible : Visibility.Hidden convert (bool b) => b ? Visibility.Visible : Visibility.Hidden
@@ -119,7 +122,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
TransitioningContent { TransitioningContent {
OpeningEffect: TransitionEffect { OpeningEffect: TransitionEffect {
Duration: "0:0:0.3" Duration: "0:0:0.3"
Kind: SlideInFromRight Kind: SlideInFromLeft
} }
Border { Border {
@@ -128,9 +131,11 @@ Window "DiscordChatExporter.Views.MainWindow" {
Cursor: CursorType.Hand Cursor: CursorType.Hand
Image { Image {
Margin: "12 4 12 4" Margin: [12, 4, 12, 4]
Width: 48 Width: 48
Height: 48 Height: 48
Source: bind IconUrl
ToolTip: bind Name
OpacityMask: RadialGradientBrush { OpacityMask: RadialGradientBrush {
GradientStops: [ GradientStops: [
GradientStop { Color: "#FF000000", Offset: 0 } GradientStop { Color: "#FF000000", Offset: 0 }
@@ -138,8 +143,6 @@ Window "DiscordChatExporter.Views.MainWindow" {
GradientStop { Color: "#00000000", Offset: 1 } GradientStop { Color: "#00000000", Offset: 1 }
] ]
} }
Source: bind IconUrl
ToolTip: bind Name
} }
} }
} }
@@ -167,19 +170,19 @@ Window "DiscordChatExporter.Views.MainWindow" {
Cursor: CursorType.Hand Cursor: CursorType.Hand
InputBindings: [ InputBindings: [
MouseBinding { MouseBinding {
Command: bind DataContext.ExportChannelCommand from $ancestor<ItemsControl> Command: bind DataContext.ShowExportSetupCommand from $ancestor<ItemsControl>
CommandParameter: bind CommandParameter: bind
MouseAction: LeftClick MouseAction: LeftClick
} }
] ]
PackIcon { PackIcon {
Margin: "16 7 0 6" Margin: [16, 7, 0, 6]
Kind: PackIconKind.Pound Kind: PackIconKind.Pound
VerticalAlignment: Center VerticalAlignment: Center
} }
TextBlock { TextBlock {
Margin: "3 8 8 8" Margin: [3, 8, 8, 8]
FontSize: 14 FontSize: 14
Text: bind Name Text: bind Name
VerticalAlignment: Center VerticalAlignment: Center
@@ -193,7 +196,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
// Content placeholder // Content placeholder
StackPanel { StackPanel {
Margin: "32 32 8 8" Margin: [32, 32, 8, 8]
Visibility: bind IsDataAvailable Visibility: bind IsDataAvailable
convert (bool b) => b ? Visibility.Hidden : Visibility.Visible convert (bool b) => b ? Visibility.Hidden : Visibility.Visible
@@ -203,13 +206,13 @@ Window "DiscordChatExporter.Views.MainWindow" {
} }
TextBlock { TextBlock {
Margin: "0 8 0 0" Margin: [0, 8, 0, 0]
FontSize: 16 FontSize: 16
Text: "To obtain it, follow these steps:" Text: "To obtain it, follow these steps:"
} }
TextBlock { TextBlock {
Margin: "8 0 0 0" Margin: [8, 0, 0, 0]
FontSize: 14 FontSize: 14
Run { Run {
+9 -16
View File
@@ -1,7 +1,5 @@
using System.Reflection; using System.Reflection;
using System.Windows.Input;
using DiscordChatExporter.Messages; using DiscordChatExporter.Messages;
using DiscordChatExporter.ViewModels;
using GalaSoft.MvvmLight.Messaging; using GalaSoft.MvvmLight.Messaging;
using MaterialDesignThemes.Wpf; using MaterialDesignThemes.Wpf;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
@@ -10,25 +8,20 @@ namespace DiscordChatExporter.Views
{ {
public partial class MainWindow public partial class MainWindow
{ {
private IMainViewModel ViewModel => (IMainViewModel) DataContext;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}"; Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
Messenger.Default.Register<ShowErrorMessage>(this, m => DialogHost.Show(new ErrorDialog()).Forget()); // Dialogs
Messenger.Default.Register<ShowSettingsMessage>(this, m => DialogHost.Show(new SettingsDialog()).Forget()); Messenger.Default.Register<ShowErrorMessage>(this,
Messenger.Default.Register<ShowExportDoneMessage>(this, m => DialogHost.Show(new ExportDoneDialog()).Forget()); m => DialogHost.Show(new ErrorDialog()).Forget());
} Messenger.Default.Register<ShowExportDoneMessage>(this,
m => DialogHost.Show(new ExportDoneDialog()).Forget());
public void TokenTextBox_KeyDown(object sender, KeyEventArgs e) Messenger.Default.Register<ShowExportSetupMessage>(this,
{ m => DialogHost.Show(new ExportSetupDialog()).Forget());
if (e.Key == Key.Enter) Messenger.Default.Register<ShowSettingsMessage>(this,
{ m => DialogHost.Show(new SettingsDialog()).Forget());
// Execute command
ViewModel.PullDataCommand.Execute(null);
}
} }
} }
} }
+2 -12
View File
@@ -5,19 +5,9 @@ UserControl "DiscordChatExporter.Views.SettingsDialog" {
Width: 250 Width: 250
StackPanel { StackPanel {
// Theme
ComboBox {
Margin: "16 16 16 8"
HintAssist.Hint: "Theme"
HintAssist.IsFloating: true
IsReadOnly: true
ItemsSource: bind AvailableThemes
SelectedItem: bind Theme
}
// Date format // Date format
TextBox { TextBox {
Margin: "16 8 16 8" Margin: [16, 16, 16, 8]
HintAssist.Hint: "Date format" HintAssist.Hint: "Date format"
HintAssist.IsFloating: true HintAssist.IsFloating: true
Text: bind DateFormat Text: bind DateFormat
@@ -25,7 +15,7 @@ UserControl "DiscordChatExporter.Views.SettingsDialog" {
// Group limit // Group limit
TextBox { TextBox {
Margin: "16 8 16 8" Margin: [16, 8, 16, 8]
HintAssist.Hint: "Message group limit" HintAssist.Hint: "Message group limit"
HintAssist.IsFloating: true HintAssist.IsFloating: true
Text: bind MessageGroupLimit Text: bind MessageGroupLimit
+19 -11
View File
@@ -1,26 +1,34 @@
# DiscordChatExporter # DiscordChatExporter
DiscordChatExporter can be used to export message history from [Discord](https://discordapp.com) to an HTML file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. There are options to configure the output, such as date format, color theme, message grouping limit, etc. [![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 ## Screenshots
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/3.png) ![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/1.png)
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/4.png)
## Download ## 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 ## Features
- Exports to a self-contained HTML file - Intuitive GUI that displays available guilds and channels
- Supports both dark and light theme - Date ranges to limit messages
- Displays user avatars
- Groups messages by author and time - Groups messages by author and time
- Handles Discord markdown characters - Export to a plain text file
- Converts URLs to links - Export to an HTML file
- Inlines attached images with a link to the full version - Dark and light themes
- Inserts download links for other types of attached files - User avatars
- Marks edited messages with a timestamp - Inline image attachments
- Full markdown support
- Automatic links
- Styled similarly to the app
## Libraries used ## Libraries used