1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-08-17 19:31:42 +02:00

21 Commits
v32 ... v33.1

Author SHA1 Message Date
edc23d616d Release v33.1 (app) 2022-02-12 11:23:24 +01:00
db191f87fd Fix not finding selected DM channel after a Discord update
Closes #161
2022-02-12 11:15:33 +01:00
e8677c09b4 Release v33 (app) 2021-12-01 08:13:19 +01:00
378c54908e Add tools to remove/keep filtered messages in the app 2021-11-29 23:43:56 +01:00
07af4ae00f Improve localization for dates and numbers in the app UI 2021-11-29 17:52:07 +01:00
c262e5aaa4 Minor visual fixes for app UI 2021-11-29 17:52:07 +01:00
96e125b812 Add channel & user filters to the app 2021-11-29 15:16:51 +01:00
c0123b9f91 Add check box dialog 2021-11-29 15:12:35 +01:00
2ec9c7cbc3 Fix Rider inspections 2021-11-28 16:20:34 +01:00
4cfe19d369 Tweak button styles in the app 2021-11-28 14:09:56 +01:00
f449859ad9 Update Avalonia to 0.10.10 2021-11-28 13:57:49 +01:00
ae5630cf96 Fix missing app icon in dialog windows 2021-11-27 20:59:16 +01:00
75c8202c77 Disable resizing of message dialogs 2021-11-27 20:58:57 +01:00
e9e8c95a19 Add feature to merge multiple database files into one 2021-11-27 20:50:01 +01:00
879a69608c Add progress dialog 2021-11-27 19:41:45 +01:00
560c515ecf Make message dialogs callable from non-UI threads 2021-11-27 19:41:45 +01:00
ec8c00853a Add empty archive file w/ Rider data source 2021-11-27 13:40:53 +01:00
5888d13d7c Add app build script for Linux 2021-11-26 13:48:52 +01:00
15db0cca8e Update Rider project files 2021-11-20 09:32:55 +01:00
52998f7d7a Release v32.1 (app) 2021-09-14 02:03:08 +02:00
c2e302f8bb Allow tracking from old Discord domain in the app (discordapp.com) 2021-09-14 02:03:08 +02:00
55 changed files with 1238 additions and 173 deletions

View File

@@ -2,7 +2,6 @@
/contentModel.xml /contentModel.xml
/dataSources /dataSources
/dataSources.local.xml /dataSources.local.xml
/dataSources.xml
/dictionaries /dictionaries
/httpRequests/ /httpRequests/
/misc.xml /misc.xml

View File

@@ -4,8 +4,11 @@
<option name="projectPerEditor"> <option name="projectPerEditor">
<map> <map>
<entry key="Desktop/App.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/App.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/CheckBoxDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/MessageDialog.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Dialogs/MessageDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/FilterPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainContentScreen.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />

View File

@@ -13,10 +13,10 @@
<CssCodeStyleSettings> <CssCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" /> <option name="HEX_COLOR_LOWER_CASE" value="true" />
</CssCodeStyleSettings> </CssCodeStyleSettings>
<DB2CodeStyleSettings version="5"> <DB2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</DB2CodeStyleSettings> </DB2CodeStyleSettings>
<DerbyCodeStyleSettings version="5"> <DerbyCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</DerbyCodeStyleSettings> </DerbyCodeStyleSettings>
<GoCodeStyleSettings> <GoCodeStyleSettings>
@@ -26,10 +26,13 @@
<option name="WRAP_FUNC_PARAMS" value="5" /> <option name="WRAP_FUNC_PARAMS" value="5" />
<option name="WRAP_FUNC_RESULT" value="5" /> <option name="WRAP_FUNC_RESULT" value="5" />
</GoCodeStyleSettings> </GoCodeStyleSettings>
<H2CodeStyleSettings version="5"> <H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings> </H2CodeStyleSettings>
<HSQLCodeStyleSettings version="5"> <H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings>
<HSQLCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</HSQLCodeStyleSettings> </HSQLCodeStyleSettings>
<HTMLCodeStyleSettings> <HTMLCodeStyleSettings>
@@ -85,10 +88,10 @@
<LessCodeStyleSettings> <LessCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" /> <option name="HEX_COLOR_LOWER_CASE" value="true" />
</LessCodeStyleSettings> </LessCodeStyleSettings>
<MSSQLCodeStyleSettings version="5"> <MSSQLCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</MSSQLCodeStyleSettings> </MSSQLCodeStyleSettings>
<MySQLCodeStyleSettings version="5"> <MySQLCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</MySQLCodeStyleSettings> </MySQLCodeStyleSettings>
<Objective-C> <Objective-C>
@@ -98,7 +101,7 @@
<option name="SPACE_BEFORE_INIT_LIST" value="true" /> <option name="SPACE_BEFORE_INIT_LIST" value="true" />
<option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" /> <option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" />
</Objective-C> </Objective-C>
<OracleCodeStyleSettings version="5"> <OracleCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</OracleCodeStyleSettings> </OracleCodeStyleSettings>
<PHPCodeStyleSettings> <PHPCodeStyleSettings>
@@ -116,7 +119,7 @@
<option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" /> <option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" />
<option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" /> <option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" />
</PHPCodeStyleSettings> </PHPCodeStyleSettings>
<PostgresCodeStyleSettings version="5"> <PostgresCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</PostgresCodeStyleSettings> </PostgresCodeStyleSettings>
<Properties> <Properties>
@@ -143,13 +146,13 @@
<option name="INDENT_WHEN_CASES" value="true" /> <option name="INDENT_WHEN_CASES" value="true" />
<option name="CHAIN_CALLS_ALIGNMENT" value="2" /> <option name="CHAIN_CALLS_ALIGNMENT" value="2" />
</Ruby> </Ruby>
<SQLiteCodeStyleSettings version="5"> <SQLiteCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</SQLiteCodeStyleSettings> </SQLiteCodeStyleSettings>
<ScssCodeStyleSettings> <ScssCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" /> <option name="HEX_COLOR_LOWER_CASE" value="true" />
</ScssCodeStyleSettings> </ScssCodeStyleSettings>
<SqlCodeStyleSettings version="5"> <SqlCodeStyleSettings version="6">
<option name="KEYWORD_CASE" value="2" /> <option name="KEYWORD_CASE" value="2" />
<option name="TYPE_CASE" value="2" /> <option name="TYPE_CASE" value="2" />
<option name="CUSTOM_TYPE_CASE" value="2" /> <option name="CUSTOM_TYPE_CASE" value="2" />
@@ -175,7 +178,7 @@
<option name="VIEW_INDENT_QUERY" value="true" /> <option name="VIEW_INDENT_QUERY" value="true" />
<option name="EXPR_CASE_WHEN_WRAP" value="false" /> <option name="EXPR_CASE_WHEN_WRAP" value="false" />
</SqlCodeStyleSettings> </SqlCodeStyleSettings>
<SybaseCodeStyleSettings version="5"> <SybaseCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</SybaseCodeStyleSettings> </SybaseCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0"> <TypeScriptCodeStyleSettings version="0">

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="empty.dht" read-only="true" uuid="e01b8deb-489e-470a-9011-f6548927191e">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/empty.dht</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project"> <configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0/Desktop.exe" /> <option name="EXE_PATH" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0/DiscordHistoryTracker.exe" />
<option name="PROGRAM_PARAMETERS" value="" /> <option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />

