mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b7da21c24 | |||
| 1d0a13c613 | |||
| 67036f59ce | |||
| 517542730a | |||
| 19dc2334e2 | |||
| 4e1f783145 | |||
| ff6ef480ab | |||
| e1e8c73613 | |||
| e0284e281c | |||
| 435510baf9 | |||
| cc2583f96b | |||
| de763f8aaa | |||
| 7b67cbc385 | |||
| 7ffb799136 | |||
| 5a84fb25e6 | |||
| 9988300942 | |||
| 4540134a98 | |||
| 656e5a5b0d | |||
| 63e3ba8891 | |||
| d7572d9fd3 | |||
| 63c835df88 | |||
| 7bfd645e8e | |||
| 4e5d3cb4ed | |||
| cff976e2e4 | |||
| 0c69fd6e45 | |||
| 909a58b641 | |||
| 2db4ad46cc | |||
| ffa1023c50 | |||
| f7ef3bbb44 | |||
| bb2b04d2e5 | |||
| 1f36fb608a | |||
| b6e93e5770 | |||
| 8515efe11b | |||
| 7da82f9ef4 | |||
| 6ccba43807 | |||
| 2ce9e2c77e | |||
| a6a54d688f | |||
| e0d2f9c1ba | |||
| 01f8cf0e32 | |||
| ebab80fda1 | |||
| c432a75f62 | |||
| be4989ea34 | |||
| f8dae72411 | |||
| ab46361489 | |||
| 4a986fe5df | |||
| e3d2afac87 | |||
| 0cfcaca6b2 | |||
| 107c9de265 | |||
| 5d6edfd937 | |||
| 60a5a69aa4 | |||
| cc565598d3 | |||
| 6a7000cbc9 | |||
| 6706ff95f1 | |||
| ecc95c9c7f | |||
| d4f2049db6 | |||
| 4d5118de76 | |||
| 171718989a | |||
| 75e8d59cc3 | |||
| 1d7264a71b | |||
| 2c0a63352c | |||
| 42d4d64695 | |||
| 6968f987ce | |||
| 5ecbfd50b8 | |||
| e59a1ea8b4 | |||
| 540ce7f3c3 | |||
| a0d261f966 | |||
| 10446064d5 | |||
| 6709821324 | |||
| 705e60c76d | |||
| 8ac8c16db3 | |||
| 1850caba71 | |||
| 6ccaf3f106 | |||
| d7345e91d3 | |||
| e5e9d4c9ff | |||
| b280f8b736 | |||
| ca452d2421 | |||
| 9e7d4033e0 | |||
| 263a606bff | |||
| 1e853f11b0 | |||
| 77d0956f5d | |||
| d251e8ecf6 | |||
| 01f0e6952d | |||
| 28a67ab60d | |||
| 42bb4e72d2 | |||
| a79d72f975 | |||
| b110413c08 | |||
| a556926fe6 | |||
| 5a57b4a6b1 | |||
| d3b311b88f | |||
| d8db4fca90 | |||
| b7979d6d1f | |||
| 749a55a30e | |||
| 7b9de314ab | |||
| 69184a74fe | |||
| efae0e99b8 | |||
| 8afe9852fb | |||
| f2178a0445 | |||
| c01b9d0e5f | |||
| 151c5e2f23 | |||
| 6d7a8ae063 | |||
| d8bbe8c8c8 | |||
| 9a1a257f1e | |||
| 5e0a39df5e | |||
| fb3b14f1ce | |||
| 34afce2a66 | |||
| 95d581797b | |||
| 22d7dd43ec | |||
| dd942824ec | |||
| 0974dd77bf |
@@ -0,0 +1,24 @@
|
||||
### v2.4.1 (15-Apr-2018)
|
||||
|
||||
- Added CSV export format.
|
||||
- Channels are now ordered by name in the UI.
|
||||
- Exported file is now always opened upon completion.
|
||||
|
||||
### v2.4 (08-Apr-2018)
|
||||
|
||||
- Added channel topic to output.
|
||||
- Added a command line interface alternative to the current graphical user interface.
|
||||
- Implemented application auto-update (can be disabled in settings).
|
||||
- Improved some GUI elements and replaced some dialogs with notifications.
|
||||
- Fixed a lot of issues with the markdown processor.
|
||||
- Fixed DM group chats that have a custom name being shown as blank in the list.
|
||||
|
||||
### v2.3 (27-Oct-2017)
|
||||
|
||||
- Improved message date filtering, it's now marginally faster.
|
||||
- Fixed underscores not recognized as italics in markdown.
|
||||
- Added support for custom emojis.
|
||||
- Added support for user and role mentions.
|
||||
- Added support for channel mentions.
|
||||
- Fixed text in pre blocks not being wrapped correctly.
|
||||
- Added workaround for non-default message types.
|
||||
@@ -0,0 +1,11 @@
|
||||
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
|
||||
|
||||
# GUI
|
||||
$files = @()
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
|
||||
|
||||
# CLI
|
||||
$files = @()
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.Cli.zip" -Force
|
||||
@@ -0,0 +1,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; }
|
||||
}
|
||||
}
|
||||
@@ -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.1</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>
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Fclp;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
private static readonly Container Container = new Container();
|
||||
|
||||
private static void ShowHelp()
|
||||
{
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var availableFormats = Enum.GetNames(typeof(ExportFormat));
|
||||
|
||||
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. 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($"Available export formats: {availableFormats.JoinToString(", ")}");
|
||||
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,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
<Version>2.4.1</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="CsvHelper" Version="7.1.0" />
|
||||
<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>
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exceptions
|
||||
{
|
||||
public class HttpErrorStatusCodeException : Exception
|
||||
{
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public HttpErrorStatusCodeException(HttpStatusCode statusCode)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class Attachment
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public AttachmentType Type { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public long FileSize { get; }
|
||||
|
||||
public Attachment(string id, AttachmentType type, string url, string fileName, long fileSize)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
Url = url;
|
||||
FileName = fileName;
|
||||
FileSize = fileSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public enum AttachmentType
|
||||
{
|
||||
Other,
|
||||
Image
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class ChannelChatLog
|
||||
{
|
||||
public Guild Guild { get; }
|
||||
|
||||
public Channel Channel { get; }
|
||||
|
||||
public IReadOnlyList<MessageGroup> MessageGroups { get; }
|
||||
|
||||
public int TotalMessageCount { get; }
|
||||
|
||||
public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList<MessageGroup> messageGroups,
|
||||
int totalMessageCount)
|
||||
{
|
||||
Guild = guild;
|
||||
Channel = channel;
|
||||
MessageGroups = messageGroups;
|
||||
TotalMessageCount = totalMessageCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public enum ChannelType
|
||||
{
|
||||
GuildTextChat,
|
||||
DirectTextChat,
|
||||
GuildVoiceChat,
|
||||
DirectGroupTextChat,
|
||||
Category
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public enum ExportFormat
|
||||
{
|
||||
PlainText,
|
||||
HtmlDark,
|
||||
HtmlLight,
|
||||
Csv
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public static class Extensions
|
||||
{
|
||||
public static string GetFileExtension(this ExportFormat format)
|
||||
{
|
||||
if (format == ExportFormat.PlainText)
|
||||
return "txt";
|
||||
if (format == ExportFormat.HtmlDark)
|
||||
return "html";
|
||||
if (format == ExportFormat.HtmlLight)
|
||||
return "html";
|
||||
if (format == ExportFormat.Csv)
|
||||
return "csv";
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(format));
|
||||
}
|
||||
|
||||
public static string GetDisplayName(this ExportFormat format)
|
||||
{
|
||||
if (format == ExportFormat.PlainText)
|
||||
return "Plain Text";
|
||||
if (format == ExportFormat.HtmlDark)
|
||||
return "HTML (Dark)";
|
||||
if (format == ExportFormat.HtmlLight)
|
||||
return "HTML (Light)";
|
||||
if (format == ExportFormat.Csv)
|
||||
return "Comma Seperated Values (CSV)";
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(format));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Generic;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public partial class Guild
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string IconHash { get; }
|
||||
|
||||
public string IconUrl => IconHash.IsNotBlank()
|
||||
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
|
||||
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
|
||||
public 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()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class MessageGroup
|
||||
{
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime TimeStamp { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public MessageGroup(User author, DateTime timeStamp, IReadOnlyList<Message> messages)
|
||||
{
|
||||
Author = author;
|
||||
TimeStamp = timeStamp;
|
||||
Messages = messages;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public enum MessageType
|
||||
{
|
||||
Default,
|
||||
RecipientAdd,
|
||||
RecipientRemove,
|
||||
Call,
|
||||
ChannelNameChange,
|
||||
ChannelIconChange,
|
||||
ChannelPinnedMessage,
|
||||
GuildMemberJoin
|
||||
}
|
||||
}
|
||||
@@ -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,29 +1,36 @@
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public int Discriminator { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||
|
||||
public string AvatarHash { get; }
|
||||
|
||||
public string AvatarUrl => AvatarHash.IsNotBlank()
|
||||
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size=256"
|
||||
: "https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.png";
|
||||
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
||||
|
||||
public User(string id, string name, string avatarHash)
|
||||
public string AvatarUrl => AvatarHash.IsNotBlank()
|
||||
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
|
||||
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
|
||||
|
||||
public User(string id, int discriminator, string name, string avatarHash)
|
||||
{
|
||||
Id = id;
|
||||
Discriminator = discriminator;
|
||||
Name = name;
|
||||
AvatarHash = avatarHash;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
return FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
body {
|
||||
background-color: #36393E;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0096CF;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div.pre {
|
||||
background-color: #2F3136;
|
||||
color: rgb(131, 148, 150);
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
span.pre {
|
||||
background-color: #2F3136;
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
div#info {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
div#log {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
img.guild-icon {
|
||||
max-height: 64px;
|
||||
max-width: 64px;
|
||||
}
|
||||
|
||||
div.info-right {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
div.guild-name {
|
||||
color: #FFFFFF;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
div.channel-name {
|
||||
color: #FFFFFF;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
div.channel-topic {
|
||||
margin-top: 2px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
div.channel-messagecount {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
div.msg {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
display: flex;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
padding-bottom: 15px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
div.msg-left {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
img.msg-avatar {
|
||||
border-radius: 50%;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
div.msg-right {
|
||||
flex: 1;
|
||||
margin-left: 20px;
|
||||
min-width: 50%;
|
||||
}
|
||||
|
||||
span.msg-user {
|
||||
color: #FFFFFF;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
span.msg-date {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
font-size: .75em;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
span.msg-edited {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
font-size: .8em;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
div.msg-content {
|
||||
font-size: .9375em;
|
||||
padding-top: 5px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
div.msg-attachment {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
img.msg-attachment {
|
||||
max-height: 500px;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
img.emoji {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
vertical-align: -.4em;
|
||||
}
|
||||
|
||||
span.mention {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
body {
|
||||
background-color: #FFFFFF;
|
||||
color: #737F8D;
|
||||
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00B0F4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div.pre {
|
||||
background-color: #F9F9F9;
|
||||
color: rgb(101, 123, 131);
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
span.pre {
|
||||
background-color: #F9F9F9;
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
div#info {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
div#log {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
img.guild-icon {
|
||||
max-height: 64px;
|
||||
max-width: 64px;
|
||||
}
|
||||
|
||||
div.info-right {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
div.guild-name {
|
||||
color: #2F3136;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
div.channel-name {
|
||||
color: #2F3136;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
div.channel-topic {
|
||||
margin-top: 2px;
|
||||
color: #2F3136;
|
||||
}
|
||||
|
||||
div.channel-messagecount {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
div.msg {
|
||||
border-top: 1px solid #ECEEEF;
|
||||
display: flex;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
padding-bottom: 15px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
div.msg-left {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
img.msg-avatar {
|
||||
border-radius: 50%;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
div.msg-right {
|
||||
flex: 1;
|
||||
margin-left: 20px;
|
||||
min-width: 50%;
|
||||
}
|
||||
|
||||
span.msg-user {
|
||||
color: #2F3136;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
span.msg-date {
|
||||
color: #99AAB5;
|
||||
font-size: .75em;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
span.msg-edited {
|
||||
color: #99AAB5;
|
||||
font-size: .8em;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
div.msg-content {
|
||||
font-size: .9375em;
|
||||
padding-top: 5px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
div.msg-attachment {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
img.msg-attachment {
|
||||
max-height: 500px;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
img.emoji {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
vertical-align: -.4em;
|
||||
}
|
||||
|
||||
span.mention {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,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,76 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using CsvHelper;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public partial class ExportService
|
||||
{
|
||||
private string FormatMessageContentCsv(Message message)
|
||||
{
|
||||
var content = message.Content;
|
||||
|
||||
// New lines
|
||||
content = content.Replace("\n", ", ");
|
||||
|
||||
// User mentions (<@id> and <@!id>)
|
||||
foreach (var mentionedUser in message.MentionedUsers)
|
||||
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
|
||||
|
||||
// Role mentions (<@&id>)
|
||||
foreach (var mentionedRole in message.MentionedRoles)
|
||||
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
|
||||
|
||||
// Channel mentions (<#id>)
|
||||
foreach (var mentionedChannel in message.MentionedChannels)
|
||||
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
|
||||
|
||||
// Custom emojis (<:name:id>)
|
||||
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private async Task ExportAsCsvAsync(ChannelChatLog log, TextWriter output)
|
||||
{
|
||||
using (var writer = new CsvWriter(output))
|
||||
{
|
||||
// Headers
|
||||
writer.WriteField("Author");
|
||||
writer.WriteField("Date");
|
||||
writer.WriteField("Content");
|
||||
writer.WriteField("Attachments");
|
||||
await writer.NextRecordAsync();
|
||||
|
||||
// Chat log
|
||||
foreach (var group in log.MessageGroups)
|
||||
{
|
||||
// Messages
|
||||
foreach (var msg in group.Messages)
|
||||
{
|
||||
// Author
|
||||
writer.WriteField(msg.Author.FullName);
|
||||
|
||||
// Date
|
||||
var timeStampFormatted = msg.TimeStamp.ToString(_settingsService.DateFormat);
|
||||
writer.WriteField(timeStampFormatted);
|
||||
|
||||
// Content
|
||||
var contentFormatted = msg.Content.IsNotBlank() ? FormatMessageContentCsv(msg) : null;
|
||||
writer.WriteField(contentFormatted);
|
||||
|
||||
// Attachments
|
||||
var attachmentsFormatted = msg.Attachments.Select(a => a.Url).JoinToString(",");
|
||||
writer.WriteField(attachmentsFormatted);
|
||||
|
||||
await writer.NextRecordAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, $"<@!?{mentionedUser.Id}>",
|
||||
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
|
||||
$"@{HtmlEncode(mentionedUser.Name)}" +
|
||||
"</span>");
|
||||
}
|
||||
|
||||
// Role mentions (<@&id>)
|
||||
foreach (var mentionedRole in message.MentionedRoles)
|
||||
{
|
||||
content = content.Replace($"<@&{mentionedRole.Id}>",
|
||||
"<span class=\"mention\">" +
|
||||
$"@{HtmlEncode(mentionedRole.Name)}" +
|
||||
"</span>");
|
||||
}
|
||||
|
||||
// Channel mentions (<#id>)
|
||||
foreach (var mentionedChannel in message.MentionedChannels)
|
||||
{
|
||||
content = content.Replace($"<#{mentionedChannel.Id}>",
|
||||
"<span class=\"mention\">" +
|
||||
$"#{HtmlEncode(mentionedChannel.Name)}" +
|
||||
"</span>");
|
||||
}
|
||||
|
||||
// Custom emojis (<:name:id>)
|
||||
content = Regex.Replace(content, "<(:.*?:)(\\d*)>",
|
||||
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css)
|
||||
{
|
||||
// Generation info
|
||||
await output.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
|
||||
|
||||
// Html start
|
||||
await output.WriteLineAsync("<!DOCTYPE html>");
|
||||
await output.WriteLineAsync("<html lang=\"en\">");
|
||||
|
||||
// HEAD
|
||||
await output.WriteLineAsync("<head>");
|
||||
await output.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
|
||||
await output.WriteLineAsync("<meta charset=\"utf-8\" />");
|
||||
await output.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
|
||||
await output.WriteLineAsync($"<style>{css}</style>");
|
||||
await output.WriteLineAsync("</head>");
|
||||
|
||||
// Body start
|
||||
await output.WriteLineAsync("<body>");
|
||||
|
||||
// Guild and channel info
|
||||
await output.WriteLineAsync("<div id=\"info\">");
|
||||
await output.WriteLineAsync("<div class=\"info-left\">");
|
||||
await output.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
|
||||
await output.WriteLineAsync("</div>"); // info-left
|
||||
await output.WriteLineAsync("<div class=\"info-right\">");
|
||||
await output.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
|
||||
await output.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
|
||||
await output.WriteLineAsync($"<div class=\"channel-topic\">{log.Channel.Topic}</div>");
|
||||
await output.WriteLineAsync(
|
||||
$"<div class=\"channel-messagecount\">{log.TotalMessageCount:N0} messages</div>");
|
||||
await output.WriteLineAsync("</div>"); // info-right
|
||||
await output.WriteLineAsync("</div>"); // info
|
||||
|
||||
// Chat log
|
||||
await output.WriteLineAsync("<div id=\"log\">");
|
||||
foreach (var group in log.MessageGroups)
|
||||
{
|
||||
await output.WriteLineAsync("<div class=\"msg\">");
|
||||
await output.WriteLineAsync("<div class=\"msg-left\">");
|
||||
await output.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
|
||||
await output.WriteLineAsync("</div>");
|
||||
|
||||
await output.WriteLineAsync("<div class=\"msg-right\">");
|
||||
await output.WriteAsync(
|
||||
$"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author.FullName)}\">");
|
||||
await output.WriteAsync(HtmlEncode(group.Author.Name));
|
||||
await output.WriteLineAsync("</span>");
|
||||
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
|
||||
await output.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
|
||||
|
||||
// Messages
|
||||
foreach (var message in group.Messages)
|
||||
{
|
||||
// Content
|
||||
if (message.Content.IsNotBlank())
|
||||
{
|
||||
await output.WriteLineAsync("<div class=\"msg-content\">");
|
||||
var contentFormatted = FormatMessageContentHtml(message);
|
||||
await output.WriteAsync(contentFormatted);
|
||||
|
||||
// Edited timestamp
|
||||
if (message.EditedTimeStamp != null)
|
||||
{
|
||||
var editedTimeStampFormatted =
|
||||
HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
|
||||
await output.WriteAsync(
|
||||
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
|
||||
}
|
||||
|
||||
await output.WriteLineAsync("</div>"); // msg-content
|
||||
}
|
||||
|
||||
// Attachments
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
if (attachment.Type == AttachmentType.Image)
|
||||
{
|
||||
await output.WriteLineAsync("<div class=\"msg-attachment\">");
|
||||
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
|
||||
await output.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
|
||||
await output.WriteLineAsync("</a>");
|
||||
await output.WriteLineAsync("</div>");
|
||||
}
|
||||
else
|
||||
{
|
||||
await output.WriteLineAsync("<div class=\"msg-attachment\">");
|
||||
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
|
||||
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
|
||||
await output.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
|
||||
await output.WriteLineAsync("</a>");
|
||||
await output.WriteLineAsync("</div>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await output.WriteLineAsync("</div>"); // msg-right
|
||||
await output.WriteLineAsync("</div>"); // msg
|
||||
}
|
||||
|
||||
await output.WriteLineAsync("</div>"); // log
|
||||
|
||||
await output.WriteLineAsync("</body>");
|
||||
await output.WriteLineAsync("</html>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,86 @@
|
||||
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 if (format == ExportFormat.Csv)
|
||||
{
|
||||
await ExportAsCsvAsync(log, output);
|
||||
}
|
||||
|
||||
else throw new ArgumentOutOfRangeException(nameof(format));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public partial class ExportService
|
||||
{
|
||||
private static string Base64Encode(string str)
|
||||
{
|
||||
return str.GetBytes().ToBase64();
|
||||
}
|
||||
|
||||
private static string Base64Decode(string str)
|
||||
{
|
||||
return str.FromBase64().GetString();
|
||||
}
|
||||
|
||||
private static string HtmlEncode(string str)
|
||||
{
|
||||
return WebUtility.HtmlEncode(str);
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long fileSize)
|
||||
{
|
||||
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
|
||||
double size = fileSize;
|
||||
var unit = 0;
|
||||
|
||||
while (size >= 1024)
|
||||
{
|
||||
size /= 1024;
|
||||
++unit;
|
||||
}
|
||||
|
||||
return $"{size:0.#} {units[unit]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IDataService
|
||||
{
|
||||
Task<Guild> GetGuildAsync(string token, string guildId);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IExportService
|
||||
{
|
||||
Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IMessageGroupService
|
||||
{
|
||||
IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface ISettingsService
|
||||
{
|
||||
bool IsAutoUpdateEnabled { get; set; }
|
||||
|
||||
string DateFormat { get; set; }
|
||||
int MessageGroupLimit { get; set; }
|
||||
|
||||
string LastToken { get; set; }
|
||||
ExportFormat LastExportFormat { get; set; }
|
||||
|
||||
void Load();
|
||||
void Save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IUpdateService
|
||||
{
|
||||
bool NeedRestart { get; set; }
|
||||
|
||||
Task<Version> CheckPrepareUpdateAsync();
|
||||
|
||||
void FinalizeUpdate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class MessageGroupService : IMessageGroupService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public MessageGroupService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
|
||||
{
|
||||
var result = new List<MessageGroup>();
|
||||
|
||||
// Group adjacent messages by timestamp and author
|
||||
var groupBuffer = new List<Message>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var groupFirst = groupBuffer.FirstOrDefault();
|
||||
|
||||
// Group break condition
|
||||
var breakCondition =
|
||||
groupFirst != null &&
|
||||
(
|
||||
message.Author.Id != groupFirst.Author.Id ||
|
||||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
|
||||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour ||
|
||||
groupBuffer.Count >= _settingsService.MessageGroupLimit
|
||||
);
|
||||
|
||||
// If condition is true - flush buffer
|
||||
if (breakCondition)
|
||||
{
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
|
||||
result.Add(group);
|
||||
groupBuffer.Clear();
|
||||
}
|
||||
|
||||
// Add message to buffer
|
||||
groupBuffer.Add(message);
|
||||
}
|
||||
|
||||
// Add what's remaining in buffer
|
||||
if (groupBuffer.Any())
|
||||
{
|
||||
var groupFirst = groupBuffer.First();
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
|
||||
result.Add(group);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Tyrrrz.Settings;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class SettingsService : SettingsManager, ISettingsService
|
||||
{
|
||||
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||
|
||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||
public int MessageGroupLimit { get; set; } = 20;
|
||||
|
||||
public string LastToken { get; set; }
|
||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
Configuration.StorageSpace = StorageSpace.Instance;
|
||||
Configuration.SubDirectoryPath = "";
|
||||
Configuration.FileName = "Settings.dat";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Onova;
|
||||
using Onova.Services;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class UpdateService : IUpdateService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IUpdateManager _manager;
|
||||
|
||||
private Version _updateVersion;
|
||||
private bool _updateFinalized;
|
||||
|
||||
public bool NeedRestart { get; set; }
|
||||
|
||||
public UpdateService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
|
||||
_manager = new UpdateManager(
|
||||
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
||||
new ZipPackageExtractor());
|
||||
}
|
||||
|
||||
public async Task<Version> CheckPrepareUpdateAsync()
|
||||
{
|
||||
// If auto-update is disabled - don't check for updates
|
||||
if (!_settingsService.IsAutoUpdateEnabled)
|
||||
return null;
|
||||
|
||||
// Cleanup leftover files
|
||||
_manager.Cleanup();
|
||||
|
||||
// Check for updates
|
||||
var check = await _manager.CheckForUpdatesAsync();
|
||||
if (!check.CanUpdate)
|
||||
return null;
|
||||
|
||||
// Prepare the update
|
||||
if (!_manager.IsUpdatePrepared(check.LastVersion))
|
||||
await _manager.PrepareUpdateAsync(check.LastVersion);
|
||||
|
||||
return _updateVersion = check.LastVersion;
|
||||
}
|
||||
|
||||
public void FinalizeUpdate()
|
||||
{
|
||||
// Check if an update is pending
|
||||
if (_updateVersion == null)
|
||||
return;
|
||||
|
||||
// Check if the update has already been finalized
|
||||
if (_updateFinalized)
|
||||
return;
|
||||
|
||||
// Launch the updater
|
||||
_manager.LaunchUpdater(_updateVersion, NeedRestart);
|
||||
_updateFinalized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<Application
|
||||
x:Class="DiscordChatExporter.Gui.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
||||
Exit="App_Exit"
|
||||
Startup="App_Startup"
|
||||
StartupUri="Views/MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
|
||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
||||
<ResourceDictionary Source="pack://application:,,,/Tyrrrz.WpfExtensions;component/ConvertersDictionary.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Colors -->
|
||||
<Color x:Key="PrimaryColor">#343838</Color>
|
||||
<Color x:Key="PrimaryLightColor">#5E6262</Color>
|
||||
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
|
||||
<Color x:Key="AccentColor">#F9A825</Color>
|
||||
<Color x:Key="AccentDarkColor">#C17900</Color>
|
||||
<Color x:Key="TextColor">#000000</Color>
|
||||
<Color x:Key="InverseTextColor">#FFFFFF</Color>
|
||||
|
||||
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
|
||||
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
|
||||
|
||||
<SolidColorBrush
|
||||
x:Key="PrimaryTextBrush"
|
||||
Opacity="0.87"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="SecondaryTextBrush"
|
||||
Opacity="0.64"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DimTextBrush"
|
||||
Opacity="0.45"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="PrimaryInverseTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="SecondaryInverseTextBrush"
|
||||
Opacity="0.7"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DimInverseTextBrush"
|
||||
Opacity="0.52"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="AccentTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource AccentColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="AccentDarkTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource AccentDarkColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DividerBrush"
|
||||
Opacity="0.12"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="InverseDividerBrush"
|
||||
Opacity="0.12"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
|
||||
<!-- Styles -->
|
||||
<Style TargetType="{x:Type Image}">
|
||||
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
|
||||
<Setter Property="RenderOptions.EdgeMode" Value="Aliased" />
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
|
||||
<Setter Property="Height" Value="2" />
|
||||
<Setter Property="Maximum" Value="1" />
|
||||
<Setter Property="Minimum" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Converters -->
|
||||
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
|
||||
|
||||
<!-- Container -->
|
||||
<local:Container x:Key="Container" />
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace DiscordChatExporter.Gui
|
||||
{
|
||||
public partial class App
|
||||
{
|
||||
private Container Container => (Container) Resources["Container"];
|
||||
|
||||
private void App_Startup(object sender, StartupEventArgs e)
|
||||
{
|
||||
Container.Init();
|
||||
}
|
||||
|
||||
private void App_Exit(object sender, ExitEventArgs e)
|
||||
{
|
||||
Container.Cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Converters
|
||||
{
|
||||
[ValueConversion(typeof(ExportFormat), typeof(string))]
|
||||
public class ExportFormatToStringConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var format = (ExportFormat) value;
|
||||
return format.GetDisplayName();
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{732A67AF-93DE-49DF-B10F-FD74710B7863}</ProjectGuid>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>DiscordChatExporter.Gui</RootNamespace>
|
||||
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<NuGetPackageImportStamp>
|
||||
</NuGetPackageImportStamp>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<StartupObject />
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="CommonServiceLocator, Version=2.0.3.0, Culture=neutral, PublicKeyToken=489b6accfaf20ef0, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\CommonServiceLocator.2.0.3\lib\net45\CommonServiceLocator.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="GalaSoft.MvvmLight, Version=5.4.1.0, Culture=neutral, PublicKeyToken=e7570ab207bcb616, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="GalaSoft.MvvmLight.Extras, Version=5.4.1.0, Culture=neutral, PublicKeyToken=669f0b5e8f868abf, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Extras.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="GalaSoft.MvvmLight.Platform, Version=5.4.1.0, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="MaterialDesignThemes.Wpf, Version=2.4.0.1044, Culture=neutral, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MaterialDesignThemes.2.4.0.1044\lib\net45\MaterialDesignThemes.Wpf.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Windows.Interactivity, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\MvvmLightLibs.5.4.1\lib\net45\System.Windows.Interactivity.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.Xaml">
|
||||
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
||||
</Reference>
|
||||
<Reference Include="Tyrrrz.Extensions, Version=1.5.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="Tyrrrz.WpfExtensions, Version=1.0.6269.37170, Culture=neutral, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Tyrrrz.WpfExtensions.1.0.5\lib\net45\Tyrrrz.WpfExtensions.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="WindowsBase" />
|
||||
<Reference Include="PresentationCore" />
|
||||
<Reference Include="PresentationFramework" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="App.xaml.cs">
|
||||
<DependentUpon>App.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Converters\ExportFormatToStringConverter.cs" />
|
||||
<Compile Include="Messages\ShowExportSetupMessage.cs" />
|
||||
<Compile Include="Messages\ShowNotificationMessage.cs" />
|
||||
<Compile Include="Messages\ShowSettingsMessage.cs" />
|
||||
<Compile Include="Messages\StartExportMessage.cs" />
|
||||
<Compile Include="ViewModels\ExportSetupViewModel.cs" />
|
||||
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
|
||||
<Compile Include="ViewModels\ISettingsViewModel.cs" />
|
||||
<Compile Include="ViewModels\SettingsViewModel.cs" />
|
||||
<Compile Include="Container.cs" />
|
||||
<Compile Include="ViewModels\IMainViewModel.cs" />
|
||||
<Compile Include="ViewModels\MainViewModel.cs" />
|
||||
<Compile Include="Views\ExportSetupDialog.xaml.cs">
|
||||
<DependentUpon>ExportSetupDialog.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Views\MainWindow.xaml.cs">
|
||||
<DependentUpon>MainWindow.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Views\SettingsDialog.xaml.cs">
|
||||
<DependentUpon>SettingsDialog.xaml</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Properties\AssemblyInfo.cs">
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Include="Properties\Resources.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DesignTime>True</DesignTime>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<EmbeddedResource Include="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<None Include="app.config" />
|
||||
<None Include="packages.config">
|
||||
<SubType>Designer</SubType>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Resource Include="..\favicon.ico" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj">
|
||||
<Project>{707c0cd0-a7e0-4cab-8db9-07a45cb87377}</Project>
|
||||
<Name>DiscordChatExporter.Core</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ApplicationDefinition Include="App.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<SubType>Designer</SubType>
|
||||
</ApplicationDefinition>
|
||||
<Page Include="Views\ExportSetupDialog.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Views\MainWindow.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
<Page Include="Views\SettingsDialog.xaml">
|
||||
<SubType>Designer</SubType>
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup />
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Messages
|
||||
{
|
||||
public class ShowExportSetupMessage
|
||||
{
|
||||
public Guild Guild { get; }
|
||||
|
||||
public Channel Channel { get; }
|
||||
|
||||
public ShowExportSetupMessage(Guild guild, Channel channel)
|
||||
{
|
||||
Guild = guild;
|
||||
Channel = channel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Messages
|
||||
{
|
||||
public class ShowNotificationMessage
|
||||
{
|
||||
public string Message { get; }
|
||||
|
||||
public string CallbackCaption { get; }
|
||||
|
||||
public Action Callback { get; }
|
||||
|
||||
public ShowNotificationMessage(string message)
|
||||
{
|
||||
Message = message;
|
||||
}
|
||||
|
||||
public ShowNotificationMessage(string message, string callbackCaption, Action callback)
|
||||
: this(message)
|
||||
{
|
||||
CallbackCaption = callbackCaption;
|
||||
Callback = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DiscordChatExporter.Gui.Messages
|
||||
{
|
||||
public class ShowSettingsMessage
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Messages
|
||||
{
|
||||
public class StartExportMessage
|
||||
{
|
||||
public Channel Channel { get; }
|
||||
|
||||
public string FilePath { get; }
|
||||
|
||||
public ExportFormat Format { get; }
|
||||
|
||||
public DateTime? From { get; }
|
||||
|
||||
public DateTime? To { get; }
|
||||
|
||||
public StartExportMessage(Channel channel, string filePath, ExportFormat format,
|
||||
DateTime? from, DateTime? to)
|
||||
{
|
||||
Channel = channel;
|
||||
FilePath = filePath;
|
||||
Format = format;
|
||||
From = from;
|
||||
To = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: AssemblyTitle("DiscordChatExporter")]
|
||||
[assembly: AssemblyCompany("Tyrrrz")]
|
||||
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
|
||||
[assembly: AssemblyVersion("2.4.1")]
|
||||
[assembly: AssemblyFileVersion("2.4.1")]
|
||||
@@ -0,0 +1,63 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace DiscordChatExporter.Gui.Properties {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DiscordChatExporter.Gui.Properties.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using DiscordChatExporter.Gui.Messages;
|
||||
using GalaSoft.MvvmLight;
|
||||
using GalaSoft.MvvmLight.CommandWpf;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
private string _filePath;
|
||||
private ExportFormat _format;
|
||||
private DateTime? _from;
|
||||
private DateTime? _to;
|
||||
|
||||
public Guild Guild { get; private set; }
|
||||
|
||||
public Channel Channel { get; private set; }
|
||||
|
||||
public string FilePath
|
||||
{
|
||||
get => _filePath;
|
||||
set
|
||||
{
|
||||
Set(ref _filePath, value);
|
||||
ExportCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ExportFormat> AvailableFormats { 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.Name} - {Channel.Name}.{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.Core.Models;
|
||||
using GalaSoft.MvvmLight.CommandWpf;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public interface IExportSetupViewModel
|
||||
{
|
||||
Guild Guild { get; }
|
||||
Channel Channel { get; }
|
||||
string FilePath { get; set; }
|
||||
IReadOnlyList<ExportFormat> AvailableFormats { get; }
|
||||
ExportFormat SelectedFormat { get; set; }
|
||||
DateTime? From { get; set; }
|
||||
DateTime? To { get; set; }
|
||||
|
||||
RelayCommand ExportCommand { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using GalaSoft.MvvmLight.CommandWpf;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public interface IMainViewModel
|
||||
{
|
||||
bool IsBusy { get; }
|
||||
bool IsDataAvailable { get; }
|
||||
|
||||
string Token { get; set; }
|
||||
|
||||
IReadOnlyList<Guild> AvailableGuilds { get; }
|
||||
Guild SelectedGuild { get; set; }
|
||||
IReadOnlyList<Channel> AvailableChannels { get; }
|
||||
|
||||
RelayCommand ViewLoadedCommand { get; }
|
||||
RelayCommand ViewClosedCommand { get; }
|
||||
RelayCommand PullDataCommand { get; }
|
||||
RelayCommand ShowSettingsCommand { get; }
|
||||
RelayCommand ShowAboutCommand { get; }
|
||||
RelayCommand<Channel> ShowExportSetupCommand { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public interface ISettingsViewModel
|
||||
{
|
||||
bool IsAutoUpdateEnabled { get; set; }
|
||||
|
||||
string DateFormat { get; set; }
|
||||
int MessageGroupLimit { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Windows;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using DiscordChatExporter.Gui.Messages;
|
||||
using GalaSoft.MvvmLight;
|
||||
using GalaSoft.MvvmLight.CommandWpf;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public class MainViewModel : ViewModelBase, IMainViewModel
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IUpdateService _updateService;
|
||||
private readonly IDataService _dataService;
|
||||
private readonly IMessageGroupService _messageGroupService;
|
||||
private readonly IExportService _exportService;
|
||||
|
||||
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
|
||||
|
||||
private bool _isBusy;
|
||||
private string _token;
|
||||
private IReadOnlyList<Guild> _availableGuilds;
|
||||
private Guild _selectedGuild;
|
||||
private IReadOnlyList<Channel> _availableChannels;
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
Set(ref _isBusy, value);
|
||||
PullDataCommand.RaiseCanExecuteChanged();
|
||||
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
|
||||
|
||||
public string Token
|
||||
{
|
||||
get => _token;
|
||||
set
|
||||
{
|
||||
// Remove invalid chars
|
||||
value = value?.Trim('"');
|
||||
|
||||
Set(ref _token, value);
|
||||
PullDataCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Guild> AvailableGuilds
|
||||
{
|
||||
get => _availableGuilds;
|
||||
private set
|
||||
{
|
||||
Set(ref _availableGuilds, value);
|
||||
RaisePropertyChanged(() => IsDataAvailable);
|
||||
}
|
||||
}
|
||||
|
||||
public Guild SelectedGuild
|
||||
{
|
||||
get => _selectedGuild;
|
||||
set
|
||||
{
|
||||
Set(ref _selectedGuild, value);
|
||||
AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0];
|
||||
ShowExportSetupCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Channel> AvailableChannels
|
||||
{
|
||||
get => _availableChannels;
|
||||
private set => Set(ref _availableChannels, value);
|
||||
}
|
||||
|
||||
public RelayCommand ViewLoadedCommand { get; }
|
||||
public RelayCommand ViewClosedCommand { get; }
|
||||
public RelayCommand PullDataCommand { get; }
|
||||
public RelayCommand ShowSettingsCommand { get; }
|
||||
public RelayCommand ShowAboutCommand { get; }
|
||||
public RelayCommand<Channel> ShowExportSetupCommand { get; }
|
||||
|
||||
public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService,
|
||||
IMessageGroupService messageGroupService, IExportService exportService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_updateService = updateService;
|
||||
_dataService = dataService;
|
||||
_messageGroupService = messageGroupService;
|
||||
_exportService = exportService;
|
||||
|
||||
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||
|
||||
// Commands
|
||||
ViewLoadedCommand = new RelayCommand(ViewLoaded);
|
||||
ViewClosedCommand = new RelayCommand(ViewClosed);
|
||||
PullDataCommand = new RelayCommand(PullData, () => 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));
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
IsBusy = true;
|
||||
|
||||
// Copy token so it doesn't get mutated
|
||||
var token = Token;
|
||||
|
||||
// Save token
|
||||
_settingsService.LastToken = token;
|
||||
|
||||
// Clear existing
|
||||
_guildChannelsMap.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
// Get DM channels
|
||||
{
|
||||
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
|
||||
var guild = Guild.DirectMessages;
|
||||
_guildChannelsMap[guild] = channels.OrderBy(c => c.Name).ToArray();
|
||||
}
|
||||
|
||||
// Get guild channels
|
||||
{
|
||||
var guilds = await _dataService.GetUserGuildsAsync(token);
|
||||
foreach (var guild in guilds)
|
||||
{
|
||||
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
|
||||
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage("Unauthorized – make sure the token is valid"));
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage("Forbidden – account may be locked by 2FA"));
|
||||
}
|
||||
|
||||
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
|
||||
SelectedGuild = AvailableGuilds.FirstOrDefault();
|
||||
IsBusy = false;
|
||||
}
|
||||
|
||||
private void ShowSettings()
|
||||
{
|
||||
MessengerInstance.Send(new ShowSettingsMessage());
|
||||
}
|
||||
|
||||
private void ShowAbout()
|
||||
{
|
||||
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
|
||||
}
|
||||
|
||||
private void ShowExportSetup(Channel channel)
|
||||
{
|
||||
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
|
||||
}
|
||||
|
||||
private async void Export(Channel channel, string filePath, ExportFormat format, DateTime? from, DateTime? to)
|
||||
{
|
||||
IsBusy = true;
|
||||
|
||||
// Get last used token
|
||||
var token = _settingsService.LastToken;
|
||||
|
||||
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);
|
||||
|
||||
// Open
|
||||
Process.Start(filePath);
|
||||
|
||||
// Notify completion
|
||||
MessengerInstance.Send(new ShowNotificationMessage("Export complete"));
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
|
||||
}
|
||||
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using GalaSoft.MvvmLight;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels
|
||||
{
|
||||
public class SettingsViewModel : ViewModelBase, ISettingsViewModel
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public bool IsAutoUpdateEnabled
|
||||
{
|
||||
get => _settingsService.IsAutoUpdateEnabled;
|
||||
set => _settingsService.IsAutoUpdateEnabled = value;
|
||||
}
|
||||
|
||||
public string DateFormat
|
||||
{
|
||||
get => _settingsService.DateFormat;
|
||||
set => _settingsService.DateFormat = value;
|
||||
}
|
||||
|
||||
public int MessageGroupLimit
|
||||
{
|
||||
get => _settingsService.MessageGroupLimit;
|
||||
set => _settingsService.MessageGroupLimit = value.ClampMin(0);
|
||||
}
|
||||
|
||||
public SettingsViewModel(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<UserControl
|
||||
x:Class="DiscordChatExporter.Gui.Views.ExportSetupDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
Width="325"
|
||||
DataContext="{Binding ExportSetupViewModel, Source={StaticResource Container}}">
|
||||
<StackPanel>
|
||||
<!-- File path -->
|
||||
<TextBox
|
||||
Margin="16,16,16,8"
|
||||
materialDesign:HintAssist.Hint="Output file"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsReadOnly="True"
|
||||
Text="{Binding FilePath, UpdateSourceTrigger=PropertyChanged}" />
|
||||
|
||||
<!-- Format -->
|
||||
<ComboBox
|
||||
Margin="16,8,16,8"
|
||||
materialDesign:HintAssist.Hint="Export format"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsReadOnly="True"
|
||||
ItemsSource="{Binding AvailableFormats}"
|
||||
SelectedItem="{Binding SelectedFormat}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource ExportFormatToStringConverter}}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!-- Date limits -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<DatePicker
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="16,20,8,8"
|
||||
materialDesign:HintAssist.Hint="From (optional)"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
SelectedDate="{Binding From}" />
|
||||
<DatePicker
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Margin="8,20,16,8"
|
||||
materialDesign:HintAssist.Hint="To (optional)"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
SelectedDate="{Binding To}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Buttons -->
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
||||
<Button
|
||||
x:Name="BrowseButton"
|
||||
Margin="8"
|
||||
Click="BrowseButton_Click"
|
||||
Content="BROWSE"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||
<Button
|
||||
x:Name="ExportButton"
|
||||
Margin="8"
|
||||
Click="ExportButton_Click"
|
||||
Command="{Binding ExportCommand}"
|
||||
Content="EXPORT"
|
||||
IsDefault="True"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||
<Button
|
||||
Margin="8"
|
||||
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
|
||||
Content="CANCEL"
|
||||
IsCancel="True"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Windows;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Gui.ViewModels;
|
||||
using MaterialDesignThemes.Wpf;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Views
|
||||
{
|
||||
public partial class ExportSetupDialog
|
||||
{
|
||||
private IExportSetupViewModel ViewModel => (IExportSetupViewModel)DataContext;
|
||||
|
||||
public ExportSetupDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void BrowseButton_Click(object sender, RoutedEventArgs args)
|
||||
{
|
||||
// Get file extension of the selected format
|
||||
var ext = ViewModel.SelectedFormat.GetFileExtension();
|
||||
|
||||
// Open dialog
|
||||
var sfd = new SaveFileDialog
|
||||
{
|
||||
FileName = ViewModel.FilePath,
|
||||
Filter = $"{ext.ToUpperInvariant()} Files|*.{ext}|All Files|*.*",
|
||||
AddExtension = true,
|
||||
Title = "Select output file"
|
||||
};
|
||||
|
||||
// Assign new file path if dialog was successful
|
||||
if (sfd.ShowDialog() == true)
|
||||
{
|
||||
ViewModel.FilePath = sfd.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
public void ExportButton_Click(object sender, RoutedEventArgs args)
|
||||
{
|
||||
DialogHost.CloseDialogCommand.Execute(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<Window
|
||||
x:Class="DiscordChatExporter.Gui.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
Title="DiscordChatExporter"
|
||||
Width="600"
|
||||
Height="550"
|
||||
Background="{DynamicResource MaterialDesignPaper}"
|
||||
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
|
||||
FocusManager.FocusedElement="{Binding ElementName=TokenTextBox}"
|
||||
FontFamily="{DynamicResource MaterialDesignFont}"
|
||||
Icon="/DiscordChatExporter;component/favicon.ico"
|
||||
SnapsToDevicePixels="True"
|
||||
TextElement.FontSize="13"
|
||||
TextElement.FontWeight="Regular"
|
||||
TextElement.Foreground="{DynamicResource SecondaryTextBrush}"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
TextOptions.TextRenderingMode="Auto"
|
||||
UseLayoutRounding="True"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<i:Interaction.Triggers>
|
||||
<i:EventTrigger EventName="Loaded">
|
||||
<i:InvokeCommandAction Command="{Binding ViewLoadedCommand}" />
|
||||
</i:EventTrigger>
|
||||
<i:EventTrigger EventName="Closed">
|
||||
<i:InvokeCommandAction Command="{Binding ViewClosedCommand}" />
|
||||
</i:EventTrigger>
|
||||
</i:Interaction.Triggers>
|
||||
<materialDesign:DialogHost SnackbarMessageQueue="{Binding ElementName=Snackbar}">
|
||||
<DockPanel>
|
||||
<!-- Toolbar -->
|
||||
<Border
|
||||
Background="{DynamicResource PrimaryHueMidBrush}"
|
||||
DockPanel.Dock="Top"
|
||||
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
|
||||
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<materialDesign:Card
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="6,6,0,6">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Token -->
|
||||
<TextBox
|
||||
x:Name="TokenTextBox"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="6"
|
||||
materialDesign:HintAssist.Hint="Token"
|
||||
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
||||
BorderThickness="0"
|
||||
FontSize="16"
|
||||
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
|
||||
|
||||
<!-- Pull data button -->
|
||||
<Button
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Margin="0,6,6,6"
|
||||
Padding="4"
|
||||
Command="{Binding PullDataCommand}"
|
||||
IsDefault="True"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}">
|
||||
<materialDesign:PackIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
Kind="ArrowRight" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</materialDesign:Card>
|
||||
|
||||
<!-- Menu -->
|
||||
<materialDesign:PopupBox
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Foreground="{DynamicResource PrimaryHueMidForegroundBrush}"
|
||||
PlacementMode="LeftAndAlignTopEdges">
|
||||
<StackPanel>
|
||||
<Button Command="{Binding ShowSettingsCommand}" Content="Settings" />
|
||||
<Button Command="{Binding ShowAboutCommand}" Content="About" />
|
||||
</StackPanel>
|
||||
</materialDesign:PopupBox>
|
||||
</Grid>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<ProgressBar
|
||||
Background="Transparent"
|
||||
IsIndeterminate="True"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<Grid>
|
||||
<DockPanel
|
||||
Background="{DynamicResource MaterialDesignCardBackground}"
|
||||
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
|
||||
Visibility="{Binding IsDataAvailable, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
|
||||
<!-- Guilds -->
|
||||
<Border
|
||||
BorderBrush="{DynamicResource DividerBrush}"
|
||||
BorderThickness="0,0,1,0"
|
||||
DockPanel.Dock="Left">
|
||||
<ListBox
|
||||
ItemsSource="{Binding AvailableGuilds}"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
||||
SelectedItem="{Binding SelectedGuild}"
|
||||
VirtualizingStackPanel.IsVirtualizing="False">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<materialDesign:TransitioningContent>
|
||||
<materialDesign:TransitioningContent.OpeningEffect>
|
||||
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
|
||||
</materialDesign:TransitioningContent.OpeningEffect>
|
||||
<Border
|
||||
Margin="-8"
|
||||
Background="Transparent"
|
||||
Cursor="Hand">
|
||||
<Image
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="12,4,12,4"
|
||||
Source="{Binding IconUrl}"
|
||||
ToolTip="{Binding Name}">
|
||||
<Image.OpacityMask>
|
||||
<RadialGradientBrush>
|
||||
<RadialGradientBrush.GradientStops>
|
||||
<GradientStop Offset="0" Color="#FF000000" />
|
||||
<GradientStop Offset="0.96" Color="#FF000000" />
|
||||
<GradientStop Offset="1" Color="#00000000" />
|
||||
</RadialGradientBrush.GradientStops>
|
||||
</RadialGradientBrush>
|
||||
</Image.OpacityMask>
|
||||
</Image>
|
||||
</Border>
|
||||
</materialDesign:TransitioningContent>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- Channels -->
|
||||
<Border>
|
||||
<ListBox
|
||||
HorizontalContentAlignment="Stretch"
|
||||
ItemsSource="{Binding AvailableChannels}"
|
||||
VirtualizingStackPanel.IsVirtualizing="False">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<materialDesign:TransitioningContent>
|
||||
<materialDesign:TransitioningContent.OpeningEffect>
|
||||
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
|
||||
</materialDesign:TransitioningContent.OpeningEffect>
|
||||
<StackPanel
|
||||
Margin="-8"
|
||||
Background="Transparent"
|
||||
Cursor="Hand"
|
||||
Orientation="Horizontal">
|
||||
<StackPanel.InputBindings>
|
||||
<MouseBinding
|
||||
Command="{Binding DataContext.ShowExportSetupCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}}"
|
||||
CommandParameter="{Binding}"
|
||||
MouseAction="LeftClick" />
|
||||
</StackPanel.InputBindings>
|
||||
<materialDesign:PackIcon
|
||||
Margin="16,7,0,6"
|
||||
VerticalAlignment="Center"
|
||||
Kind="Pound" />
|
||||
<TextBlock
|
||||
Margin="3,8,8,8"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Text="{Binding Name}" />
|
||||
</StackPanel>
|
||||
</materialDesign:TransitioningContent>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Usage instructions -->
|
||||
<StackPanel Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
|
||||
<TextBlock FontSize="18" Text="DiscordChatExporter needs your authorization token to work." />
|
||||
<TextBlock
|
||||
Margin="0,8,0,0"
|
||||
FontSize="16"
|
||||
Text="To obtain it, follow these steps:" />
|
||||
<TextBlock Margin="8,0,0,0" FontSize="14">
|
||||
<Run Text="1. Open the Discord app" />
|
||||
<LineBreak />
|
||||
<Run Text="2. Log in if you haven't" />
|
||||
<LineBreak />
|
||||
<Run Text="3. Press" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
||||
<LineBreak />
|
||||
<Run Text="4. Navigate to" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
||||
<Run Text="tab" />
|
||||
<LineBreak />
|
||||
<Run Text="5. Expand" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Storage > Local Storage > https://discordapp.com" />
|
||||
<LineBreak />
|
||||
<Run Text="6. Find" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text=""token"" />
|
||||
<Run Text="under key and copy the value" />
|
||||
<LineBreak />
|
||||
<Run Text="7. Paste the value in the textbox above" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<materialDesign:Snackbar x:Name="Snackbar" />
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</materialDesign:DialogHost>
|
||||
</Window>
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using DiscordChatExporter.Gui.Messages;
|
||||
using GalaSoft.MvvmLight.Messaging;
|
||||
using MaterialDesignThemes.Wpf;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Views
|
||||
{
|
||||
public partial class MainWindow
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
|
||||
|
||||
Snackbar.MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Notification messages
|
||||
Messenger.Default.Register<ShowNotificationMessage>(this, m =>
|
||||
{
|
||||
if (m.CallbackCaption != null && m.Callback != null)
|
||||
Snackbar.MessageQueue.Enqueue(m.Message, m.CallbackCaption, m.Callback);
|
||||
else
|
||||
Snackbar.MessageQueue.Enqueue(m.Message);
|
||||
});
|
||||
|
||||
// Dialog messages
|
||||
Messenger.Default.Register<ShowExportSetupMessage>(this,
|
||||
m => DialogHost.Show(new ExportSetupDialog()).Forget());
|
||||
Messenger.Default.Register<ShowSettingsMessage>(this,
|
||||
m => DialogHost.Show(new SettingsDialog()).Forget());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<UserControl
|
||||
x:Class="DiscordChatExporter.Gui.Views.SettingsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
Width="250"
|
||||
DataContext="{Binding SettingsViewModel, Source={StaticResource Container}}">
|
||||
<StackPanel>
|
||||
<!-- Date format -->
|
||||
<TextBox
|
||||
Margin="16,16,16,8"
|
||||
materialDesign:HintAssist.Hint="Date format"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
Text="{Binding DateFormat}" />
|
||||
|
||||
<!-- Message group limit -->
|
||||
<TextBox
|
||||
Margin="16,8,16,8"
|
||||
materialDesign:HintAssist.Hint="Message group limit"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
Text="{Binding MessageGroupLimit}" />
|
||||
|
||||
<!-- Auto-updates -->
|
||||
<DockPanel LastChildFill="False">
|
||||
<TextBlock
|
||||
Margin="16,8,16,8"
|
||||
DockPanel.Dock="Left"
|
||||
Text="Auto-updates" />
|
||||
<ToggleButton
|
||||
Margin="16,8,16,8"
|
||||
DockPanel.Dock="Right"
|
||||
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Save button -->
|
||||
<Button
|
||||
Margin="8"
|
||||
HorizontalAlignment="Right"
|
||||
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
|
||||
Content="SAVE"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DiscordChatExporter.Gui.Views
|
||||
{
|
||||
public partial class SettingsDialog
|
||||
{
|
||||
public SettingsDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="CommonServiceLocator" publicKeyToken="489b6accfaf20ef0" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.0.3.0" newVersion="2.0.3.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="CommonServiceLocator" version="2.0.3" targetFramework="net461" />
|
||||
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
|
||||
<package id="MaterialDesignThemes" version="2.4.0.1044" targetFramework="net461" />
|
||||
<package id="MvvmLightLibs" version="5.4.1" targetFramework="net461" />
|
||||
<package id="Tyrrrz.Extensions" version="1.5.0" targetFramework="net461" />
|
||||
<package id="Tyrrrz.WpfExtensions" version="1.0.5" targetFramework="net461" />
|
||||
</packages>
|
||||
+23
-7
@@ -1,28 +1,44 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 15
|
||||
VisualStudioVersion = 15.0.26430.13
|
||||
VisualStudioVersion = 15.0.27130.2026
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{4BE915D1-129C-49E2-860E-62045ACA5EAD}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
Changelog.md = Changelog.md
|
||||
License.txt = License.txt
|
||||
Readme.md = Readme.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core", "DiscordChatExporter.Core\DiscordChatExporter.Core.csproj", "{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {51587D08-01E1-4511-AC57-A417D1A9162F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net45</TargetFramework>
|
||||
<AssemblyVersion>1.0.0</AssemblyVersion>
|
||||
<FileVersion>$(AssemblyVersion)</FileVersion>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) 2017 Alexey Golub</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Services\ExportTemplate.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.5.1" />
|
||||
<PackageReference Include="Newtonsoft.json" Version="10.0.3" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class Attachment
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public bool IsImage { get; }
|
||||
|
||||
public Attachment(string id, string url, string fileName, bool isImage)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
FileName = fileName;
|
||||
IsImage = isImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class ChatLog
|
||||
{
|
||||
public string ChannelId { get; }
|
||||
|
||||
public IReadOnlyList<User> Participants { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public ChatLog(string channelId, IEnumerable<Message> messages)
|
||||
{
|
||||
ChannelId = channelId;
|
||||
Messages = messages.ToArray();
|
||||
Participants = Messages.Select(m => m.Author).Distinct(a => a.Name).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class Message
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public DateTime TimeStamp { get; }
|
||||
|
||||
public DateTime? EditedTimeStamp { get; }
|
||||
|
||||
public User Author { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
public IReadOnlyList<Attachment> Attachments { get; }
|
||||
|
||||
public Message(string id, DateTime timeStamp, DateTime? editedTimeStamp, User author, string content, IEnumerable<Attachment> attachments)
|
||||
{
|
||||
Id = id;
|
||||
TimeStamp = timeStamp;
|
||||
EditedTimeStamp = editedTimeStamp;
|
||||
Author = author;
|
||||
Content = content;
|
||||
Attachments = attachments.ToArray();
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class MessageGroup
|
||||
{
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime FirstTimeStamp { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable<Message> messages)
|
||||
{
|
||||
Author = author;
|
||||
FirstTimeStamp = firstTimeStamp;
|
||||
Messages = messages.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class Options
|
||||
{
|
||||
public string Token { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public Options(string token, string channelId)
|
||||
{
|
||||
Token = token;
|
||||
ChannelId = channelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Models;
|
||||
using DiscordChatExporter.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
private static readonly DiscordApiService DiscordApiService = new DiscordApiService();
|
||||
private static readonly ExportService ExportService = new ExportService();
|
||||
|
||||
private static Options GetOptions(string[] args)
|
||||
{
|
||||
// Parse the arguments
|
||||
var argsDic = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string arg in args)
|
||||
{
|
||||
var match = Regex.Match(arg, "/(.*?):\"?(.*?)\"?$");
|
||||
string key = match.Groups[1].Value;
|
||||
string value = match.Groups[2].Value;
|
||||
|
||||
if (key.IsBlank())
|
||||
continue;
|
||||
|
||||
argsDic[key] = value;
|
||||
}
|
||||
|
||||
// Extract required arguments
|
||||
string token = argsDic.GetOrDefault("token");
|
||||
string channelId = argsDic.GetOrDefault("channelId");
|
||||
|
||||
// Verify arguments
|
||||
if (token.IsBlank() || channelId.IsBlank())
|
||||
throw new ArgumentException("Some or all required command line arguments are missing");
|
||||
|
||||
// Create option set
|
||||
return new Options(token, channelId);
|
||||
}
|
||||
|
||||
private static async Task MainAsync(string[] args)
|
||||
{
|
||||
// Parse cmd args
|
||||
var options = GetOptions(args);
|
||||
|
||||
// Get messages
|
||||
Console.WriteLine("Getting messages...");
|
||||
var messages = await DiscordApiService.GetMessagesAsync(options.Token, options.ChannelId);
|
||||
var chatLog = new ChatLog(options.ChannelId, messages);
|
||||
|
||||
// Export
|
||||
Console.WriteLine("Exporting messages...");
|
||||
ExportService.Export($"{options.ChannelId}.html", chatLog);
|
||||
}
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Console.Title = "Discord Chat Exporter";
|
||||
|
||||
MainAsync(args).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Services
|
||||
{
|
||||
public class DiscordApiService
|
||||
{
|
||||
private const string ApiRoot = "https://discordapp.com/api";
|
||||
private readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
private IEnumerable<Message> ParseMessages(string json)
|
||||
{
|
||||
var messagesJson = JArray.Parse(json);
|
||||
foreach (var messageJson in messagesJson)
|
||||
{
|
||||
// Get basic data
|
||||
string id = messageJson.Value<string>("id");
|
||||
var timeStamp = messageJson.Value<DateTime>("timestamp");
|
||||
var editedTimeStamp = messageJson.Value<DateTime?>("edited_timestamp");
|
||||
string content = messageJson.Value<string>("content");
|
||||
|
||||
// Lazy workaround for calls
|
||||
if (messageJson["call"] != null)
|
||||
content = "Started a call.";
|
||||
|
||||
// Get author
|
||||
var authorJson = messageJson["author"];
|
||||
string authorId = authorJson.Value<string>("id");
|
||||
string authorName = authorJson.Value<string>("username");
|
||||
string authorAvatarHash = authorJson.Value<string>("avatar");
|
||||
|
||||
// Get attachment
|
||||
var attachmentsJson = messageJson["attachments"];
|
||||
var attachments = new List<Attachment>();
|
||||
foreach (var attachmentJson in attachmentsJson)
|
||||
{
|
||||
string attachmentId = attachmentJson.Value<string>("id");
|
||||
string attachmentUrl = attachmentJson.Value<string>("url");
|
||||
string attachmentFileName = attachmentJson.Value<string>("filename");
|
||||
bool attachmentIsImage = attachmentJson["width"] != null;
|
||||
|
||||
var attachment = new Attachment(attachmentId, attachmentUrl, attachmentFileName, attachmentIsImage);
|
||||
attachments.Add(attachment);
|
||||
}
|
||||
|
||||
var author = new User(authorId, authorName, authorAvatarHash);
|
||||
var message = new Message(id, timeStamp, editedTimeStamp, author, content, attachments);
|
||||
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Message>> GetMessagesAsync(string token, string channelId)
|
||||
{
|
||||
var result = new List<Message>();
|
||||
|
||||
// We are going backwards from last message to first
|
||||
// ...collecting everything between them in batches
|
||||
string beforeId = null;
|
||||
while (true)
|
||||
{
|
||||
// Form request url
|
||||
string url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
|
||||
if (beforeId.IsNotBlank())
|
||||
url += $"&before={beforeId}";
|
||||
|
||||
// Get response
|
||||
string response = await _httpClient.GetStringAsync(url);
|
||||
|
||||
// Parse
|
||||
var messages = ParseMessages(response);
|
||||
|
||||
// Add messages to list
|
||||
string currentMessageId = null;
|
||||
foreach (var message in messages)
|
||||
{
|
||||
result.Add(message);
|
||||
currentMessageId = message.Id;
|
||||
}
|
||||
|
||||
// If no messages - break
|
||||
if (currentMessageId == null) break;
|
||||
|
||||
// Otherwise offset the next request
|
||||
beforeId = currentMessageId;
|
||||
}
|
||||
|
||||
// Messages appear newest first, we need to reverse
|
||||
result.Reverse();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Models;
|
||||
using HtmlAgilityPack;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Services
|
||||
{
|
||||
public class ExportService
|
||||
{
|
||||
private HtmlDocument GetTemplate()
|
||||
{
|
||||
const string templateName = "DiscordChatExporter.Services.ExportTemplate.html";
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using (var stream = assembly.GetManifestResourceStream(templateName))
|
||||
{
|
||||
var doc = new HtmlDocument();
|
||||
doc.Load(stream);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
||||
{
|
||||
var result = new List<MessageGroup>();
|
||||
|
||||
// Group adjacent messages by timestamp and author
|
||||
var groupBuffer = new List<Message>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var groupFirst = groupBuffer.FirstOrDefault();
|
||||
|
||||
// Group break condition
|
||||
bool breakCondition =
|
||||
groupFirst != null &&
|
||||
(
|
||||
message.Author.Id != groupFirst.Author.Id ||
|
||||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
|
||||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour
|
||||
);
|
||||
|
||||
// If condition is true - flush buffer
|
||||
if (breakCondition)
|
||||
{
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
|
||||
result.Add(group);
|
||||
groupBuffer.Clear();
|
||||
}
|
||||
|
||||
// Add message to buffer
|
||||
groupBuffer.Add(message);
|
||||
}
|
||||
|
||||
// Add what's remaining in buffer
|
||||
if (groupBuffer.Any())
|
||||
{
|
||||
var groupFirst = groupBuffer.First();
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
|
||||
result.Add(group);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string FormatMessageContent(string content)
|
||||
{
|
||||
// Encode HTML
|
||||
content = HtmlDocument.HtmlEncode(content);
|
||||
|
||||
// Links from URLs
|
||||
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
|
||||
"<a href=\"$1\">$1</a>");
|
||||
|
||||
// Preformatted multiline
|
||||
content = Regex.Replace(content, "```([^`]*?)```",
|
||||
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
|
||||
|
||||
// Preformatted inline
|
||||
content = Regex.Replace(content, "`([^`]*?)`",
|
||||
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
|
||||
|
||||
// Bold
|
||||
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
|
||||
|
||||
// Italic
|
||||
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
|
||||
|
||||
// Underline
|
||||
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
|
||||
|
||||
// Strike through
|
||||
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
|
||||
|
||||
// New lines
|
||||
content = content.Replace("\n", "</br>");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
public void Export(string filePath, ChatLog chatLog)
|
||||
{
|
||||
var doc = GetTemplate();
|
||||
|
||||
// Info
|
||||
var infoHtml = doc.GetElementbyId("info");
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
|
||||
string participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Participants: <b>{participants}</b></div>"));
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Messages: <b>{chatLog.Messages.Count:N0}</b></div>"));
|
||||
|
||||
// Log
|
||||
var logHtml = doc.GetElementbyId("log");
|
||||
var messageGroups = GroupMessages(chatLog.Messages);
|
||||
foreach (var messageGroup in messageGroups)
|
||||
{
|
||||
// Container
|
||||
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
|
||||
|
||||
// Avatar
|
||||
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-avatar\">" +
|
||||
$"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />" +
|
||||
"</div>"));
|
||||
|
||||
// Body
|
||||
var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-body\"></div>"));
|
||||
|
||||
// Author
|
||||
string authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
|
||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
|
||||
|
||||
// Date
|
||||
string timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
|
||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
|
||||
|
||||
// Individual messages
|
||||
foreach (var message in messageGroup.Messages)
|
||||
{
|
||||
// Content
|
||||
if (message.Content.IsNotBlank())
|
||||
{
|
||||
string content = FormatMessageContent(message.Content);
|
||||
var contentHtml =
|
||||
messageBodyHtml.AppendChild(
|
||||
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
|
||||
|
||||
// Edited timestamp
|
||||
if (message.EditedTimeStamp != null)
|
||||
{
|
||||
contentHtml.AppendChild(
|
||||
HtmlNode.CreateNode(
|
||||
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp:g}\">(edited)</span>"));
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
if (attachment.IsImage)
|
||||
{
|
||||
messageBodyHtml.AppendChild(
|
||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
||||
$"<a href=\"{attachment.Url}\">" +
|
||||
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
|
||||
"</a>" +
|
||||
"</div>"));
|
||||
}
|
||||
else
|
||||
{
|
||||
messageBodyHtml.AppendChild(
|
||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
||||
$"<a href=\"{attachment.Url}\">" +
|
||||
$"Attachment: {attachment.FileName}" +
|
||||
"</a>" +
|
||||
"</div>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.Save(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Discord Chat Log</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
color: #37bcf7;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div.pre, span.pre {
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
|
||||
padding-right: 2px;
|
||||
padding-left: 2px;
|
||||
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
div#info {
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
|
||||
color: #939799;
|
||||
}
|
||||
|
||||
div#log {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
div.msg {
|
||||
display: flex;
|
||||
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
|
||||
border-top: 1px solid #eceeef;
|
||||
}
|
||||
|
||||
div.msg-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
img.msg-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
div.msg-body {
|
||||
margin-left: 15px;
|
||||
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
span.msg-user {
|
||||
font-size: 1.15em;
|
||||
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
span.msg-date {
|
||||
font-size: .8em;
|
||||
font-weight: 200;
|
||||
|
||||
margin-left: 5px;
|
||||
|
||||
color: #b7bcbf;
|
||||
}
|
||||
|
||||
span.msg-edited {
|
||||
font-size: .8em;
|
||||
font-weight: 200;
|
||||
|
||||
margin-left: 5px;
|
||||
|
||||
color: #b7bcbf;
|
||||
}
|
||||
|
||||
div.msg-content {
|
||||
padding-top: 5px;
|
||||
|
||||
color: #939799;
|
||||
}
|
||||
|
||||
div.msg-attachment {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
img.msg-attachment {
|
||||
max-width: 50%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="info"></div>
|
||||
<div id="log"></div>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
# DiscordChatExporter
|
||||
|
||||
Command line executable that can export [Discord](https://discordapp.com) channel chat logs to a pretty HTML file.
|
||||
[](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
|
||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||
[](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
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Download
|
||||
|
||||
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
|
||||
|
||||
## Features
|
||||
|
||||
- Produces output styled similar to the Discord's light theme
|
||||
- Displays user avatars
|
||||
- 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
|
||||
- Handles Discord markdown characters
|
||||
- Converts URLs to links
|
||||
- Inlines attached images with a link to the full version
|
||||
- Inserts download links for other types of attached files
|
||||
- Marks edited messages with a timestamp
|
||||
|
||||
## Usage
|
||||
|
||||
The program expects an access token and channel ID as parameters.
|
||||
|
||||
`DiscordChatExporter.exe /token:REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 /channelId:459360869055190534`
|
||||
|
||||
#### Getting access token:
|
||||
|
||||
- Open Discord desktop or web client
|
||||
- Press `Ctrl+Shift+I`
|
||||
- Navigate to `Storage > LocalStorage > discordapp.com`
|
||||
- Find the value for `token` and extract it
|
||||
|
||||
#### Getting channel ID:
|
||||
|
||||
- Open Discord desktop or web client
|
||||
- Navigate to any DM or server channel
|
||||
- Extract the current URL:
|
||||
- If using desktop client, press `Ctrl+Shift+I`, type `window.location.href` in console and extract the result
|
||||
- If using web client, just take the current URL from the address bar
|
||||
- Pull the ID from the URL:
|
||||
- If it's a DM channel, the format looks like this: `https://discordapp.com/channels/@me/CHANNEL_ID`
|
||||
- If it's a server channel, the format looks like this:
|
||||
`https://discordapp.com/channels/WHATEVER/CHANNEL_ID`
|
||||
- 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
|
||||
- Exports to a CSV file
|
||||
- Renders custom emojis
|
||||
- Resolves user, role and channel mentions
|
||||
|
||||
## Libraries used
|
||||
|
||||
- [HtmlAgilityPack](https://github.com/zzzprojects/html-agility-pack)
|
||||
- [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json)
|
||||
- [GalaSoft.MVVMLight](http://www.mvvmlight.net)
|
||||
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
|
||||
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
|
||||
- [CsvHelper](https://github.com/JoshClose/CsvHelper)
|
||||
- [Onova](https://github.com/Tyrrrz/Onova)
|
||||
- [FluentCommandLineParser](https://github.com/fclp/fluent-command-line-parser)
|
||||
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
|
||||
- [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions)
|
||||
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)
|
||||
|
||||
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Reference in New Issue
Block a user