Rework HTML export

Fixes #8
pull/17/head
Alexey Golub 7 years ago
parent 5a57b4a6b1
commit a556926fe6

@ -48,9 +48,6 @@
<Reference Include="GalaSoft.MvvmLight.Platform, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL"> <Reference Include="GalaSoft.MvvmLight.Platform, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath> <HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
</Reference> </Reference>
<Reference Include="HtmlAgilityPack, Version=1.5.5.0, Culture=neutral, PublicKeyToken=bd319b19eaf3b43a, processorArchitecture=MSIL">
<HintPath>..\packages\HtmlAgilityPack.1.5.5\lib\Net45\HtmlAgilityPack.dll</HintPath>
</Reference>
<Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL"> <Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath> <HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath>
</Reference> </Reference>
@ -186,7 +183,6 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" /> <EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" /> <EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportService\Template.html" />
</ItemGroup> </ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\packages\Ammy.1.2.87\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" /> <Import Project="..\packages\Ammy.1.2.87\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" />

@ -2,7 +2,7 @@
{ {
public enum AttachmentType public enum AttachmentType
{ {
Unrecognized, Other,
Image Image
} }
} }

@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models namespace DiscordChatExporter.Models
{ {
@ -11,11 +10,15 @@ namespace DiscordChatExporter.Models
public IReadOnlyList<MessageGroup> MessageGroups { get; } public IReadOnlyList<MessageGroup> MessageGroups { get; }
public ChannelChatLog(Guild guild, Channel channel, IEnumerable<MessageGroup> messageGroups) public int TotalMessageCount { get; }
public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList<MessageGroup> messageGroups,
int totalMessageCount)
{ {
Guild = guild; Guild = guild;
Channel = channel; Channel = channel;
MessageGroups = messageGroups.ToArray(); MessageGroups = messageGroups;
TotalMessageCount = totalMessageCount;
} }
} }
} }

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models namespace DiscordChatExporter.Models
{ {
@ -20,14 +19,14 @@ namespace DiscordChatExporter.Models
public Message(string id, User author, public Message(string id, User author,
DateTime timeStamp, DateTime? editedTimeStamp, DateTime timeStamp, DateTime? editedTimeStamp,
string content, IEnumerable<Attachment> attachments) string content, IReadOnlyList<Attachment> attachments)
{ {
Id = id; Id = id;
Author = author; Author = author;
TimeStamp = timeStamp; TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp; EditedTimeStamp = editedTimeStamp;
Content = content; Content = content;
Attachments = attachments.ToArray(); Attachments = attachments;
} }
public override string ToString() public override string ToString()

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models namespace DiscordChatExporter.Models
{ {
@ -12,11 +11,11 @@ namespace DiscordChatExporter.Models
public IReadOnlyList<Message> Messages { get; } public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime timeStamp, IEnumerable<Message> messages) public MessageGroup(User author, DateTime timeStamp, IReadOnlyList<Message> messages)
{ {
Author = author; Author = author;
TimeStamp = timeStamp; TimeStamp = timeStamp;
Messages = messages.ToArray(); Messages = messages;
} }
} }
} }