View File

@@ -1,5 +1,6 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="clr-namespace:DHT.Desktop.Common"
x:Class="DHT.Desktop.App"> x:Class="DHT.Desktop.App">
<Application.Styles> <Application.Styles>
@@ -8,20 +9,20 @@
<Style Selector="Button"> <Style Selector="Button">
<Setter Property="Padding" Value="10 7 10 6" /> <Setter Property="Padding" Value="10 7 10 6" />
<Setter Property="Background" Value="#D2D2D2" /> <Setter Property="Background" Value="#DDDDDD" />
<Setter Property="BorderBrush" Value="#999999" /> <Setter Property="BorderBrush" Value="#999999" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" /> <Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" /> <Setter Property="VerticalContentAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Button /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="#070707" /> <Setter Property="TextBlock.Foreground" Value="#060606" />
<Setter Property="Background" Value="#D2D2D2" /> <Setter Property="Background" Value="#DDDDDD" />
<Setter Property="BorderBrush" Value="#999999" /> <Setter Property="BorderBrush" Value="#999999" />
<Setter Property="CornerRadius" Value="0" />
</Style> </Style>
<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#DEDEDE" /> <Setter Property="Background" Value="#E7E7E7" />
<Setter Property="BorderBrush" Value="#A2A2A2" /> <Setter Property="BorderBrush" Value="#A2A2A2" />
<Setter Property="CornerRadius" Value="0" /> <Setter Property="CornerRadius" Value="0" />
</Style> </Style>
@@ -29,28 +30,25 @@
<Setter Property="RenderTransform" Value="none" /> <Setter Property="RenderTransform" Value="none" />
</Style> </Style>
<Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#E5E5E5" /> <Setter Property="Background" Value="#EBEBEB" />
<Setter Property="BorderBrush" Value="#A5A5A5" /> <Setter Property="BorderBrush" Value="#A5A5A5" />
<Setter Property="CornerRadius" Value="0" /> <Setter Property="CornerRadius" Value="0" />
</Style> </Style>
<Style Selector="Button:disabled /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="#909090" /> <Setter Property="TextBlock.Foreground" Value="#7A7A7A" />
<Setter Property="Background" Value="#E9E9E9" /> <Setter Property="Background" Value="#E9E9E9" />
<Setter Property="BorderBrush" Value="#BFBFBF" /> <Setter Property="BorderBrush" Value="#BFBFBF" />
<Setter Property="CornerRadius" Value="0" />
</Style> </Style>
<Style Selector="TextBox"> <Style Selector="TextBox">
<Setter Property="BorderBrush" Value="#999999" /> <Setter Property="BorderBrush" Value="#999999" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="SelectionBrush" Value="#72C0FF" /> <Setter Property="SelectionBrush" Value="#72C0FF" />
<Setter Property="Background" Value="#F4F4F4" /> <Setter Property="Background" Value="#F4F4F4" />
<Setter Property="Padding" Value="6 0" /> <Setter Property="Padding" Value="6 0" />
<Setter Property="VerticalContentAlignment" Value="Center" /> <Setter Property="VerticalContentAlignment" Value="Center" />
</Style> </Style>
<Style Selector="TextBox /template/ Border#PART_BorderElement">
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="TextBox:pointerover /template/ Border#PART_BorderElement"> <Style Selector="TextBox:pointerover /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="#999999" /> <Setter Property="BorderBrush" Value="#999999" />
<Setter Property="Background" Value="#F8F8F8" /> <Setter Property="Background" Value="#F8F8F8" />
@@ -68,6 +66,59 @@
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Expander">
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="ToggleButtonBackground">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="35">
<Border x:Name="ExpandCollapseChevronBorder"
Grid.Column="0"
Width="35"
Height="35"
Margin="2,0"
RenderTransformOrigin="50%,50%">
<Path x:Name="ExpandCollapseChevron"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderTransformOrigin="50%,50%"
Stretch="None"
Stroke="{DynamicResource ExpanderChevronForeground}"
StrokeThickness="1" />
<Border.RenderTransform>
<RotateTransform />
</Border.RenderTransform>
</Border>
<ContentPresenter x:Name="PART_ContentPresenter"
Grid.Column="1"
Margin="0"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="Center"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
TextBlock.Foreground="{DynamicResource ExpanderForeground}" />
</Grid>
</Border>
</ControlTemplate>
</Setter>
</Style>
</Application.Styles> </Application.Styles>
<Application.Resources>
<common:NumberValueConverter x:Key="NumberValueConverter" />
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
<Thickness x:Key="ExpanderContentPadding">15</Thickness>
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FCFCFC" />
</Application.Resources>
</Application> </Application>

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs;
using DHT.Server.Database;
using DHT.Server.Database.Exceptions;
using DHT.Server.Database.Sqlite;
using DHT.Server.Logging;
namespace DHT.Desktop.Common {
public static class DatabaseGui {
private const string DatabaseFileInitialName = "archive.dht";
private static readonly List<FileDialogFilter> DatabaseFileDialogFilter = new() {
new FileDialogFilter {
Name = "Discord History Tracker Database",
Extensions = { "dht" }
}
};
public static OpenFileDialog NewOpenDatabaseFileDialog() {
return new OpenFileDialog {
Title = "Open Database File",
InitialFileName = DatabaseFileInitialName,
Filters = DatabaseFileDialogFilter
};
}
public static SaveFileDialog NewOpenOrCreateDatabaseFileDialog() {
return new SaveFileDialog {
Title = "Open or Create Database File",
InitialFileName = DatabaseFileInitialName,
Filters = DatabaseFileDialogFilter
};
}
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
IDatabaseFile? file = null;
try {
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
} catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
}
return file;
}
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT. Do you want to proceed with the upgrade?");
}
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped. Do you want to proceed with the upgrade?");
}
}
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace DHT.Desktop.Common {
public class NumberValueConverter : IValueConverter {
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) {
return string.Format(Program.Culture, "{0:n0}", value);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) {
throw new NotSupportedException();
}
}
}

View File

@@ -0,0 +1,19 @@
namespace DHT.Desktop.Common {
public static class TextFormat {
public static string Format(this int number) {
return number.ToString("N0", Program.Culture);
}
public static string Format(this long number) {
return number.ToString("N0", Program.Culture);
}
public static string Pluralize(this int number, string singular) {
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
}
public static string Pluralize(this long number, string singular) {
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
}
}
}

View File

@@ -12,7 +12,7 @@
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<AssemblyName>DiscordHistoryTracker</AssemblyName> <AssemblyName>DiscordHistoryTracker</AssemblyName>
<Version>32.0.0.0</Version> <Version>33.1.0.0</Version>
<AssemblyVersion>$(Version)</AssemblyVersion> <AssemblyVersion>$(Version)</AssemblyVersion>
<FileVersion>$(Version)</FileVersion> <FileVersion>$(Version)</FileVersion>
<PackageVersion>$(Version)</PackageVersion> <PackageVersion>$(Version)</PackageVersion>
@@ -22,12 +22,12 @@
<DebugType>none</DebugType> <DebugType>none</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="0.10.3" /> <PackageReference Include="Avalonia" Version="0.10.10" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.3" /> <PackageReference Include="Avalonia.Desktop" Version="0.10.10" />
<ProjectReference Include="..\Server\Server.csproj" /> <ProjectReference Include="..\Server\Server.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition=" '$(Configuration)' == 'Debug' "> <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.3" /> <PackageReference Include="Avalonia.Diagnostics" Version="0.10.10" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Update="Windows\MainWindow.axaml.cs"> <Compile Update="Windows\MainWindow.axaml.cs">

