diff --git a/app/.idea/.idea.DiscordHistoryTracker/.idea/avalonia.xml b/app/.idea/.idea.DiscordHistoryTracker/.idea/avalonia.xml index e1c959d..e2bd7e1 100644 --- a/app/.idea/.idea.DiscordHistoryTracker/.idea/avalonia.xml +++ b/app/.idea/.idea.DiscordHistoryTracker/.idea/avalonia.xml @@ -8,6 +8,7 @@ + diff --git a/app/Desktop/Main/Controls/FilterPanel.axaml b/app/Desktop/Main/Controls/FilterPanel.axaml new file mode 100644 index 0000000..a39659c --- /dev/null +++ b/app/Desktop/Main/Controls/FilterPanel.axaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + Filter by Date + + + + + + + + + Filter by Channel + + + + + Filter by User + + + + + + diff --git a/app/Desktop/Main/Controls/FilterPanel.axaml.cs b/app/Desktop/Main/Controls/FilterPanel.axaml.cs new file mode 100644 index 0000000..2d0aba6 --- /dev/null +++ b/app/Desktop/Main/Controls/FilterPanel.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DHT.Desktop.Main.Controls { + public class FilterPanel : UserControl { + public FilterPanel() { + InitializeComponent(); + } + + private void InitializeComponent() { + AvaloniaXamlLoader.Load(this); + } + + public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) { + if (DataContext is FilterPanelModel model) { + model.StartDate = this.FindControl("StartDatePicker").SelectedDate; + model.EndDate = this.FindControl("EndDatePicker").SelectedDate; + } + } + } +} diff --git a/app/Desktop/Main/Controls/FilterPanelModel.cs b/app/Desktop/Main/Controls/FilterPanelModel.cs new file mode 100644 index 0000000..eae84ed --- /dev/null +++ b/app/Desktop/Main/Controls/FilterPanelModel.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Controls; +using DHT.Desktop.Dialogs; +using DHT.Desktop.Models; +using DHT.Server.Data; +using DHT.Server.Data.Filters; +using DHT.Server.Database; + +namespace DHT.Desktop.Main.Controls { + public class FilterPanelModel : BaseModel { + private static readonly HashSet FilterProperties = new () { + nameof(FilterByDate), + nameof(StartDate), + nameof(EndDate), + nameof(FilterByChannel), + nameof(IncludedChannels), + nameof(FilterByUser), + nameof(IncludedUsers) + }; + + public event PropertyChangedEventHandler? FilterPropertyChanged; + + private bool filterByDate = false; + private DateTime? startDate = null; + private DateTime? endDate = null; + private bool filterByChannel = false; + private HashSet? includedChannels = null; + private bool filterByUser = false; + private HashSet? includedUsers = null; + + public bool FilterByDate { + get => filterByDate; + set => Change(ref filterByDate, value); + } + + public DateTime? StartDate { + get => startDate; + set => Change(ref startDate, value); + } + + public DateTime? EndDate { + get => endDate; + set => Change(ref endDate, value); + } + + public bool FilterByChannel { + get => filterByChannel; + set => Change(ref filterByChannel, value); + } + + public HashSet IncludedChannels { + get => includedChannels ?? db.GetAllChannels().Select(channel => channel.Id).ToHashSet(); + set => Change(ref includedChannels, value); + } + + public bool FilterByUser { + get => filterByUser; + set => Change(ref filterByUser, value); + } + + + public HashSet IncludedUsers { + get => includedUsers ?? db.GetAllUsers().Select(user => user.Id).ToHashSet(); + set => Change(ref includedUsers, value); + } + + private string channelFilterLabel = ""; + + public string ChannelFilterLabel { + get => channelFilterLabel; + set => Change(ref channelFilterLabel, value); + } + + private string userFilterLabel = ""; + + public string UserFilterLabel { + get => userFilterLabel; + set => Change(ref userFilterLabel, value); + } + + private readonly Window window; + private readonly IDatabaseFile db; + + [Obsolete("Designer")] + public FilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {} + + public FilterPanelModel(Window window, IDatabaseFile db) { + this.window = window; + this.db = db; + + UpdateChannelFilterLabel(); + UpdateUserFilterLabel(); + + PropertyChanged += OnPropertyChanged; + db.Statistics.PropertyChanged += OnDbStatisticsChanged; + } + + private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) { + FilterPropertyChanged?.Invoke(sender, e); + } + + if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) { + UpdateChannelFilterLabel(); + } + else if (e.PropertyName is nameof(FilterByUser) or nameof(IncludedUsers)) { + UpdateUserFilterLabel(); + } + } + + private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) { + UpdateChannelFilterLabel(); + } + else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) { + UpdateUserFilterLabel(); + } + } + + public async void OpenChannelFilterDialog() { + var servers = db.GetAllServers().ToDictionary(server => server.Id); + var items = new List>(); + var included = IncludedChannels; + + foreach (var channel in db.GetAllChannels()) { + var channelId = channel.Id; + var channelName = channel.Name; + + string title; + if (servers.TryGetValue(channel.Server, out var server)) { + var titleBuilder = new StringBuilder(); + var serverType = server.Type; + + titleBuilder.Append('[') + .Append(ServerTypes.ToString(serverType)) + .Append("] "); + + if (serverType == ServerType.DirectMessage) { + titleBuilder.Append(channelName); + } + else { + titleBuilder.Append(server.Name) + .Append(" - ") + .Append(channelName); + } + + title = titleBuilder.ToString(); + } + else { + title = channelName; + } + + items.Add(new CheckBoxItem(channelId) { + Title = title, + Checked = included.Contains(channelId) + }); + } + + var result = await OpenIdFilterDialog(window, "Included Channels", items); + if (result != null) { + IncludedChannels = result; + } + } + + public async void OpenUserFilterDialog() { + var items = new List>(); + var included = IncludedUsers; + + foreach (var user in db.GetAllUsers()) { + var name = user.Name; + var discriminator = user.Discriminator; + + items.Add(new CheckBoxItem(user.Id) { + Title = discriminator == null ? name : name + " #" + discriminator, + Checked = included.Contains(user.Id) + }); + } + + var result = await OpenIdFilterDialog(window, "Included Users", items); + if (result != null) { + IncludedUsers = result; + } + } + + private void UpdateChannelFilterLabel() { + long total = db.Statistics.TotalChannels; + long included = FilterByChannel ? IncludedChannels.Count : total; + ChannelFilterLabel = "Selected " + included + " / " + total + (total == 1 ? " channel." : " channels."); + } + + private void UpdateUserFilterLabel() { + long total = db.Statistics.TotalUsers; + long included = FilterByUser ? IncludedUsers.Count : total; + UserFilterLabel = "Selected " + included + " / " + total + (total == 1 ? " user." : " users."); + } + + public MessageFilter CreateFilter() { + MessageFilter filter = new(); + + if (FilterByDate) { + filter.StartDate = StartDate; + filter.EndDate = EndDate; + } + + if (FilterByChannel) { + filter.ChannelIds = new HashSet(IncludedChannels); + } + + if (FilterByUser) { + filter.UserIds = new HashSet(IncludedUsers); + } + + return filter; + } + + private static async Task?> OpenIdFilterDialog(Window window, string title, List> items) { + items.Sort((item1, item2) => item1.Title.CompareTo(item2.Title)); + + var model = new CheckBoxDialogModel(items) { + Title = title + }; + + var dialog = new CheckBoxDialog { DataContext = model }; + var result = await dialog.ShowDialog(window); + + return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(item => item.Item).ToHashSet() : null; + } + } +} diff --git a/app/Desktop/Main/Pages/ViewerPage.axaml b/app/Desktop/Main/Pages/ViewerPage.axaml index b5d7c54..95c6be3 100644 --- a/app/Desktop/Main/Pages/ViewerPage.axaml +++ b/app/Desktop/Main/Pages/ViewerPage.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" + xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="DHT.Desktop.Main.Pages.ViewerPage"> @@ -10,36 +11,13 @@ - - - - - - + - - - Filter by Date - - - - - - - + + diff --git a/app/Desktop/Main/Pages/ViewerPage.axaml.cs b/app/Desktop/Main/Pages/ViewerPage.axaml.cs index ba937f7..b00c763 100644 --- a/app/Desktop/Main/Pages/ViewerPage.axaml.cs +++ b/app/Desktop/Main/Pages/ViewerPage.axaml.cs @@ -10,12 +10,5 @@ namespace DHT.Desktop.Main.Pages { private void InitializeComponent() { AvaloniaXamlLoader.Load(this); } - - public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) { - if (DataContext is ViewerPageModel model) { - model.StartDate = this.FindControl("StartDatePicker").SelectedDate; - model.EndDate = this.FindControl("EndDatePicker").SelectedDate; - } - } } } diff --git a/app/Desktop/Main/Pages/ViewerPageModel.cs b/app/Desktop/Main/Pages/ViewerPageModel.cs index 8f373ca..bef8257 100644 --- a/app/Desktop/Main/Pages/ViewerPageModel.cs +++ b/app/Desktop/Main/Pages/ViewerPageModel.cs @@ -6,9 +6,9 @@ using System.IO; using System.Threading.Tasks; using System.Web; using Avalonia.Controls; +using DHT.Desktop.Main.Controls; using DHT.Desktop.Models; using DHT.Desktop.Resources; -using DHT.Server.Data.Filters; using DHT.Server.Database; using DHT.Server.Database.Export; @@ -16,26 +16,7 @@ namespace DHT.Desktop.Main.Pages { public class ViewerPageModel : BaseModel { public string ExportedMessageText { get; private set; } = ""; - private bool filterByDate = false; - - public bool FilterByDate { - get => filterByDate; - set => Change(ref filterByDate, value); - } - - private DateTime? startDate = null; - - public DateTime? StartDate { - get => startDate; - set => Change(ref startDate, value); - } - - private DateTime? endDate = null; - - public DateTime? EndDate { - get => endDate; - set => Change(ref endDate, value); - } + private FilterPanelModel FilterModel { get; } private readonly Window window; private readonly IDatabaseFile db; @@ -47,15 +28,14 @@ namespace DHT.Desktop.Main.Pages { this.window = window; this.db = db; - this.PropertyChanged += OnPropertyChanged; + this.FilterModel = new FilterPanelModel(window, db); + this.FilterModel.FilterPropertyChanged += OnFilterPropertyChanged; this.db.Statistics.PropertyChanged += OnDbStatisticsChanged; UpdateStatistics(); } - private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(FilterByDate) or nameof(StartDate) or nameof(EndDate)) { - UpdateStatistics(); - } + private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) { + UpdateStatistics(); } private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { @@ -64,24 +44,13 @@ namespace DHT.Desktop.Main.Pages { } } - private MessageFilter CreateFilter() { - MessageFilter filter = new(); - - if (FilterByDate) { - filter.StartDate = StartDate; - filter.EndDate = EndDate; - } - - return filter; - } - private void UpdateStatistics() { - ExportedMessageText = "Will export " + db.CountMessages(CreateFilter()) + " out of " + db.Statistics.TotalMessages + " message(s)."; + ExportedMessageText = "Will export " + db.CountMessages(FilterModel.CreateFilter()) + " out of " + db.Statistics.TotalMessages + " message(s)."; OnPropertyChanged(nameof(ExportedMessageText)); } private async Task GenerateViewerContents() { - string json = ViewerJsonExport.Generate(db, CreateFilter()); + string json = ViewerJsonExport.Generate(db, FilterModel.CreateFilter()); string index = await ResourceLoader.ReadTextAsync("Viewer/index.html"); string viewer = index.Replace("/*[JS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/scripts/", '\n')) diff --git a/app/Server/Data/Filters/MessageFilter.cs b/app/Server/Data/Filters/MessageFilter.cs index d86361c..a14286b 100644 --- a/app/Server/Data/Filters/MessageFilter.cs +++ b/app/Server/Data/Filters/MessageFilter.cs @@ -6,6 +6,8 @@ namespace DHT.Server.Data.Filters { public DateTime? StartDate { get; set; } public DateTime? EndDate { get; set; } - public HashSet MessageIds { get; } = new(); + public HashSet? ChannelIds { get; set; } = null; + public HashSet? UserIds { get; set; } = null; + public HashSet? MessageIds { get; set; } = null; } } diff --git a/app/Server/Database/DatabaseStatistics.cs b/app/Server/Database/DatabaseStatistics.cs index 5027e92..3b43a88 100644 --- a/app/Server/Database/DatabaseStatistics.cs +++ b/app/Server/Database/DatabaseStatistics.cs @@ -5,6 +5,7 @@ namespace DHT.Server.Database { public class DatabaseStatistics : INotifyPropertyChanged { private long totalServers; private long totalChannels; + private long totalUsers; private long totalMessages; public long TotalServers { @@ -17,6 +18,11 @@ namespace DHT.Server.Database { internal set => Change(out totalChannels, value); } + public long TotalUsers { + get => totalUsers; + internal set => Change(out totalUsers, value); + } + public long TotalMessages { get => totalMessages; internal set => Change(out totalMessages, value); @@ -33,6 +39,7 @@ namespace DHT.Server.Database { return new DatabaseStatistics { totalServers = totalServers, totalChannels = totalChannels, + totalUsers = TotalUsers, totalMessages = totalMessages }; } diff --git a/app/Server/Database/Export/ViewerJsonExport.cs b/app/Server/Database/Export/ViewerJsonExport.cs index 3ca6a29..9e1dd89 100644 --- a/app/Server/Database/Export/ViewerJsonExport.cs +++ b/app/Server/Database/Export/ViewerJsonExport.cs @@ -8,26 +8,48 @@ using DHT.Server.Data.Filters; namespace DHT.Server.Database.Export { public static class ViewerJsonExport { public static string Generate(IDatabaseFile db, MessageFilter? filter = null) { - JsonSerializerOptions opts = new(); + var includedUserIds = new HashSet(); + var includedChannelIds = new HashSet(); + var includedServerIds = new HashSet(); + + var includedMessages = db.GetMessages(filter); + var includedChannels = new List(); + + foreach (var message in includedMessages) { + includedUserIds.Add(message.Sender); + includedChannelIds.Add(message.Channel); + } + + foreach (var channel in db.GetAllChannels()) { + if (includedChannelIds.Contains(channel.Id)) { + includedChannels.Add(channel); + includedServerIds.Add(channel.Server); + } + } + + var opts = new JsonSerializerOptions(); opts.Converters.Add(new ViewerJsonSnowflakeSerializer()); - var users = GenerateUserList(db, out var userindex, out var userIndices); - var servers = GenerateServerList(db, out var serverindex); - var channels = GenerateChannelList(db, serverindex); + var users = GenerateUserList(db, includedUserIds, out var userindex, out var userIndices); + var servers = GenerateServerList(db, includedServerIds, out var serverindex); + var channels = GenerateChannelList(includedChannels, serverindex); return JsonSerializer.Serialize(new { meta = new { users, userindex, servers, channels }, - data = GenerateMessageList(db, filter, userIndices) + data = GenerateMessageList(includedMessages, userIndices) }, opts); } - private static dynamic GenerateUserList(IDatabaseFile db, out List userindex, out Dictionary userIndices) { + private static dynamic GenerateUserList(IDatabaseFile db, HashSet userIds, out List userindex, out Dictionary userIndices) { var users = new Dictionary(); userindex = new List(); userIndices = new Dictionary(); foreach (var user in db.GetAllUsers()) { - var id = user.Id.ToString(); + var id = user.Id; + if (!userIds.Contains(id)) { + continue; + } dynamic obj = new ExpandoObject(); obj.name = user.Name; @@ -40,20 +62,26 @@ namespace DHT.Server.Database.Export { obj.tag = user.Discriminator; } - userIndices[user.Id] = users.Count; - userindex.Add(id); - users[id] = obj; + var idStr = id.ToString(); + userIndices[id] = users.Count; + userindex.Add(idStr); + users[idStr] = obj; } return users; } - private static dynamic GenerateServerList(IDatabaseFile db, out Dictionary serverIndices) { + private static dynamic GenerateServerList(IDatabaseFile db, HashSet serverIds, out Dictionary serverIndices) { var servers = new List(); serverIndices = new Dictionary(); foreach (var server in db.GetAllServers()) { - serverIndices[server.Id] = servers.Count; + var id = server.Id; + if (!serverIds.Contains(id)) { + continue; + } + + serverIndices[id] = servers.Count; servers.Add(new { name = server.Name, type = ServerTypes.ToJsonViewerString(server.Type) @@ -63,10 +91,10 @@ namespace DHT.Server.Database.Export { return servers; } - private static dynamic GenerateChannelList(IDatabaseFile db, Dictionary serverIndices) { + private static dynamic GenerateChannelList(List includedChannels, Dictionary serverIndices) { var channels = new Dictionary(); - foreach (var channel in db.GetAllChannels()) { + foreach (var channel in includedChannels) { dynamic obj = new ExpandoObject(); obj.server = serverIndices[channel.Server]; obj.name = channel.Name; @@ -93,10 +121,10 @@ namespace DHT.Server.Database.Export { return channels; } - private static dynamic GenerateMessageList(IDatabaseFile db, MessageFilter? filter, Dictionary userIndices) { + private static dynamic GenerateMessageList(List includedMessages, Dictionary userIndices) { var data = new Dictionary>(); - foreach (var grouping in db.GetMessages(filter).GroupBy(message => message.Channel)) { + foreach (var grouping in includedMessages.GroupBy(message => message.Channel)) { var channel = grouping.Key.ToString(); var channelData = new Dictionary(); diff --git a/app/Server/Database/Sqlite/SqliteDatabaseFile.cs b/app/Server/Database/Sqlite/SqliteDatabaseFile.cs index 7db1c9f..c324fb6 100644 --- a/app/Server/Database/Sqlite/SqliteDatabaseFile.cs +++ b/app/Server/Database/Sqlite/SqliteDatabaseFile.cs @@ -32,6 +32,7 @@ namespace DHT.Server.Database.Sqlite { this.Statistics = new DatabaseStatistics(); UpdateServerStatistics(); UpdateChannelStatistics(); + UpdateUserStatistics(); UpdateMessageStatistics(); } @@ -129,6 +130,7 @@ namespace DHT.Server.Database.Sqlite { } tx.Commit(); + UpdateUserStatistics(); } public List GetAllUsers() { @@ -369,6 +371,11 @@ namespace DHT.Server.Database.Sqlite { Statistics.TotalChannels = cmd.ExecuteScalar() as long? ?? 0; } + private void UpdateUserStatistics() { + using var cmd = conn.Command("SELECT COUNT(*) FROM users"); + Statistics.TotalUsers = cmd.ExecuteScalar() as long? ?? 0; + } + private void UpdateMessageStatistics() { using var cmd = conn.Command("SELECT COUNT(*) FROM messages"); Statistics.TotalMessages = cmd.ExecuteScalar() as long? ?? 0L; diff --git a/app/Server/Database/Sqlite/SqliteMessageFilter.cs b/app/Server/Database/Sqlite/SqliteMessageFilter.cs index fc89522..6489ee7 100644 --- a/app/Server/Database/Sqlite/SqliteMessageFilter.cs +++ b/app/Server/Database/Sqlite/SqliteMessageFilter.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using DHT.Server.Data.Filters; namespace DHT.Server.Database.Sqlite { @@ -20,8 +19,16 @@ namespace DHT.Server.Database.Sqlite { conditions.Add("timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds()); } - if (filter.MessageIds.Count > 0) { - conditions.Add("(" + string.Join(" OR ", filter.MessageIds.Select(id => "message_id = " + id)) + ")"); + if (filter.ChannelIds != null) { + conditions.Add("channel_id IN (" + string.Join(",", filter.ChannelIds) + ")"); + } + + if (filter.UserIds != null) { + conditions.Add("sender_id IN (" + string.Join(",", filter.UserIds) + ")"); + } + + if (filter.MessageIds != null) { + conditions.Add("message_id IN (" + string.Join(",", filter.MessageIds) + ")"); } return conditions.Count == 0 ? "" : " WHERE " + string.Join(" AND ", conditions); diff --git a/app/Server/Endpoints/TrackMessagesEndpoint.cs b/app/Server/Endpoints/TrackMessagesEndpoint.cs index 7748808..b113e5a 100644 --- a/app/Server/Endpoints/TrackMessagesEndpoint.cs +++ b/app/Server/Endpoints/TrackMessagesEndpoint.cs @@ -22,17 +22,19 @@ namespace DHT.Server.Endpoints { throw new HttpException(HttpStatusCode.BadRequest, "Expected root element to be an array."); } - var addedMessageIdFilter = new MessageFilter(); + var addedMessageIds = new HashSet(); var messages = new Message[root.GetArrayLength()]; int i = 0; foreach (JsonElement ele in root.EnumerateArray()) { var message = ReadMessage(ele, "message"); messages[i++] = message; - addedMessageIdFilter.MessageIds.Add(message.Id); + addedMessageIds.Add(message.Id); } - bool anyNewMessages = Db.CountMessages(addedMessageIdFilter) < messages.Length; + var addedMessageFilter = new MessageFilter { MessageIds = addedMessageIds }; + bool anyNewMessages = Db.CountMessages(addedMessageFilter) < messages.Length; + Db.AddMessages(messages); return (HttpStatusCode.OK, anyNewMessages ? 1 : 0);