@ -1,18 +0,0 @@
<!-- This chat log was automatically generated by DiscordChatExporter (https://github.com/Tyrrrz/DiscordChatExporter) -->
<!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 id="theme"></style>
</head>
<body>
<div id="info"></div>
<div id="log"></div>
</body>
</html>

@ -29,7 +29,7 @@ namespace DiscordChatExporter.Services
} }
} }
public async Task<IEnumerable<Guild>> GetGuildsAsync(string token) public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token)
{ {
// Form request url // Form request url
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100"; var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
@ -38,12 +38,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url); var content = await GetStringAsync(url);
// Parse // Parse
var guilds = JArray.Parse(content).Select(ParseGuild); var guilds = JArray.Parse(content).Select(ParseGuild).ToArray();
return guilds; return guilds;
} }
public async Task<IEnumerable<Channel>> GetDirectMessageChannelsAsync(string token) public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
{ {
// Form request url // Form request url
var url = $"{ApiRoot}/users/@me/channels?token={token}"; var url = $"{ApiRoot}/users/@me/channels?token={token}";
@ -52,12 +52,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url); var content = await GetStringAsync(url);
// Parse // Parse
var channels = JArray.Parse(content).Select(ParseChannel); var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels; return channels;
} }
public async Task<IEnumerable<Channel>> GetGuildChannelsAsync(string token, string guildId) public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
{ {
// Form request url // Form request url
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}"; var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
@ -66,12 +66,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url); var content = await GetStringAsync(url);
// Parse // Parse
var channels = JArray.Parse(content).Select(ParseChannel); var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels; return channels;
} }
public async Task<IEnumerable<Message>> GetChannelMessagesAsync(string token, string channelId) public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId)
{ {
var result = new List<Message>(); var result = new List<Message>();
@ -92,7 +92,7 @@ namespace DiscordChatExporter.Services
var messages = JArray.Parse(content).Select(ParseMessage); var messages = JArray.Parse(content).Select(ParseMessage);
// Add messages to list // Add messages to list
string currentMessageId = null; var currentMessageId = default(string);
foreach (var message in messages) foreach (var message in messages)
{ {
result.Add(message); result.Add(message);
@ -192,7 +192,7 @@ namespace DiscordChatExporter.Services
var attachmentUrl = attachmentJson.Value<string>("url"); var attachmentUrl = attachmentJson.Value<string>("url");
var attachmentType = attachmentJson["width"] != null var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image ? AttachmentType.Image
: AttachmentType.Unrecognized; : AttachmentType.Other;
var attachmentFileName = attachmentJson.Value<string>("filename"); var attachmentFileName = attachmentJson.Value<string>("filename");
var attachmentFileSize = attachmentJson.Value<long>("size"); var attachmentFileSize = attachmentJson.Value<long>("size");

@ -1,11 +1,11 @@
using System.IO; using System.IO;
using System.Linq; using System.Net;
using System.Reflection; using System.Reflection;
using System.Resources; using System.Resources;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Models; using DiscordChatExporter.Models;
using HtmlAgilityPack;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services namespace DiscordChatExporter.Services
@ -19,80 +19,77 @@ namespace DiscordChatExporter.Services
_settingsService = settingsService; _settingsService = settingsService;
} }
public Task ExportAsync(string filePath, ChannelChatLog log, Theme theme) public async Task ExportAsync(string filePath, ChannelChatLog log, Theme theme)
{ {
return Task.Run(() => var themeCss = GetThemeCss(theme);
var dateFormat = _settingsService.DateFormat;
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128*1024))
{ {
var doc = GetTemplate(); // Generation info
var style = GetStyle(theme); await writer.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
var dateFormat = _settingsService.DateFormat;
// Html start
// Set theme await writer.WriteLineAsync("<!DOCTYPE html>");
var themeHtml = doc.GetElementbyId("theme"); await writer.WriteLineAsync("<html lang=\"en\">");
themeHtml.InnerHtml = style;
// HEAD
// Title await writer.WriteLineAsync("<head>");
var titleHtml = doc.DocumentNode.Element("html").Element("head").Element("title"); await writer.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
titleHtml.InnerHtml = $"{log.Guild.Name} - {log.Channel.Name}"; await writer.WriteLineAsync("<meta charset=\"utf-8\" />");
await writer.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
// Info await writer.WriteLineAsync($"<style>{themeCss}</style>");
var infoHtml = doc.GetElementbyId("info"); await writer.WriteLineAsync("</head>");
var infoLeftHtml = infoHtml.AppendChild(HtmlNode.CreateNode("<div class=\"info-left\"></div>"));
infoLeftHtml.AppendChild(HtmlNode.CreateNode( // Body start
$"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />")); await writer.WriteLineAsync("<body>");
var infoRightHtml = infoHtml.AppendChild(HtmlNode.CreateNode("<div class=\"info-right\"></div>"));
infoRightHtml.AppendChild(HtmlNode.CreateNode( // Guild and channel info
$"<div class=\"guild-name\">{log.Guild.Name}</div>")); await writer.WriteLineAsync("<div id=\"info\">");
infoRightHtml.AppendChild(HtmlNode.CreateNode( await writer.WriteLineAsync("<div class=\"info-left\">");
$"<div class=\"channel-name\">{log.Channel.Name}</div>")); await writer.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
infoRightHtml.AppendChild(HtmlNode.CreateNode( await writer.WriteLineAsync("</div>"); // info-left
$"<div class=\"misc\">{log.MessageGroups.SelectMany(g => g.Messages).Count():N0} messages</div>")); await writer.WriteLineAsync("<div class=\"info-right\">");
await writer.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
// Log await writer.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
var logHtml = doc.GetElementbyId("log"); await writer.WriteLineAsync($"<div class=\"misc\">{log.TotalMessageCount:N0} messages</div>");
foreach (var messageGroup in log.MessageGroups) await writer.WriteLineAsync("</div>"); // info-right
await writer.WriteLineAsync("</div>"); // info
// Chat log
await writer.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{ {
// Container await writer.WriteLineAsync("<div class=\"msg\">");
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>")); await writer.WriteLineAsync("<div class=\"msg-left\">");
await writer.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
// Left await writer.WriteLineAsync("</div>");
var messageLeftHtml =
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-left\"></div>")); await writer.WriteLineAsync("<div class=\"msg-right\">");
await writer.WriteLineAsync($"<span class=\"msg-user\">{HtmlEncode(group.Author.Name)}</span>");
// Avatar var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(dateFormat));
messageLeftHtml.AppendChild( await writer.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
HtmlNode.CreateNode($"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />"));
// Message
// Right foreach (var message in group.Messages)
var messageRightHtml =
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-right\"></div>"));
// Author
var authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
messageRightHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
// Date
var timeStamp = HtmlDocument.HtmlEncode(messageGroup.TimeStamp.ToString(dateFormat));
messageRightHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
// Individual messages
foreach (var message in messageGroup.Messages)
{ {
// Content // Content
if (message.Content.IsNotBlank()) if (message.Content.IsNotBlank())
{ {
var content = FormatMessageContent(message.Content); await writer.WriteLineAsync("<div class=\"msg-content\">");
var contentHtml = var contentFormatted = FormatMessageContent(message.Content);
messageRightHtml.AppendChild( await writer.WriteAsync(contentFormatted);
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
// Edited timestamp // Edited timestamp
if (message.EditedTimeStamp != null) if (message.EditedTimeStamp != null)
{ {
contentHtml.AppendChild( var editedTimeStampFormatted =
HtmlNode.CreateNode( HtmlEncode(message.EditedTimeStamp.Value.ToString(dateFormat));
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp.Value.ToString(dateFormat)}\">(edited)</span>")); await writer.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
} }
await writer.WriteLineAsync("</div>"); // msg-content
} }
// Attachments // Attachments
@ -100,51 +97,37 @@ namespace DiscordChatExporter.Services
{ {
if (attachment.Type == AttachmentType.Image) if (attachment.Type == AttachmentType.Image)
{ {
messageRightHtml.AppendChild( await writer.WriteLineAsync("<div class=\"msg-attachment\">");
HtmlNode.CreateNode("<div class=\"msg-attachment\">" + await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
$"<a href=\"{attachment.Url}\">" + await writer.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" + await writer.WriteLineAsync("</a>");
"</a>" + await writer.WriteLineAsync("</div>");
"</div>"));
} }
else else
{ {
messageRightHtml.AppendChild( await writer.WriteLineAsync("<div class=\"msg-attachment\">");
HtmlNode.CreateNode("<div class=\"msg-attachment\">" + await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
$"<a href=\"{attachment.Url}\">" + var fileSizeFormatted = FormatFileSize(attachment.FileSize);
$"Attachment: {attachment.FileName} ({NormalizeFileSize(attachment.FileSize)})" + await writer.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
"</a>" + await writer.WriteLineAsync("</a>");
"</div>")); await writer.WriteLineAsync("</div>");
} }
} }
} }
await writer.WriteLineAsync("</div>"); // msg-right
await writer.WriteLineAsync("</div>"); // msg
} }
await writer.WriteLineAsync("</div>"); // log
doc.Save(filePath); await writer.WriteLineAsync("</body>");
}); await writer.WriteLineAsync("</html>");
}
} }
} }
public partial class ExportService public partial class ExportService
{ {
private static HtmlDocument GetTemplate() private static string GetThemeCss(Theme theme)
{
var resourcePath = "DiscordChatExporter.Resources.ExportService.Template.html";
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream == null)
throw new MissingManifestResourceException("Could not find template resource");
using (stream)
{
var doc = new HtmlDocument();
doc.Load(stream);
return doc;
}
}
private static string GetStyle(Theme theme)
{ {
var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css"; var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css";
@ -160,7 +143,12 @@ namespace DiscordChatExporter.Services
} }
} }
private static string NormalizeFileSize(long fileSize) 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"}; string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize; double size = fileSize;
@ -178,7 +166,7 @@ namespace DiscordChatExporter.Services
private static string FormatMessageContent(string content) private static string FormatMessageContent(string content)
{ {
// Encode HTML // Encode HTML
content = HtmlDocument.HtmlEncode(content); content = HtmlEncode(content);
// Links from URLs // Links from URLs
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)", content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",

@ -6,12 +6,12 @@ namespace DiscordChatExporter.Services
{ {
public interface IDataService public interface IDataService
{ {
Task<IEnumerable<Guild>> GetGuildsAsync(string token); Task<IReadOnlyList<Guild>> GetGuildsAsync(string token);
Task<IEnumerable<Channel>> GetDirectMessageChannelsAsync(string token); Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IEnumerable<Channel>> GetGuildChannelsAsync(string token, string guildId); Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IEnumerable<Message>> GetChannelMessagesAsync(string token, string channelId); Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId);
} }
} }