View File

@@ -0,0 +1,61 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:dialogs="clr-namespace:DHT.Desktop.Dialogs"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.CheckBoxDialog"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner">
<Window.DataContext>
<dialogs:CheckBoxDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="Panel.buttons">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.left Button">
<Setter Property="Margin" Value="0 0 8 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right Button">
<Setter Property="Margin" Value="8 0 0 0" />
</Style>
</Window.Styles>
<StackPanel Margin="20">
<ScrollViewer MaxHeight="400">
<ItemsControl Items="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding Checked}">
<Label>
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
</Label>
</CheckBox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Panel Classes="buttons">
<WrapPanel Classes="left">
<Button Command="{Binding SelectAll}" IsEnabled="{Binding !AreAllSelected}">Select All</Button>
<Button Command="{Binding SelectNone}" IsEnabled="{Binding !AreNoneSelected}">Select None</Button>
</WrapPanel>
<WrapPanel Classes="right">
<Button Click="ClickOk">OK</Button>
<Button Click="ClickCancel">Cancel</Button>
</WrapPanel>
</Panel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,28 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Dialogs {
public class CheckBoxDialog : Window {
public CheckBoxDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Ok);
}
public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel);
}
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using DHT.Desktop.Models;
namespace DHT.Desktop.Dialogs {
public class CheckBoxDialogModel : BaseModel {
public string Title { get; init; } = "";
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
public IReadOnlyList<CheckBoxItem> Items {
get => items;
protected set {
foreach (var item in items) {
item.PropertyChanged -= OnItemPropertyChanged;
}
items = value;
foreach (var item in items) {
item.PropertyChanged += OnItemPropertyChanged;
}
}
}
private bool pauseCheckEvents = false;
public bool AreAllSelected => Items.All(item => item.Checked);
public bool AreNoneSelected => Items.All(item => !item.Checked);
public void SelectAll() => SetAllChecked(true);
public void SelectNone() => SetAllChecked(false);
private void SetAllChecked(bool isChecked) {
pauseCheckEvents = true;
foreach (var item in Items) {
item.Checked = isChecked;
}
pauseCheckEvents = false;
UpdateBulkButtons();
}
private void UpdateBulkButtons() {
OnPropertyChanged(nameof(AreAllSelected));
OnPropertyChanged(nameof(AreNoneSelected));
}
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
UpdateBulkButtons();
}
}
}
public class CheckBoxDialogModel<T> : CheckBoxDialogModel {
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(item => item.Checked);
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
this.Items = new List<CheckBoxItem<T>>(items);
base.Items = this.Items;
}
}
}

View File

@@ -0,0 +1,24 @@
using DHT.Desktop.Models;
namespace DHT.Desktop.Dialogs {
public class CheckBoxItem : BaseModel {
public string Title { get; init; } = "";
public object? Item { get; init; } = null;
private bool isChecked = false;
public bool Checked {
get => isChecked;
set => Change(ref isChecked, value);
}
}
public class CheckBoxItem<T> : CheckBoxItem {
public new T Item { get; }
public CheckBoxItem(T item) {
this.Item = item;
base.Item = item;
}
}
}

View File

