mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 19:31:42 +02:00
Compare commits
56 Commits
v36.2.beta
...
v39.1
Author | SHA1 | Date | |
---|---|---|---|
4f5e27f651
|
|||
cbf81ec95a
|
|||
8a80cb8c20
|
|||
865deb356a
|
|||
069ab97196
|
|||
caab038eaa
|
|||
fb837374fc
|
|||
65d935cca1
|
|||
6e64c86d7a
|
|||
8aeb590bb3
|
|||
8dc1adc9f0
|
|||
ddf70b02e7
|
|||
ef59fd992e
|
|||
d044627fac
|
|||
a624745602 | |||
6da3c185e5 | |||
d4d14cab97
|
|||
095c9a061a
|
|||
d01f9ed218
|
|||
dd6f121059
|
|||
8bba33d815
|
|||
9eab8ac92a
|
|||
fe588686fc
|
|||
7392987165
|
|||
492dddb35d
|
|||
d35280a6a6
|
|||
ae8a34f938
|
|||
18f5823f2a
|
|||
37640c97b0
|
|||
3cc5c75c48
|
|||
2424a8ac8d
|
|||
e7705587c2
|
|||
bd7904b88e
|
|||
3db599e901
|
|||
69a68b463c
|
|||
b2389dd521
|
|||
610516de1f
|
|||
3a6b83e0ba
|
|||
22958536e7
|
|||
4db8c302d8
|
|||
7e7d140957
|
|||
8c68438fbb
|
|||
f625a39b4d
|
|||
7fd644449c
|
|||
e4a09515b0
|
|||
9ac9f2246f
|
|||
bbc734ba9b
|
|||
6837b05b0d
|
|||
c94808a15f
|
|||
739e87c5ab
|
|||
d463b407f4
|
|||
cd418f4871
|
|||
176a81e055 | |||
![]() |
1cf3e76644 | ||
33f5ab7cce
|
|||
b9a5664740
|
20
README.md
20
README.md
@@ -16,10 +16,9 @@ Fork the repository and clone it to your computer (if you've never used git, you
|
|||||||
|
|
||||||
Folder organization:
|
Folder organization:
|
||||||
* `app/` contains a Visual Studio solution for the desktop app
|
* `app/` contains a Visual Studio solution for the desktop app
|
||||||
* `lib/` contains utilities required to build the project
|
|
||||||
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
||||||
|
|
||||||
To start editing source code for the desktop app, install the [.NET 5 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/5.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
@@ -29,27 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
|
|||||||
|
|
||||||
#### Release – Windows (64-bit)
|
#### Release – Windows (64-bit)
|
||||||
|
|
||||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH`
|
1. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
|
||||||
2. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
|
|
||||||
|
|
||||||
The `lib/` folder contains an installation of [Node](https://nodejs.org/en) and [uglify-js](https://www.npmjs.com/package/uglify-js), which are used to minify the tracking script. This installation will only work on 64-bit Windows; building on 32-bit Windows is not supported, but you can try.
|
|
||||||
|
|
||||||
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
||||||
|
|
||||||
#### Release – Other Operating Systems
|
#### Release – Other Operating Systems
|
||||||
|
|
||||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3
|
1. Install the `zip` package from your repository
|
||||||
- On Debian and derivatives, you can install `python-is-python3`
|
|
||||||
- On other distributions, you can create a link manually, for ex. `ln -s /usr/bin/python3 /usr/bin/python`
|
|
||||||
- If you don't want `python` to mean Python 3, then edit `Desktop.csproj` and change `python` to `python3`
|
|
||||||
2. Install [Node + npm](https://nodejs.org/en)
|
|
||||||
3. Install [uglify-js](https://www.npmjs.com/package/uglify-js) globally (`npm install -g uglify-js`)
|
|
||||||
4. Install the `zip` package from your repository
|
|
||||||
|
|
||||||
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
|
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
|
||||||
|
|
||||||
#### Distribution
|
#### Distribution
|
||||||
|
|
||||||
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 5 to be installed.
|
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 8 to be installed.
|
||||||
|
|
||||||
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking `DiscordHistoryTracker`. I tried using Python to re-create the archives with correct file permissions, but found that Linux `zip` tools could not see them. The only working solution is building the Windows + portable version on Windows, and Linux + Mac version on Linux.
|
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking the executable. Since .NET 8 fixed several issues with publishing Windows executables on Linux, I recommend using Linux to build the app for all operating systems.
|
||||||
|
@@ -29,9 +29,6 @@
|
|||||||
<H2CodeStyleSettings version="6">
|
<H2CodeStyleSettings version="6">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</H2CodeStyleSettings>
|
</H2CodeStyleSettings>
|
||||||
<H2CodeStyleSettings version="6">
|
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
|
||||||
</H2CodeStyleSettings>
|
|
||||||
<HSQLCodeStyleSettings version="6">
|
<HSQLCodeStyleSettings version="6">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</HSQLCodeStyleSettings>
|
</HSQLCodeStyleSettings>
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
<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/DiscordHistoryTracker.exe" />
|
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Desktop/debug/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$/.artifacts/bin/Desktop/debug" />
|
||||||
<option name="PASS_PARENT_ENVS" value="1" />
|
<option name="PASS_PARENT_ENVS" value="1" />
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
<option name="USE_MONO" value="0" />
|
<option name="USE_MONO" value="0" />
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
|
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
|
||||||
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
|
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
|
||||||
<option name="PROJECT_KIND" value="DotNetCore" />
|
<option name="PROJECT_KIND" value="DotNetCore" />
|
||||||
<option name="PROJECT_TFM" value="net5.0" />
|
<option name="PROJECT_TFM" value="net8.0" />
|
||||||
<method v="2">
|
<method v="2">
|
||||||
<option name="Build" />
|
<option name="Build" />
|
||||||
</method>
|
</method>
|
||||||
|
@@ -1,23 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="Minify" type="PythonConfigurationType" factoryName="Python">
|
|
||||||
<module name="rider.module" />
|
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
|
||||||
<option name="PARENT_ENVS" value="true" />
|
|
||||||
<envs>
|
|
||||||
<env name="PYTHONUNBUFFERED" value="1" />
|
|
||||||
</envs>
|
|
||||||
<option name="SDK_HOME" value="" />
|
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
||||||
<option name="IS_MODULE_SDK" value="false" />
|
|
||||||
<option name="ADD_CONTENT_ROOTS" value="false" />
|
|
||||||
<option name="ADD_SOURCE_ROOTS" value="false" />
|
|
||||||
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/minify.py" />
|
|
||||||
<option name="PARAMETERS" value="" />
|
|
||||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
|
||||||
<option name="EMULATE_TERMINAL" value="false" />
|
|
||||||
<option name="MODULE_MODE" value="false" />
|
|
||||||
<option name="REDIRECT_INPUT" value="false" />
|
|
||||||
<option name="INPUT_FILE" value="" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
@@ -2,12 +2,13 @@
|
|||||||
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"
|
xmlns:common="clr-namespace:DHT.Desktop.Common"
|
||||||
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
||||||
x:Class="DHT.Desktop.App">
|
x:Class="DHT.Desktop.App"
|
||||||
|
RequestedThemeVariant="Light">
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
|
|
||||||
<FluentTheme Mode="Light" />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml" />
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" />
|
||||||
|
|
||||||
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
|
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
|
||||||
<Setter Property="Cursor" Value="Hand" />
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
@@ -33,9 +34,6 @@
|
|||||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBox:disabled"><!-- TODO bug in Avalonia (https://github.com/AvaloniaUI/Avalonia/pull/7792) -->
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextControlForegroundDisabled}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBox:error DataValidationErrors">
|
<Style Selector="TextBox:error DataValidationErrors">
|
||||||
<Style.Resources>
|
<Style.Resources>
|
||||||
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
|
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
|
||||||
@@ -51,70 +49,11 @@
|
|||||||
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
|
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
|
<Style Selector="Expander">
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
<Setter Property="MinHeight" Value="40" />
|
||||||
<Setter Property="Template">
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<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>
|
</Style>
|
||||||
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
|
|
||||||
<Style.Animations>
|
|
||||||
<Animation FillMode="Both" Duration="0:0:0.0625">
|
|
||||||
<KeyFrame Cue="0%">
|
|
||||||
<Setter Property="RotateTransform.Angle" Value="180" />
|
|
||||||
</KeyFrame>
|
|
||||||
<KeyFrame Cue="100%">
|
|
||||||
<Setter Property="RotateTransform.Angle" Value="180" />
|
|
||||||
</KeyFrame>
|
|
||||||
</Animation>
|
|
||||||
</Style.Animations>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
|
|
||||||
<Style.Animations>
|
|
||||||
<Animation FillMode="Both" Duration="0:0:0.0625">
|
|
||||||
<KeyFrame Cue="0%">
|
|
||||||
<Setter Property="RotateTransform.Angle" Value="0" />
|
|
||||||
</KeyFrame>
|
|
||||||
<KeyFrame Cue="100%">
|
|
||||||
<Setter Property="RotateTransform.Angle" Value="0" />
|
|
||||||
</KeyFrame>
|
|
||||||
</Animation>
|
|
||||||
</Style.Animations>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
@@ -168,15 +107,29 @@
|
|||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
|
||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
|
||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
|
||||||
|
|
||||||
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
|
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
|
||||||
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
|
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
|
||||||
<SolidColorBrush x:Key="ExpanderBorderBrush" Color="#697DAB" />
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />
|
||||||
<SolidColorBrush x:Key="ExpanderBackground" Color="#697DAB" />
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="#697DAB" />
|
||||||
<SolidColorBrush x:Key="ExpanderForeground" Color="#FFFFFF" />
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPressed" Color="#697DAB" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushDisabled" Color="#697DAB" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackground" Color="#697DAB" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPointerOver" Color="#536794" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPressed" Color="#47587F" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackgroundDisabled" Color="#697DAB" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForeground" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForegroundPointerOver" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForegroundPressed" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForegroundDisabled" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronBackground" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronBackgroundPointerOver" Color="#536794" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronBackgroundPressed" Color="#47587F" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronBackgroundDisabled" Color="Transparent" />
|
||||||
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
|
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
|
||||||
<SolidColorBrush x:Key="ExpanderDropDownBorderBrush" Color="#697DAB" />
|
<SolidColorBrush x:Key="ExpanderChevronForegroundPointerOver" Color="#FFFFFF" />
|
||||||
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FFFFFF" />
|
<SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="#FFFFFF" />
|
||||||
|
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|
||||||
|
@@ -1,20 +1,21 @@
|
|||||||
|
using System;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using DHT.Desktop.Main;
|
using DHT.Desktop.Main;
|
||||||
|
|
||||||
namespace DHT.Desktop {
|
namespace DHT.Desktop;
|
||||||
sealed class App : Application {
|
|
||||||
public override void Initialize() {
|
sealed class App : Application {
|
||||||
AvaloniaXamlLoader.Load(this);
|
public override void Initialize() {
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted() {
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||||
|
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args ?? Array.Empty<string>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted() {
|
base.OnFrameworkInitializationCompleted();
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
|
||||||
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args));
|
|
||||||
}
|
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,64 +1,64 @@
|
|||||||
using System;
|
using System;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop {
|
namespace DHT.Desktop;
|
||||||
sealed class Arguments {
|
|
||||||
private static readonly Log Log = Log.ForType<Arguments>();
|
|
||||||
|
|
||||||
public static Arguments Empty => new(Array.Empty<string>());
|
sealed class Arguments {
|
||||||
|
private static readonly Log Log = Log.ForType<Arguments>();
|
||||||
|
|
||||||
public string? DatabaseFile { get; }
|
public static Arguments Empty => new(Array.Empty<string>());
|
||||||
public ushort? ServerPort { get; }
|
|
||||||
public string? ServerToken { get; }
|
|
||||||
|
|
||||||
public Arguments(string[] args) {
|
public string? DatabaseFile { get; }
|
||||||
for (int i = 0; i < args.Length; i++) {
|
public ushort? ServerPort { get; }
|
||||||
string key = args[i];
|
public string? ServerToken { get; }
|
||||||
|
|
||||||
switch (key) {
|
public Arguments(string[] args) {
|
||||||
case "-debug":
|
for (int i = 0; i < args.Length; i++) {
|
||||||
Log.IsDebugEnabled = true;
|
string key = args[i];
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
string value;
|
switch (key) {
|
||||||
|
case "-debug":
|
||||||
if (i == 0 && !key.StartsWith('-')) {
|
Log.IsDebugEnabled = true;
|
||||||
value = key;
|
|
||||||
key = "-db";
|
|
||||||
}
|
|
||||||
else if (i >= args.Length - 1) {
|
|
||||||
Log.Warn("Missing value for command line argument: " + key);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
value = args[++i];
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (key) {
|
string value;
|
||||||
case "-db":
|
|
||||||
DatabaseFile = value;
|
|
||||||
continue;
|
|
||||||
|
|
||||||
case "-port": {
|
if (i == 0 && !key.StartsWith('-')) {
|
||||||
if (ushort.TryParse(value, out var port)) {
|
value = key;
|
||||||
ServerPort = port;
|
key = "-db";
|
||||||
}
|
}
|
||||||
else {
|
else if (i >= args.Length - 1) {
|
||||||
Log.Warn("Invalid port number: " + value);
|
Log.Warn("Missing value for command line argument: " + key);
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
value = args[++i];
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
switch (key) {
|
||||||
|
case "-db":
|
||||||
|
DatabaseFile = value;
|
||||||
|
continue;
|
||||||
|
|
||||||
|
case "-port": {
|
||||||
|
if (ushort.TryParse(value, out var port)) {
|
||||||
|
ServerPort = port;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Log.Warn("Invalid port number: " + value);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "-token":
|
continue;
|
||||||
ServerToken = value;
|
|
||||||
continue;
|
|
||||||
|
|
||||||
default:
|
|
||||||
Log.Warn("Unknown command line argument: " + key);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "-token":
|
||||||
|
ServerToken = value;
|
||||||
|
continue;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Log.Warn("Unknown command line argument: " + key);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,44 +2,52 @@ using System;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Avalonia.Data.Converters;
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
sealed class BytesValueConverter : IValueConverter {
|
|
||||||
private static readonly string[] Units = {
|
|
||||||
"B",
|
|
||||||
"kB",
|
|
||||||
"MB",
|
|
||||||
"GB",
|
|
||||||
"TB"
|
|
||||||
};
|
|
||||||
|
|
||||||
private const int Scale = 1000;
|
sealed class BytesValueConverter : IValueConverter {
|
||||||
|
private sealed class Unit {
|
||||||
|
private readonly string label;
|
||||||
|
private readonly string numberFormat;
|
||||||
|
|
||||||
private static string Convert(ulong size) {
|
public Unit(string label, int decimalPlaces) {
|
||||||
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
this.label = label;
|
||||||
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
||||||
if (unit == 0) {
|
|
||||||
return string.Format(Program.Culture, "{0:n0}", size) + " " + Units[unit];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
double humanReadableSize = size / Math.Pow(Scale, unit);
|
|
||||||
return string.Format(Program.Culture, "{0:n0}", humanReadableSize) + " " + Units[unit];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public string Format(double size) {
|
||||||
if (value is long size and >= 0L) {
|
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
||||||
return Convert((ulong) size);
|
|
||||||
}
|
|
||||||
else if (value is ulong usize) {
|
|
||||||
return Convert(usize);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly Unit[] Units = {
|
||||||
|
new ("B", decimalPlaces: 0),
|
||||||
|
new ("kB", decimalPlaces: 0),
|
||||||
|
new ("MB", decimalPlaces: 1),
|
||||||
|
new ("GB", decimalPlaces: 1),
|
||||||
|
new ("TB", decimalPlaces: 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
private const int Scale = 1000;
|
||||||
|
|
||||||
|
private static string Convert(ulong size) {
|
||||||
|
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
||||||
|
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
||||||
|
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
|
if (value is long size and >= 0L) {
|
||||||
|
return Convert((ulong) size);
|
||||||
|
}
|
||||||
|
else if (value is ulong usize) {
|
||||||
|
return Convert(usize);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,66 +1,75 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using DHT.Desktop.Dialogs.File;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Exceptions;
|
using DHT.Server.Database.Exceptions;
|
||||||
using DHT.Server.Database.Sqlite;
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
static class DatabaseGui {
|
|
||||||
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
|
||||||
|
|
||||||
private const string DatabaseFileInitialName = "archive.dht";
|
static class DatabaseGui {
|
||||||
|
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
||||||
|
|
||||||
private static readonly List<FileDialogFilter> DatabaseFileDialogFilter = new() {
|
private const string DatabaseFileInitialName = "archive.dht";
|
||||||
new FileDialogFilter {
|
|
||||||
Name = "Discord History Tracker Database",
|
|
||||||
Extensions = { "dht" }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public static OpenFileDialog NewOpenDatabaseFileDialog() {
|
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = new List<FilePickerFileType> {
|
||||||
return new OpenFileDialog {
|
FileDialogs.CreateFilter("Discord History Tracker Database", new [] { "dht" })
|
||||||
Title = "Open Database File",
|
};
|
||||||
InitialFileName = DatabaseFileInitialName,
|
|
||||||
Filters = DatabaseFileDialogFilter
|
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
||||||
};
|
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
|
Title = "Open Database File",
|
||||||
|
FileTypeFilter = DatabaseFileDialogFilter,
|
||||||
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
|
AllowMultiple = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
||||||
|
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||||
|
Title = "Open or Create Database File",
|
||||||
|
FileTypeChoices = DatabaseFileDialogFilter,
|
||||||
|
SuggestedFileName = DatabaseFileInitialName,
|
||||||
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
|
ShowOverwritePrompt = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||||
|
var prevSynchronizationContext = SynchronizationContext.Current;
|
||||||
|
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||||
|
var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
||||||
|
SynchronizationContext.SetSynchronizationContext(prevSynchronizationContext);
|
||||||
|
|
||||||
|
IDatabaseFile? file = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler);
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SaveFileDialog NewOpenOrCreateDatabaseFileDialog() {
|
return file;
|
||||||
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) {
|
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
||||||
IDatabaseFile? file = null;
|
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.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
||||||
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
|
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.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
|
||||||
} 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.\n\nPlease ensure you have a backup of the database. 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.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,14 +2,14 @@ using System;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Avalonia.Data.Converters;
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
sealed class NumberValueConverter : IValueConverter {
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
|
||||||
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
sealed class NumberValueConverter : IValueConverter {
|
||||||
throw new NotSupportedException();
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
}
|
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,19 +1,19 @@
|
|||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
static class TextFormat {
|
|
||||||
public static string Format(this int number) {
|
|
||||||
return number.ToString("N0", Program.Culture);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Format(this long number) {
|
static class TextFormat {
|
||||||
return number.ToString("N0", Program.Culture);
|
public static string Format(this int number) {
|
||||||
}
|
return number.ToString("N0", Program.Culture);
|
||||||
|
}
|
||||||
|
|
||||||
public static string Pluralize(this int number, string singular) {
|
public static string Format(this long number) {
|
||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
return number.ToString("N0", Program.Culture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Pluralize(this long number, string singular) {
|
public static string Pluralize(this int number, string singular) {
|
||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,34 +1,33 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>DHT.Desktop</RootNamespace>
|
||||||
|
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
||||||
|
<PackageId>DiscordHistoryTracker</PackageId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
|
||||||
<RootNamespace>DHT.Desktop</RootNamespace>
|
|
||||||
<PackageId>DiscordHistoryTracker</PackageId>
|
|
||||||
<Authors>chylex</Authors>
|
|
||||||
<Company>DiscordHistoryTracker</Company>
|
|
||||||
<Product>DiscordHistoryTracker</Product>
|
|
||||||
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
||||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
|
|
||||||
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
|
|
||||||
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
|
||||||
<DebugSymbols>true</DebugSymbols>
|
|
||||||
<DebugType>none</DebugType>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="0.10.14" />
|
<PackageReference Include="Avalonia" Version="11.0.6" />
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.14" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.6" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.14" />
|
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.14" Condition=" '$(Configuration)' == 'Debug' " />
|
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
|
||||||
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Server\Server.csproj" />
|
<ProjectReference Include="..\Server\Server.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||||
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
|
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
|
||||||
@@ -36,22 +35,11 @@
|
|||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Resources/icon.ico" />
|
<AvaloniaResource Include="Resources/icon.ico" />
|
||||||
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js">
|
<EmbeddedResource Include="Resources/tracker-loader.js">
|
||||||
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
<LogicalName>tracker-loader.js</LogicalName>
|
||||||
<Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
|
||||||
<EmbeddedResource Include="../Resources/Tracker/scripts/**" Condition=" '$(Configuration)' == 'Debug' ">
|
|
||||||
<LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
|
||||||
<Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
|
||||||
<EmbeddedResource Include="../Resources/Tracker/styles/**">
|
|
||||||
<LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
|
||||||
<Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
<EmbeddedResource Include="../Resources/Viewer/**">
|
<EmbeddedResource Include="../Resources/Viewer/**">
|
||||||
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||||
@@ -59,19 +47,5 @@
|
|||||||
<Visible>false</Visible>
|
<Visible>false</Visible>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Target Name="MinifyResources" BeforeTargets="PrepareForBuild" Condition=" '$(Configuration)' == 'Release' ">
|
|
||||||
<PropertyGroup>
|
|
||||||
<MinifiedResourceDir>$(ProjectDir)bin/.res/scripts</MinifiedResourceDir>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<UpToDateCheckInput Include="$(ProjectDir)../Resources/Tracker/scripts/**" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/discord.js" LogicalName="Tracker\scripts\discord.js" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/dom.js" LogicalName="Tracker\scripts\dom.js" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/gui.js" LogicalName="Tracker\scripts\gui.js" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/settings.js" LogicalName="Tracker\scripts\settings.js" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/state.js" LogicalName="Tracker\scripts\state.js" Visible="false" />
|
|
||||||
</ItemGroup>
|
|
||||||
<RemoveDir Directories="$(ProjectDir)bin/.res/scripts" />
|
|
||||||
<Exec Command="python $(ProjectDir)../Resources/minify.py" WorkingDirectory="$(ProjectDir)../Resources" IgnoreExitCode="false" />
|
|
||||||
</Target>
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
|
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
|
||||||
|
x:DataType="namespace:CheckBoxDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
|
|
||||||
<StackPanel Margin="20">
|
<StackPanel Margin="20">
|
||||||
<ScrollViewer MaxHeight="400">
|
<ScrollViewer MaxHeight="400">
|
||||||
<ItemsRepeater Items="{Binding Items}">
|
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||||
<ItemsRepeater.ItemTemplate>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<CheckBox IsChecked="{Binding Checked}">
|
<CheckBox IsChecked="{Binding Checked}">
|
||||||
|
@@ -1,31 +1,21 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class CheckBoxDialog : Window {
|
|
||||||
public CheckBoxDialog() {
|
|
||||||
InitializeComponent();
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class CheckBoxDialog : Window {
|
||||||
}
|
public CheckBoxDialog() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Ok);
|
Close(DialogResult.OkCancel.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Cancel);
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -4,67 +4,67 @@ using System.ComponentModel;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
class CheckBoxDialogModel : BaseModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
|
|
||||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
class CheckBoxDialogModel : BaseModel {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
public IReadOnlyList<CheckBoxItem> Items {
|
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
||||||
get => items;
|
|
||||||
|
|
||||||
protected set {
|
public IReadOnlyList<CheckBoxItem> Items {
|
||||||
foreach (var item in items) {
|
get => items;
|
||||||
item.PropertyChanged -= OnItemPropertyChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
items = value;
|
protected set {
|
||||||
|
foreach (var item in items) {
|
||||||
foreach (var item in items) {
|
item.PropertyChanged -= OnItemPropertyChanged;
|
||||||
item.PropertyChanged += OnItemPropertyChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool pauseCheckEvents = false;
|
|
||||||
|
|
||||||
public bool AreAllSelected => Items.All(static item => item.Checked);
|
|
||||||
public bool AreNoneSelected => Items.All(static 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;
|
items = value;
|
||||||
UpdateBulkButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateBulkButtons() {
|
foreach (var item in items) {
|
||||||
OnPropertyChanged(nameof(AreAllSelected));
|
item.PropertyChanged += OnItemPropertyChanged;
|
||||||
OnPropertyChanged(nameof(AreNoneSelected));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
|
|
||||||
UpdateBulkButtons();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
private bool pauseCheckEvents = false;
|
||||||
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
|
||||||
|
|
||||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
public bool AreAllSelected => Items.All(static item => item.Checked);
|
||||||
|
public bool AreNoneSelected => Items.All(static item => !item.Checked);
|
||||||
|
|
||||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
public void SelectAll() => SetAllChecked(true);
|
||||||
this.Items = new List<CheckBoxItem<T>>(items);
|
public void SelectNone() => SetAllChecked(false);
|
||||||
base.Items = this.Items;
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||||
|
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
|
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
||||||
|
|
||||||
|
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||||
|
this.Items = new List<CheckBoxItem<T>>(items);
|
||||||
|
base.Items = this.Items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,24 +1,24 @@
|
|||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
class CheckBoxItem : BaseModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
public object? Item { get; init; } = null;
|
|
||||||
|
|
||||||
private bool isChecked = false;
|
class CheckBoxItem : BaseModel {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
public bool Checked {
|
private bool isChecked = false;
|
||||||
get => isChecked;
|
|
||||||
set => Change(ref isChecked, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
public bool Checked {
|
||||||
public new T Item { get; }
|
get => isChecked;
|
||||||
|
set => Change(ref isChecked, value);
|
||||||
public CheckBoxItem(T item) {
|
}
|
||||||
this.Item = item;
|
}
|
||||||
base.Item = item;
|
|
||||||
}
|
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||||
|
public new T Item { get; }
|
||||||
|
|
||||||
|
public CheckBoxItem(T item) {
|
||||||
|
this.Item = item;
|
||||||
|
base.Item = item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
36
app/Desktop/Dialogs/File/FileDialogs.cs
Normal file
36
app/Desktop/Dialogs/File/FileDialogs.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.File;
|
||||||
|
|
||||||
|
static class FileDialogs {
|
||||||
|
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
||||||
|
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
||||||
|
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
||||||
|
return new FilePickerFileType(name) {
|
||||||
|
Patterns = extensions.Select(static ext => "*." + ext).ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
||||||
|
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToLocalPath(this IStorageFile file) {
|
||||||
|
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
|
||||||
|
return files.Select(ToLocalPath).ToArray();
|
||||||
|
}
|
||||||
|
}
|
@@ -2,73 +2,73 @@ using System.Threading.Tasks;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
static class Dialog {
|
|
||||||
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 {
|
static class Dialog {
|
||||||
DataContext = new MessageDialogModel {
|
public static async Task ShowOk(Window owner, string title, string message) {
|
||||||
Title = title,
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
Message = message,
|
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
||||||
IsOkVisible = true
|
return;
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All>(owner);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
await new MessageDialog {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
DataContext = new MessageDialogModel {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
IsOkVisible = true
|
||||||
}
|
}
|
||||||
|
}.ShowDialog<DialogResult.All>(owner);
|
||||||
|
}
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
||||||
DataContext = new MessageDialogModel {
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
Title = title,
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
||||||
Message = message,
|
|
||||||
IsOkVisible = true,
|
|
||||||
IsCancelVisible = true
|
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
|
||||||
|
|
||||||
return result.ToOkCancel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
var result = await new MessageDialog {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
DataContext = new MessageDialogModel {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
IsOkVisible = true,
|
||||||
|
IsCancelVisible = true
|
||||||
}
|
}
|
||||||
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
return result.ToOkCancel();
|
||||||
DataContext = new MessageDialogModel {
|
}
|
||||||
Title = title,
|
|
||||||
Message = message,
|
|
||||||
IsYesVisible = true,
|
|
||||||
IsNoVisible = true
|
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
|
||||||
|
|
||||||
return result.ToYesNo();
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
var result = await new MessageDialog {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
DataContext = new MessageDialogModel {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
IsYesVisible = true,
|
||||||
|
IsNoVisible = true
|
||||||
}
|
}
|
||||||
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
return result.ToYesNo();
|
||||||
DataContext = new MessageDialogModel {
|
}
|
||||||
Title = title,
|
|
||||||
Message = message,
|
|
||||||
IsYesVisible = true,
|
|
||||||
IsNoVisible = true,
|
|
||||||
IsCancelVisible = true
|
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
|
||||||
|
|
||||||
return result.ToYesNoCancel();
|
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 {
|
||||||
|
DataContext = new MessageDialogModel {
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
IsYesVisible = true,
|
||||||
|
IsNoVisible = true,
|
||||||
|
IsCancelVisible = true
|
||||||
|
}
|
||||||
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
|
return result.ToYesNoCancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,59 +1,59 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
static class DialogResult {
|
|
||||||
public enum All {
|
|
||||||
Ok,
|
|
||||||
Yes,
|
|
||||||
No,
|
|
||||||
Cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum OkCancel {
|
static class DialogResult {
|
||||||
Closed,
|
public enum All {
|
||||||
Ok,
|
Ok,
|
||||||
Cancel
|
Yes,
|
||||||
}
|
No,
|
||||||
|
Cancel
|
||||||
|
}
|
||||||
|
|
||||||
public enum YesNo {
|
public enum OkCancel {
|
||||||
Closed,
|
Closed,
|
||||||
Yes,
|
Ok,
|
||||||
No
|
Cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum YesNoCancel {
|
public enum YesNo {
|
||||||
Closed,
|
Closed,
|
||||||
Yes,
|
Yes,
|
||||||
No,
|
No
|
||||||
Cancel
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public static OkCancel ToOkCancel(this All? result) {
|
public enum YesNoCancel {
|
||||||
return result switch {
|
Closed,
|
||||||
null => OkCancel.Closed,
|
Yes,
|
||||||
All.Ok => OkCancel.Ok,
|
No,
|
||||||
All.Cancel => OkCancel.Cancel,
|
Cancel
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static YesNo ToYesNo(this All? result) {
|
public static OkCancel ToOkCancel(this All? result) {
|
||||||
return result switch {
|
return result switch {
|
||||||
null => YesNo.Closed,
|
null => OkCancel.Closed,
|
||||||
All.Yes => YesNo.Yes,
|
All.Ok => OkCancel.Ok,
|
||||||
All.No => YesNo.No,
|
All.Cancel => OkCancel.Cancel,
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static YesNoCancel ToYesNoCancel(this All? result) {
|
public static YesNo ToYesNo(this All? result) {
|
||||||
return result switch {
|
return result switch {
|
||||||
null => YesNoCancel.Closed,
|
null => YesNo.Closed,
|
||||||
All.Yes => YesNoCancel.Yes,
|
All.Yes => YesNo.Yes,
|
||||||
All.No => YesNoCancel.No,
|
All.No => YesNo.No,
|
||||||
All.Cancel => YesNoCancel.Cancel,
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
|
};
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
public static YesNoCancel ToYesNoCancel(this All? result) {
|
||||||
|
return result switch {
|
||||||
|
null => YesNoCancel.Closed,
|
||||||
|
All.Yes => YesNoCancel.Yes,
|
||||||
|
All.No => YesNoCancel.No,
|
||||||
|
All.Cancel => YesNoCancel.Cancel,
|
||||||
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
||||||
|
x:DataType="namespace:MessageDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
|
@@ -1,37 +1,28 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class MessageDialog : Window {
|
|
||||||
public MessageDialog() {
|
|
||||||
InitializeComponent();
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class MessageDialog : Window {
|
||||||
}
|
public MessageDialog() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Ok);
|
Close(DialogResult.All.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickYes(object? sender, RoutedEventArgs e) {
|
public void ClickYes(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Yes);
|
Close(DialogResult.All.Yes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickNo(object? sender, RoutedEventArgs e) {
|
public void ClickNo(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.No);
|
Close(DialogResult.All.No);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Cancel);
|
Close(DialogResult.All.Cancel);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
sealed class MessageDialogModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
public string Message { get; init; } = "";
|
|
||||||
|
|
||||||
public bool IsOkVisible { get; init; } = false;
|
sealed class MessageDialogModel {
|
||||||
public bool IsYesVisible { get; init; } = false;
|
public string Title { get; init; } = "";
|
||||||
public bool IsNoVisible { get; init; } = false;
|
public string Message { get; init; } = "";
|
||||||
public bool IsCancelVisible { get; init; } = false;
|
|
||||||
}
|
public bool IsOkVisible { get; init; } = false;
|
||||||
|
public bool IsYesVisible { get; init; } = false;
|
||||||
|
public bool IsNoVisible { get; init; } = false;
|
||||||
|
public bool IsCancelVisible { get; init; } = false;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress {
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
interface IProgressCallback {
|
|
||||||
Task Update(string message, int finishedItems, int totalItems);
|
interface IProgressCallback {
|
||||||
}
|
Task Update(string message, int finishedItems, int totalItems);
|
||||||
|
Task Hide();
|
||||||
}
|
}
|
||||||
|
@@ -5,9 +5,10 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
|
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
|
||||||
|
x:DataType="namespace:ProgressDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Opened="Loaded"
|
Opened="OnOpened"
|
||||||
Closing="OnClosing"
|
Closing="OnClosing"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
WindowStartupLocation="CenterOwner">
|
WindowStartupLocation="CenterOwner">
|
||||||
@@ -31,12 +32,18 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<StackPanel Margin="20">
|
<ItemsRepeater ItemsSource="{Binding Items}" Margin="0 10">
|
||||||
<DockPanel>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
<DataTemplate>
|
||||||
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
<StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
|
||||||
</DockPanel>
|
<DockPanel>
|
||||||
<ProgressBar Value="{Binding Progress}" />
|
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
||||||
</StackPanel>
|
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
||||||
|
</DockPanel>
|
||||||
|
<ProgressBar Value="{Binding Progress}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsRepeater.ItemTemplate>
|
||||||
|
</ItemsRepeater>
|
||||||
|
|
||||||
</Window>
|
</Window>
|
||||||
|
@@ -1,40 +1,37 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress {
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class ProgressDialog : Window {
|
|
||||||
private bool isFinished = false;
|
|
||||||
|
|
||||||
public ProgressDialog() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
InitializeComponent();
|
public sealed partial class ProgressDialog : Window {
|
||||||
#if DEBUG
|
private bool isFinished = false;
|
||||||
this.AttachDevTools();
|
private Task progressTask = Task.CompletedTask;
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
public ProgressDialog() {
|
||||||
AvaloniaXamlLoader.Load(this);
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClosing(object? sender, CancelEventArgs e) {
|
public void OnOpened(object? sender, EventArgs e) {
|
||||||
e.Cancel = !isFinished;
|
if (DataContext is ProgressDialogModel model) {
|
||||||
}
|
progressTask = Task.Run(model.StartTask);
|
||||||
|
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
|
e.Cancel = !isFinished;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFinished(Task task) {
|
||||||
|
isFinished = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShowProgressDialog(Window owner) {
|
||||||
|
await ShowDialog(owner);
|
||||||
|
await progressTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,65 +1,53 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress {
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
sealed class ProgressDialogModel : BaseModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
|
|
||||||
private string message = "";
|
sealed class ProgressDialogModel : BaseModel {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
public string Message {
|
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
||||||
get => message;
|
|
||||||
private set => Change(ref message, value);
|
private readonly TaskRunner? task;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public ProgressDialogModel() {}
|
||||||
|
|
||||||
|
public ProgressDialogModel(TaskRunner task, int progressItems = 1) {
|
||||||
|
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||||
|
this.task = task;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task StartTask() {
|
||||||
|
if (task != null) {
|
||||||
|
await task(Items.Select(static item => new Callback(item)).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
||||||
|
|
||||||
|
private sealed class Callback : IProgressCallback {
|
||||||
|
private readonly ProgressItem item;
|
||||||
|
|
||||||
|
public Callback(ProgressItem item) {
|
||||||
|
this.item = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string items = "";
|
public async Task Update(string message, int finishedItems, int totalItems) {
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||||
public string Items {
|
item.Message = message;
|
||||||
get => items;
|
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||||
private set => Change(ref items, value);
|
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private int progress = 0;
|
public Task Hide() {
|
||||||
|
return Update(string.Empty, 0, 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 sealed 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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
41
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
41
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
|
sealed class ProgressItem : BaseModel {
|
||||||
|
private bool isVisible = false;
|
||||||
|
|
||||||
|
public bool IsVisible {
|
||||||
|
get => isVisible;
|
||||||
|
private set {
|
||||||
|
Change(ref isVisible, value);
|
||||||
|
OnPropertyChanged(nameof(Opacity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||||
|
|
||||||
|
private string message = "";
|
||||||
|
|
||||||
|
public string Message {
|
||||||
|
get => message;
|
||||||
|
set {
|
||||||
|
Change(ref message, value);
|
||||||
|
IsVisible = !string.IsNullOrEmpty(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string items = "";
|
||||||
|
|
||||||
|
public string Items {
|
||||||
|
get => items;
|
||||||
|
set => Change(ref items, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int progress = 0;
|
||||||
|
|
||||||
|
public int Progress {
|
||||||
|
get => progress;
|
||||||
|
set => Change(ref progress, value);
|
||||||
|
}
|
||||||
|
}
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
||||||
|
x:DataType="namespace:TextBoxDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
<ScrollViewer MaxHeight="400">
|
<ScrollViewer MaxHeight="400">
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
|
||||||
<ItemsRepeater Items="{Binding Items}">
|
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||||
<ItemsRepeater.ItemTemplate>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<DockPanel Margin="0 5 25 0">
|
<DockPanel Margin="0 5 25 0">
|
||||||
|
@@ -1,31 +1,21 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox {
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class TextBoxDialog : Window {
|
|
||||||
public TextBoxDialog() {
|
|
||||||
InitializeComponent();
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class TextBoxDialog : Window {
|
||||||
}
|
public TextBoxDialog() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Ok);
|
Close(DialogResult.OkCancel.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Cancel);
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -4,44 +4,44 @@ using System.ComponentModel;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox {
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
class TextBoxDialogModel : BaseModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
public string Description { get; init; } = "";
|
|
||||||
|
|
||||||
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
|
class TextBoxDialogModel : BaseModel {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
public IReadOnlyList<TextBoxItem> Items {
|
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
|
||||||
get => items;
|
|
||||||
|
|
||||||
protected set {
|
public IReadOnlyList<TextBoxItem> Items {
|
||||||
foreach (var item in items) {
|
get => items;
|
||||||
item.ErrorsChanged -= OnItemErrorsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
items = value;
|
protected set {
|
||||||
|
foreach (var item in items) {
|
||||||
|
item.ErrorsChanged -= OnItemErrorsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var item in items) {
|
items = value;
|
||||||
item.ErrorsChanged += OnItemErrorsChanged;
|
|
||||||
}
|
foreach (var item in items) {
|
||||||
|
item.ErrorsChanged += OnItemErrorsChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
|
||||||
|
|
||||||
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
|
||||||
OnPropertyChanged(nameof(HasErrors));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
||||||
|
|
||||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||||
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
OnPropertyChanged(nameof(HasErrors));
|
||||||
|
}
|
||||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
}
|
||||||
|
|
||||||
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||||
this.Items = new List<TextBoxItem<T>>(items);
|
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||||
base.Items = this.Items;
|
|
||||||
}
|
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||||
|
|
||||||
|
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
||||||
|
this.Items = new List<TextBoxItem<T>>(items);
|
||||||
|
base.Items = this.Items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,40 +3,40 @@ using System.Collections;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox {
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
|
||||||
public string Title { get; init; } = "";
|
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
||||||
public object? Item { get; init; } = null;
|
public string Title { get; init; } = "";
|
||||||
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
||||||
public bool IsValid => ValidityCheck(Value);
|
public bool IsValid => ValidityCheck(Value);
|
||||||
|
|
||||||
private string value = string.Empty;
|
private string value = string.Empty;
|
||||||
|
|
||||||
public string Value {
|
public string Value {
|
||||||
get => this.value;
|
get => this.value;
|
||||||
set {
|
set {
|
||||||
Change(ref this.value, value);
|
Change(ref this.value, value);
|
||||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable GetErrors(string? propertyName) {
|
|
||||||
if (propertyName == nameof(Value) && !IsValid) {
|
|
||||||
yield return string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasErrors => !IsValid;
|
|
||||||
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class TextBoxItem<T> : TextBoxItem {
|
public IEnumerable GetErrors(string? propertyName) {
|
||||||
public new T Item { get; }
|
if (propertyName == nameof(Value) && !IsValid) {
|
||||||
|
yield return string.Empty;
|
||||||
public TextBoxItem(T item) {
|
|
||||||
this.Item = item;
|
|
||||||
base.Item = item;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasErrors => !IsValid;
|
||||||
|
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class TextBoxItem<T> : TextBoxItem {
|
||||||
|
public new T Item { get; }
|
||||||
|
|
||||||
|
public TextBoxItem(T item) {
|
||||||
|
this.Item = item;
|
||||||
|
base.Item = item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,119 +1,121 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using static System.Environment.SpecialFolder;
|
using static System.Environment.SpecialFolder;
|
||||||
using static System.Environment.SpecialFolderOption;
|
using static System.Environment.SpecialFolderOption;
|
||||||
|
|
||||||
namespace DHT.Desktop.Discord {
|
namespace DHT.Desktop.Discord;
|
||||||
static class DiscordAppSettings {
|
|
||||||
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
|
||||||
|
|
||||||
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
static class DiscordAppSettings {
|
||||||
|
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
||||||
|
|
||||||
public static string JsonFilePath { get; }
|
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
||||||
private static string JsonBackupFilePath { get; }
|
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
public static string JsonFilePath { get; }
|
||||||
static DiscordAppSettings() {
|
private static string JsonBackupFilePath { get; }
|
||||||
string rootFolder;
|
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
static DiscordAppSettings() {
|
||||||
}
|
string rootFolder;
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
|
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(UserProfile, DoNotVerify), "Library", "Application Support", "Discord");
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
JsonBackupFilePath = JsonFilePath + ".bak";
|
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
|
||||||
|
rootFolder = Path.Combine(Environment.GetFolderPath(UserProfile, DoNotVerify), "Library", "Application Support", "Discord");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<bool?> AreDevToolsEnabled() {
|
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
||||||
try {
|
JsonBackupFilePath = JsonFilePath + ".bak";
|
||||||
return AreDevToolsEnabled(await ReadSettingsJson());
|
}
|
||||||
} catch (Exception) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) {
|
public static async Task<bool?> AreDevToolsEnabled() {
|
||||||
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True };
|
try {
|
||||||
}
|
return AreDevToolsEnabled(await ReadSettingsJson());
|
||||||
|
} catch (Exception e) {
|
||||||
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
Log.Error("Cannot read settings file.");
|
||||||
Dictionary<string, object?> json;
|
Log.Error(e);
|
||||||
|
return null;
|
||||||
try {
|
|
||||||
json = await ReadSettingsJson();
|
|
||||||
} catch (FileNotFoundException) {
|
|
||||||
return SettingsJsonResult.FileNotFound;
|
|
||||||
} catch (JsonException) {
|
|
||||||
return SettingsJsonResult.InvalidJson;
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error(e);
|
|
||||||
return SettingsJsonResult.ReadError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enable == AreDevToolsEnabled(json)) {
|
|
||||||
return SettingsJsonResult.AlreadySet;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enable) {
|
|
||||||
json[JsonKeyDevTools] = true;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
json.Remove(JsonKeyDevTools);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!File.Exists(JsonBackupFilePath)) {
|
|
||||||
File.Copy(JsonFilePath, JsonBackupFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await WriteSettingsJson(json);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error("An error occurred when writing settings file.");
|
|
||||||
Log.Error(e);
|
|
||||||
|
|
||||||
if (File.Exists(JsonBackupFilePath)) {
|
|
||||||
try {
|
|
||||||
File.Move(JsonBackupFilePath, JsonFilePath, true);
|
|
||||||
Log.Info("Restored settings file from backup.");
|
|
||||||
} catch (Exception e2) {
|
|
||||||
Log.Error("Cannot restore settings file from backup.");
|
|
||||||
Log.Error(e2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SettingsJsonResult.WriteError;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
File.Delete(JsonBackupFilePath);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error("Cannot delete backup file.");
|
|
||||||
Log.Error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SettingsJsonResult.Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
|
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
|
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
|
||||||
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool AreDevToolsEnabled(JsonObject json) {
|
||||||
|
return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
||||||
|
JsonObject json;
|
||||||
|
|
||||||
|
try {
|
||||||
|
json = await ReadSettingsJson();
|
||||||
|
} catch (FileNotFoundException) {
|
||||||
|
return SettingsJsonResult.FileNotFound;
|
||||||
|
} catch (JsonException) {
|
||||||
|
return SettingsJsonResult.InvalidJson;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
return SettingsJsonResult.ReadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enable == AreDevToolsEnabled(json)) {
|
||||||
|
return SettingsJsonResult.AlreadySet;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enable) {
|
||||||
|
json[JsonKeyDevTools] = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
json.Remove(JsonKeyDevTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!File.Exists(JsonBackupFilePath)) {
|
||||||
|
File.Copy(JsonFilePath, JsonBackupFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteSettingsJson(json);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error("An error occurred when writing settings file.");
|
||||||
|
Log.Error(e);
|
||||||
|
|
||||||
|
if (File.Exists(JsonBackupFilePath)) {
|
||||||
|
try {
|
||||||
|
File.Move(JsonBackupFilePath, JsonFilePath, true);
|
||||||
|
Log.Info("Restored settings file from backup.");
|
||||||
|
} catch (Exception e2) {
|
||||||
|
Log.Error("Cannot restore settings file from backup.");
|
||||||
|
Log.Error(e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SettingsJsonResult.WriteError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File.Delete(JsonBackupFilePath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error("Cannot delete backup file.");
|
||||||
|
Log.Error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SettingsJsonResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonObject> ReadSettingsJson() {
|
||||||
|
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteSettingsJson(JsonObject json) {
|
||||||
|
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
8
app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
Normal file
8
app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Discord;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
||||||
|
[JsonSerializable(typeof(JsonObject))]
|
||||||
|
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {}
|
@@ -1,10 +1,10 @@
|
|||||||
namespace DHT.Desktop.Discord {
|
namespace DHT.Desktop.Discord;
|
||||||
enum SettingsJsonResult {
|
|
||||||
Success,
|
enum SettingsJsonResult {
|
||||||
AlreadySet,
|
Success,
|
||||||
FileNotFound,
|
AlreadySet,
|
||||||
ReadError,
|
FileNotFound,
|
||||||
InvalidJson,
|
ReadError,
|
||||||
WriteError
|
InvalidJson,
|
||||||
}
|
WriteError
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||||
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
|
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
|
||||||
x:Class="DHT.Desktop.Main.AboutWindow"
|
x:Class="DHT.Desktop.Main.AboutWindow"
|
||||||
|
x:DataType="main:AboutWindowModel"
|
||||||
Title="About Discord History Tracker"
|
Title="About Discord History Tracker"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="480" Height="295" CanResize="False"
|
Width="480" Height="295" CanResize="False"
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
|
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
|
||||||
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
|
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Grid.Column="0">.NET 5</TextBlock>
|
<TextBlock Grid.Row="2" Grid.Column="0">.NET 8</TextBlock>
|
||||||
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
|
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
|
||||||
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
||||||
|
|
||||||
|
@@ -1,20 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class AboutWindow : Window {
|
|
||||||
public AboutWindow() {
|
|
||||||
InitializeComponent();
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class AboutWindow : Window {
|
||||||
}
|
public AboutWindow() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,33 +1,33 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
sealed class AboutWindowModel {
|
|
||||||
public void ShowOfficialWebsite() {
|
|
||||||
OpenUrl("https://dht.chylex.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowSourceCode() {
|
sealed class AboutWindowModel {
|
||||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
public void ShowOfficialWebsite() {
|
||||||
}
|
OpenUrl("https://dht.chylex.com");
|
||||||
|
}
|
||||||
|
|
||||||
public void ShowLibraryAvalonia() {
|
public void ShowSourceCode() {
|
||||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibrarySqlite() {
|
public void ShowLibraryAvalonia() {
|
||||||
OpenUrl("https://www.sqlite.org");
|
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibrarySqliteAdoNet() {
|
public void ShowLibrarySqlite() {
|
||||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
OpenUrl("https://www.sqlite.org");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibraryNetCore() {
|
public void ShowLibrarySqliteAdoNet() {
|
||||||
OpenUrl("https://github.com/dotnet/core");
|
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OpenUrl(string url) {
|
public void ShowLibraryNetCore() {
|
||||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
OpenUrl("https://github.com/dotnet/core");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void OpenUrl(string url) {
|
||||||
|
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel">
|
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"
|
||||||
|
x:DataType="controls:AttachmentFilterPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:AttachmentFilterPanelModel />
|
<controls:AttachmentFilterPanelModel />
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
|
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
|
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
|
||||||
<ComboBox IsEnabled="{Binding LimitSize}" Items="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
|
<ComboBox IsEnabled="{Binding LimitSize}" ItemsSource="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock Text="{Binding Name}" />
|
<TextBlock Text="{Binding Name}" />
|
||||||
|
@@ -1,17 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class AttachmentFilterPanel : UserControl {
|
|
||||||
public AttachmentFilterPanel() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class AttachmentFilterPanel : UserControl {
|
||||||
}
|
public AttachmentFilterPanel() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -7,123 +7,123 @@ using DHT.Server.Database;
|
|||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
|
||||||
public sealed record Unit(string Name, uint Scale);
|
|
||||||
|
|
||||||
private static readonly Unit[] AllUnits = {
|
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
||||||
new ("B", 1),
|
public sealed record Unit(string Name, uint Scale);
|
||||||
new ("kB", 1024),
|
|
||||||
new ("MB", 1024 * 1024)
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly HashSet<string> FilterProperties = new () {
|
private static readonly Unit[] AllUnits = {
|
||||||
nameof(LimitSize),
|
new ("B", 1),
|
||||||
nameof(MaximumSize),
|
new ("kB", 1024),
|
||||||
nameof(MaximumSizeUnit)
|
new ("MB", 1024 * 1024)
|
||||||
};
|
};
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
private static readonly HashSet<string> FilterProperties = new () {
|
||||||
|
nameof(LimitSize),
|
||||||
|
nameof(MaximumSize),
|
||||||
|
nameof(MaximumSizeUnit)
|
||||||
|
};
|
||||||
|
|
||||||
private bool limitSize = false;
|
public string FilterStatisticsText { get; private set; } = "";
|
||||||
private ulong maximumSize = 0L;
|
|
||||||
private Unit maximumSizeUnit = AllUnits[0];
|
|
||||||
|
|
||||||
public bool LimitSize {
|
private bool limitSize = false;
|
||||||
get => limitSize;
|
private ulong maximumSize = 0L;
|
||||||
set => Change(ref limitSize, value);
|
private Unit maximumSizeUnit = AllUnits[0];
|
||||||
}
|
|
||||||
|
|
||||||
public ulong MaximumSize {
|
public bool LimitSize {
|
||||||
get => maximumSize;
|
get => limitSize;
|
||||||
set => Change(ref maximumSize, value);
|
set => Change(ref limitSize, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Unit MaximumSizeUnit {
|
public ulong MaximumSize {
|
||||||
get => maximumSizeUnit;
|
get => maximumSize;
|
||||||
set => Change(ref maximumSizeUnit, value);
|
set => Change(ref maximumSize, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<Unit> Units => AllUnits;
|
public Unit MaximumSizeUnit {
|
||||||
|
get => maximumSizeUnit;
|
||||||
|
set => Change(ref maximumSizeUnit, value);
|
||||||
|
}
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
public IEnumerable<Unit> Units => AllUnits;
|
||||||
private readonly string verb;
|
|
||||||
|
|
||||||
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
|
private readonly IDatabaseFile db;
|
||||||
private long? matchingAttachmentCount;
|
private readonly string verb;
|
||||||
private long? totalAttachmentCount;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
|
||||||
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {}
|
private long? matchingAttachmentCount;
|
||||||
|
private long? totalAttachmentCount;
|
||||||
|
|
||||||
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") {
|
[Obsolete("Designer")]
|
||||||
this.db = db;
|
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {}
|
||||||
this.verb = verb;
|
|
||||||
|
|
||||||
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
|
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") {
|
||||||
|
this.db = db;
|
||||||
|
this.verb = verb;
|
||||||
|
|
||||||
|
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
|
||||||
|
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
|
||||||
|
PropertyChanged += OnPropertyChanged;
|
||||||
|
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
|
||||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
|
||||||
totalAttachmentCount = db.Statistics.TotalAttachments;
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
|
||||||
var filter = CreateFilter();
|
|
||||||
if (filter.IsEmpty) {
|
|
||||||
matchingAttachmentCountComputer.Cancel();
|
|
||||||
matchingAttachmentCount = totalAttachmentCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
matchingAttachmentCount = null;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetAttachmentCounts(long matchingAttachmentCount) {
|
|
||||||
this.matchingAttachmentCount = matchingAttachmentCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatisticsText() {
|
|
||||||
var matchingAttachmentCountStr = matchingAttachmentCount?.Format() ?? "(...)";
|
|
||||||
var totalAttachmentCountStr = totalAttachmentCount?.Format() ?? "(...)";
|
|
||||||
|
|
||||||
FilterStatisticsText = verb + " " + matchingAttachmentCountStr + " out of " + totalAttachmentCountStr + " attachment" + (totalAttachmentCount is null or 1 ? "." : "s.");
|
|
||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
|
||||||
}
|
|
||||||
|
|
||||||
public AttachmentFilter CreateFilter() {
|
|
||||||
AttachmentFilter filter = new();
|
|
||||||
|
|
||||||
if (LimitSize) {
|
|
||||||
try {
|
|
||||||
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
|
||||||
} catch (ArithmeticException) {
|
|
||||||
// set no size limit, because the overflown size is larger than any file could possibly be
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
||||||
|
totalAttachmentCount = db.Statistics.TotalAttachments;
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatistics() {
|
||||||
|
var filter = CreateFilter();
|
||||||
|
if (filter.IsEmpty) {
|
||||||
|
matchingAttachmentCountComputer.Cancel();
|
||||||
|
matchingAttachmentCount = totalAttachmentCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
matchingAttachmentCount = null;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetAttachmentCounts(long matchingAttachmentCount) {
|
||||||
|
this.matchingAttachmentCount = matchingAttachmentCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatisticsText() {
|
||||||
|
var matchingAttachmentCountStr = matchingAttachmentCount?.Format() ?? "(...)";
|
||||||
|
var totalAttachmentCountStr = totalAttachmentCount?.Format() ?? "(...)";
|
||||||
|
|
||||||
|
FilterStatisticsText = verb + " " + matchingAttachmentCountStr + " out of " + totalAttachmentCountStr + " attachment" + (totalAttachmentCount is null or 1 ? "." : "s.");
|
||||||
|
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||||
|
}
|
||||||
|
|
||||||
|
public AttachmentFilter CreateFilter() {
|
||||||
|
AttachmentFilter filter = new();
|
||||||
|
|
||||||
|
if (LimitSize) {
|
||||||
|
try {
|
||||||
|
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||||
|
} catch (ArithmeticException) {
|
||||||
|
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel">
|
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
|
||||||
|
x:DataType="controls:MessageFilterPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:MessageFilterPanelModel />
|
<controls:MessageFilterPanelModel />
|
||||||
|
@@ -1,34 +1,26 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class MessageFilterPanel : UserControl {
|
|
||||||
private CalendarDatePicker StartDatePicker => this.FindControl<CalendarDatePicker>("StartDatePicker");
|
|
||||||
private CalendarDatePicker EndDatePicker => this.FindControl<CalendarDatePicker>("EndDatePicker");
|
|
||||||
|
|
||||||
public MessageFilterPanel() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
InitializeComponent();
|
public sealed partial class MessageFilterPanel : UserControl {
|
||||||
|
public MessageFilterPanel() {
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
||||||
AvaloniaXamlLoader.Load(this);
|
if (DataContext is MessageFilterPanelModel model) {
|
||||||
|
model.StartDate = StartDatePicker.SelectedDate;
|
||||||
var culture = Program.Culture;
|
model.EndDate = EndDatePicker.SelectedDate;
|
||||||
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 MessageFilterPanelModel model) {
|
|
||||||
model.StartDate = StartDatePicker.SelectedDate;
|
|
||||||
model.EndDate = EndDatePicker.SelectedDate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,272 +14,272 @@ using DHT.Server.Database;
|
|||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|
||||||
private static readonly HashSet<string> FilterProperties = new () {
|
|
||||||
nameof(FilterByDate),
|
|
||||||
nameof(StartDate),
|
|
||||||
nameof(EndDate),
|
|
||||||
nameof(FilterByChannel),
|
|
||||||
nameof(IncludedChannels),
|
|
||||||
nameof(FilterByUser),
|
|
||||||
nameof(IncludedUsers)
|
|
||||||
};
|
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
||||||
|
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 string FilterStatisticsText { get; private set; } = "";
|
||||||
|
|
||||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
||||||
|
|
||||||
private bool filterByDate = false;
|
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||||
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 {
|
private bool filterByDate = false;
|
||||||
get => filterByDate;
|
private DateTime? startDate = null;
|
||||||
set => Change(ref filterByDate, value);
|
private DateTime? endDate = null;
|
||||||
}
|
private bool filterByChannel = false;
|
||||||
|
private HashSet<ulong>? includedChannels = null;
|
||||||
|
private bool filterByUser = false;
|
||||||
|
private HashSet<ulong>? includedUsers = null;
|
||||||
|
|
||||||
public DateTime? StartDate {
|
public bool FilterByDate {
|
||||||
get => startDate;
|
get => filterByDate;
|
||||||
set => Change(ref startDate, value);
|
set => Change(ref filterByDate, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public DateTime? EndDate {
|
public DateTime? StartDate {
|
||||||
get => endDate;
|
get => startDate;
|
||||||
set => Change(ref endDate, value);
|
set => Change(ref startDate, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool FilterByChannel {
|
public DateTime? EndDate {
|
||||||
get => filterByChannel;
|
get => endDate;
|
||||||
set => Change(ref filterByChannel, value);
|
set => Change(ref endDate, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public HashSet<ulong> IncludedChannels {
|
public bool FilterByChannel {
|
||||||
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
|
get => filterByChannel;
|
||||||
set => Change(ref includedChannels, value);
|
set => Change(ref filterByChannel, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool FilterByUser {
|
public HashSet<ulong> IncludedChannels {
|
||||||
get => filterByUser;
|
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
|
||||||
set => Change(ref filterByUser, value);
|
set => Change(ref includedChannels, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public HashSet<ulong> IncludedUsers {
|
public bool FilterByUser {
|
||||||
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
|
get => filterByUser;
|
||||||
set => Change(ref includedUsers, value);
|
set => Change(ref filterByUser, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string channelFilterLabel = "";
|
public HashSet<ulong> IncludedUsers {
|
||||||
|
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
|
||||||
|
set => Change(ref includedUsers, value);
|
||||||
|
}
|
||||||
|
|
||||||
public string ChannelFilterLabel {
|
private string channelFilterLabel = "";
|
||||||
get => channelFilterLabel;
|
|
||||||
set => Change(ref channelFilterLabel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string userFilterLabel = "";
|
public string ChannelFilterLabel {
|
||||||
|
get => channelFilterLabel;
|
||||||
|
set => Change(ref channelFilterLabel, value);
|
||||||
|
}
|
||||||
|
|
||||||
public string UserFilterLabel {
|
private string userFilterLabel = "";
|
||||||
get => userFilterLabel;
|
|
||||||
set => Change(ref userFilterLabel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Window window;
|
public string UserFilterLabel {
|
||||||
private readonly IDatabaseFile db;
|
get => userFilterLabel;
|
||||||
private readonly string verb;
|
set => Change(ref userFilterLabel, value);
|
||||||
|
}
|
||||||
|
|
||||||
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
|
private readonly Window window;
|
||||||
private long? exportedMessageCount;
|
private readonly IDatabaseFile db;
|
||||||
private long? totalMessageCount;
|
private readonly string verb;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
|
||||||
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
private long? exportedMessageCount;
|
||||||
|
private long? totalMessageCount;
|
||||||
|
|
||||||
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
|
[Obsolete("Designer")]
|
||||||
this.window = window;
|
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
this.db = db;
|
|
||||||
this.verb = verb;
|
|
||||||
|
|
||||||
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
|
|
||||||
|
|
||||||
|
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
|
||||||
|
this.window = window;
|
||||||
|
this.db = db;
|
||||||
|
this.verb = verb;
|
||||||
|
|
||||||
|
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
|
||||||
|
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
UpdateChannelFilterLabel();
|
||||||
|
UpdateUserFilterLabel();
|
||||||
|
|
||||||
|
PropertyChanged += OnPropertyChanged;
|
||||||
|
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
|
FilterPropertyChanged?.Invoke(sender, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
|
}
|
||||||
|
else if (e.PropertyName is nameof(FilterByUser) or nameof(IncludedUsers)) {
|
||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
|
||||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
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.TotalMessages)) {
|
|
||||||
totalMessageCount = db.Statistics.TotalMessages;
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
|
||||||
UpdateChannelFilterLabel();
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
|
||||||
UpdateUserFilterLabel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
|
||||||
var filter = CreateFilter();
|
|
||||||
if (filter.IsEmpty) {
|
|
||||||
exportedMessageCountComputer.Cancel();
|
|
||||||
exportedMessageCount = totalMessageCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
exportedMessageCount = null;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetExportedMessageCount(long exportedMessageCount) {
|
|
||||||
this.exportedMessageCount = exportedMessageCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatisticsText() {
|
|
||||||
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
|
||||||
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
|
||||||
|
|
||||||
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
|
|
||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OpenChannelFilterDialog() {
|
|
||||||
var servers = db.GetAllServers().ToDictionary(static 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?.AddDays(1).AddMilliseconds(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(static (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(static item => item.Item).ToHashSet() : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
||||||
|
totalMessageCount = db.Statistics.TotalMessages;
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
}
|
||||||
|
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
||||||
|
UpdateChannelFilterLabel();
|
||||||
|
}
|
||||||
|
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
||||||
|
UpdateUserFilterLabel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatistics() {
|
||||||
|
var filter = CreateFilter();
|
||||||
|
if (filter.IsEmpty) {
|
||||||
|
exportedMessageCountComputer.Cancel();
|
||||||
|
exportedMessageCount = totalMessageCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
exportedMessageCount = null;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetExportedMessageCount(long exportedMessageCount) {
|
||||||
|
this.exportedMessageCount = exportedMessageCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatisticsText() {
|
||||||
|
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
||||||
|
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
||||||
|
|
||||||
|
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
|
||||||
|
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void OpenChannelFilterDialog() {
|
||||||
|
var servers = db.GetAllServers().ToDictionary(static 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?.AddDays(1).AddMilliseconds(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(static (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(static item => item.Item).ToHashSet() : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel">
|
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
|
||||||
|
x:DataType="controls:ServerConfigurationPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:ServerConfigurationPanelModel />
|
<controls:ServerConfigurationPanelModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class ServerConfigurationPanel : UserControl {
|
|
||||||
public ServerConfigurationPanel() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class ServerConfigurationPanel : UserControl {
|
||||||
}
|
public ServerConfigurationPanel() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,111 +6,111 @@ using DHT.Server.Database;
|
|||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
|
||||||
private string inputPort;
|
|
||||||
|
|
||||||
public string InputPort {
|
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
||||||
get => inputPort;
|
private string inputPort;
|
||||||
set {
|
|
||||||
Change(ref inputPort, value);
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string inputToken;
|
public string InputPort {
|
||||||
|
get => inputPort;
|
||||||
public string InputToken {
|
set {
|
||||||
get => inputToken;
|
Change(ref inputPort, value);
|
||||||
set {
|
|
||||||
Change(ref inputToken, value);
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
|
||||||
|
|
||||||
private bool isToggleServerButtonEnabled = true;
|
|
||||||
|
|
||||||
public bool IsToggleServerButtonEnabled {
|
|
||||||
get => isToggleServerButtonEnabled;
|
|
||||||
set => Change(ref isToggleServerButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
|
|
||||||
|
|
||||||
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
|
|
||||||
|
|
||||||
private readonly Window window;
|
|
||||||
private readonly ServerManager serverManager;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
|
|
||||||
|
|
||||||
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
|
|
||||||
this.window = window;
|
|
||||||
this.serverManager = serverManager;
|
|
||||||
this.inputPort = ServerManager.Port.ToString();
|
|
||||||
this.inputToken = ServerManager.Token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Initialize() {
|
|
||||||
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
|
||||||
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
|
||||||
OnPropertyChanged(nameof(ToggleServerButtonText));
|
|
||||||
IsToggleServerButtonEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BeforeServerStart() {
|
|
||||||
IsToggleServerButtonEnabled = false;
|
|
||||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartServer() {
|
|
||||||
BeforeServerStart();
|
|
||||||
serverManager.Launch();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StopServer() {
|
|
||||||
IsToggleServerButtonEnabled = false;
|
|
||||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
|
||||||
serverManager.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClickToggleServerButton() {
|
|
||||||
if (serverManager.IsRunning) {
|
|
||||||
StopServer();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
StartServer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClickRandomizeToken() {
|
|
||||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickApplyChanges() {
|
|
||||||
if (!ushort.TryParse(InputPort, out ushort port)) {
|
|
||||||
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BeforeServerStart();
|
|
||||||
serverManager.Relaunch(port, InputToken);
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
OnPropertyChanged(nameof(HasMadeChanges));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void OnClickCancelChanges() {
|
private string inputToken;
|
||||||
InputPort = ServerManager.Port.ToString();
|
|
||||||
InputToken = ServerManager.Token;
|
public string InputToken {
|
||||||
|
get => inputToken;
|
||||||
|
set {
|
||||||
|
Change(ref inputToken, value);
|
||||||
|
OnPropertyChanged(nameof(HasMadeChanges));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
||||||
|
|
||||||
|
private bool isToggleServerButtonEnabled = true;
|
||||||
|
|
||||||
|
public bool IsToggleServerButtonEnabled {
|
||||||
|
get => isToggleServerButtonEnabled;
|
||||||
|
set => Change(ref isToggleServerButtonEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
|
||||||
|
|
||||||
|
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
private readonly ServerManager serverManager;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||||
|
|
||||||
|
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
|
||||||
|
this.window = window;
|
||||||
|
this.serverManager = serverManager;
|
||||||
|
this.inputPort = ServerManager.Port.ToString();
|
||||||
|
this.inputToken = ServerManager.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize() {
|
||||||
|
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
||||||
|
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
||||||
|
OnPropertyChanged(nameof(ToggleServerButtonText));
|
||||||
|
IsToggleServerButtonEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeforeServerStart() {
|
||||||
|
IsToggleServerButtonEnabled = false;
|
||||||
|
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartServer() {
|
||||||
|
BeforeServerStart();
|
||||||
|
serverManager.Launch();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopServer() {
|
||||||
|
IsToggleServerButtonEnabled = false;
|
||||||
|
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
||||||
|
serverManager.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClickToggleServerButton() {
|
||||||
|
if (serverManager.IsRunning) {
|
||||||
|
StopServer();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
StartServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClickRandomizeToken() {
|
||||||
|
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void OnClickApplyChanges() {
|
||||||
|
if (!ushort.TryParse(InputPort, out ushort port)) {
|
||||||
|
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BeforeServerStart();
|
||||||
|
serverManager.Relaunch(port, InputToken);
|
||||||
|
OnPropertyChanged(nameof(HasMadeChanges));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClickCancelChanges() {
|
||||||
|
InputPort = ServerManager.Port.ToString();
|
||||||
|
InputToken = ServerManager.Token;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.StatusBar">
|
x:Class="DHT.Desktop.Main.Controls.StatusBar"
|
||||||
|
x:DataType="controls:StatusBarModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:StatusBarModel />
|
<controls:StatusBarModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class StatusBar : UserControl {
|
|
||||||
public StatusBar() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class StatusBar : UserControl {
|
||||||
}
|
public StatusBar() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,44 +2,44 @@ using System;
|
|||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class StatusBarModel : BaseModel {
|
|
||||||
public DatabaseStatistics DatabaseStatistics { get; }
|
|
||||||
|
|
||||||
private Status status = Status.Stopped;
|
sealed class StatusBarModel : BaseModel {
|
||||||
|
public DatabaseStatistics DatabaseStatistics { get; }
|
||||||
|
|
||||||
public Status CurrentStatus {
|
private Status status = Status.Stopped;
|
||||||
get => status;
|
|
||||||
set {
|
|
||||||
status = value;
|
|
||||||
OnPropertyChanged(nameof(StatusText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string StatusText {
|
public Status CurrentStatus {
|
||||||
get {
|
get => status;
|
||||||
return CurrentStatus switch {
|
set {
|
||||||
Status.Starting => "STARTING",
|
status = value;
|
||||||
Status.Ready => "READY",
|
OnPropertyChanged(nameof(StatusText));
|
||||||
Status.Stopping => "STOPPING",
|
|
||||||
Status.Stopped => "STOPPED",
|
|
||||||
_ => ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public StatusBarModel() : this(new DatabaseStatistics()) {}
|
|
||||||
|
|
||||||
public StatusBarModel(DatabaseStatistics databaseStatistics) {
|
|
||||||
this.DatabaseStatistics = databaseStatistics;
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum Status {
|
|
||||||
Starting,
|
|
||||||
Ready,
|
|
||||||
Stopping,
|
|
||||||
Stopped
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string StatusText {
|
||||||
|
get {
|
||||||
|
return CurrentStatus switch {
|
||||||
|
Status.Starting => "STARTING",
|
||||||
|
Status.Ready => "READY",
|
||||||
|
Status.Stopping => "STOPPING",
|
||||||
|
Status.Stopped => "STOPPED",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public StatusBarModel() : this(new DatabaseStatistics()) {}
|
||||||
|
|
||||||
|
public StatusBarModel(DatabaseStatistics databaseStatistics) {
|
||||||
|
this.DatabaseStatistics = databaseStatistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
Starting,
|
||||||
|
Ready,
|
||||||
|
Stopping,
|
||||||
|
Stopped
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.MainWindow"
|
x:Class="DHT.Desktop.Main.MainWindow"
|
||||||
|
x:DataType="main:MainWindowModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="800" Height="500"
|
Width="800" Height="500"
|
||||||
|
@@ -1,44 +1,35 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
using DHT.Desktop.Main.Pages;
|
using DHT.Desktop.Main.Pages;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class MainWindow : Window {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
[UsedImplicitly]
|
public sealed partial class MainWindow : Window {
|
||||||
public MainWindow() {
|
[UsedImplicitly]
|
||||||
InitializeComponent(Arguments.Empty);
|
public MainWindow() {
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = new MainWindowModel(this, Arguments.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal MainWindow(Arguments args) {
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = new MainWindowModel(this, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClosed(object? sender, EventArgs e) {
|
||||||
|
if (DataContext is IDisposable disposable) {
|
||||||
|
disposable.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal MainWindow(Arguments args) {
|
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
||||||
InitializeComponent(args);
|
try {
|
||||||
}
|
File.Delete(temporaryFile);
|
||||||
|
} catch (Exception) {
|
||||||
private void InitializeComponent(Arguments args) {
|
// ignored
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
DataContext = new MainWindowModel(this, args);
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClosed(object? sender, EventArgs e) {
|
|
||||||
if (DataContext is IDisposable disposable) {
|
|
||||||
disposable.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
|
||||||
try {
|
|
||||||
File.Delete(temporaryFile);
|
|
||||||
} catch (Exception) {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,107 +10,107 @@ using DHT.Desktop.Server;
|
|||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
sealed class MainWindowModel : BaseModel, IDisposable {
|
|
||||||
private const string DefaultTitle = "Discord History Tracker";
|
|
||||||
|
|
||||||
public string Title { get; private set; } = DefaultTitle;
|
sealed class MainWindowModel : BaseModel, IDisposable {
|
||||||
|
private const string DefaultTitle = "Discord History Tracker";
|
||||||
|
|
||||||
public UserControl CurrentScreen { get; private set; }
|
public string Title { get; private set; } = DefaultTitle;
|
||||||
|
|
||||||
private readonly WelcomeScreen welcomeScreen;
|
|
||||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
|
||||||
|
|
||||||
private MainContentScreen? mainContentScreen;
|
public UserControl CurrentScreen { get; private set; }
|
||||||
private MainContentScreenModel? mainContentScreenModel;
|
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly WelcomeScreen welcomeScreen;
|
||||||
|
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||||
|
|
||||||
private IDatabaseFile? db;
|
private MainContentScreen? mainContentScreen;
|
||||||
|
private MainContentScreenModel? mainContentScreenModel;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly Window window;
|
||||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
|
||||||
|
|
||||||
public MainWindowModel(Window window, Arguments args) {
|
private IDatabaseFile? db;
|
||||||
this.window = window;
|
|
||||||
|
|
||||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
[Obsolete("Designer")]
|
||||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||||
CurrentScreen = welcomeScreen;
|
|
||||||
|
|
||||||
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
public MainWindowModel(Window window, Arguments args) {
|
||||||
|
this.window = window;
|
||||||
|
|
||||||
var dbFile = args.DatabaseFile;
|
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||||
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
CurrentScreen = welcomeScreen;
|
||||||
window.Opened -= OnWindowOpened;
|
|
||||||
|
|
||||||
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
|
||||||
await Task.Delay(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(dbFile)) {
|
var dbFile = args.DatabaseFile;
|
||||||
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||||
}
|
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
||||||
else {
|
window.Opened -= OnWindowOpened;
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
|
||||||
}
|
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
||||||
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
|
await Task.Delay(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Opened += OnWindowOpened;
|
if (File.Exists(dbFile)) {
|
||||||
}
|
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
||||||
|
|
||||||
if (args.ServerPort != null) {
|
|
||||||
ServerManager.Port = args.ServerPort.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.ServerToken != null) {
|
|
||||||
ServerManager.Token = args.ServerToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
|
|
||||||
if (mainContentScreenModel != null) {
|
|
||||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
|
||||||
mainContentScreenModel.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
db?.Dispose();
|
|
||||||
db = welcomeScreenModel.Db;
|
|
||||||
|
|
||||||
if (db == null) {
|
|
||||||
Title = DefaultTitle;
|
|
||||||
mainContentScreenModel = null;
|
|
||||||
mainContentScreen = null;
|
|
||||||
CurrentScreen = welcomeScreen;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
||||||
mainContentScreenModel = new MainContentScreenModel(window, db);
|
|
||||||
await mainContentScreenModel.Initialize();
|
|
||||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
|
||||||
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
|
||||||
CurrentScreen = mainContentScreen;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(CurrentScreen));
|
|
||||||
OnPropertyChanged(nameof(Title));
|
|
||||||
|
|
||||||
window.Focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.Opened += OnWindowOpened;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
if (args.ServerPort != null) {
|
||||||
welcomeScreenModel.CloseDatabase();
|
ServerManager.Port = args.ServerPort.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
if (args.ServerToken != null) {
|
||||||
welcomeScreenModel.Dispose();
|
ServerManager.Token = args.ServerToken;
|
||||||
mainContentScreenModel?.Dispose();
|
|
||||||
db?.Dispose();
|
|
||||||
db = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
|
||||||
|
if (mainContentScreenModel != null) {
|
||||||
|
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||||
|
mainContentScreenModel.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
db?.Dispose();
|
||||||
|
db = welcomeScreenModel.Db;
|
||||||
|
|
||||||
|
if (db == null) {
|
||||||
|
Title = DefaultTitle;
|
||||||
|
mainContentScreenModel = null;
|
||||||
|
mainContentScreen = null;
|
||||||
|
CurrentScreen = welcomeScreen;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
||||||
|
mainContentScreenModel = new MainContentScreenModel(window, db);
|
||||||
|
await mainContentScreenModel.Initialize();
|
||||||
|
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||||
|
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||||
|
CurrentScreen = mainContentScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CurrentScreen));
|
||||||
|
OnPropertyChanged(nameof(Title));
|
||||||
|
|
||||||
|
window.Focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||||
|
welcomeScreenModel.CloseDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
welcomeScreenModel.Dispose();
|
||||||
|
mainContentScreenModel?.Dispose();
|
||||||
|
db?.Dispose();
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
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.AdvancedPage">
|
x:Class="DHT.Desktop.Main.Pages.AdvancedPage"
|
||||||
|
x:DataType="pages:AdvancedPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:AdvancedPageModel />
|
<pages:AdvancedPageModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class AdvancedPage : UserControl {
|
|
||||||
public AdvancedPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class AdvancedPage : UserControl {
|
||||||
}
|
public AdvancedPage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,34 +6,34 @@ using DHT.Desktop.Server;
|
|||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
|
||||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
|
||||||
|
|
||||||
private readonly Window window;
|
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
||||||
private readonly IDatabaseFile db;
|
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly Window window;
|
||||||
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
|
private readonly IDatabaseFile db;
|
||||||
|
|
||||||
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
|
[Obsolete("Designer")]
|
||||||
this.window = window;
|
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||||
this.db = db;
|
|
||||||
|
|
||||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
|
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
|
||||||
}
|
this.window = window;
|
||||||
|
this.db = db;
|
||||||
|
|
||||||
public void Initialize() {
|
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
|
||||||
ServerConfigurationModel.Initialize();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
public void Initialize() {
|
||||||
ServerConfigurationModel.Dispose();
|
ServerConfigurationModel.Initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void VacuumDatabase() {
|
public void Dispose() {
|
||||||
db.Vacuum();
|
ServerConfigurationModel.Dispose();
|
||||||
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
|
}
|
||||||
}
|
|
||||||
|
public async void VacuumDatabase() {
|
||||||
|
db.Vacuum();
|
||||||
|
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
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.AttachmentsPage">
|
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
|
||||||
|
x:DataType="pages:AttachmentsPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:AttachmentsPageModel />
|
<pages:AttachmentsPageModel />
|
||||||
@@ -35,10 +36,10 @@
|
|||||||
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
|
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
|
||||||
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
|
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" />
|
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
|
||||||
<StackPanel Orientation="Vertical" Spacing="12">
|
<StackPanel Orientation="Vertical" Spacing="12">
|
||||||
<Expander Header="Download Status" IsExpanded="True">
|
<Expander Header="Download Status" IsExpanded="True">
|
||||||
<DataGrid Items="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
||||||
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class AttachmentsPage : UserControl {
|
|
||||||
public AttachmentsPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class AttachmentsPage : UserControl {
|
||||||
}
|
public AttachmentsPage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Threading;
|
||||||
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
@@ -11,193 +13,193 @@ using DHT.Server.Download;
|
|||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
|
||||||
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
|
||||||
DownloadStatus.Enqueued
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private bool isThreadDownloadButtonEnabled = true;
|
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
||||||
|
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
|
||||||
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
|
IncludeStatuses = new HashSet<DownloadStatus> {
|
||||||
|
DownloadStatus.Enqueued
|
||||||
public bool IsToggleDownloadButtonEnabled {
|
|
||||||
get => isThreadDownloadButtonEnabled;
|
|
||||||
set => Change(ref isThreadDownloadButtonEnabled, value);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
public string DownloadMessage { get; set; } = "";
|
private bool isThreadDownloadButtonEnabled = true;
|
||||||
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
|
|
||||||
|
|
||||||
public AttachmentFilterPanelModel FilterModel { get; }
|
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
|
||||||
|
|
||||||
private readonly StatisticsRow statisticsEnqueued = new ("Enqueued");
|
public bool IsToggleDownloadButtonEnabled {
|
||||||
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
get => isThreadDownloadButtonEnabled;
|
||||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
set => Change(ref isThreadDownloadButtonEnabled, value);
|
||||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
}
|
||||||
|
|
||||||
public List<StatisticsRow> StatisticsRows {
|
public string DownloadMessage { get; set; } = "";
|
||||||
get {
|
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
|
||||||
return new List<StatisticsRow> {
|
|
||||||
statisticsEnqueued,
|
|
||||||
statisticsDownloaded,
|
|
||||||
statisticsFailed,
|
|
||||||
statisticsSkipped
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsDownloading => downloadThread != null;
|
public AttachmentFilterPanelModel FilterModel { get; }
|
||||||
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
private readonly StatisticsRow statisticsEnqueued = new ("Enqueued");
|
||||||
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
|
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
||||||
private BackgroundDownloadThread? downloadThread;
|
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||||
|
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||||
|
|
||||||
private int doneItemsCount;
|
public List<StatisticsRow> StatisticsRows {
|
||||||
private int? allItemsCount;
|
get {
|
||||||
|
return new List<StatisticsRow> {
|
||||||
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {}
|
statisticsEnqueued,
|
||||||
|
statisticsDownloaded,
|
||||||
public AttachmentsPageModel(IDatabaseFile db) {
|
statisticsFailed,
|
||||||
this.db = db;
|
statisticsSkipped
|
||||||
this.FilterModel = new AttachmentFilterPanelModel(db);
|
|
||||||
|
|
||||||
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics);
|
|
||||||
this.downloadStatisticsComputer.Recompute();
|
|
||||||
|
|
||||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
|
|
||||||
FilterModel.Dispose();
|
|
||||||
DisposeDownloadThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
|
||||||
if (IsDownloading) {
|
|
||||||
EnqueueDownloadItems();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnqueueDownloadItems() {
|
|
||||||
var filter = FilterModel.CreateFilter();
|
|
||||||
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
|
||||||
db.EnqueueDownloadItems(filter);
|
|
||||||
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
|
||||||
var hadFailedDownloads = HasFailedDownloads;
|
|
||||||
|
|
||||||
statisticsEnqueued.Items = statusStatistics.EnqueuedCount;
|
|
||||||
statisticsEnqueued.Size = statusStatistics.EnqueuedSize;
|
|
||||||
|
|
||||||
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
|
||||||
statisticsDownloaded.Size = statusStatistics.SuccessfulSize;
|
|
||||||
|
|
||||||
statisticsFailed.Items = statusStatistics.FailedCount;
|
|
||||||
statisticsFailed.Size = statusStatistics.FailedSize;
|
|
||||||
|
|
||||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
|
||||||
statisticsSkipped.Size = statusStatistics.SkippedSize;
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(StatisticsRows));
|
|
||||||
|
|
||||||
if (hadFailedDownloads != HasFailedDownloads) {
|
|
||||||
OnPropertyChanged(nameof(HasFailedDownloads));
|
|
||||||
}
|
|
||||||
|
|
||||||
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateDownloadMessage() {
|
|
||||||
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(DownloadMessage));
|
|
||||||
OnPropertyChanged(nameof(DownloadProgress));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
|
|
||||||
++doneItemsCount;
|
|
||||||
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DownloadThreadOnOnServerStopped(object? sender, EventArgs e) {
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
IsToggleDownloadButtonEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClickToggleDownload() {
|
|
||||||
if (downloadThread == null) {
|
|
||||||
EnqueueDownloadItems();
|
|
||||||
downloadThread = new BackgroundDownloadThread(db);
|
|
||||||
downloadThread.OnItemFinished += DownloadThreadOnOnItemFinished;
|
|
||||||
downloadThread.OnServerStopped += DownloadThreadOnOnServerStopped;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
IsToggleDownloadButtonEnabled = false;
|
|
||||||
DisposeDownloadThread();
|
|
||||||
|
|
||||||
db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
|
|
||||||
|
|
||||||
doneItemsCount = 0;
|
|
||||||
allItemsCount = null;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
|
||||||
OnPropertyChanged(nameof(IsDownloading));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClickRetryFailedDownloads() {
|
|
||||||
var allExceptFailedFilter = new DownloadItemFilter {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
|
||||||
DownloadStatus.Enqueued,
|
|
||||||
DownloadStatus.Success
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
public bool IsDownloading => downloadThread != null;
|
||||||
|
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
||||||
|
|
||||||
|
private readonly IDatabaseFile db;
|
||||||
|
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
|
||||||
|
private BackgroundDownloadThread? downloadThread;
|
||||||
|
|
||||||
|
private int doneItemsCount;
|
||||||
|
private int? allItemsCount;
|
||||||
|
|
||||||
|
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
|
public AttachmentsPageModel(IDatabaseFile db) {
|
||||||
|
this.db = db;
|
||||||
|
this.FilterModel = new AttachmentFilterPanelModel(db);
|
||||||
|
|
||||||
|
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics);
|
||||||
|
this.downloadStatisticsComputer.Recompute();
|
||||||
|
|
||||||
|
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||||
|
|
||||||
|
FilterModel.Dispose();
|
||||||
|
DisposeDownloadThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
||||||
if (IsDownloading) {
|
if (IsDownloading) {
|
||||||
EnqueueDownloadItems();
|
EnqueueDownloadItems();
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
downloadStatisticsComputer.Recompute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
||||||
|
downloadStatisticsComputer.Recompute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnqueueDownloadItems() {
|
||||||
|
var filter = FilterModel.CreateFilter();
|
||||||
|
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
||||||
|
db.EnqueueDownloadItems(filter);
|
||||||
|
|
||||||
|
downloadStatisticsComputer.Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||||
|
var hadFailedDownloads = HasFailedDownloads;
|
||||||
|
|
||||||
|
statisticsEnqueued.Items = statusStatistics.EnqueuedCount;
|
||||||
|
statisticsEnqueued.Size = statusStatistics.EnqueuedSize;
|
||||||
|
|
||||||
|
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
||||||
|
statisticsDownloaded.Size = statusStatistics.SuccessfulSize;
|
||||||
|
|
||||||
|
statisticsFailed.Items = statusStatistics.FailedCount;
|
||||||
|
statisticsFailed.Size = statusStatistics.FailedSize;
|
||||||
|
|
||||||
|
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||||
|
statisticsSkipped.Size = statusStatistics.SkippedSize;
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(StatisticsRows));
|
||||||
|
|
||||||
|
if (hadFailedDownloads != HasFailedDownloads) {
|
||||||
|
OnPropertyChanged(nameof(HasFailedDownloads));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisposeDownloadThread() {
|
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
|
||||||
if (downloadThread != null) {
|
UpdateDownloadMessage();
|
||||||
downloadThread.OnItemFinished -= DownloadThreadOnOnItemFinished;
|
}
|
||||||
downloadThread.StopThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadThread = null;
|
private void UpdateDownloadMessage() {
|
||||||
|
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(DownloadMessage));
|
||||||
|
OnPropertyChanged(nameof(DownloadProgress));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
|
||||||
|
Interlocked.Increment(ref doneItemsCount);
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Invoke(UpdateDownloadMessage);
|
||||||
|
downloadStatisticsComputer.Recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DownloadThreadOnOnServerStopped(object? sender, EventArgs e) {
|
||||||
|
downloadStatisticsComputer.Recompute();
|
||||||
|
IsToggleDownloadButtonEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClickToggleDownload() {
|
||||||
|
if (downloadThread == null) {
|
||||||
|
EnqueueDownloadItems();
|
||||||
|
downloadThread = new BackgroundDownloadThread(db);
|
||||||
|
downloadThread.OnItemFinished += DownloadThreadOnOnItemFinished;
|
||||||
|
downloadThread.OnServerStopped += DownloadThreadOnOnServerStopped;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
IsToggleDownloadButtonEnabled = false;
|
||||||
|
DisposeDownloadThread();
|
||||||
|
|
||||||
|
db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
|
||||||
|
|
||||||
|
doneItemsCount = 0;
|
||||||
|
allItemsCount = null;
|
||||||
|
UpdateDownloadMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StatisticsRow {
|
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
||||||
public string State { get; }
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
public int Items { get; set; }
|
}
|
||||||
public ulong? Size { get; set; }
|
|
||||||
|
|
||||||
public StatisticsRow(string state) {
|
public void OnClickRetryFailedDownloads() {
|
||||||
State = state;
|
var allExceptFailedFilter = new DownloadItemFilter {
|
||||||
|
IncludeStatuses = new HashSet<DownloadStatus> {
|
||||||
|
DownloadStatus.Enqueued,
|
||||||
|
DownloadStatus.Success
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
||||||
|
|
||||||
|
if (IsDownloading) {
|
||||||
|
EnqueueDownloadItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeDownloadThread() {
|
||||||
|
if (downloadThread != null) {
|
||||||
|
downloadThread.OnItemFinished -= DownloadThreadOnOnItemFinished;
|
||||||
|
downloadThread.StopThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StatisticsRow {
|
||||||
|
public string State { get; }
|
||||||
|
public int Items { get; set; }
|
||||||
|
public ulong? Size { get; set; }
|
||||||
|
|
||||||
|
public StatisticsRow(string state) {
|
||||||
|
State = state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
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"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.DatabasePage">
|
x:Class="DHT.Desktop.Main.Pages.DatabasePage"
|
||||||
|
x:DataType="pages:DatabasePageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DatabasePageModel />
|
<pages:DatabasePageModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class DatabasePage : UserControl {
|
|
||||||
public DatabasePage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class DatabasePage : UserControl {
|
||||||
}
|
public DatabasePage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,242 +7,262 @@ using System.Text;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Desktop.Dialogs.File;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Dialogs.TextBox;
|
using DHT.Desktop.Dialogs.TextBox;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Import;
|
using DHT.Server.Database.Import;
|
||||||
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class DatabasePageModel : BaseModel {
|
|
||||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
|
||||||
|
|
||||||
public IDatabaseFile Db { get; }
|
sealed class DatabasePageModel : BaseModel {
|
||||||
|
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||||
|
|
||||||
public event EventHandler? DatabaseClosed;
|
public IDatabaseFile Db { get; }
|
||||||
|
|
||||||
private readonly Window window;
|
public event EventHandler? DatabaseClosed;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly Window window;
|
||||||
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public DatabasePageModel(Window window, IDatabaseFile db) {
|
[Obsolete("Designer")]
|
||||||
this.window = window;
|
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
this.Db = db;
|
|
||||||
|
public DatabasePageModel(Window window, IDatabaseFile db) {
|
||||||
|
this.window = window;
|
||||||
|
this.Db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void OpenDatabaseFolder() {
|
||||||
|
string file = Db.Path;
|
||||||
|
string? folder = Path.GetDirectoryName(file);
|
||||||
|
|
||||||
|
if (folder == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenDatabaseFolder() {
|
switch (Environment.OSVersion.Platform) {
|
||||||
string file = Db.Path;
|
case PlatformID.Win32NT:
|
||||||
string? folder = Path.GetDirectoryName(file);
|
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
||||||
|
break;
|
||||||
|
|
||||||
if (folder == null) {
|
case PlatformID.Unix:
|
||||||
return;
|
Process.Start("xdg-open", new string[] { folder });
|
||||||
}
|
break;
|
||||||
|
|
||||||
switch (Environment.OSVersion.Platform) {
|
case PlatformID.MacOSX:
|
||||||
case PlatformID.Win32NT:
|
Process.Start("open", new string[] { folder });
|
||||||
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
break;
|
||||||
break;
|
|
||||||
|
|
||||||
case PlatformID.Unix:
|
default:
|
||||||
Process.Start("xdg-open", new string[] { folder });
|
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlatformID.MacOSX:
|
|
||||||
Process.Start("open", new string[] { folder });
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CloseDatabase() {
|
|
||||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
|
||||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
|
||||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
|
|
||||||
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
|
||||||
|
|
||||||
if (db == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
target.AddFrom(db);
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
db.Dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void ImportLegacyArchive() {
|
|
||||||
var fileDialog = new OpenFileDialog {
|
|
||||||
Title = "Open Legacy DHT Archive",
|
|
||||||
Directory = Path.GetDirectoryName(Db.Path),
|
|
||||||
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 ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) {
|
|
||||||
Title = "Legacy Archive Import"
|
|
||||||
};
|
|
||||||
|
|
||||||
await progressDialog.ShowDialog(window);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
|
||||||
var fakeSnowflake = new FakeSnowflake();
|
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
|
||||||
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
|
|
||||||
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
|
||||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
|
||||||
Dictionary<DHT.Server.Data.Server, ulong>? result = await Dispatcher.UIThread.InvokeAsync(() => AskForServerIds(dialog, servers));
|
|
||||||
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
|
||||||
static bool IsValidSnowflake(string value) {
|
|
||||||
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
|
||||||
|
|
||||||
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
|
||||||
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
|
||||||
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
|
||||||
ValidityCheck = IsValidSnowflake
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
|
||||||
Title = "Imported Server IDs",
|
|
||||||
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
|
|
||||||
};
|
|
||||||
|
|
||||||
var dialog = new TextBoxDialog { DataContext = model };
|
|
||||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
|
||||||
|
|
||||||
if (result != DialogResult.OkCancel.Ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return model.ValidItems
|
|
||||||
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
|
||||||
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
|
||||||
int total = paths.Length;
|
|
||||||
var oldStatistics = target.SnapshotStatistics();
|
|
||||||
|
|
||||||
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, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (await performImport(path)) {
|
|
||||||
++successful;
|
|
||||||
}
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Log.Error(ex);
|
|
||||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await callback.Update("Done", finished, total);
|
|
||||||
|
|
||||||
if (successful == 0) {
|
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
|
|
||||||
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
|
||||||
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
|
||||||
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
|
|
||||||
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
|
|
||||||
|
|
||||||
StringBuilder message = new StringBuilder();
|
|
||||||
message.Append("Processed ");
|
|
||||||
|
|
||||||
if (successfulItems == totalItems) {
|
|
||||||
message.Append(successfulItems.Pluralize(itemName));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
|
||||||
}
|
|
||||||
|
|
||||||
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(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
|
||||||
message.Append(newMessages.Pluralize("message"));
|
|
||||||
|
|
||||||
return message.ToString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CloseDatabase() {
|
||||||
|
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void MergeWithDatabase() {
|
||||||
|
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||||
|
if (paths.Length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressDialog progressDialog = new ProgressDialog();
|
||||||
|
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
||||||
|
Title = "Database Merge"
|
||||||
|
};
|
||||||
|
|
||||||
|
await progressDialog.ShowProgressDialog(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
|
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||||
|
|
||||||
|
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
||||||
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||||
|
|
||||||
|
if (db == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
target.AddFrom(db);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
db.Dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
|
private readonly ProgressDialog dialog;
|
||||||
|
private readonly int total;
|
||||||
|
private bool? decision;
|
||||||
|
|
||||||
|
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||||
|
this.total = total;
|
||||||
|
this.dialog = dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUpgrade() {
|
||||||
|
return decision ??= (total > 1
|
||||||
|
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||||
|
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
|
return doUpgrade(new NullReporter());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
|
public Task NextVersion() {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void ImportLegacyArchive() {
|
||||||
|
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
|
Title = "Open Legacy DHT Archive",
|
||||||
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
||||||
|
AllowMultiple = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paths.Length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressDialog progressDialog = new ProgressDialog();
|
||||||
|
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
||||||
|
Title = "Legacy Archive Import"
|
||||||
|
};
|
||||||
|
|
||||||
|
await progressDialog.ShowProgressDialog(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
|
var fakeSnowflake = new FakeSnowflake();
|
||||||
|
|
||||||
|
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
||||||
|
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
|
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||||
|
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||||
|
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||||
|
Dictionary<DHT.Server.Data.Server, ulong>? result = await Dispatcher.UIThread.InvokeAsync(() => AskForServerIds(dialog, servers));
|
||||||
|
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
||||||
|
static bool IsValidSnowflake(string value) {
|
||||||
|
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||||
|
|
||||||
|
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||||
|
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
||||||
|
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
||||||
|
ValidityCheck = IsValidSnowflake
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
||||||
|
Title = "Imported Server IDs",
|
||||||
|
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = new TextBoxDialog { DataContext = model };
|
||||||
|
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||||
|
|
||||||
|
if (result != DialogResult.OkCancel.Ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.ValidItems
|
||||||
|
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
||||||
|
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
||||||
|
int total = paths.Length;
|
||||||
|
var oldStatistics = target.SnapshotStatistics();
|
||||||
|
|
||||||
|
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, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await performImport(path)) {
|
||||||
|
++successful;
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.Error(ex);
|
||||||
|
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.Update("Done", finished, total);
|
||||||
|
|
||||||
|
if (successful == 0) {
|
||||||
|
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
|
||||||
|
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
||||||
|
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
||||||
|
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
|
||||||
|
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
|
||||||
|
|
||||||
|
StringBuilder message = new StringBuilder();
|
||||||
|
message.Append("Processed ");
|
||||||
|
|
||||||
|
if (successfulItems == totalItems) {
|
||||||
|
message.Append(successfulItems.Pluralize(itemName));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
||||||
|
message.Append(newMessages.Pluralize("message"));
|
||||||
|
|
||||||
|
return message.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
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"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.DebugPage">
|
x:Class="DHT.Desktop.Main.Pages.DebugPage"
|
||||||
|
x:DataType="pages:DebugPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DebugPageModel />
|
<pages:DebugPageModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class DebugPage : UserControl {
|
|
||||||
public DebugPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class DebugPage : UserControl {
|
||||||
}
|
public DebugPage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -45,16 +45,16 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog {
|
ProgressDialog progressDialog = new ProgressDialog {
|
||||||
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
|
DataContext = new ProgressDialogModel(async callbacks => await GenerateRandomData(channels, users, messages, callbacks[0])) {
|
||||||
Title = "Generating Random Data"
|
Title = "Generating Random Data"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await progressDialog.ShowDialog(window);
|
await progressDialog.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int BatchSize = 500;
|
private const int BatchSize = 500;
|
||||||
|
|
||||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||||
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
||||||
@@ -63,7 +63,7 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
var server = new DHT.Server.Data.Server {
|
var server = new DHT.Server.Data.Server {
|
||||||
Id = RandomId(rand),
|
Id = RandomId(rand),
|
||||||
Name = RandomName("s"),
|
Name = RandomName("s"),
|
||||||
Type = ServerType.Server
|
Type = ServerType.Server,
|
||||||
};
|
};
|
||||||
|
|
||||||
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
||||||
@@ -73,36 +73,36 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
ParentId = null,
|
ParentId = null,
|
||||||
Position = i,
|
Position = i,
|
||||||
Topic = RandomText(rand, 10),
|
Topic = RandomText(rand, 10),
|
||||||
Nsfw = rand.Next(4) == 0
|
Nsfw = rand.Next(4) == 0,
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
||||||
Id = RandomId(rand),
|
Id = RandomId(rand),
|
||||||
Name = RandomName("u"),
|
Name = RandomName("u"),
|
||||||
AvatarUrl = null,
|
AvatarUrl = null,
|
||||||
Discriminator = rand.Next(0, 9999).ToString()
|
Discriminator = rand.Next(0, 9999).ToString(),
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
db.AddServer(server);
|
db.AddServer(server);
|
||||||
db.AddUsers(users);
|
db.AddUsers(users);
|
||||||
|
|
||||||
foreach (var channel in channels) {
|
foreach (var channel in channels) {
|
||||||
db.AddChannel(channel);
|
db.AddChannel(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.Now;
|
var now = DateTimeOffset.Now;
|
||||||
int batchIndex = 0;
|
int batchIndex = 0;
|
||||||
|
|
||||||
while (messageCount > 0) {
|
while (messageCount > 0) {
|
||||||
int hourOffset = batchIndex;
|
int hourOffset = batchIndex;
|
||||||
|
|
||||||
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
|
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
|
||||||
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes((i * 60.0) / BatchSize);
|
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
|
||||||
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
||||||
|
|
||||||
var timeMillis = time.ToUnixTimeMilliseconds();
|
var timeMillis = time.ToUnixTimeMilliseconds();
|
||||||
var editMillis = edit?.ToUnixTimeMilliseconds();
|
var editMillis = edit?.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
return new Message {
|
return new Message {
|
||||||
Id = (ulong) timeMillis,
|
Id = (ulong) timeMillis,
|
||||||
Sender = RandomBiasedIndex(rand, users).Id,
|
Sender = RandomBiasedIndex(rand, users).Id,
|
||||||
@@ -113,12 +113,12 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
RepliedToId = null,
|
RepliedToId = null,
|
||||||
Attachments = ImmutableArray<Attachment>.Empty,
|
Attachments = ImmutableArray<Attachment>.Empty,
|
||||||
Embeds = ImmutableArray<Embed>.Empty,
|
Embeds = ImmutableArray<Embed>.Empty,
|
||||||
Reactions = ImmutableArray<Reaction>.Empty
|
Reactions = ImmutableArray<Reaction>.Empty,
|
||||||
};
|
};
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
db.AddMessages(messages);
|
db.AddMessages(messages);
|
||||||
|
|
||||||
messageCount -= BatchSize;
|
messageCount -= BatchSize;
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||||
}
|
}
|
||||||
@@ -161,9 +161,9 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
"vanilla",
|
"vanilla",
|
||||||
"watercress", "watermelon",
|
"watercress", "watermelon",
|
||||||
"yam",
|
"yam",
|
||||||
"zucchini"
|
"zucchini",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string RandomText(Random rand, int maxWords) {
|
private static string RandomText(Random rand, int maxWords) {
|
||||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
||||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
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"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.TrackingPage">
|
x:Class="DHT.Desktop.Main.Pages.TrackingPage"
|
||||||
|
x:DataType="pages:TrackingPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:TrackingPageModel />
|
<pages:TrackingPageModel />
|
||||||
|
@@ -3,35 +3,29 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class TrackingPage : UserControl {
|
|
||||||
private bool isCopyingScript;
|
|
||||||
|
|
||||||
public TrackingPage() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
InitializeComponent();
|
public sealed partial class TrackingPage : UserControl {
|
||||||
}
|
private bool isCopyingScript;
|
||||||
|
|
||||||
private void InitializeComponent() {
|
public TrackingPage() {
|
||||||
AvaloniaXamlLoader.Load(this);
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||||
if (DataContext is TrackingPageModel model) {
|
if (DataContext is TrackingPageModel model) {
|
||||||
var button = this.FindControl<Button>("CopyTrackingScript");
|
var originalText = CopyTrackingScript.Content;
|
||||||
var originalText = button.Content;
|
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
||||||
button.MinWidth = button.Bounds.Width;
|
|
||||||
|
|
||||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||||
isCopyingScript = true;
|
isCopyingScript = true;
|
||||||
button.Content = "Script Copied!";
|
CopyTrackingScript.Content = "Script Copied!";
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
button.Content = originalText;
|
CopyTrackingScript.Content = originalText;
|
||||||
isCopyingScript = false;
|
isCopyingScript = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Discord;
|
using DHT.Desktop.Discord;
|
||||||
@@ -9,109 +8,105 @@ using DHT.Desktop.Server;
|
|||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
using static DHT.Desktop.Program;
|
using static DHT.Desktop.Program;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class TrackingPageModel : BaseModel {
|
|
||||||
private bool areDevToolsEnabled;
|
|
||||||
|
|
||||||
private bool AreDevToolsEnabled {
|
sealed class TrackingPageModel : BaseModel {
|
||||||
get => areDevToolsEnabled;
|
private bool areDevToolsEnabled;
|
||||||
set {
|
|
||||||
Change(ref areDevToolsEnabled, value);
|
private bool AreDevToolsEnabled {
|
||||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
get => areDevToolsEnabled;
|
||||||
|
set {
|
||||||
|
Change(ref areDevToolsEnabled, value);
|
||||||
|
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
||||||
|
|
||||||
|
public string ToggleAppDevToolsButtonText {
|
||||||
|
get {
|
||||||
|
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||||
|
return "Unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public TrackingPageModel() : this(null!) {}
|
||||||
|
|
||||||
|
public TrackingPageModel(Window window) {
|
||||||
|
this.window = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
||||||
|
if (devToolsEnabled.HasValue) {
|
||||||
|
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
IsToggleAppDevToolsButtonEnabled = false;
|
||||||
|
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> OnClickCopyTrackingScript() {
|
||||||
|
string url = $"http://127.0.0.1:{ServerManager.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerManager.Token)}";
|
||||||
|
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
||||||
|
|
||||||
|
var clipboard = window.Clipboard;
|
||||||
|
if (clipboard == null) {
|
||||||
|
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
try {
|
||||||
|
await clipboard.SetTextAsync(script);
|
||||||
public string ToggleAppDevToolsButtonText {
|
return true;
|
||||||
get {
|
} catch {
|
||||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
|
||||||
return "Unavailable";
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readonly Window window;
|
public async void OnClickToggleAppDevTools() {
|
||||||
|
const string DialogTitle = "Discord App Settings File";
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
bool oldState = AreDevToolsEnabled;
|
||||||
public TrackingPageModel() : this(null!) {}
|
bool newState = !oldState;
|
||||||
|
|
||||||
public TrackingPageModel(Window window) {
|
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||||
this.window = window;
|
case SettingsJsonResult.Success:
|
||||||
}
|
AreDevToolsEnabled = newState;
|
||||||
|
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
||||||
|
break;
|
||||||
|
|
||||||
public async Task Initialize() {
|
case SettingsJsonResult.AlreadySet:
|
||||||
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
|
||||||
if (devToolsEnabled.HasValue) {
|
AreDevToolsEnabled = newState;
|
||||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
break;
|
||||||
}
|
|
||||||
else {
|
|
||||||
IsToggleAppDevToolsButtonEnabled = false;
|
|
||||||
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> OnClickCopyTrackingScript() {
|
case SettingsJsonResult.FileNotFound:
|
||||||
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
|
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
|
break;
|
||||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
|
|
||||||
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
|
||||||
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
|
|
||||||
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
|
|
||||||
|
|
||||||
var clipboard = Application.Current?.Clipboard;
|
case SettingsJsonResult.ReadError:
|
||||||
if (clipboard == null) {
|
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
break;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
case SettingsJsonResult.InvalidJson:
|
||||||
await clipboard.SetTextAsync(script);
|
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
return true;
|
break;
|
||||||
} catch {
|
|
||||||
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickToggleAppDevTools() {
|
case SettingsJsonResult.WriteError:
|
||||||
const string DialogTitle = "Discord App Settings File";
|
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
|
break;
|
||||||
|
|
||||||
bool oldState = AreDevToolsEnabled;
|
default:
|
||||||
bool newState = !oldState;
|
throw new ArgumentOutOfRangeException();
|
||||||
|
|
||||||
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
|
||||||
case SettingsJsonResult.Success:
|
|
||||||
AreDevToolsEnabled = newState;
|
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SettingsJsonResult.AlreadySet:
|
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
|
|
||||||
AreDevToolsEnabled = newState;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SettingsJsonResult.FileNotFound:
|
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SettingsJsonResult.ReadError:
|
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SettingsJsonResult.InvalidJson:
|
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SettingsJsonResult.WriteError:
|
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new ArgumentOutOfRangeException();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
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"
|
||||||
|
x:DataType="pages:ViewerPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:ViewerPageModel />
|
<pages:ViewerPageModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class ViewerPage : UserControl {
|
|
||||||
public ViewerPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class ViewerPage : UserControl {
|
||||||
}
|
public ViewerPage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -8,140 +7,142 @@ using System.Text;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Desktop.Dialogs.File;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
|
using DHT.Desktop.Server;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Export;
|
using DHT.Server.Database.Export;
|
||||||
|
using DHT.Server.Database.Export.Strategy;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
using static DHT.Desktop.Program;
|
using static DHT.Desktop.Program;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
|
||||||
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
|
|
||||||
|
|
||||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
|
||||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
|
||||||
|
|
||||||
private bool hasFilters = false;
|
sealed class ViewerPageModel : BaseModel, IDisposable {
|
||||||
|
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
|
||||||
|
|
||||||
public bool HasFilters {
|
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||||
get => hasFilters;
|
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||||
set => Change(ref hasFilters, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private MessageFilterPanelModel FilterModel { get; }
|
private bool hasFilters = false;
|
||||||
|
|
||||||
private readonly Window window;
|
public bool HasFilters {
|
||||||
private readonly IDatabaseFile db;
|
get => hasFilters;
|
||||||
|
set => Change(ref hasFilters, value);
|
||||||
|
}
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
public MessageFilterPanelModel FilterModel { get; }
|
||||||
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public ViewerPageModel(Window window, IDatabaseFile db) {
|
private readonly Window window;
|
||||||
this.window = window;
|
private readonly IDatabaseFile db;
|
||||||
this.db = db;
|
|
||||||
|
|
||||||
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
|
[Obsolete("Designer")]
|
||||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
public ViewerPageModel(Window window, IDatabaseFile db) {
|
||||||
FilterModel.Dispose();
|
this.window = window;
|
||||||
}
|
this.db = db;
|
||||||
|
|
||||||
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
|
||||||
HasFilters = FilterModel.HasAnyFilters;
|
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task WriteViewerFile(string path) {
|
public void Dispose() {
|
||||||
const string ArchiveTag = "/*[ARCHIVE]*/";
|
FilterModel.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
HasFilters = FilterModel.HasAnyFilters;
|
||||||
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
|
}
|
||||||
|
|
||||||
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
|
|
||||||
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
|
|
||||||
|
|
||||||
string jsonTempFile = path + ".tmp";
|
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
|
||||||
|
const string ArchiveTag = "/*[ARCHIVE]*/";
|
||||||
|
|
||||||
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
||||||
await ViewerJsonExport.Generate(jsonStream, db, FilterModel.CreateFilter());
|
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
||||||
|
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
|
||||||
|
|
||||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
|
||||||
jsonStream.Position = 0;
|
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
|
||||||
|
|
||||||
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
|
string jsonTempFile = path + ".tmp";
|
||||||
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
|
|
||||||
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
|
|
||||||
|
|
||||||
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
|
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
||||||
int readBytes;
|
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
|
||||||
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
|
|
||||||
string jsonChunk = new string(jsonBuffer, 0, readBytes);
|
|
||||||
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
|
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
||||||
}
|
jsonStream.Position = 0;
|
||||||
}
|
|
||||||
|
|
||||||
File.Delete(jsonTempFile);
|
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
|
||||||
}
|
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
|
||||||
|
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
|
||||||
|
|
||||||
public async void OnClickOpenViewer() {
|
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
|
||||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
int readBytes;
|
||||||
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
|
||||||
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
string jsonChunk = new string(jsonBuffer, 0, readBytes);
|
||||||
int counter = 0;
|
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
|
||||||
|
|
||||||
while (File.Exists(fullPath)) {
|
|
||||||
++counter;
|
|
||||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
|
||||||
}
|
|
||||||
|
|
||||||
TemporaryFiles.Add(fullPath);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(rootPath);
|
|
||||||
await WriteViewerFile(fullPath);
|
|
||||||
|
|
||||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickSaveViewer() {
|
|
||||||
var dialog = new SaveFileDialog {
|
|
||||||
Title = "Save Viewer",
|
|
||||||
InitialFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
|
|
||||||
Directory = Path.GetDirectoryName(db.Path),
|
|
||||||
Filters = new List<FileDialogFilter> {
|
|
||||||
new() {
|
|
||||||
Name = "Discord History Viewer",
|
|
||||||
Extensions = { "html" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.ShowAsync(window);
|
|
||||||
|
|
||||||
string? path = await dialog;
|
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
|
||||||
if (!string.IsNullOrEmpty(path)) {
|
|
||||||
await WriteViewerFile(path);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickApplyFiltersToDatabase() {
|
File.Delete(jsonTempFile);
|
||||||
var filter = FilterModel.CreateFilter();
|
}
|
||||||
|
|
||||||
if (DatabaseToolFilterModeKeep) {
|
public async void OnClickOpenViewer() {
|
||||||
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?")) {
|
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
||||||
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
|
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
||||||
}
|
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
||||||
|
int counter = 0;
|
||||||
|
|
||||||
|
while (File.Exists(fullPath)) {
|
||||||
|
++counter;
|
||||||
|
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
||||||
|
}
|
||||||
|
|
||||||
|
TemporaryFiles.Add(fullPath);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(rootPath);
|
||||||
|
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
|
||||||
|
|
||||||
|
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly FilePickerFileType[] ViewerFileTypes = {
|
||||||
|
FileDialogs.CreateFilter("Discord History Viewer", new string[] { "html" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
public async void OnClickSaveViewer() {
|
||||||
|
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||||
|
Title = "Save Viewer",
|
||||||
|
FileTypeChoices = ViewerFileTypes,
|
||||||
|
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
|
||||||
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (path != null) {
|
||||||
|
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, FilterRemovalMode.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?")) {
|
else if (DatabaseToolFilterModeRemove) {
|
||||||
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
|
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, FilterRemovalMode.RemoveMatching);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Screens.MainContentScreen">
|
x:Class="DHT.Desktop.Main.Screens.MainContentScreen"
|
||||||
|
x:DataType="screens:MainContentScreenModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<screens:MainContentScreenModel />
|
<screens:MainContentScreenModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class MainContentScreen : UserControl {
|
|
||||||
public MainContentScreen() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class MainContentScreen : UserControl {
|
||||||
}
|
public MainContentScreen() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,111 +9,111 @@ using DHT.Server.Database;
|
|||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
sealed class MainContentScreenModel : IDisposable {
|
|
||||||
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
|
|
||||||
|
|
||||||
public DatabasePage DatabasePage { get; }
|
sealed class MainContentScreenModel : IDisposable {
|
||||||
private DatabasePageModel DatabasePageModel { get; }
|
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
|
||||||
|
|
||||||
public TrackingPage TrackingPage { get; }
|
public DatabasePage DatabasePage { get; }
|
||||||
private TrackingPageModel TrackingPageModel { get; }
|
private DatabasePageModel DatabasePageModel { get; }
|
||||||
|
|
||||||
public AttachmentsPage AttachmentsPage { get; }
|
public TrackingPage TrackingPage { get; }
|
||||||
private AttachmentsPageModel AttachmentsPageModel { get; }
|
private TrackingPageModel TrackingPageModel { get; }
|
||||||
|
|
||||||
public ViewerPage ViewerPage { get; }
|
public AttachmentsPage AttachmentsPage { get; }
|
||||||
private ViewerPageModel ViewerPageModel { get; }
|
private AttachmentsPageModel AttachmentsPageModel { get; }
|
||||||
|
|
||||||
public AdvancedPage AdvancedPage { get; }
|
public ViewerPage ViewerPage { get; }
|
||||||
private AdvancedPageModel AdvancedPageModel { get; }
|
private ViewerPageModel ViewerPageModel { get; }
|
||||||
|
|
||||||
public DebugPage? DebugPage { get; }
|
public AdvancedPage AdvancedPage { get; }
|
||||||
|
private AdvancedPageModel AdvancedPageModel { get; }
|
||||||
|
|
||||||
#if DEBUG
|
public DebugPage? DebugPage { get; }
|
||||||
public bool HasDebugPage => true;
|
|
||||||
private DebugPageModel DebugPageModel { get; }
|
#if DEBUG
|
||||||
#else
|
public bool HasDebugPage => true;
|
||||||
|
private DebugPageModel DebugPageModel { get; }
|
||||||
|
#else
|
||||||
public bool HasDebugPage => false;
|
public bool HasDebugPage => false;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public StatusBarModel StatusBarModel { get; }
|
public StatusBarModel StatusBarModel { get; }
|
||||||
|
|
||||||
public event EventHandler? DatabaseClosed {
|
public event EventHandler? DatabaseClosed {
|
||||||
add {
|
add {
|
||||||
DatabasePageModel.DatabaseClosed += value;
|
DatabasePageModel.DatabaseClosed += value;
|
||||||
}
|
|
||||||
remove {
|
|
||||||
DatabasePageModel.DatabaseClosed -= value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
remove {
|
||||||
private readonly Window window;
|
DatabasePageModel.DatabaseClosed -= value;
|
||||||
private readonly ServerManager serverManager;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
|
||||||
this.window = window;
|
|
||||||
this.serverManager = new ServerManager(db);
|
|
||||||
|
|
||||||
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
|
||||||
|
|
||||||
DatabasePageModel = new DatabasePageModel(window, db);
|
|
||||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
|
||||||
|
|
||||||
TrackingPageModel = new TrackingPageModel(window);
|
|
||||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
|
||||||
|
|
||||||
AttachmentsPageModel = new AttachmentsPageModel(db);
|
|
||||||
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
|
||||||
|
|
||||||
ViewerPageModel = new ViewerPageModel(window, db);
|
|
||||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
|
||||||
|
|
||||||
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
|
|
||||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
DebugPageModel = new DebugPageModel(window, db);
|
|
||||||
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
|
||||||
#else
|
|
||||||
DebugPage = null;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
StatusBarModel = new StatusBarModel(db.Statistics);
|
|
||||||
|
|
||||||
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
|
|
||||||
DatabaseClosed += OnDatabaseClosed;
|
|
||||||
|
|
||||||
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Initialize() {
|
|
||||||
await TrackingPageModel.Initialize();
|
|
||||||
AdvancedPageModel.Initialize();
|
|
||||||
serverManager.Launch();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
|
|
||||||
AttachmentsPageModel.Dispose();
|
|
||||||
ViewerPageModel.Dispose();
|
|
||||||
serverManager.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
|
|
||||||
StatusBarModel.CurrentStatus = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDatabaseClosed(object? sender, EventArgs e) {
|
|
||||||
serverManager.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
|
|
||||||
Log.Error(ex);
|
|
||||||
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
private readonly ServerManager serverManager;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||||
|
|
||||||
|
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
||||||
|
this.window = window;
|
||||||
|
this.serverManager = new ServerManager(db);
|
||||||
|
|
||||||
|
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
||||||
|
|
||||||
|
DatabasePageModel = new DatabasePageModel(window, db);
|
||||||
|
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||||
|
|
||||||
|
TrackingPageModel = new TrackingPageModel(window);
|
||||||
|
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||||
|
|
||||||
|
AttachmentsPageModel = new AttachmentsPageModel(db);
|
||||||
|
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
||||||
|
|
||||||
|
ViewerPageModel = new ViewerPageModel(window, db);
|
||||||
|
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||||
|
|
||||||
|
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
|
||||||
|
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
DebugPageModel = new DebugPageModel(window, db);
|
||||||
|
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
||||||
|
#else
|
||||||
|
DebugPage = null;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
StatusBarModel = new StatusBarModel(db.Statistics);
|
||||||
|
|
||||||
|
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
|
||||||
|
DatabaseClosed += OnDatabaseClosed;
|
||||||
|
|
||||||
|
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
await TrackingPageModel.Initialize();
|
||||||
|
AdvancedPageModel.Initialize();
|
||||||
|
serverManager.Launch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
|
||||||
|
AttachmentsPageModel.Dispose();
|
||||||
|
ViewerPageModel.Dispose();
|
||||||
|
serverManager.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
|
||||||
|
StatusBarModel.CurrentStatus = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDatabaseClosed(object? sender, EventArgs e) {
|
||||||
|
serverManager.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
|
||||||
|
Log.Error(ex);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen">
|
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
|
||||||
|
x:DataType="screens:WelcomeScreenModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<screens:WelcomeScreenModel />
|
<screens:WelcomeScreenModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class WelcomeScreen : UserControl {
|
|
||||||
public WelcomeScreen() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class WelcomeScreen : UserControl {
|
||||||
}
|
public WelcomeScreen() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,73 +1,130 @@
|
|||||||
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.Common;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Models;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
|
||||||
public string Version => Program.Version;
|
|
||||||
|
|
||||||
public IDatabaseFile? Db { get; private set; }
|
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
||||||
public bool HasDatabase => Db != null;
|
public string Version => Program.Version;
|
||||||
|
|
||||||
|
public IDatabaseFile? Db { get; private set; }
|
||||||
|
public bool HasDatabase => Db != null;
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
|
||||||
|
private string? dbFilePath;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public WelcomeScreenModel() : this(null!) {}
|
||||||
|
|
||||||
|
public WelcomeScreenModel(Window window) {
|
||||||
|
this.window = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void OpenOrCreateDatabase() {
|
||||||
|
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||||
|
if (path != null) {
|
||||||
|
await OpenOrCreateDatabaseFromPath(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||||
|
if (Db != null) {
|
||||||
|
Db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbFilePath = path;
|
||||||
|
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(Db));
|
||||||
|
OnPropertyChanged(nameof(HasDatabase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
private string? dbFilePath;
|
public SchemaUpgradeCallbacks(Window window) {
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public WelcomeScreenModel() : this(null!) {}
|
|
||||||
|
|
||||||
public WelcomeScreenModel(Window window) {
|
|
||||||
this.window = window;
|
this.window = window;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenOrCreateDatabase() {
|
public async Task<bool> CanUpgrade() {
|
||||||
var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog();
|
|
||||||
dialog.Directory = Path.GetDirectoryName(dbFilePath);
|
|
||||||
|
|
||||||
string? path = await dialog.ShowAsync(window);
|
|
||||||
if (!string.IsNullOrWhiteSpace(path)) {
|
|
||||||
await OpenOrCreateDatabaseFromPath(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
|
||||||
if (Db != null) {
|
|
||||||
Db = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
dbFilePath = path;
|
|
||||||
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Db));
|
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> CheckCanUpgradeDatabase() {
|
|
||||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CloseDatabase() {
|
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
Dispose();
|
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
OnPropertyChanged(nameof(Db));
|
var reporter = new ProgressReporter(versionSteps, callbacks);
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
await reporter.NextVersion();
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(800));
|
||||||
|
await doUpgrade(reporter);
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||||
|
}
|
||||||
|
|
||||||
|
await new ProgressDialog {
|
||||||
|
DataContext = new ProgressDialogModel(StartUpgrade, progressItems: 3) {
|
||||||
|
Title = "Upgrading Database"
|
||||||
|
}
|
||||||
|
}.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void ShowAboutDialog() {
|
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
private readonly IReadOnlyList<IProgressCallback> callbacks;
|
||||||
}
|
|
||||||
|
private readonly int versionSteps;
|
||||||
|
private int versionProgress = 0;
|
||||||
|
|
||||||
|
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.versionSteps = versionSteps;
|
||||||
|
}
|
||||||
|
|
||||||
public void Exit() {
|
public async Task NextVersion() {
|
||||||
window.Close();
|
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||||
}
|
await HideChildren(0);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
Db?.Dispose();
|
await callbacks[1].Update(message, finishedItems, totalItems);
|
||||||
Db = null;
|
await HideChildren(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
|
await callbacks[2].Update(message, finishedItems, totalItems);
|
||||||
|
await HideChildren(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HideChildren(int parentIndex) {
|
||||||
|
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
||||||
|
await callbacks[i].Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CloseDatabase() {
|
||||||
|
Dispose();
|
||||||
|
OnPropertyChanged(nameof(Db));
|
||||||
|
OnPropertyChanged(nameof(HasDatabase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void ShowAboutDialog() {
|
||||||
|
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Exit() {
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
Db?.Dispose();
|
||||||
|
Db = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,37 +3,38 @@ using System.Reflection;
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using DHT.Utils.Resources;
|
using DHT.Utils.Resources;
|
||||||
|
|
||||||
namespace DHT.Desktop {
|
namespace DHT.Desktop;
|
||||||
static class Program {
|
|
||||||
public static string Version { get; }
|
|
||||||
public static CultureInfo Culture { get; }
|
|
||||||
public static ResourceLoader Resources { get; }
|
|
||||||
|
|
||||||
static Program() {
|
static class Program {
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
public static string Version { get; }
|
||||||
|
public static CultureInfo Culture { get; }
|
||||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
public static ResourceLoader Resources { get; }
|
||||||
while (Version.EndsWith(".0")) {
|
|
||||||
Version = Version[..^2];
|
|
||||||
}
|
|
||||||
|
|
||||||
Culture = CultureInfo.CurrentCulture;
|
static Program() {
|
||||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
|
||||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
Version = assembly.GetName().Version?.ToString() ?? "";
|
||||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
while (Version.EndsWith(".0")) {
|
||||||
|
Version = Version[..^2];
|
||||||
Resources = new ResourceLoader(assembly);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Main(string[] args) {
|
Culture = CultureInfo.CurrentCulture;
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||||
}
|
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
|
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
||||||
|
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
private static AppBuilder BuildAvaloniaApp() {
|
Resources = new ResourceLoader(assembly);
|
||||||
return AppBuilder.Configure<App>()
|
}
|
||||||
.UsePlatformDetect()
|
|
||||||
.LogToTrace();
|
public static void Main(string[] args) {
|
||||||
}
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppBuilder BuildAvaloniaApp() {
|
||||||
|
return AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
app/Desktop/Resources/tracker-loader.js
Normal file
1
app/Desktop/Resources/tracker-loader.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fetch("{url}").then(r => r.ok ? (r.headers.get("X-DHT") === "1" ? r.text() : Promise.reject("Invalid response")) : Promise.reject(r.status + " " + r.statusText)).then(s => eval(s)).catch(e => alert("Could not load tracking script:\n" + e));
|
@@ -2,49 +2,49 @@ using System;
|
|||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
|
|
||||||
namespace DHT.Desktop.Server {
|
namespace DHT.Desktop.Server;
|
||||||
sealed class ServerManager : IDisposable {
|
|
||||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
|
||||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
|
||||||
|
|
||||||
private static ServerManager? instance;
|
sealed class ServerManager : IDisposable {
|
||||||
|
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
||||||
|
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||||
|
|
||||||
public bool IsRunning => ServerLauncher.IsRunning;
|
private static ServerManager? instance;
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
|
|
||||||
public ServerManager(IDatabaseFile db) {
|
|
||||||
if (db != DummyDatabaseFile.Instance) {
|
|
||||||
if (instance != null) {
|
|
||||||
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
|
|
||||||
}
|
|
||||||
|
|
||||||
instance = this;
|
public bool IsRunning => ServerLauncher.IsRunning;
|
||||||
|
|
||||||
|
private readonly IDatabaseFile db;
|
||||||
|
|
||||||
|
public ServerManager(IDatabaseFile db) {
|
||||||
|
if (db != DummyDatabaseFile.Instance) {
|
||||||
|
if (instance != null) {
|
||||||
|
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.db = db;
|
instance = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Launch() {
|
this.db = db;
|
||||||
ServerLauncher.Relaunch(Port, Token, db);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public void Relaunch(ushort port, string token) {
|
public void Launch() {
|
||||||
Port = port;
|
ServerLauncher.Relaunch(Port, Token, db);
|
||||||
Token = token;
|
}
|
||||||
Launch();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Stop() {
|
public void Relaunch(ushort port, string token) {
|
||||||
ServerLauncher.Stop();
|
Port = port;
|
||||||
}
|
Token = token;
|
||||||
|
Launch();
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Stop() {
|
||||||
Stop();
|
ServerLauncher.Stop();
|
||||||
|
}
|
||||||
if (instance == this) {
|
|
||||||
instance = null;
|
public void Dispose() {
|
||||||
}
|
Stop();
|
||||||
|
|
||||||
|
if (instance == this) {
|
||||||
|
instance = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
49
app/Directory.Build.props
Normal file
49
app/Directory.Build.props
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<LangVersion>11</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Authors>chylex</Authors>
|
||||||
|
<Company>DiscordHistoryTracker</Company>
|
||||||
|
<Product>DiscordHistoryTracker</Product>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
|
||||||
|
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
|
||||||
|
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
|
<TrimMode>partial</TrimMode>
|
||||||
|
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
|
||||||
|
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
|
||||||
|
<EventSourceSupport>false</EventSourceSupport>
|
||||||
|
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||||
|
<DebugSymbols>false</DebugSymbols>
|
||||||
|
<DebugType>none</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
|
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
5
app/Resources/Tracker/bootstrap.js
vendored
5
app/Resources/Tracker/bootstrap.js
vendored
@@ -64,9 +64,11 @@
|
|||||||
let action = null;
|
let action = null;
|
||||||
|
|
||||||
if (!DISCORD.hasMoreMessages()) {
|
if (!DISCORD.hasMoreMessages()) {
|
||||||
|
console.debug("[DHT] Reached first message.");
|
||||||
action = SETTINGS.afterFirstMsg;
|
action = SETTINGS.afterFirstMsg;
|
||||||
}
|
}
|
||||||
if (isNoAction(action) && !anyNewMessages) {
|
if (isNoAction(action) && !anyNewMessages) {
|
||||||
|
console.debug("[DHT] No new messages.");
|
||||||
action = SETTINGS.afterSavedMsg;
|
action = SETTINGS.afterSavedMsg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +123,7 @@
|
|||||||
onTrackingContinued(false);
|
onTrackingContinued(false);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);
|
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||||
onTrackingContinued(anyNewMessages);
|
onTrackingContinued(anyNewMessages);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -156,3 +158,4 @@
|
|||||||
GUI.showSettings();
|
GUI.showSettings();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
/*[DEBUGGER]*/
|
||||||
|
@@ -1,5 +1,23 @@
|
|||||||
// noinspection JSUnresolvedVariable
|
// noinspection JSUnresolvedVariable
|
||||||
|
// noinspection LocalVariableNamingConventionJS
|
||||||
class DISCORD {
|
class DISCORD {
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
|
static CHANNEL_TYPE = {
|
||||||
|
DM: 1,
|
||||||
|
GROUP_DM: 3,
|
||||||
|
ANNOUNCEMENT_THREAD: 10,
|
||||||
|
PUBLIC_THREAD: 11,
|
||||||
|
PRIVATE_THREAD: 12
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
|
static MESSAGE_TYPE = {
|
||||||
|
DEFAULT: 0,
|
||||||
|
REPLY: 19,
|
||||||
|
THREAD_STARTER: 21
|
||||||
|
};
|
||||||
|
|
||||||
static getMessageOuterElement() {
|
static getMessageOuterElement() {
|
||||||
return DOM.queryReactClass("messagesWrapper");
|
return DOM.queryReactClass("messagesWrapper");
|
||||||
}
|
}
|
||||||
@@ -9,7 +27,7 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getMessageElements() {
|
static getMessageElements() {
|
||||||
return this.getMessageOuterElement().querySelectorAll("[class*='message-']");
|
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
|
||||||
}
|
}
|
||||||
|
|
||||||
static hasMoreMessages() {
|
static hasMoreMessages() {
|
||||||
@@ -28,46 +46,11 @@ class DISCORD {
|
|||||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||||
*/
|
*/
|
||||||
static setupMessageCallback(callback) {
|
static setupMessageCallback(callback) {
|
||||||
let skipsLeft = 0;
|
|
||||||
let waitForCleanup = false;
|
|
||||||
const previousMessages = new Set();
|
const previousMessages = new Set();
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const onMessageElementsChanged = function() {
|
||||||
if (skipsLeft > 0) {
|
const messages = DISCORD.getMessages();
|
||||||
--skipsLeft;
|
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = this.getMessageOuterElement();
|
|
||||||
|
|
||||||
if (!view) {
|
|
||||||
skipsLeft = 2;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anyMessage = DOM.queryReactClass("message", this.getMessageOuterElement());
|
|
||||||
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
|
|
||||||
|
|
||||||
if (messageCount > 300) {
|
|
||||||
if (waitForCleanup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
skipsLeft = 3;
|
|
||||||
waitForCleanup = true;
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
const view = this.getMessageScrollerElement();
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
view.scrollTop = view.scrollHeight / 2;
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
waitForCleanup = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = this.getMessages();
|
|
||||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
|
|
||||||
|
|
||||||
if (!hasChanged) {
|
if (!hasChanged) {
|
||||||
return;
|
return;
|
||||||
@@ -79,23 +62,75 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
callback(messages);
|
callback(messages);
|
||||||
}, 200);
|
};
|
||||||
|
|
||||||
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer));
|
let debounceTimer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do not trigger the callback too often due to autoscrolling.
|
||||||
|
*/
|
||||||
|
const onMessageElementsChangedLater = function() {
|
||||||
|
window.clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver(function () {
|
||||||
|
onMessageElementsChangedLater();
|
||||||
|
});
|
||||||
|
|
||||||
|
let skipsLeft = 0;
|
||||||
|
let observedElement = null;
|
||||||
|
|
||||||
|
const observerTimer = window.setInterval(() => {
|
||||||
|
if (skipsLeft > 0) {
|
||||||
|
--skipsLeft;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = this.getMessageOuterElement();
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
skipsLeft = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observedElement !== null && observedElement.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observedElement = view.querySelector("[data-list-id='chat-messages']");
|
||||||
|
|
||||||
|
if (observedElement) {
|
||||||
|
console.debug("[DHT] Observed message container.");
|
||||||
|
observer.observe(observedElement, { childList: true });
|
||||||
|
onMessageElementsChangedLater();
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
window.DHT_ON_UNLOAD.push(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
observedElement = null;
|
||||||
|
window.clearInterval(observerTimer);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the property object of a message element.
|
* Returns the message from a message element.
|
||||||
* @returns { null | { message: DiscordMessage, channel: Object } }
|
* @returns { null | DiscordMessage } }
|
||||||
*/
|
*/
|
||||||
static getMessageElementProps(ele) {
|
static getMessageFromElement(ele) {
|
||||||
const props = DOM.getReactProps(ele);
|
const props = DOM.getReactProps(ele);
|
||||||
|
|
||||||
if (props.children && props.children.length >= 4) {
|
if (props && Array.isArray(props.children)) {
|
||||||
const childProps = props.children[3].props;
|
for (const child of props.children) {
|
||||||
|
if (!(child instanceof Object)) {
|
||||||
if ("message" in childProps && "channel" in childProps) {
|
continue;
|
||||||
return childProps;
|
}
|
||||||
|
|
||||||
|
const childProps = child.props;
|
||||||
|
if (childProps instanceof Object && "message" in childProps) {
|
||||||
|
return childProps.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,10 +146,10 @@ class DISCORD {
|
|||||||
|
|
||||||
for (const ele of this.getMessageElements()) {
|
for (const ele of this.getMessageElements()) {
|
||||||
try {
|
try {
|
||||||
const props = this.getMessageElementProps(ele);
|
const message = this.getMessageFromElement(ele);
|
||||||
|
|
||||||
if (props != null) {
|
if (message != null) {
|
||||||
messages.push(props.message);
|
messages.push(message);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
||||||
@@ -135,7 +170,7 @@ class DISCORD {
|
|||||||
*/
|
*/
|
||||||
static getSelectedChannel() {
|
static getSelectedChannel() {
|
||||||
try {
|
try {
|
||||||
let obj;
|
let obj = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
||||||
@@ -146,15 +181,6 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
||||||
|
|
||||||
for (const ele of this.getMessageElements()) {
|
|
||||||
const props = this.getMessageElementProps(ele);
|
|
||||||
|
|
||||||
if (props != null) {
|
|
||||||
obj = props.channel;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!obj || typeof obj.id !== "string") {
|
if (!obj || typeof obj.id !== "string") {
|
||||||
@@ -166,7 +192,7 @@ class DISCORD {
|
|||||||
if (dms) {
|
if (dms) {
|
||||||
let name;
|
let name;
|
||||||
|
|
||||||
for (const ele of dms.querySelectorAll("[class*='channel-'] [class*='selected-'] [class^='name-'] *, [class*='channel-'][class*='selected-'] [class^='name-'] *")) {
|
for (const ele of dms.querySelectorAll("[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) {
|
||||||
@@ -183,8 +209,8 @@ class DISCORD {
|
|||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
switch (obj.type) {
|
switch (obj.type) {
|
||||||
case 1: type = "DM"; break;
|
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
|
||||||
case 3: type = "GROUP"; break;
|
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +221,22 @@ class DISCORD {
|
|||||||
return { server, channel };
|
return { server, channel };
|
||||||
}
|
}
|
||||||
else if (obj.guild_id) {
|
else if (obj.guild_id) {
|
||||||
|
let guild;
|
||||||
|
|
||||||
|
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
|
||||||
|
if (child && child.props && child.props.guild) {
|
||||||
|
guild = child.props.guild;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const server = {
|
const server = {
|
||||||
"id": obj.guild_id,
|
"id": guild.id,
|
||||||
"name": document.querySelector("nav header h1[class*='name-']").innerText,
|
"name": guild.name,
|
||||||
"type": "SERVER"
|
"type": "SERVER"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,7 +248,7 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (obj.parent_id) {
|
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||||
channel["extra"]["parent"] = obj.parent_id;
|
channel["extra"]["parent"] = obj.parent_id;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -236,10 +275,10 @@ class DISCORD {
|
|||||||
|
|
||||||
if (dms) {
|
if (dms) {
|
||||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
const currentChannel = DOM.queryReactClass("selected", dms);
|
||||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel-']");
|
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
|
||||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
||||||
|
|
||||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-")) {
|
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +305,7 @@ class DISCORD {
|
|||||||
let nextChannel = null;
|
let nextChannel = null;
|
||||||
|
|
||||||
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
||||||
if (allTextChannels[index].className.includes("selected-")) {
|
if (allTextChannels[index].className.includes("selected_")) {
|
||||||
nextChannel = allTextChannels[index + 1];
|
nextChannel = allTextChannels[index + 1];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,7 @@ class DOM {
|
|||||||
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
||||||
*/
|
*/
|
||||||
static queryReactClass(cls, parent) {
|
static queryReactClass(cls, parent) {
|
||||||
return (parent || document).querySelector(`[class*="${cls}-"]`);
|
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -86,12 +86,12 @@ const GUI = (function() {
|
|||||||
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
||||||
<br>
|
<br>
|
||||||
<label>After reaching the first message in channel...</label><br>
|
<label>After reaching the first message in channel...</label><br>
|
||||||
${radio("afm", "nothing", "Do Nothing")}
|
${radio("afm", "nothing", "Continue Tracking")}
|
||||||
${radio("afm", "pause", "Pause Tracking")}
|
${radio("afm", "pause", "Pause Tracking")}
|
||||||
${radio("afm", "switch", "Switch to Next Channel")}
|
${radio("afm", "switch", "Switch to Next Channel")}
|
||||||
<br>
|
<br>
|
||||||
<label>After reaching a previously saved message...</label><br>
|
<label>After reaching a previously saved message...</label><br>
|
||||||
${radio("asm", "nothing", "Do Nothing")}
|
${radio("asm", "nothing", "Continue Tracking")}
|
||||||
${radio("asm", "pause", "Pause Tracking")}
|
${radio("asm", "pause", "Pause Tracking")}
|
||||||
${radio("asm", "switch", "Switch to Next Channel")}
|
${radio("asm", "switch", "Switch to Next Channel")}
|
||||||
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;
|
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;
|
||||||
|
@@ -174,12 +174,10 @@ const STATE = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {String} channelId
|
|
||||||
* @param {DiscordMessage[]} discordMessageArray
|
* @param {DiscordMessage[]} discordMessageArray
|
||||||
*/
|
*/
|
||||||
async addDiscordMessages(channelId, discordMessageArray) {
|
async addDiscordMessages(discordMessageArray) {
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
discordMessageArray = discordMessageArray.filter(msg => (msg.type === DISCORD.MESSAGE_TYPE.DEFAULT || msg.type === DISCORD.MESSAGE_TYPE.REPLY || msg.type === DISCORD.MESSAGE_TYPE.THREAD_STARTER) && msg.state === "SENT");
|
||||||
discordMessageArray = discordMessageArray.filter(msg => (msg.type === 0 || msg.type === 19 || msg.type === 21) && msg.state === "SENT");
|
|
||||||
|
|
||||||
if (discordMessageArray.length === 0) {
|
if (discordMessageArray.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -251,6 +249,11 @@ const STATE = (function() {
|
|||||||
mapped.type = attachment.content_type;
|
mapped.type = attachment.content_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachment.width && attachment.height) {
|
||||||
|
mapped.width = attachment.width;
|
||||||
|
mapped.height = attachment.height;
|
||||||
|
}
|
||||||
|
|
||||||
return mapped;
|
return mapped;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,5 @@
|
|||||||
#app-mount div[class*="app-"] {
|
#app-mount {
|
||||||
margin-bottom: 48px !important;
|
height: calc(100% - 48px) !important;
|
||||||
}
|
|
||||||
|
|
||||||
#app-mount div[class*="app-"] > div[class*="app-"] {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#dht-ctrl {
|
#dht-ctrl {
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
const DISCORD = (function() {
|
const DISCORD = (function() {
|
||||||
const regex = {
|
const regex = {
|
||||||
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
||||||
formatItalic: /(.)?([_*])([\s\S]+?)\2(?!\2)/g,
|
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
||||||
|
formatItalic2: /_([\s\S]+?)_(?!_)\b/g,
|
||||||
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
||||||
formatStrike: /~~([\s\S]+?)~~(?!~)/g,
|
formatStrike: /~~([\s\S]+?)~~(?!~)/g,
|
||||||
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
|
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
|
||||||
@@ -48,7 +49,8 @@ const DISCORD = (function() {
|
|||||||
.replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
|
.replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
|
||||||
.replace(regex.formatBold, "<b>$1</b>")
|
.replace(regex.formatBold, "<b>$1</b>")
|
||||||
.replace(regex.formatUnderline, "<u>$1</u>")
|
.replace(regex.formatUnderline, "<u>$1</u>")
|
||||||
.replace(regex.formatItalic, (full, pre, char, match) => pre === "\\" ? full : (pre || "") + "<i>" + match + "</i>")
|
.replace(regex.formatItalic1, "<i>$1</i>")
|
||||||
|
.replace(regex.formatItalic2, "<i>$1</i>")
|
||||||
.replace(regex.formatStrike, "<s>$1</s>");
|
.replace(regex.formatStrike, "<s>$1</s>");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,12 @@ const DISCORD = (function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isImageUrl = function(url) {
|
||||||
|
const dot = url.pathname.lastIndexOf(".");
|
||||||
|
const ext = dot === -1 ? "" : url.pathname.substring(dot).toLowerCase();
|
||||||
|
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setup() {
|
setup() {
|
||||||
templateChannelServer = new TEMPLATE([
|
templateChannelServer = new TEMPLATE([
|
||||||
@@ -123,7 +131,7 @@ const DISCORD = (function() {
|
|||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateAttachmentDownload = new TEMPLATE([
|
templateAttachmentDownload = new TEMPLATE([
|
||||||
"<a href='{url}' class='embed download'>Download {filename}</a>"
|
"<a href='{url}' class='embed download'>Download {name}</a>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
@@ -176,9 +184,8 @@ const DISCORD = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
isImageAttachment(attachment) {
|
isImageAttachment(attachment) {
|
||||||
const dot = attachment.url.lastIndexOf(".");
|
const url = DOM.tryParseUrl(attachment.url);
|
||||||
const ext = dot === -1 ? "" : attachment.url.substring(dot).toLowerCase();
|
return url != null && isImageUrl(url);
|
||||||
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getChannelHTML(channel) { // noinspection FunctionWithInconsistentReturnsJS
|
getChannelHTML(channel) { // noinspection FunctionWithInconsistentReturnsJS
|
||||||
@@ -235,16 +242,14 @@ const DISCORD = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return value.map(attachment => {
|
return value.map(attachment => {
|
||||||
if (this.isImageAttachment(attachment) && SETTINGS.enableImagePreviews) {
|
if (!DISCORD.isImageAttachment(attachment) || !SETTINGS.enableImagePreviews) {
|
||||||
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
|
return templateAttachmentDownload.apply(attachment);
|
||||||
|
}
|
||||||
|
else if ("width" in attachment && "height" in attachment) {
|
||||||
|
return templateEmbedImageWithSize.apply({ url: attachment.url, src: attachment.url, width: attachment.width, height: attachment.height });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const sliced = attachment.url.split("/");
|
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
|
||||||
|
|
||||||
return templateAttachmentDownload.apply({
|
|
||||||
"url": attachment.url,
|
|
||||||
"filename": sliced[sliced.length - 1]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
@@ -51,4 +51,15 @@ class DOM {
|
|||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleDateString() + ", " + date.toLocaleTimeString();
|
return date.toLocaleDateString() + ", " + date.toLocaleTimeString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a url string into a URL object and returns it. If the parsing fails, returns null.
|
||||||
|
*/
|
||||||
|
static tryParseUrl(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url);
|
||||||
|
} catch (ignore) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import glob
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
|
|
||||||
if os.name == "nt":
|
|
||||||
uglifyjs = os.path.abspath("../../lib/uglifyjs.cmd")
|
|
||||||
else:
|
|
||||||
uglifyjs = "uglifyjs"
|
|
||||||
|
|
||||||
if shutil.which(uglifyjs) is None:
|
|
||||||
print("Cannot find executable: {0}".format(uglifyjs))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
input_dir = os.path.abspath("./Tracker/scripts")
|
|
||||||
output_dir = os.path.abspath("../Desktop/bin/.res/scripts")
|
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
|
|
||||||
for file in glob.glob(input_dir + "/*.js"):
|
|
||||||
name = os.path.basename(file)
|
|
||||||
print("Minifying {0}...".format(name))
|
|
||||||
os.system("{0} {1} -o {2}/{3}".format(uglifyjs, file, output_dir, name))
|
|
||||||
|
|
||||||
print("Done!")
|
|
@@ -1,15 +1,15 @@
|
|||||||
namespace DHT.Server.Data.Aggregations {
|
namespace DHT.Server.Data.Aggregations;
|
||||||
public sealed class DownloadStatusStatistics {
|
|
||||||
public int EnqueuedCount { get; internal set; }
|
|
||||||
public ulong EnqueuedSize { get; internal set; }
|
|
||||||
|
|
||||||
public int SuccessfulCount { get; internal set; }
|
public sealed class DownloadStatusStatistics {
|
||||||
public ulong SuccessfulSize { get; internal set; }
|
public int EnqueuedCount { get; internal set; }
|
||||||
|
public ulong EnqueuedSize { get; internal set; }
|
||||||
|
|
||||||
public int FailedCount { get; internal set; }
|
public int SuccessfulCount { get; internal set; }
|
||||||
public ulong FailedSize { get; internal set; }
|
public ulong SuccessfulSize { get; internal set; }
|
||||||
|
|
||||||
public int SkippedCount { get; internal set; }
|
public int FailedCount { get; internal set; }
|
||||||
public ulong SkippedSize { get; internal set; }
|
public ulong FailedSize { get; internal set; }
|
||||||
}
|
|
||||||
|
public int SkippedCount { get; internal set; }
|
||||||
|
public ulong SkippedSize { get; internal set; }
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,12 @@
|
|||||||
namespace DHT.Server.Data {
|
namespace DHT.Server.Data;
|
||||||
public readonly struct Attachment {
|
|
||||||
public ulong Id { get; internal init; }
|
public readonly struct Attachment {
|
||||||
public string Name { get; internal init; }
|
public ulong Id { get; internal init; }
|
||||||
public string? Type { get; internal init; }
|
public string Name { get; internal init; }
|
||||||
public string Url { get; internal init; }
|
public string? Type { get; internal init; }
|
||||||
public ulong Size { get; internal init; }
|
public string NormalizedUrl { get; internal init; }
|
||||||
}
|
public string DownloadUrl { get; internal init; }
|
||||||
|
public ulong Size { get; internal init; }
|
||||||
|
public int? Width { get; internal init; }
|
||||||
|
public int? Height { get; internal init; }
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
namespace DHT.Server.Data {
|
namespace DHT.Server.Data;
|
||||||
public readonly struct Channel {
|
|
||||||
public ulong Id { get; init; }
|
public readonly struct Channel {
|
||||||
public ulong Server { get; init; }
|
public ulong Id { get; init; }
|
||||||
public string Name { get; init; }
|
public ulong Server { get; init; }
|
||||||
public ulong? ParentId { get; init; }
|
public string Name { get; init; }
|
||||||
public int? Position { get; init; }
|
public ulong? ParentId { get; init; }
|
||||||
public string? Topic { get; init; }
|
public int? Position { get; init; }
|
||||||
public bool? Nsfw { get; init; }
|
public string? Topic { get; init; }
|
||||||
}
|
public bool? Nsfw { get; init; }
|
||||||
}
|
}
|
||||||
|
@@ -1,30 +1,33 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using DHT.Server.Download;
|
||||||
|
|
||||||
namespace DHT.Server.Data {
|
namespace DHT.Server.Data;
|
||||||
public readonly struct Download {
|
|
||||||
internal static Download NewSuccess(string url, byte[] data) {
|
|
||||||
return new Download(url, DownloadStatus.Success, (ulong) Math.Max(data.LongLength, 0), data);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Download NewFailure(string url, HttpStatusCode? statusCode, ulong size) {
|
public readonly struct Download {
|
||||||
return new Download(url, statusCode.HasValue ? (DownloadStatus) (int) statusCode : DownloadStatus.GenericError, size);
|
internal static Download NewSuccess(DownloadItem item, byte[] data) {
|
||||||
}
|
return new Download(item.NormalizedUrl, item.DownloadUrl, DownloadStatus.Success, (ulong) Math.Max(data.LongLength, 0), data);
|
||||||
|
}
|
||||||
|
|
||||||
public string Url { get; }
|
internal static Download NewFailure(DownloadItem item, HttpStatusCode? statusCode, ulong size) {
|
||||||
public DownloadStatus Status { get; }
|
return new Download(item.NormalizedUrl, item.DownloadUrl, statusCode.HasValue ? (DownloadStatus) (int) statusCode : DownloadStatus.GenericError, size);
|
||||||
public ulong Size { get; }
|
}
|
||||||
public byte[]? Data { get; }
|
|
||||||
|
|
||||||
internal Download(string url, DownloadStatus status, ulong size, byte[]? data = null) {
|
public string NormalizedUrl { get; }
|
||||||
Url = url;
|
public string DownloadUrl { get; }
|
||||||
Status = status;
|
public DownloadStatus Status { get; }
|
||||||
Size = size;
|
public ulong Size { get; }
|
||||||
Data = data;
|
public byte[]? Data { get; }
|
||||||
}
|
|
||||||
|
|
||||||
internal Download WithData(byte[] data) {
|
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, ulong size, byte[]? data = null) {
|
||||||
return new Download(Url, Status, Size, data);
|
NormalizedUrl = normalizedUrl;
|
||||||
}
|
DownloadUrl = downloadUrl;
|
||||||
|
Status = status;
|
||||||
|
Size = size;
|
||||||
|
Data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Download WithData(byte[] data) {
|
||||||
|
return new Download(NormalizedUrl, DownloadUrl, Status, Size, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
namespace DHT.Server.Data {
|
namespace DHT.Server.Data;
|
||||||
/// <summary>
|
|
||||||
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
/// <summary>
|
||||||
/// </summary>
|
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
||||||
public enum DownloadStatus {
|
/// </summary>
|
||||||
Enqueued = 0,
|
public enum DownloadStatus {
|
||||||
GenericError = 1,
|
Enqueued = 0,
|
||||||
Success = HttpStatusCode.OK
|
GenericError = 1,
|
||||||
}
|
Success = HttpStatusCode.OK
|
||||||
}
|
}
|
||||||
|
6
app/Server/Data/DownloadedAttachment.cs
Normal file
6
app/Server/Data/DownloadedAttachment.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace DHT.Server.Data;
|
||||||
|
|
||||||
|
public readonly struct DownloadedAttachment {
|
||||||
|
public string? Type { get; internal init; }
|
||||||
|
public byte[] Data { get; internal init; }
|
||||||
|
}
|
@@ -1,5 +1,5 @@
|
|||||||
namespace DHT.Server.Data {
|
namespace DHT.Server.Data;
|
||||||
public readonly struct Embed {
|
|
||||||
public string Json { get; internal init; }
|
public readonly struct Embed {
|
||||||
}
|
public string Json { get; internal init; }
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace DHT.Server.Data {
|
namespace DHT.Server.Data;
|
||||||
[Flags]
|
|
||||||
public enum EmojiFlags : ushort {
|
[Flags]
|
||||||
None = 0,
|
public enum EmojiFlags : ushort {
|
||||||
Animated = 0b1
|
None = 0,
|
||||||
}
|
Animated = 0b1
|
||||||
}
|
}
|
||||||
|
@@ -1,15 +1,15 @@
|
|||||||
namespace DHT.Server.Data.Filters {
|
namespace DHT.Server.Data.Filters;
|
||||||
public sealed class AttachmentFilter {
|
|
||||||
public ulong? MaxBytes { get; set; } = null;
|
|
||||||
|
|
||||||
public DownloadItemRules? DownloadItemRule { get; set; } = null;
|
public sealed class AttachmentFilter {
|
||||||
|
public ulong? MaxBytes { get; set; } = null;
|
||||||
|
|
||||||
public bool IsEmpty => MaxBytes == null &&
|
public DownloadItemRules? DownloadItemRule { get; set; } = null;
|
||||||
DownloadItemRule == null;
|
|
||||||
|
|
||||||
public enum DownloadItemRules {
|
public bool IsEmpty => MaxBytes == null &&
|
||||||
OnlyNotPresent,
|
DownloadItemRule == null;
|
||||||
OnlyPresent
|
|
||||||
}
|
public enum DownloadItemRules {
|
||||||
|
OnlyNotPresent,
|
||||||
|
OnlyPresent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace DHT.Server.Data.Filters {
|
namespace DHT.Server.Data.Filters;
|
||||||
public sealed class DownloadItemFilter {
|
|
||||||
public HashSet<DownloadStatus>? IncludeStatuses { get; set; } = null;
|
|
||||||
public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
|
|
||||||
|
|
||||||
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null;
|
public sealed class DownloadItemFilter {
|
||||||
}
|
public HashSet<DownloadStatus>? IncludeStatuses { get; init; } = null;
|
||||||
|
public HashSet<DownloadStatus>? ExcludeStatuses { get; init; } = null;
|
||||||
|
|
||||||
|
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null;
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
namespace DHT.Server.Data.Filters {
|
namespace DHT.Server.Data.Filters;
|
||||||
public enum FilterRemovalMode {
|
|
||||||
KeepMatching,
|
public enum FilterRemovalMode {
|
||||||
RemoveMatching
|
KeepMatching,
|
||||||
}
|
RemoveMatching
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user