@ -5,6 +5,6 @@ namespace DiscordChatExporter.Services
{ {
public interface IMessageGroupService public interface IMessageGroupService
{ {
IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages); IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages);
} }
} }

@ -13,7 +13,7 @@ namespace DiscordChatExporter.Services
_settingsService = settingsService; _settingsService = settingsService;
} }
public IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages) public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
{ {
var groupLimit = _settingsService.MessageGroupLimit; var groupLimit = _settingsService.MessageGroupLimit;
var result = new List<MessageGroup>(); var result = new List<MessageGroup>();
@ -37,7 +37,7 @@ namespace DiscordChatExporter.Services
// If condition is true - flush buffer // If condition is true - flush buffer
if (breakCondition) if (breakCondition)
{ {
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer); var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
result.Add(group); result.Add(group);
groupBuffer.Clear(); groupBuffer.Clear();
} }
@ -50,7 +50,7 @@ namespace DiscordChatExporter.Services
if (groupBuffer.Any()) if (groupBuffer.Any())
{ {
var groupFirst = groupBuffer.First(); var groupFirst = groupBuffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer); var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
result.Add(group); result.Add(group);
} }

@ -127,8 +127,7 @@ namespace DiscordChatExporter.ViewModels
foreach (var guild in guilds) foreach (var guild in guilds)
{ {
var channels = await _dataService.GetGuildChannelsAsync(_cachedToken, guild.Id); var channels = await _dataService.GetGuildChannelsAsync(_cachedToken, guild.Id);
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat); _guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
_guildChannelsMap[guild] = channels.ToArray();
} }
} }
} }
@ -175,7 +174,7 @@ namespace DiscordChatExporter.ViewModels
var messageGroups = _messageGroupService.GroupMessages(messages); var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log // Create log
var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups); var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
// Export // Export
await _exportService.ExportAsync(sfd.FileName, chatLog, _settingsService.Theme); await _exportService.ExportAsync(sfd.FileName, chatLog, _settingsService.Theme);

@ -3,7 +3,6 @@
<package id="Ammy" version="1.2.87" targetFramework="net461" /> <package id="Ammy" version="1.2.87" targetFramework="net461" />
<package id="Ammy.WPF" version="1.2.87" targetFramework="net461" /> <package id="Ammy.WPF" version="1.2.87" targetFramework="net461" />
<package id="CommonServiceLocator" version="1.3" targetFramework="net461" /> <package id="CommonServiceLocator" version="1.3" targetFramework="net461" />
<package id="HtmlAgilityPack" version="1.5.5" targetFramework="net461" />
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" /> <package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.3.1.953" targetFramework="net461" /> <package id="MaterialDesignThemes" version="2.3.1.953" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.3.0.0" targetFramework="net461" /> <package id="MvvmLightLibs" version="5.3.0.0" targetFramework="net461" />

Loading…
Cancel
Save