@@ -1,9 +1,15 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading;
namespace DHT.Desktop.Dialogs { namespace DHT.Desktop.Dialogs {
public static class Dialog { public static class Dialog {
public static async Task ShowOk(Window owner, string title, string message) { public static async Task ShowOk(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
return;
}
await new MessageDialog { await new MessageDialog {
DataContext = new MessageDialogModel { DataContext = new MessageDialogModel {
Title = title, Title = title,
@@ -14,6 +20,10 @@ namespace DHT.Desktop.Dialogs {
} }
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) { public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
}
var result = await new MessageDialog { var result = await new MessageDialog {
DataContext = new MessageDialogModel { DataContext = new MessageDialogModel {
Title = title, Title = title,
@@ -27,6 +37,10 @@ namespace DHT.Desktop.Dialogs {
} }
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) { public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
}
var result = await new MessageDialog { var result = await new MessageDialog {
DataContext = new MessageDialogModel { DataContext = new MessageDialogModel {
Title = title, Title = title,
@@ -40,6 +54,10 @@ namespace DHT.Desktop.Dialogs {
} }
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) { public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
}
var result = await new MessageDialog { var result = await new MessageDialog {
DataContext = new MessageDialogModel { DataContext = new MessageDialogModel {
Title = title, Title = title,

View File

@@ -0,0 +1,7 @@
using System.Threading.Tasks;
namespace DHT.Desktop.Dialogs {
public interface IProgressCallback {
Task Update(string message, int finishedItems, int totalItems);
}
}

View File

@@ -6,7 +6,8 @@
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.MessageDialog" x:Class="DHT.Desktop.Dialogs.MessageDialog"
Title="{Binding Title}" Title="{Binding Title}"
Width="500" SizeToContent="Height" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<Window.DataContext> <Window.DataContext>

View File

@@ -0,0 +1,42 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:dialogs="clr-namespace:DHT.Desktop.Dialogs"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.ProgressDialog"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Opened="Loaded"
Closing="OnClosing"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner">
<Window.DataContext>
<dialogs:ProgressDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="TextBlock">
<Setter Property="FontSize" Value="15" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.items">
<Setter Property="Margin" Value="10 0 0 0" />
<Setter Property="TextAlignment" Value="Right" />
<Setter Property="TextWrapping" Value="NoWrap" />
</Style>
<Style Selector="ProgressBar">
<Setter Property="Margin" Value="0 15 0 0" />
</Style>
</Window.Styles>
<StackPanel Margin="20">
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
</DockPanel>
<ProgressBar Value="{Binding Progress}" />
</StackPanel>
</Window>

View File

@@ -0,0 +1,38 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Dialogs {
public class ProgressDialog : Window {
private bool isFinished = false;
public ProgressDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public void OnClosing(object? sender, CancelEventArgs e) {
e.Cancel = !isFinished;
}
public void Loaded(object? sender, EventArgs e) {
if (DataContext is ProgressDialogModel model) {
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
}
}
private void OnFinished(Task task) {
isFinished = true;
Close();
}
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Threading.Tasks;
using Avalonia.Threading;
using DHT.Desktop.Common;
using DHT.Desktop.Models;
namespace DHT.Desktop.Dialogs {
public class ProgressDialogModel : BaseModel {
public string Title { get; init; } = "";
private string message = "";
public string Message {
get => message;
private set => Change(ref message, value);
}
private string items = "";
public string Items {
get => items;
private set => Change(ref items, value);
}
private int progress = 0;
public int Progress {
get => progress;
private set => Change(ref progress, value);
}
private readonly TaskRunner? task;
[Obsolete("Designer")]
public ProgressDialogModel() {}
public ProgressDialogModel(TaskRunner task) {
this.task = task;
}
internal async Task StartTask() {
if (task != null) {
await task(new Callback(this));
}
}
public delegate Task TaskRunner(IProgressCallback callback);
private class Callback : IProgressCallback {
private readonly ProgressDialogModel model;
public Callback(ProgressDialogModel model) {
this.model = model;
}
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => {
model.Message = message;
model.Items = finishedItems.Format() + " / " + totalItems.Format();
model.Progress = 100 * finishedItems / totalItems;
});
}
}
}
}

View File

@@ -0,0 +1,57 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.FilterPanel">
<Design.DataContext>
<controls:FilterPanelModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Margin" Value="0 20 40 0" />
<Setter Property="Spacing" Value="4" />
</Style>
<Style Selector="Grid > Label">
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Grid > CalendarDatePicker">
<Setter Property="CornerRadius" Value="0" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="IsTodayHighlighted" Value="True" />
<Setter Property="SelectedDateFormat" Value="Short" />
</Style>
<Style Selector="Button">
<Setter Property="Margin" Value="0 0 0 8" />
</Style>
</UserControl.Styles>
<WrapPanel>
<StackPanel>
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox>
<Grid ColumnDefinitions="Auto, 4, 125" RowDefinitions="Auto, 4, Auto" Margin="4 0">
<Label Grid.Row="0" Grid.Column="0">From:</Label>
<CalendarDatePicker Grid.Row="0" Grid.Column="2" x:Name="StartDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" />
<Label Grid.Row="2" Grid.Column="0">To:</Label>
<CalendarDatePicker Grid.Row="2" Grid.Column="2" x:Name="EndDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" />
</Grid>
</StackPanel>
<StackPanel>
<CheckBox IsChecked="{Binding FilterByChannel}">Filter by Channel</CheckBox>
<Button Command="{Binding OpenChannelFilterDialog}" IsEnabled="{Binding FilterByChannel}">Select Channels...</Button>
<TextBlock Text="{Binding ChannelFilterLabel}" />
</StackPanel>
<StackPanel>
<CheckBox IsChecked="{Binding FilterByUser}">Filter by User</CheckBox>
<Button Command="{Binding OpenUserFilterDialog}" IsEnabled="{Binding FilterByUser}">Select Users...</Button>
<TextBlock Text="{Binding UserFilterLabel}" />
</StackPanel>
</WrapPanel>
</UserControl>

View File

@@ -0,0 +1,32 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Controls {
public class FilterPanel : UserControl {
private CalendarDatePicker StartDatePicker => this.FindControl<CalendarDatePicker>("StartDatePicker");
private CalendarDatePicker EndDatePicker => this.FindControl<CalendarDatePicker>("EndDatePicker");
public FilterPanel() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
var culture = Program.Culture;
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
}
}
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
if (DataContext is FilterPanelModel model) {
model.StartDate = StartDatePicker.SelectedDate;
model.EndDate = EndDatePicker.SelectedDate;
}
}
}
}

View File

@@ -0,0 +1,236 @@
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.Common;
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<string> FilterProperties = new () {
nameof(FilterByDate),
nameof(StartDate),
nameof(EndDate),
nameof(FilterByChannel),
nameof(IncludedChannels),
nameof(FilterByUser),
nameof(IncludedUsers)
};
public event PropertyChangedEventHandler? FilterPropertyChanged;
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
private bool filterByDate = false;
private DateTime? startDate = null;
private DateTime? endDate = null;
private bool filterByChannel = false;
private HashSet<ulong>? includedChannels = null;
private bool filterByUser = false;
private HashSet<ulong>? 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<ulong> 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<ulong> 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<CheckBoxItem<ulong>>();
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<ulong>(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<CheckBoxItem<ulong>>();
var included = IncludedUsers;
foreach (var user in db.GetAllUsers()) {
var name = user.Name;
var discriminator = user.Discriminator;
items.Add(new CheckBoxItem<ulong>(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.Format() + " / " + total.Pluralize("channel") + ".";
}
private void UpdateUserFilterLabel() {
long total = db.Statistics.TotalUsers;
long included = FilterByUser ? IncludedUsers.Count : total;
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
}
public MessageFilter CreateFilter() {
MessageFilter filter = new();
if (FilterByDate) {
filter.StartDate = StartDate;
filter.EndDate = EndDate;
}
if (FilterByChannel) {
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
}
if (FilterByUser) {
filter.UserIds = new HashSet<ulong>(IncludedUsers);
}
return filter;
}
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
items.Sort((item1, item2) => item1.Title.CompareTo(item2.Title));
var model = new CheckBoxDialogModel<ulong>(items) {
Title = title
};
var dialog = new CheckBoxDialog { DataContext = model };
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(item => item.Item).ToHashSet() : null;
}
}
}

View File

@@ -41,17 +41,17 @@
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Servers</TextBlock> <TextBlock Classes="label">Servers</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, StringFormat={}{0:n0}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Channels</TextBlock> <TextBlock Classes="label">Channels</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, StringFormat={}{0:n0}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Messages</TextBlock> <TextBlock Classes="label">Messages</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, StringFormat={}{0:n0}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

@@ -12,7 +12,7 @@
</Design.DataContext> </Design.DataContext>
<UserControl.Styles> <UserControl.Styles>
<Style Selector="TabControl WrapPanel"> <Style Selector="TabControl ItemsPresenter > WrapPanel">
<Setter Property="Background" Value="#546A9F" /> <Setter Property="Background" Value="#546A9F" />
</Style> </Style>
<Style Selector="TabItem"> <Style Selector="TabItem">

View File

@@ -7,7 +7,7 @@
x:Class="DHT.Desktop.Main.MainWindow" x:Class="DHT.Desktop.Main.MainWindow"
Title="Discord History Tracker" Title="Discord History Tracker"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="800" Height="450" Width="800" Height="500"
MinWidth="480" MinHeight="240" MinWidth="480" MinHeight="240"
WindowStartupLocation="CenterScreen"> WindowStartupLocation="CenterScreen">

View File

@@ -10,12 +10,21 @@
<pages:DatabasePageModel /> <pages:DatabasePageModel />
</Design.DataContext> </Design.DataContext>
<UserControl.Styles>
<Style Selector="WrapPanel > Button">
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles>
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<DockPanel> <DockPanel>
<Button Command="{Binding CloseDatabase}" DockPanel.Dock="Right">Close Database</Button> <Button Command="{Binding CloseDatabase}" DockPanel.Dock="Right">Close Database</Button>
<TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsEnabled="True" /> <TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsEnabled="True" />
</DockPanel> </DockPanel>
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button> <WrapPanel>
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
</WrapPanel>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -1,10 +1,14 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs; using DHT.Desktop.Dialogs;
using DHT.Desktop.Models; using DHT.Desktop.Models;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Logging;
using DHT.Server.Service; using DHT.Server.Service;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages {
@@ -50,9 +54,101 @@ namespace DHT.Desktop.Main.Pages {
} }
} }
public async void MergeWithDatabase() {
var fileDialog = DatabaseGui.NewOpenDatabaseFileDialog();
fileDialog.Directory = Path.GetDirectoryName(Db.Path);
fileDialog.AllowMultiple = true;
string[]? paths = await fileDialog.ShowAsync(window);
if (paths == null || paths.Length == 0) {
return;
}
ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) {
Title = "Database Merge"
};
await progressDialog.ShowDialog(window);
}
public void CloseDatabase() { public void CloseDatabase() {
ServerLauncher.Stop(); ServerLauncher.Stop();
DatabaseClosed?.Invoke(this, EventArgs.Empty); DatabaseClosed?.Invoke(this, EventArgs.Empty);
} }
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
int total = paths.Length;
DialogResult.YesNo? upgradeResult = null;
async Task<bool> CheckCanUpgradeDatabase() {
upgradeResult ??= total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
return DialogResult.YesNo.Yes == upgradeResult;
}
var oldStatistics = target.Statistics.Clone();
int successful = 0;
int finished = 0;
foreach (string path in paths) {
await callback.Update(Path.GetFileName(path), finished, total);
++finished;
if (!File.Exists(path)) {
await Dialog.ShowOk(dialog, "Database Error", "Database '" + Path.GetFileName(path) + "' no longer exists.");
continue;
}
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
if (db == null) {
continue;
}
try {
target.AddFrom(db);
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(dialog, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be merged: " + ex.Message);
continue;
} finally {
db.Dispose();
}
++successful;
}
await callback.Update("Done", finished, total);
if (successful == 0) {
await Dialog.ShowOk(dialog, "Database Merge", "Nothing was merged.");
return;
}
var newStatistics = target.Statistics;
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
StringBuilder message = new StringBuilder();
message.Append("Processed ");
if (successful == total) {
message.Append(successful.Pluralize("database file"));
}
else {
message.Append(successful.Format()).Append(" out of ").Append(total.Pluralize("database file"));
}
message.Append(" and added:\n\n \u2022 ");
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
message.Append(newMessages.Pluralize("message"));
await Dialog.ShowOk(dialog, "Database Merge", message.ToString());
}
} }
} }

View File

@@ -15,6 +15,10 @@
<Setter Property="FontFamily" Value="Consolas,Courier" /> <Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" /> <Setter Property="FontSize" Value="15" />
</Style> </Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles> </UserControl.Styles>
<StackPanel Spacing="10"> <StackPanel Spacing="10">
@@ -26,23 +30,25 @@
<Button Command="{Binding OnClickToggleButton}" Content="{Binding ToggleButtonText}" IsEnabled="{Binding IsToggleButtonEnabled}" /> <Button Command="{Binding OnClickToggleButton}" Content="{Binding ToggleButtonText}" IsEnabled="{Binding IsToggleButtonEnabled}" />
</StackPanel> </StackPanel>
<Expander Header="Advanced Settings"> <Expander Header="Advanced Settings">
<StackPanel Spacing="10" Margin="0 10 0 0"> <StackPanel Spacing="10">
<TextBlock TextWrapping="Wrap"> <TextBlock TextWrapping="Wrap">
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy and apply the tracking script again. The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy and apply the tracking script again.
</TextBlock> </TextBlock>
<StackPanel Orientation="Horizontal" Spacing="10"> <WrapPanel>
<StackPanel Orientation="Vertical"> <StackPanel>
<Label Target="Port">Port</Label> <Label Target="Port">Port</Label>
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" /> <TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Vertical"> <StackPanel>
<Label Target="Token">Token</Label> <Label Target="Token">Token</Label>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBox x:Name="Token" Width="200" Text="{Binding InputToken}" /> <TextBox x:Name="Token" Width="200" Text="{Binding InputToken}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<Button Command="{Binding OnClickRandomizeToken}" VerticalAlignment="Bottom">Randomize Token</Button> <StackPanel VerticalAlignment="Bottom">
</StackPanel> <Button Command="{Binding OnClickRandomizeToken}">Randomize Token</Button>
</StackPanel>
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickApplyChanges}">Apply &amp; Restart</Button> <Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickApplyChanges}">Apply &amp; Restart</Button>
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickCancelChanges}">Cancel</Button> <Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickCancelChanges}">Cancel</Button>

View File

@@ -3,7 +3,6 @@ using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading;
using DHT.Desktop.Dialogs; using DHT.Desktop.Dialogs;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Models; using DHT.Desktop.Models;
@@ -140,11 +139,9 @@ namespace DHT.Desktop.Main.Pages {
IsToggleButtonEnabled = true; IsToggleButtonEnabled = true;
} }
private void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) { private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
Log.Error(ex); Log.Error(ex);
await Dialog.ShowOk(window, "Server Error", ex.Message);
string message = ex.Message;
Dispatcher.UIThread.Post(async () => { await Dialog.ShowOk(window, "Server Error", message); });
} }
} }
} }

View File

@@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" 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" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.ViewerPage"> x:Class="DHT.Desktop.Main.Pages.ViewerPage">
@@ -11,34 +12,27 @@
</Design.DataContext> </Design.DataContext>
<UserControl.Styles> <UserControl.Styles>
<Style Selector="Grid > Label"> <Style Selector="Expander">
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="Margin" Value="0 25 0 0" />
</Style>
<Style Selector="Grid > CalendarDatePicker">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="IsTodayHighlighted" Value="True" />
<Setter Property="SelectedDateFormat" Value="Short" />
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<StackPanel Orientation="Vertical" Spacing="20"> <StackPanel Orientation="Vertical">
<StackPanel Orientation="Horizontal" VerticalAlignment="Top"> <StackPanel Orientation="Horizontal" VerticalAlignment="Top">
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button> <Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
<Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button> <Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button>
</StackPanel> </StackPanel>
<TextBlock Text="{Binding ExportedMessageText}" /> <TextBlock Text="{Binding ExportedMessageText}" Margin="0 20 0 0" />
<StackPanel> <controls:FilterPanel DataContext="{Binding FilterModel}" />
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox> <Expander Header="Database Tools">
<Grid ColumnDefinitions="Auto, 4, 140" RowDefinitions="Auto, 4, Auto" Margin="4"> <StackPanel Orientation="Vertical" Spacing="10">
<Label Grid.Row="0" Grid.Column="0">From:</Label> <StackPanel Orientation="Vertical" Spacing="4">
<CalendarDatePicker Grid.Row="0" Grid.Column="2" x:Name="StartDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" /> <RadioButton GroupName="DatabaseToolFilterMode" IsEnabled="{Binding HasFilters}" IsChecked="{Binding DatabaseToolFilterModeKeep}">Keep Only Messages Matching Filters</RadioButton>
<Label Grid.Row="2" Grid.Column="0">To:</Label> <RadioButton GroupName="DatabaseToolFilterMode" IsEnabled="{Binding HasFilters}" IsChecked="{Binding DatabaseToolFilterModeRemove}">Remove Messages Matching Filters</RadioButton>
<CalendarDatePicker Grid.Row="2" Grid.Column="2" x:Name="EndDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" /> </StackPanel>
</Grid> <Button IsEnabled="{Binding HasFilters}" Command="{Binding OnClickApplyFiltersToDatabase}">Apply Filters to Database</Button>
</StackPanel> </StackPanel>
</Expander>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -10,12 +10,5 @@ namespace DHT.Desktop.Main.Pages {
private void InitializeComponent() { private void InitializeComponent() {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
if (DataContext is ViewerPageModel model) {
model.StartDate = this.FindControl<CalendarDatePicker>("StartDatePicker").SelectedDate;
model.EndDate = this.FindControl<CalendarDatePicker>("EndDatePicker").SelectedDate;
}
}
} }
} }

View File

@@ -6,6 +6,9 @@ using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Models; using DHT.Desktop.Models;
using DHT.Desktop.Resources; using DHT.Desktop.Resources;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
@@ -16,26 +19,17 @@ namespace DHT.Desktop.Main.Pages {
public class ViewerPageModel : BaseModel { public class ViewerPageModel : BaseModel {
public string ExportedMessageText { get; private set; } = ""; public string ExportedMessageText { get; private set; } = "";
private bool filterByDate = false; public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false;
public bool FilterByDate { private bool hasFilters = false;
get => filterByDate;
set => Change(ref filterByDate, value); public bool HasFilters {
get => hasFilters;
set => Change(ref hasFilters, value);
} }
private DateTime? startDate = null; private FilterPanelModel FilterModel { get; }
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 readonly Window window; private readonly Window window;
private readonly IDatabaseFile db; private readonly IDatabaseFile db;
@@ -47,15 +41,15 @@ namespace DHT.Desktop.Main.Pages {
this.window = window; this.window = window;
this.db = db; this.db = db;
this.PropertyChanged += OnPropertyChanged; this.FilterModel = new FilterPanelModel(window, db);
this.FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
this.db.Statistics.PropertyChanged += OnDbStatisticsChanged; this.db.Statistics.PropertyChanged += OnDbStatisticsChanged;
UpdateStatistics(); UpdateStatistics();
} }
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName is nameof(FilterByDate) or nameof(StartDate) or nameof(EndDate)) { UpdateStatistics();
UpdateStatistics(); HasFilters = FilterModel.HasAnyFilters;
}
} }
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
@@ -64,24 +58,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() { private void UpdateStatistics() {
ExportedMessageText = "Will export " + db.CountMessages(CreateFilter()) + " out of " + db.Statistics.TotalMessages + " message(s)."; ExportedMessageText = "Will export " + db.CountMessages(FilterModel.CreateFilter()).Format() + " out of " + db.Statistics.TotalMessages.Format() + " message(s).";
OnPropertyChanged(nameof(ExportedMessageText)); OnPropertyChanged(nameof(ExportedMessageText));
} }
private async Task<string> GenerateViewerContents() { private async Task<string> GenerateViewerContents() {
string json = ViewerJsonExport.Generate(db, CreateFilter()); string json = ViewerJsonExport.Generate(db, FilterModel.CreateFilter());
string index = await ResourceLoader.ReadTextAsync("Viewer/index.html"); string index = await ResourceLoader.ReadTextAsync("Viewer/index.html");
string viewer = index.Replace("/*[JS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/scripts/", '\n')) string viewer = index.Replace("/*[JS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/scripts/", '\n'))
@@ -97,7 +80,8 @@ namespace DHT.Desktop.Main.Pages {
int counter = 0; int counter = 0;
while (File.Exists(fullPath)) { while (File.Exists(fullPath)) {
fullPath = Path.Combine(rootPath, filenameBase + "-" + (++counter) + ".html"); ++counter;
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
} }
Directory.CreateDirectory(rootPath); Directory.CreateDirectory(rootPath);
@@ -119,10 +103,25 @@ namespace DHT.Desktop.Main.Pages {
} }
}.ShowAsync(window); }.ShowAsync(window);
string path = await dialog; string? path = await dialog;
if (!string.IsNullOrEmpty(path)) { if (!string.IsNullOrEmpty(path)) {
await File.WriteAllTextAsync(path, await GenerateViewerContents()); await File.WriteAllTextAsync(path, await GenerateViewerContents());
} }
} }
public async void OnClickApplyFiltersToDatabase() {
var filter = FilterModel.CreateFilter();
if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, MessageFilterRemovalMode.KeepMatching);
}
}
else if (DatabaseToolFilterModeRemove) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, MessageFilterRemovalMode.RemoveMatching);
}
}
}
} }
} }

View File

@@ -28,7 +28,7 @@
<Panel Name="RootPanel"> <Panel Name="RootPanel">
<StackPanel Margin="42"> <StackPanel Margin="42">
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v.{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" /> <TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button> <Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>

View File

@@ -1,14 +1,11 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs; using DHT.Desktop.Dialogs;
using DHT.Desktop.Models; using DHT.Desktop.Models;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Exceptions;
using DHT.Server.Database.Sqlite;
using DHT.Server.Logging;
namespace DHT.Desktop.Main { namespace DHT.Desktop.Main {
public class WelcomeScreenModel : BaseModel { public class WelcomeScreenModel : BaseModel {
@@ -29,19 +26,10 @@ namespace DHT.Desktop.Main {
} }
public async void OpenOrCreateDatabase() { public async void OpenOrCreateDatabase() {
var dialog = new SaveFileDialog { var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog();
Title = "Open or Create Database File", dialog.Directory = Path.GetDirectoryName(dbFilePath);
InitialFileName = "archive.dht",
Directory = Path.GetDirectoryName(dbFilePath),
Filters = new List<FileDialogFilter> {
new() {
Name = "Discord History Tracker Database",
Extensions = { "dht" }
}
}
}.ShowAsync(window);
string path = await dialog; string? path = await dialog.ShowAsync(window);
if (!string.IsNullOrWhiteSpace(path)) { if (!string.IsNullOrWhiteSpace(path)) {
await OpenOrCreateDatabaseFromPath(path); await OpenOrCreateDatabaseFromPath(path);
} }
@@ -53,24 +41,14 @@ namespace DHT.Desktop.Main {
} }
dbFilePath = path; dbFilePath = path;
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
try {
Db = await SqliteDatabaseFile.OpenOrCreate(path, CheckCanUpgradeDatabase);
} catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "This database appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) {
await Dialog.ShowOk(window, "Database Error", "This database was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Database Error", ex.Message);
}
OnPropertyChanged(nameof(Db)); OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase)); OnPropertyChanged(nameof(HasDatabase));
} }
private async Task<bool> CheckCanUpgradeDatabase() { private async Task<bool> CheckCanUpgradeDatabase() {
return DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT. Do you want to proceed?"); return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
} }
public void CloseDatabase() { public void CloseDatabase() {
@@ -81,7 +59,7 @@ namespace DHT.Desktop.Main {
} }
public async void ShowAboutDialog() { public async void ShowAboutDialog() {
await new AboutWindow() { DataContext = new AboutWindowModel() }.ShowDialog(this.window); await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
} }
public void Exit() { public void Exit() {

View File

@@ -5,6 +5,7 @@ using Avalonia;
namespace DHT.Desktop { namespace DHT.Desktop {
internal static class Program { internal static class Program {
public static string Version { get; } public static string Version { get; }
public static CultureInfo Culture { get; }
static Program() { static Program() {
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? ""; Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "";
@@ -12,6 +13,7 @@ namespace DHT.Desktop {
Version = Version[..^2]; Version = Version[..^2];
} }
Culture = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;

File diff suppressed because one or more lines are too long

View File

@@ -157,7 +157,7 @@ class DISCORD {
if (dms) { if (dms) {
let name; let name;
for (const ele of dms.querySelectorAll("[class*='channel-'][class*='selected-'] [class^='name-'] *")) { for (const ele of dms.querySelectorAll("[class*='channel-'] [class*='selected-'] [class^='name-'] *, [class*='channel-'][class*='selected-'] [class^='name-'] *")) {
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE); const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
if (node) { if (node) {

View File

@@ -6,6 +6,8 @@ namespace DHT.Server.Data.Filters {
public DateTime? StartDate { get; set; } public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; } public DateTime? EndDate { get; set; }
public HashSet<ulong> MessageIds { get; } = new(); public HashSet<ulong>? ChannelIds { get; set; } = null;
public HashSet<ulong>? UserIds { get; set; } = null;
public HashSet<ulong>? MessageIds { get; set; } = null;
} }
} }

View File

@@ -0,0 +1,6 @@
namespace DHT.Server.Data.Filters {
public enum MessageFilterRemovalMode {
KeepMatching,
RemoveMatching
}
}

View File

@@ -0,0 +1,16 @@
namespace DHT.Server.Database {
public static class DatabaseExtensions {
public static void AddFrom(this IDatabaseFile target, IDatabaseFile source) {
foreach (var server in source.GetAllServers()) {
target.AddServer(server);
}
foreach (var channel in source.GetAllChannels()) {
target.AddChannel(channel);
}
target.AddUsers(source.GetAllUsers().ToArray());
target.AddMessages(source.GetMessages().ToArray());
}
}
}

View File

@@ -5,6 +5,7 @@ namespace DHT.Server.Database {
public class DatabaseStatistics : INotifyPropertyChanged { public class DatabaseStatistics : INotifyPropertyChanged {
private long totalServers; private long totalServers;
private long totalChannels; private long totalChannels;
private long totalUsers;
private long totalMessages; private long totalMessages;
public long TotalServers { public long TotalServers {
@@ -17,6 +18,11 @@ namespace DHT.Server.Database {
internal set => Change(out totalChannels, value); internal set => Change(out totalChannels, value);
} }
public long TotalUsers {
get => totalUsers;
internal set => Change(out totalUsers, value);
}
public long TotalMessages { public long TotalMessages {
get => totalMessages; get => totalMessages;
internal set => Change(out totalMessages, value); internal set => Change(out totalMessages, value);
@@ -28,5 +34,14 @@ namespace DHT.Server.Database {
field = value; field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
} }
public DatabaseStatistics Clone() {
return new DatabaseStatistics {
totalServers = totalServers,
totalChannels = totalChannels,
totalUsers = TotalUsers,
totalMessages = totalMessages
};
}
} }
} }

View File

@@ -40,6 +40,8 @@ namespace DHT.Server.Database {
return new(); return new();
} }
public void RemoveMessages(MessageFilter filter, MessageFilterRemovalMode mode) {}
public void Dispose() { public void Dispose() {
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }

View File

@@ -8,26 +8,48 @@ using DHT.Server.Data.Filters;
namespace DHT.Server.Database.Export { namespace DHT.Server.Database.Export {
public static class ViewerJsonExport { public static class ViewerJsonExport {
public static string Generate(IDatabaseFile db, MessageFilter? filter = null) { public static string Generate(IDatabaseFile db, MessageFilter? filter = null) {
JsonSerializerOptions opts = new(); var includedUserIds = new HashSet<ulong>();
var includedChannelIds = new HashSet<ulong>();
var includedServerIds = new HashSet<ulong>();
var includedMessages = db.GetMessages(filter);
var includedChannels = new List<Channel>();
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()); opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
var users = GenerateUserList(db, out var userindex, out var userIndices); var users = GenerateUserList(db, includedUserIds, out var userindex, out var userIndices);
var servers = GenerateServerList(db, out var serverindex); var servers = GenerateServerList(db, includedServerIds, out var serverindex);
var channels = GenerateChannelList(db, serverindex); var channels = GenerateChannelList(includedChannels, serverindex);
return JsonSerializer.Serialize(new { return JsonSerializer.Serialize(new {
meta = new { users, userindex, servers, channels }, meta = new { users, userindex, servers, channels },
data = GenerateMessageList(db, filter, userIndices) data = GenerateMessageList(includedMessages, userIndices)
}, opts); }, opts);
} }
private static dynamic GenerateUserList(IDatabaseFile db, out List<string> userindex, out Dictionary<ulong, int> userIndices) { private static dynamic GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, int> userIndices) {
var users = new Dictionary<string, dynamic>(); var users = new Dictionary<string, dynamic>();
userindex = new List<string>(); userindex = new List<string>();
userIndices = new Dictionary<ulong, int>(); userIndices = new Dictionary<ulong, int>();
foreach (var user in db.GetAllUsers()) { foreach (var user in db.GetAllUsers()) {
var id = user.Id.ToString(); var id = user.Id;
if (!userIds.Contains(id)) {
continue;
}
dynamic obj = new ExpandoObject(); dynamic obj = new ExpandoObject();
obj.name = user.Name; obj.name = user.Name;
@@ -40,20 +62,26 @@ namespace DHT.Server.Database.Export {
obj.tag = user.Discriminator; obj.tag = user.Discriminator;
} }
userIndices[user.Id] = users.Count; var idStr = id.ToString();
userindex.Add(id); userIndices[id] = users.Count;
users[id] = obj; userindex.Add(idStr);
users[idStr] = obj;
} }
return users; return users;
} }
private static dynamic GenerateServerList(IDatabaseFile db, out Dictionary<ulong, int> serverIndices) { private static dynamic GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, int> serverIndices) {
var servers = new List<dynamic>(); var servers = new List<dynamic>();
serverIndices = new Dictionary<ulong, int>(); serverIndices = new Dictionary<ulong, int>();
foreach (var server in db.GetAllServers()) { 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 { servers.Add(new {
name = server.Name, name = server.Name,
type = ServerTypes.ToJsonViewerString(server.Type) type = ServerTypes.ToJsonViewerString(server.Type)
@@ -63,10 +91,10 @@ namespace DHT.Server.Database.Export {
return servers; return servers;
} }
private static dynamic GenerateChannelList(IDatabaseFile db, Dictionary<ulong, int> serverIndices) { private static dynamic GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
var channels = new Dictionary<string, dynamic>(); var channels = new Dictionary<string, dynamic>();
foreach (var channel in db.GetAllChannels()) { foreach (var channel in includedChannels) {
dynamic obj = new ExpandoObject(); dynamic obj = new ExpandoObject();
obj.server = serverIndices[channel.Server]; obj.server = serverIndices[channel.Server];
obj.name = channel.Name; obj.name = channel.Name;
@@ -93,10 +121,10 @@ namespace DHT.Server.Database.Export {
return channels; return channels;
} }
private static dynamic GenerateMessageList(IDatabaseFile db, MessageFilter? filter, Dictionary<ulong, int> userIndices) { private static dynamic GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices) {
var data = new Dictionary<string, Dictionary<string, dynamic>>(); var data = new Dictionary<string, Dictionary<string, dynamic>>();
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 channel = grouping.Key.ToString();
var channelData = new Dictionary<string, dynamic>(); var channelData = new Dictionary<string, dynamic>();

View File

@@ -20,5 +20,6 @@ namespace DHT.Server.Database {
void AddMessages(Message[] messages); void AddMessages(Message[] messages);
int CountMessages(MessageFilter? filter = null); int CountMessages(MessageFilter? filter = null);
List<Message> GetMessages(MessageFilter? filter = null); List<Message> GetMessages(MessageFilter? filter = null);
void RemoveMessages(MessageFilter filter, MessageFilterRemovalMode mode);
} }
} }

View File

@@ -80,7 +80,7 @@ namespace DHT.Server.Database.Sqlite {
Execute(@"CREATE TABLE attachments ( Execute(@"CREATE TABLE attachments (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL, attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
type TEXT, type TEXT,
url TEXT NOT NULL, url TEXT NOT NULL,

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Collections; using DHT.Server.Collections;
using DHT.Server.Data; using DHT.Server.Data;
@@ -32,6 +33,7 @@ namespace DHT.Server.Database.Sqlite {
this.Statistics = new DatabaseStatistics(); this.Statistics = new DatabaseStatistics();
UpdateServerStatistics(); UpdateServerStatistics();
UpdateChannelStatistics(); UpdateChannelStatistics();
UpdateUserStatistics();
UpdateMessageStatistics(); UpdateMessageStatistics();
} }
@@ -129,6 +131,7 @@ namespace DHT.Server.Database.Sqlite {
} }
tx.Commit(); tx.Commit();
UpdateUserStatistics();
} }
public List<User> GetAllUsers() { public List<User> GetAllUsers() {
@@ -301,6 +304,24 @@ namespace DHT.Server.Database.Sqlite {
return list; return list;
} }
public void RemoveMessages(MessageFilter filter, MessageFilterRemovalMode mode) {
var whereClause = filter.GenerateWhereClause(invert: mode == MessageFilterRemovalMode.KeepMatching);
if (string.IsNullOrEmpty(whereClause)) {
return;
}
// Rider is being stupid...
StringBuilder build = new StringBuilder()
.Append("DELETE ")
.Append("FROM messages")
.Append(whereClause);
using var cmd = conn.Command(build.ToString());
cmd.ExecuteNonQuery();
UpdateMessageStatistics();
}
private MultiDictionary<ulong, Attachment> GetAllAttachments() { private MultiDictionary<ulong, Attachment> GetAllAttachments() {
var dict = new MultiDictionary<ulong, Attachment>(); var dict = new MultiDictionary<ulong, Attachment>();
@@ -369,6 +390,11 @@ namespace DHT.Server.Database.Sqlite {
Statistics.TotalChannels = cmd.ExecuteScalar() as long? ?? 0; 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() { private void UpdateMessageStatistics() {
using var cmd = conn.Command("SELECT COUNT(*) FROM messages"); using var cmd = conn.Command("SELECT COUNT(*) FROM messages");
Statistics.TotalMessages = cmd.ExecuteScalar() as long? ?? 0L; Statistics.TotalMessages = cmd.ExecuteScalar() as long? ?? 0L;

View File

@@ -1,11 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
namespace DHT.Server.Database.Sqlite { namespace DHT.Server.Database.Sqlite {
public static class SqliteMessageFilter { public static class SqliteMessageFilter {
public static string GenerateWhereClause(this MessageFilter? filter) { public static string GenerateWhereClause(this MessageFilter? filter, bool invert = false) {
if (filter == null) { if (filter == null) {
return ""; return "";
} }
@@ -15,15 +14,33 @@ namespace DHT.Server.Database.Sqlite {
if (filter.StartDate != null) { if (filter.StartDate != null) {
conditions.Add("timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds()); conditions.Add("timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds());
} }
if (filter.EndDate != null) { if (filter.EndDate != null) {
conditions.Add("timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds()); conditions.Add("timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds());
} }
if (filter.MessageIds.Count > 0) { if (filter.ChannelIds != null) {
conditions.Add("(" + string.Join(" OR ", filter.MessageIds.Select(id => "message_id = " + id)) + ")"); conditions.Add("channel_id IN (" + string.Join(",", filter.ChannelIds) + ")");
} }
return conditions.Count == 0 ? "" : " WHERE " + string.Join(" AND ", conditions); 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) + ")");
}
if (conditions.Count == 0) {
return "";
}
if (invert) {
return " WHERE NOT (" + string.Join(" AND ", conditions) + ")";
}
else {
return " WHERE " + string.Join(" AND ", conditions);
}
} }
} }
} }

View File

@@ -22,17 +22,19 @@ namespace DHT.Server.Endpoints {
throw new HttpException(HttpStatusCode.BadRequest, "Expected root element to be an array."); throw new HttpException(HttpStatusCode.BadRequest, "Expected root element to be an array.");
} }
MessageFilter addedMessageIdFilter = new(); var addedMessageIds = new HashSet<ulong>();
Message[] messages = new Message[root.GetArrayLength()]; var messages = new Message[root.GetArrayLength()];
int i = 0; int i = 0;
foreach (JsonElement ele in root.EnumerateArray()) { foreach (JsonElement ele in root.EnumerateArray()) {
var message = ReadMessage(ele, "message"); var message = ReadMessage(ele, "message");
messages[i++] = 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); Db.AddMessages(messages);
return (HttpStatusCode.OK, anyNewMessages ? 1 : 0); return (HttpStatusCode.OK, anyNewMessages ? 1 : 0);

View File

@@ -18,7 +18,7 @@ namespace DHT.Server.Endpoints {
throw new HttpException(HttpStatusCode.BadRequest, "Expected root element to be an array."); throw new HttpException(HttpStatusCode.BadRequest, "Expected root element to be an array.");
} }
User[] users = new User[root.GetArrayLength()]; var users = new User[root.GetArrayLength()];
int i = 0; int i = 0;
foreach (JsonElement user in root.EnumerateArray()) { foreach (JsonElement user in root.EnumerateArray()) {

View File

@@ -8,7 +8,7 @@
<Authors>chylex</Authors> <Authors>chylex</Authors>
<Company>DiscordHistoryTracker</Company> <Company>DiscordHistoryTracker</Company>
<Product>DiscordHistoryTrackerServer</Product> <Product>DiscordHistoryTrackerServer</Product>
<Version>32.0.0.0</Version> <Version>33.1.0.0</Version>
<AssemblyVersion>$(Version)</AssemblyVersion> <AssemblyVersion>$(Version)</AssemblyVersion>
<FileVersion>$(Version)</FileVersion> <FileVersion>$(Version)</FileVersion>
<PackageVersion>$(Version)</PackageVersion> <PackageVersion>$(Version)</PackageVersion>

View File

@@ -16,7 +16,7 @@ namespace DHT.Server.Service {
services.AddCors(cors => { services.AddCors(cors => {
cors.AddDefaultPolicy(builder => { cors.AddDefaultPolicy(builder => {
builder.WithOrigins("https://discord.com").AllowCredentials().AllowAnyMethod().AllowAnyHeader(); builder.WithOrigins("https://discord.com", "https://discordapp.com").AllowCredentials().AllowAnyMethod().AllowAnyHeader();
}); });
}); });
} }

25
app/build.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -e
if [ ! -f "DiscordHistoryTracker.sln" ]; then
echo "Missing DiscordHistoryTracker.sln in working directory!"
exit 1
fi
makezip() {
pushd "./bin/$1"
zip -9 -r "../$1.zip" .
popd
}
rm -rf "./bin"
configurations=(win-x64 linux-x64 osx-x64)
for cfg in ${configurations[@]}; do
dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=false -p:PublishTrimmed=true --self-contained true
makezip "$cfg"
done
dotnet publish Desktop -c Release -o "./bin/portable" --self-contained false
makezip "portable"

BIN
app/empty.dht Normal file

Binary file not shown.

6
app/global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "5.0.0",
"rollForward": "latestMinor"
}
}