mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 19:31:42 +02:00
Compare commits
64 Commits
Author | SHA1 | Date | |
---|---|---|---|
ef3e34066a
|
|||
37374eeb18
|
|||
23ddb45a0d
|
|||
9904a711f7
|
|||
d5720c8758
|
|||
89161e14b1
|
|||
9d208b026c
|
|||
119649ef9b
|
|||
0bc6232da7
|
|||
de266473c5
|
|||
e0f359c15b
|
|||
935f11d736
|
|||
f64141e768
|
|||
edea3470df
|
|||
031d521402
|
|||
0131f8cb50
|
|||
3bf5acfa65
|
|||
f603c861c5
|
|||
d2934f4d6a
|
|||
567253d147
|
|||
aa6555990c
|
|||
3d9d6a454a
|
|||
ee39780928
|
|||
7b58f973a0
|
|||
93fe018343
|
|||
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
|
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:
|
||||
* `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
|
||||
|
||||
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
|
||||
|
||||
@@ -29,27 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
|
||||
|
||||
#### Release – Windows (64-bit)
|
||||
|
||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH`
|
||||
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.
|
||||
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)
|
||||
|
||||
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
||||
|
||||
#### Release – Other Operating Systems
|
||||
|
||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3
|
||||
- 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
|
||||
1. Install the `zip` package from your repository
|
||||
|
||||
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
|
||||
|
||||
#### 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">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</H2CodeStyleSettings>
|
||||
<H2CodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</H2CodeStyleSettings>
|
||||
<HSQLCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</HSQLCodeStyleSettings>
|
||||
|
@@ -1,8 +1,8 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<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="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="USE_EXTERNAL_CONSOLE" value="0" />
|
||||
<option name="USE_MONO" value="0" />
|
||||
@@ -12,7 +12,7 @@
|
||||
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
|
||||
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
|
||||
<option name="PROJECT_KIND" value="DotNetCore" />
|
||||
<option name="PROJECT_TFM" value="net5.0" />
|
||||
<option name="PROJECT_TFM" value="net8.0" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</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:common="clr-namespace:DHT.Desktop.Common"
|
||||
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
||||
x:Class="DHT.Desktop.App">
|
||||
x:Class="DHT.Desktop.App"
|
||||
RequestedThemeVariant="Light">
|
||||
|
||||
<Application.Styles>
|
||||
|
||||
<FluentTheme Mode="Light" />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml" />
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" />
|
||||
|
||||
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
@@ -48,68 +49,9 @@
|
||||
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border x:Name="ToggleButtonBackground">
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="35">
|
||||
<Border x:Name="ExpandCollapseChevronBorder"
|
||||
Grid.Column="0"
|
||||
Width="35"
|
||||
Height="35"
|
||||
Margin="2,0"
|
||||
RenderTransformOrigin="50%,50%">
|
||||
<Path x:Name="ExpandCollapseChevron"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
RenderTransformOrigin="50%,50%"
|
||||
Stretch="None"
|
||||
Stroke="{DynamicResource ExpanderChevronForeground}"
|
||||
StrokeThickness="1" />
|
||||
<Border.RenderTransform>
|
||||
<RotateTransform />
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
<ContentPresenter x:Name="PART_ContentPresenter"
|
||||
Grid.Column="1"
|
||||
Margin="0"
|
||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalContentAlignment="Center"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
TextBlock.Foreground="{DynamicResource ExpanderForeground}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<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 Selector="Expander">
|
||||
<Setter Property="MinHeight" Value="40" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
|
||||
</Application.Styles>
|
||||
@@ -168,12 +110,26 @@
|
||||
|
||||
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
|
||||
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
|
||||
<SolidColorBrush x:Key="ExpanderBorderBrush" Color="#697DAB" />
|
||||
<SolidColorBrush x:Key="ExpanderBackground" Color="#697DAB" />
|
||||
<SolidColorBrush x:Key="ExpanderForeground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />
|
||||
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="#697DAB" />
|
||||
<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="ExpanderDropDownBorderBrush" Color="#697DAB" />
|
||||
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ExpanderChevronForegroundPointerOver" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="#FFFFFF" />
|
||||
|
||||
</Application.Resources>
|
||||
|
||||
|
@@ -3,18 +3,18 @@ using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using DHT.Desktop.Main;
|
||||
|
||||
namespace DHT.Desktop {
|
||||
sealed class App : Application {
|
||||
namespace DHT.Desktop;
|
||||
|
||||
sealed class App : Application {
|
||||
public override void Initialize() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted() {
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args));
|
||||
desktop.MainWindow = new MainWindow(Program.Arguments);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,33 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop {
|
||||
sealed class Arguments {
|
||||
namespace DHT.Desktop;
|
||||
|
||||
sealed class Arguments {
|
||||
private static readonly Log Log = Log.ForType<Arguments>();
|
||||
|
||||
public static Arguments Empty => new(Array.Empty<string>());
|
||||
private const int FirstArgument = 1;
|
||||
|
||||
public static Arguments Empty => new (Array.Empty<string>());
|
||||
|
||||
public bool Console { get; }
|
||||
public string? DatabaseFile { get; }
|
||||
public ushort? ServerPort { get; }
|
||||
public string? ServerToken { get; }
|
||||
|
||||
public Arguments(string[] args) {
|
||||
for (int i = 0; i < args.Length; i++) {
|
||||
public Arguments(IReadOnlyList<string> args) {
|
||||
for (int i = FirstArgument; i < args.Count; i++) {
|
||||
string key = args[i];
|
||||
|
||||
switch (key) {
|
||||
case "-debug":
|
||||
Log.IsDebugEnabled = true;
|
||||
continue;
|
||||
|
||||
case "-console":
|
||||
Console = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
string value;
|
||||
|
||||
if (i == 0 && !key.StartsWith('-')) {
|
||||
if (i == FirstArgument && !key.StartsWith('-')) {
|
||||
value = key;
|
||||
key = "-db";
|
||||
}
|
||||
else if (i >= args.Length - 1) {
|
||||
else if (i >= args.Count - 1) {
|
||||
Log.Warn("Missing value for command line argument: " + key);
|
||||
continue;
|
||||
}
|
||||
@@ -61,5 +70,4 @@ namespace DHT.Desktop {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,8 +2,9 @@ using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace DHT.Desktop.Common {
|
||||
sealed class BytesValueConverter : IValueConverter {
|
||||
namespace DHT.Desktop.Common;
|
||||
|
||||
sealed class BytesValueConverter : IValueConverter {
|
||||
private sealed class Unit {
|
||||
private readonly string label;
|
||||
private readonly string numberFormat;
|
||||
@@ -18,13 +19,13 @@ namespace DHT.Desktop.Common {
|
||||
}
|
||||
}
|
||||
|
||||
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 static readonly Unit[] Units = [
|
||||
new Unit("B", decimalPlaces: 0),
|
||||
new Unit("kB", decimalPlaces: 0),
|
||||
new Unit("MB", decimalPlaces: 1),
|
||||
new Unit("GB", decimalPlaces: 1),
|
||||
new Unit("TB", decimalPlaces: 1)
|
||||
];
|
||||
|
||||
private const int Scale = 1000;
|
||||
|
||||
@@ -49,5 +50,4 @@ namespace DHT.Desktop.Common {
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,46 +3,50 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DHT.Desktop.Dialogs.File;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Exceptions;
|
||||
using DHT.Server.Database.Sqlite;
|
||||
using DHT.Server.Database.Sqlite.Utils;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Common {
|
||||
static class DatabaseGui {
|
||||
namespace DHT.Desktop.Common;
|
||||
|
||||
static class DatabaseGui {
|
||||
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
||||
|
||||
private const string DatabaseFileInitialName = "archive.dht";
|
||||
|
||||
private static readonly List<FileDialogFilter> DatabaseFileDialogFilter = new() {
|
||||
new FileDialogFilter {
|
||||
Name = "Discord History Tracker Database",
|
||||
Extensions = { "dht" }
|
||||
}
|
||||
};
|
||||
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
|
||||
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"])
|
||||
];
|
||||
|
||||
public static OpenFileDialog NewOpenDatabaseFileDialog() {
|
||||
return new OpenFileDialog {
|
||||
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
||||
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||
Title = "Open Database File",
|
||||
InitialFileName = DatabaseFileInitialName,
|
||||
Filters = DatabaseFileDialogFilter
|
||||
};
|
||||
FileTypeFilter = DatabaseFileDialogFilter,
|
||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||
AllowMultiple = true
|
||||
});
|
||||
}
|
||||
|
||||
public static SaveFileDialog NewOpenOrCreateDatabaseFileDialog() {
|
||||
return new SaveFileDialog {
|
||||
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
||||
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||
Title = "Open or Create Database File",
|
||||
InitialFileName = DatabaseFileInitialName,
|
||||
Filters = DatabaseFileDialogFilter
|
||||
};
|
||||
FileTypeChoices = DatabaseFileDialogFilter,
|
||||
SuggestedFileName = DatabaseFileInitialName,
|
||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||
ShowOverwritePrompt = false
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
|
||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||
IDatabaseFile? file = null;
|
||||
|
||||
try {
|
||||
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
|
||||
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
|
||||
} catch (InvalidDatabaseVersionException ex) {
|
||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
||||
} catch (DatabaseTooNewException ex) {
|
||||
@@ -62,5 +66,4 @@ namespace DHT.Desktop.Common {
|
||||
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,8 +2,9 @@ using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace DHT.Desktop.Common {
|
||||
sealed class NumberValueConverter : IValueConverter {
|
||||
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);
|
||||
}
|
||||
@@ -11,5 +12,4 @@ namespace DHT.Desktop.Common {
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
namespace DHT.Desktop.Common {
|
||||
static class TextFormat {
|
||||
namespace DHT.Desktop.Common;
|
||||
|
||||
static class TextFormat {
|
||||
public static string Format(this int number) {
|
||||
return number.ToString("N0", Program.Culture);
|
||||
}
|
||||
@@ -15,5 +16,4 @@ namespace DHT.Desktop.Common {
|
||||
public static string Pluralize(this long number, string singular) {
|
||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,34 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>DHT.Desktop</RootNamespace>
|
||||
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
||||
<PackageId>DiscordHistoryTracker</PackageId>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<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>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
|
||||
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
|
||||
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="0.10.16" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.16" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.16" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.16" Condition=" '$(Configuration)' == 'Debug' " />
|
||||
<PackageReference Include="Avalonia" Version="11.0.6" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.6" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" />
|
||||
<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.ReactiveUI" Version="11.0.6" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Server\Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
|
||||
@@ -36,22 +37,11 @@
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Resources/icon.ico" />
|
||||
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js">
|
||||
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</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 Include="Resources/tracker-loader.js">
|
||||
<LogicalName>tracker-loader.js</LogicalName>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="../Resources/Viewer/**">
|
||||
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
@@ -59,19 +49,5 @@
|
||||
<Visible>false</Visible>
|
||||
</EmbeddedResource>
|
||||
</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>
|
||||
|
@@ -5,6 +5,7 @@
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
|
||||
mc:Ignorable="d" d:DesignWidth="500"
|
||||
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
|
||||
x:DataType="namespace:CheckBoxDialogModel"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="500" SizeToContent="Height" CanResize="False"
|
||||
@@ -34,10 +35,10 @@
|
||||
|
||||
<StackPanel Margin="20">
|
||||
<ScrollViewer MaxHeight="400">
|
||||
<ItemsRepeater Items="{Binding Items}">
|
||||
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Checked}">
|
||||
<CheckBox IsChecked="{Binding IsChecked}">
|
||||
<Label>
|
||||
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
||||
</Label>
|
||||
|
@@ -1,22 +1,14 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class CheckBoxDialog : Window {
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class CheckBoxDialog : Window {
|
||||
public CheckBoxDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
@@ -26,6 +18,4 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -2,10 +2,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using DHT.Utils.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
class CheckBoxDialogModel : BaseModel {
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
|
||||
class CheckBoxDialogModel : ObservableObject {
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
||||
@@ -28,8 +29,8 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
|
||||
private bool pauseCheckEvents = false;
|
||||
|
||||
public bool AreAllSelected => Items.All(static item => item.Checked);
|
||||
public bool AreNoneSelected => Items.All(static item => !item.Checked);
|
||||
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
||||
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
||||
|
||||
public void SelectAll() => SetAllChecked(true);
|
||||
public void SelectNone() => SetAllChecked(false);
|
||||
@@ -38,7 +39,7 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
pauseCheckEvents = true;
|
||||
|
||||
foreach (var item in Items) {
|
||||
item.Checked = isChecked;
|
||||
item.IsChecked = isChecked;
|
||||
}
|
||||
|
||||
pauseCheckEvents = false;
|
||||
@@ -51,20 +52,19 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
}
|
||||
|
||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
|
||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
||||
UpdateBulkButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||
private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||
|
||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
|
||||
|
||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||
this.Items = new List<CheckBoxItem<T>>(items);
|
||||
base.Items = this.Items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,24 +1,20 @@
|
||||
using DHT.Utils.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
class CheckBoxItem : BaseModel {
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
|
||||
partial class CheckBoxItem : ObservableObject {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isChecked = false;
|
||||
}
|
||||
|
||||
public bool Checked {
|
||||
get => isChecked;
|
||||
set => Change(ref isChecked, value);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||
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,8 +2,9 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
static class Dialog {
|
||||
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));
|
||||
@@ -70,5 +71,4 @@ namespace DHT.Desktop.Dialogs.Message {
|
||||
|
||||
return result.ToYesNoCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
static class DialogResult {
|
||||
namespace DHT.Desktop.Dialogs.Message;
|
||||
|
||||
static class DialogResult {
|
||||
public enum All {
|
||||
Ok,
|
||||
Yes,
|
||||
@@ -55,5 +56,4 @@ namespace DHT.Desktop.Dialogs.Message {
|
||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
||||
mc:Ignorable="d" d:DesignWidth="500"
|
||||
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
||||
x:DataType="namespace:MessageDialogModel"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="500" SizeToContent="Height" CanResize="False"
|
||||
|
@@ -1,21 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MessageDialog : Window {
|
||||
namespace DHT.Desktop.Dialogs.Message;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class MessageDialog : Window {
|
||||
public MessageDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
@@ -33,5 +25,4 @@ namespace DHT.Desktop.Dialogs.Message {
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
sealed class MessageDialogModel {
|
||||
namespace DHT.Desktop.Dialogs.Message;
|
||||
|
||||
sealed class MessageDialogModel {
|
||||
public string Title { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
|
||||
@@ -7,5 +8,4 @@ namespace DHT.Desktop.Dialogs.Message {
|
||||
public bool IsYesVisible { get; init; } = false;
|
||||
public bool IsNoVisible { get; init; } = false;
|
||||
public bool IsCancelVisible { get; init; } = false;
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
interface IProgressCallback {
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
interface IProgressCallback {
|
||||
Task Update(string message, int finishedItems, int totalItems);
|
||||
}
|
||||
Task UpdateIndeterminate(string message);
|
||||
Task Hide();
|
||||
}
|
||||
|
@@ -5,9 +5,10 @@
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
||||
mc:Ignorable="d" d:DesignWidth="500"
|
||||
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
|
||||
x:DataType="namespace:ProgressDialogModel"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Opened="Loaded"
|
||||
Opened="OnOpened"
|
||||
Closing="OnClosing"
|
||||
Width="500" SizeToContent="Height" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
@@ -31,12 +32,18 @@
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<StackPanel Margin="20">
|
||||
<ItemsRepeater ItemsSource="{Binding Items}" Margin="0 10">
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
||||
</DockPanel>
|
||||
<ProgressBar Value="{Binding Progress}" />
|
||||
<ProgressBar IsIndeterminate="{Binding IsIndeterminate}" Value="{Binding Progress}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
|
||||
</Window>
|
||||
|
@@ -1,40 +1,89 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class ProgressDialog : Window {
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class ProgressDialog : Window {
|
||||
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
try {
|
||||
await action(dialog, callbacks[0]);
|
||||
taskCompletionSource.SetResult();
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
internal static async Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
await callbacks[0].UpdateIndeterminate(message);
|
||||
try {
|
||||
await action(dialog);
|
||||
taskCompletionSource.SetResult();
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource<T>();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
await callbacks[0].UpdateIndeterminate(message);
|
||||
try {
|
||||
taskCompletionSource.SetResult(await action(dialog));
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
return await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
private bool isFinished = false;
|
||||
private Task progressTask = Task.CompletedTask;
|
||||
|
||||
public ProgressDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void OnClosing(object? sender, CancelEventArgs e) {
|
||||
e.Cancel = !isFinished;
|
||||
}
|
||||
|
||||
public void Loaded(object? sender, EventArgs e) {
|
||||
public void OnOpened(object? sender, EventArgs e) {
|
||||
if (DataContext is ProgressDialogModel model) {
|
||||
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
progressTask = Task.Run(model.StartTask);
|
||||
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
}
|
||||
|
||||
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,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
sealed class ProgressDialogModel : BaseModel {
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
sealed class ProgressDialogModel {
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
private string message = "";
|
||||
|
||||
public string Message {
|
||||
get => message;
|
||||
private set => Change(ref message, value);
|
||||
}
|
||||
|
||||
private string items = "";
|
||||
|
||||
public string Items {
|
||||
get => items;
|
||||
private set => Change(ref items, value);
|
||||
}
|
||||
|
||||
private int progress = 0;
|
||||
|
||||
public int Progress {
|
||||
get => progress;
|
||||
private set => Change(ref progress, value);
|
||||
}
|
||||
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
||||
|
||||
private readonly TaskRunner? task;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ProgressDialogModel() {}
|
||||
|
||||
public ProgressDialogModel(TaskRunner task) {
|
||||
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
|
||||
this.Title = title;
|
||||
this.task = task;
|
||||
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||
}
|
||||
|
||||
internal async Task StartTask() {
|
||||
if (task != null) {
|
||||
await task(new Callback(this));
|
||||
await task(Items.Select(static item => new Callback(item)).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public delegate Task TaskRunner(IProgressCallback callback);
|
||||
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
||||
|
||||
private sealed class Callback : IProgressCallback {
|
||||
private readonly ProgressDialogModel model;
|
||||
private readonly ProgressItem item;
|
||||
|
||||
public Callback(ProgressDialogModel model) {
|
||||
this.model = model;
|
||||
public Callback(ProgressItem item) {
|
||||
this.item = item;
|
||||
}
|
||||
|
||||
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
|
||||
public async Task 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;
|
||||
item.Message = message;
|
||||
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
||||
item.IsIndeterminate = false;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task UpdateIndeterminate(string message) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||
item.Message = message;
|
||||
item.Items = string.Empty;
|
||||
item.Progress = 0;
|
||||
item.IsIndeterminate = true;
|
||||
});
|
||||
}
|
||||
|
||||
public Task Hide() {
|
||||
return Update(string.Empty, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
30
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
30
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
sealed partial class ProgressItem : ObservableObject {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(Opacity))]
|
||||
private bool isVisible = false;
|
||||
|
||||
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||
|
||||
private string message = "";
|
||||
|
||||
public string Message {
|
||||
get => message;
|
||||
set {
|
||||
SetProperty(ref message, value);
|
||||
IsVisible = !string.IsNullOrEmpty(value);
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string items = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private int progress = 0;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isIndeterminate;
|
||||
}
|
@@ -5,6 +5,7 @@
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
|
||||
mc:Ignorable="d" d:DesignWidth="500"
|
||||
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
||||
x:DataType="namespace:TextBoxDialogModel"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="500" SizeToContent="Height" CanResize="False"
|
||||
@@ -31,7 +32,7 @@
|
||||
<ScrollViewer MaxHeight="400">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
|
||||
<ItemsRepeater Items="{Binding Items}">
|
||||
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0 5 25 0">
|
||||
|
@@ -1,22 +1,14 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class TextBoxDialog : Window {
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class TextBoxDialog : Window {
|
||||
public TextBoxDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
@@ -26,6 +18,4 @@ namespace DHT.Desktop.Dialogs.TextBox {
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -2,10 +2,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using DHT.Utils.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox {
|
||||
class TextBoxDialogModel : BaseModel {
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
class TextBoxDialogModel : ObservableObject {
|
||||
public string Title { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
@@ -32,10 +33,10 @@ namespace DHT.Desktop.Dialogs.TextBox {
|
||||
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||
OnPropertyChanged(nameof(HasErrors));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||
|
||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||
|
||||
@@ -43,5 +44,4 @@ namespace DHT.Desktop.Dialogs.TextBox {
|
||||
this.Items = new List<TextBoxItem<T>>(items);
|
||||
base.Items = this.Items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox {
|
||||
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
@@ -16,7 +17,7 @@ namespace DHT.Desktop.Dialogs.TextBox {
|
||||
public string Value {
|
||||
get => this.value;
|
||||
set {
|
||||
Change(ref this.value, value);
|
||||
SetProperty(ref this.value, value);
|
||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||
}
|
||||
}
|
||||
@@ -29,14 +30,13 @@ namespace DHT.Desktop.Dialogs.TextBox {
|
||||
|
||||
public bool HasErrors => !IsValid;
|
||||
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TextBoxItem<T> : TextBoxItem {
|
||||
sealed class TextBoxItem<T> : TextBoxItem {
|
||||
public new T Item { get; }
|
||||
|
||||
public TextBoxItem(T item) {
|
||||
this.Item = item;
|
||||
base.Item = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,16 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Utils.Logging;
|
||||
using static System.Environment.SpecialFolder;
|
||||
using static System.Environment.SpecialFolderOption;
|
||||
|
||||
namespace DHT.Desktop.Discord {
|
||||
static class DiscordAppSettings {
|
||||
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";
|
||||
@@ -38,18 +39,21 @@ namespace DHT.Desktop.Discord {
|
||||
|
||||
public static async Task<bool?> AreDevToolsEnabled() {
|
||||
try {
|
||||
return AreDevToolsEnabled(await ReadSettingsJson());
|
||||
} catch (Exception) {
|
||||
var settingsJson = await ReadSettingsJson();
|
||||
return AreDevToolsEnabled(settingsJson);
|
||||
} catch (Exception e) {
|
||||
Log.Error("Cannot read settings file.");
|
||||
Log.Error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) {
|
||||
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.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) {
|
||||
Dictionary<string, object?> json;
|
||||
JsonObject json;
|
||||
|
||||
try {
|
||||
json = await ReadSettingsJson();
|
||||
@@ -106,14 +110,13 @@ namespace DHT.Desktop.Discord {
|
||||
return SettingsJsonResult.Success;
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
|
||||
private static async Task<JsonObject> 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();
|
||||
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
||||
}
|
||||
|
||||
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
|
||||
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, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
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 {
|
||||
enum SettingsJsonResult {
|
||||
namespace DHT.Desktop.Discord;
|
||||
|
||||
enum SettingsJsonResult {
|
||||
Success,
|
||||
AlreadySet,
|
||||
FileNotFound,
|
||||
ReadError,
|
||||
InvalidJson,
|
||||
WriteError
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@
|
||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
|
||||
x:Class="DHT.Desktop.Main.AboutWindow"
|
||||
x:DataType="main:AboutWindowModel"
|
||||
Title="About Discord History Tracker"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
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="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>
|
||||
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
||||
|
||||
|
@@ -1,20 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class AboutWindow : Window {
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class AboutWindow : Window {
|
||||
public AboutWindow() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
sealed class AboutWindowModel {
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
sealed class AboutWindowModel {
|
||||
public void ShowOfficialWebsite() {
|
||||
OpenUrl("https://dht.chylex.com");
|
||||
}
|
||||
@@ -29,5 +30,4 @@ namespace DHT.Desktop.Main {
|
||||
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:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel">
|
||||
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"
|
||||
x:DataType="controls:AttachmentFilterPanelModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:AttachmentFilterPanelModel />
|
||||
@@ -36,7 +37,7 @@
|
||||
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<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>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}" />
|
||||
|
@@ -1,17 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class AttachmentFilterPanel : UserControl {
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class AttachmentFilterPanel : UserControl {
|
||||
public AttachmentFilterPanel() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,75 +1,71 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class AttachmentFilterPanelModel : ObservableObject, IDisposable {
|
||||
public sealed record Unit(string Name, uint Scale);
|
||||
|
||||
private static readonly Unit[] AllUnits = {
|
||||
new ("B", 1),
|
||||
new ("kB", 1024),
|
||||
new ("MB", 1024 * 1024)
|
||||
};
|
||||
private static readonly Unit[] AllUnits = [
|
||||
new Unit("B", 1),
|
||||
new Unit("kB", 1024),
|
||||
new Unit("MB", 1024 * 1024)
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> FilterProperties = new () {
|
||||
private static readonly HashSet<string> FilterProperties = [
|
||||
nameof(LimitSize),
|
||||
nameof(MaximumSize),
|
||||
nameof(MaximumSizeUnit)
|
||||
};
|
||||
];
|
||||
|
||||
public string FilterStatisticsText { get; private set; } = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool limitSize = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private ulong maximumSize = 0L;
|
||||
|
||||
[ObservableProperty]
|
||||
private Unit maximumSizeUnit = AllUnits[0];
|
||||
|
||||
public bool LimitSize {
|
||||
get => limitSize;
|
||||
set => Change(ref limitSize, value);
|
||||
}
|
||||
|
||||
public ulong MaximumSize {
|
||||
get => maximumSize;
|
||||
set => Change(ref maximumSize, value);
|
||||
}
|
||||
|
||||
public Unit MaximumSizeUnit {
|
||||
get => maximumSizeUnit;
|
||||
set => Change(ref maximumSizeUnit, value);
|
||||
}
|
||||
|
||||
public IEnumerable<Unit> Units => AllUnits;
|
||||
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly State state;
|
||||
private readonly string verb;
|
||||
|
||||
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
|
||||
private readonly RestartableTask<long> matchingAttachmentCountTask;
|
||||
private long? matchingAttachmentCount;
|
||||
|
||||
private readonly IDisposable attachmentCountSubscription;
|
||||
private long? totalAttachmentCount;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {}
|
||||
public AttachmentFilterPanelModel() : this(State.Dummy) {}
|
||||
|
||||
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") {
|
||||
this.db = db;
|
||||
public AttachmentFilterPanelModel(State state, string verb = "Matches") {
|
||||
this.state = state;
|
||||
this.verb = verb;
|
||||
|
||||
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
|
||||
this.matchingAttachmentCountTask = new RestartableTask<long>(SetAttachmentCounts, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
this.attachmentCountSubscription = state.Db.Attachments.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnAttachmentCountChanged);
|
||||
|
||||
UpdateFilterStatistics();
|
||||
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||
attachmentCountSubscription.Dispose();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
@@ -78,24 +74,23 @@ namespace DHT.Desktop.Main.Controls {
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
||||
totalAttachmentCount = db.Statistics.TotalAttachments;
|
||||
private void OnAttachmentCountChanged(long newAttachmentCount) {
|
||||
totalAttachmentCount = newAttachmentCount;
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void UpdateFilterStatistics() {
|
||||
var filter = CreateFilter();
|
||||
if (filter.IsEmpty) {
|
||||
matchingAttachmentCountComputer.Cancel();
|
||||
matchingAttachmentCountTask.Cancel();
|
||||
matchingAttachmentCount = totalAttachmentCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
else {
|
||||
matchingAttachmentCount = null;
|
||||
UpdateFilterStatisticsText();
|
||||
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter));
|
||||
matchingAttachmentCountTask.Restart(cancellationToken => state.Db.Attachments.Count(filter, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +108,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
}
|
||||
|
||||
public AttachmentFilter CreateFilter() {
|
||||
AttachmentFilter filter = new();
|
||||
AttachmentFilter filter = new ();
|
||||
|
||||
if (LimitSize) {
|
||||
try {
|
||||
@@ -125,5 +120,4 @@ namespace DHT.Desktop.Main.Controls {
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel">
|
||||
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
|
||||
x:DataType="controls:MessageFilterPanelModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:MessageFilterPanelModel />
|
||||
|
@@ -1,19 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
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");
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class MessageFilterPanel : UserControl {
|
||||
public MessageFilterPanel() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
var culture = Program.Culture;
|
||||
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
||||
@@ -30,5 +23,4 @@ namespace DHT.Desktop.Main.Controls {
|
||||
model.EndDate = EndDatePicker.SelectedDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,21 +2,25 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.CheckBox;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
||||
private static readonly HashSet<string> FilterProperties = new () {
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
private static readonly HashSet<string> FilterProperties = [
|
||||
nameof(FilterByDate),
|
||||
nameof(StartDate),
|
||||
nameof(EndDate),
|
||||
@@ -24,7 +28,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
nameof(IncludedChannels),
|
||||
nameof(FilterByUser),
|
||||
nameof(IncludedUsers)
|
||||
};
|
||||
];
|
||||
|
||||
public string FilterStatisticsText { get; private set; } = "";
|
||||
|
||||
@@ -32,91 +36,76 @@ namespace DHT.Desktop.Main.Controls {
|
||||
|
||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool filterByDate = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime? startDate = null;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime? endDate = null;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool filterByChannel = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private HashSet<ulong>? includedChannels = null;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool filterByUser = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private HashSet<ulong>? includedUsers = null;
|
||||
|
||||
public bool FilterByDate {
|
||||
get => filterByDate;
|
||||
set => Change(ref filterByDate, value);
|
||||
}
|
||||
|
||||
public DateTime? StartDate {
|
||||
get => startDate;
|
||||
set => Change(ref startDate, value);
|
||||
}
|
||||
|
||||
public DateTime? EndDate {
|
||||
get => endDate;
|
||||
set => Change(ref endDate, value);
|
||||
}
|
||||
|
||||
public bool FilterByChannel {
|
||||
get => filterByChannel;
|
||||
set => Change(ref filterByChannel, value);
|
||||
}
|
||||
|
||||
public HashSet<ulong> IncludedChannels {
|
||||
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
|
||||
set => Change(ref includedChannels, value);
|
||||
}
|
||||
|
||||
public bool FilterByUser {
|
||||
get => filterByUser;
|
||||
set => Change(ref filterByUser, value);
|
||||
}
|
||||
|
||||
public HashSet<ulong> IncludedUsers {
|
||||
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
|
||||
set => Change(ref includedUsers, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string channelFilterLabel = "";
|
||||
|
||||
public string ChannelFilterLabel {
|
||||
get => channelFilterLabel;
|
||||
set => Change(ref channelFilterLabel, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string userFilterLabel = "";
|
||||
|
||||
public string UserFilterLabel {
|
||||
get => userFilterLabel;
|
||||
set => Change(ref userFilterLabel, value);
|
||||
}
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly State state;
|
||||
private readonly string verb;
|
||||
|
||||
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
|
||||
private readonly RestartableTask<long> exportedMessageCountTask;
|
||||
private long? exportedMessageCount;
|
||||
|
||||
private readonly IDisposable messageCountSubscription;
|
||||
private long? totalMessageCount;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
private readonly IDisposable channelCountSubscription;
|
||||
private long? totalChannelCount;
|
||||
|
||||
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
|
||||
private readonly IDisposable userCountSubscription;
|
||||
private long? totalUserCount;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
this.state = state;
|
||||
this.verb = verb;
|
||||
|
||||
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
|
||||
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
|
||||
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
|
||||
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
|
||||
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
|
||||
|
||||
UpdateFilterStatistics();
|
||||
UpdateChannelFilterLabel();
|
||||
UpdateUserFilterLabel();
|
||||
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||
exportedMessageCountTask.Cancel();
|
||||
|
||||
messageCountSubscription.Dispose();
|
||||
channelCountSubscription.Dispose();
|
||||
userCountSubscription.Dispose();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
@@ -133,30 +122,54 @@ namespace DHT.Desktop.Main.Controls {
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
||||
totalMessageCount = db.Statistics.TotalMessages;
|
||||
private void OnMessageCountChanged(long newMessageCount) {
|
||||
totalMessageCount = newMessageCount;
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
||||
|
||||
private void OnChannelCountChanged(long newChannelCount) {
|
||||
totalChannelCount = newChannelCount;
|
||||
UpdateChannelFilterLabel();
|
||||
}
|
||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
||||
|
||||
private void OnUserCountChanged(long newUserCount) {
|
||||
totalUserCount = newUserCount;
|
||||
UpdateUserFilterLabel();
|
||||
}
|
||||
|
||||
private void UpdateChannelFilterLabel() {
|
||||
if (totalChannelCount.HasValue) {
|
||||
long total = totalChannelCount.Value;
|
||||
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
|
||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
||||
}
|
||||
else {
|
||||
ChannelFilterLabel = "Loading...";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUserFilterLabel() {
|
||||
if (totalUserCount.HasValue) {
|
||||
long total = totalUserCount.Value;
|
||||
long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total;
|
||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
||||
}
|
||||
else {
|
||||
UserFilterLabel = "Loading...";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFilterStatistics() {
|
||||
var filter = CreateFilter();
|
||||
if (filter.IsEmpty) {
|
||||
exportedMessageCountComputer.Cancel();
|
||||
exportedMessageCountTask.Cancel();
|
||||
exportedMessageCount = totalMessageCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
else {
|
||||
exportedMessageCount = null;
|
||||
UpdateFilterStatisticsText();
|
||||
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
|
||||
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,12 +186,12 @@ namespace DHT.Desktop.Main.Controls {
|
||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||
}
|
||||
|
||||
public async void OpenChannelFilterDialog() {
|
||||
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
||||
public async Task OpenChannelFilterDialog() {
|
||||
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var included = IncludedChannels;
|
||||
var servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
||||
|
||||
foreach (var channel in db.GetAllChannels()) {
|
||||
await foreach (var channel in state.Db.Channels.Get()) {
|
||||
var channelId = channel.Id;
|
||||
var channelName = channel.Name;
|
||||
|
||||
@@ -208,68 +221,63 @@ namespace DHT.Desktop.Main.Controls {
|
||||
|
||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||
Title = title,
|
||||
Checked = included.Contains(channelId)
|
||||
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId)
|
||||
});
|
||||
}
|
||||
|
||||
var result = await OpenIdFilterDialog(window, "Included Channels", items);
|
||||
return items;
|
||||
}
|
||||
|
||||
const string Title = "Included Channels";
|
||||
|
||||
List<CheckBoxItem<ulong>> items;
|
||||
try {
|
||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await OpenIdFilterDialog(Title, items);
|
||||
if (result != null) {
|
||||
IncludedChannels = result;
|
||||
}
|
||||
}
|
||||
|
||||
public async void OpenUserFilterDialog() {
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var included = IncludedUsers;
|
||||
public async Task OpenUserFilterDialog() {
|
||||
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
|
||||
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
||||
|
||||
foreach (var user in db.GetAllUsers()) {
|
||||
await foreach (var user in state.Db.Users.Get()) {
|
||||
var name = user.Name;
|
||||
var discriminator = user.Discriminator;
|
||||
|
||||
items.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||
Title = discriminator == null ? name : name + " #" + discriminator,
|
||||
Checked = included.Contains(user.Id)
|
||||
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
||||
});
|
||||
}
|
||||
|
||||
var result = await OpenIdFilterDialog(window, "Included Users", items);
|
||||
return checkBoxItems;
|
||||
}
|
||||
|
||||
const string Title = "Included Users";
|
||||
|
||||
List<CheckBoxItem<ulong>> items;
|
||||
try {
|
||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await OpenIdFilterDialog(Title, 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) {
|
||||
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) {
|
||||
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||
|
||||
var model = new CheckBoxDialogModel<ulong>(items) {
|
||||
@@ -281,5 +289,23 @@ namespace DHT.Desktop.Main.Controls {
|
||||
|
||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
||||
}
|
||||
|
||||
public MessageFilter CreateFilter() {
|
||||
MessageFilter filter = new ();
|
||||
|
||||
if (FilterByDate) {
|
||||
filter.StartDate = StartDate;
|
||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
||||
}
|
||||
|
||||
if (FilterByChannel && IncludedChannels != null) {
|
||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
||||
}
|
||||
|
||||
if (FilterByUser && IncludedUsers != null) {
|
||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel">
|
||||
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
|
||||
x:DataType="controls:ServerConfigurationPanelModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:ServerConfigurationPanelModel />
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class ServerConfigurationPanel : UserControl {
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class ServerConfigurationPanel : UserControl {
|
||||
public ServerConfigurationPanel() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,95 +1,95 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable {
|
||||
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||
private string inputPort;
|
||||
|
||||
public string InputPort {
|
||||
get => inputPort;
|
||||
set {
|
||||
Change(ref inputPort, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||
private string inputToken;
|
||||
|
||||
public string InputToken {
|
||||
get => inputToken;
|
||||
set {
|
||||
Change(ref inputToken, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
||||
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
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;
|
||||
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
||||
|
||||
private readonly Window window;
|
||||
private readonly ServerManager serverManager;
|
||||
private readonly ServerManager server;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
|
||||
public ServerConfigurationPanelModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.serverManager = serverManager;
|
||||
this.inputPort = ServerManager.Port.ToString();
|
||||
this.inputToken = ServerManager.Token;
|
||||
}
|
||||
this.server = state.Server;
|
||||
this.inputPort = ServerConfiguration.Port.ToString();
|
||||
this.inputToken = ServerConfiguration.Token;
|
||||
|
||||
public void Initialize() {
|
||||
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
||||
server.StatusChanged += OnServerStatusChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
|
||||
server.StatusChanged -= OnServerStatusChanged;
|
||||
}
|
||||
|
||||
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
||||
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
|
||||
}
|
||||
|
||||
private void UpdateServerStatus() {
|
||||
OnPropertyChanged(nameof(ToggleServerButtonText));
|
||||
}
|
||||
|
||||
private async Task StartServer() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
|
||||
try {
|
||||
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||
}
|
||||
|
||||
UpdateServerStatus();
|
||||
IsToggleServerButtonEnabled = true;
|
||||
}
|
||||
|
||||
private void BeforeServerStart() {
|
||||
private async Task StopServer() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
||||
|
||||
try {
|
||||
await server.Stop();
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||
}
|
||||
|
||||
private void StartServer() {
|
||||
BeforeServerStart();
|
||||
serverManager.Launch();
|
||||
UpdateServerStatus();
|
||||
IsToggleServerButtonEnabled = true;
|
||||
}
|
||||
|
||||
private void StopServer() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
||||
serverManager.Stop();
|
||||
}
|
||||
|
||||
public void OnClickToggleServerButton() {
|
||||
if (serverManager.IsRunning) {
|
||||
StopServer();
|
||||
public async Task OnClickToggleServerButton() {
|
||||
if (server.IsRunning) {
|
||||
await StopServer();
|
||||
}
|
||||
else {
|
||||
StartServer();
|
||||
await StartServer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,20 +97,22 @@ namespace DHT.Desktop.Main.Controls {
|
||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||
}
|
||||
|
||||
public async void OnClickApplyChanges() {
|
||||
public async Task 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);
|
||||
ServerConfiguration.Port = port;
|
||||
ServerConfiguration.Token = inputToken;
|
||||
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
|
||||
await StartServer();
|
||||
}
|
||||
|
||||
public void OnClickCancelChanges() {
|
||||
InputPort = ServerManager.Port.ToString();
|
||||
InputToken = ServerManager.Token;
|
||||
}
|
||||
InputPort = ServerConfiguration.Port.ToString();
|
||||
InputToken = ServerConfiguration.Token;
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.StatusBar">
|
||||
x:Class="DHT.Desktop.Main.Controls.StatusBar"
|
||||
x:DataType="controls:StatusBarModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:StatusBarModel />
|
||||
@@ -39,22 +40,22 @@
|
||||
<StackPanel Orientation="Horizontal" Margin="6 3">
|
||||
<StackPanel Orientation="Vertical" Width="65">
|
||||
<TextBlock Classes="label">Status</TextBlock>
|
||||
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
|
||||
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Servers</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding ServerCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Channels</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding ChannelCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Messages</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding MessageCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class StatusBar : UserControl {
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class StatusBar : UserControl {
|
||||
public StatusBar() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,45 +1,63 @@
|
||||
using System;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
using System.Reactive.Linq;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Service;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
sealed class StatusBarModel : BaseModel {
|
||||
public DatabaseStatistics DatabaseStatistics { get; }
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
private Status status = Status.Stopped;
|
||||
sealed partial class StatusBarModel : ObservableObject, IDisposable {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? serverCount;
|
||||
|
||||
public Status CurrentStatus {
|
||||
get => status;
|
||||
set {
|
||||
status = value;
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
}
|
||||
}
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? channelCount;
|
||||
|
||||
public string StatusText {
|
||||
get {
|
||||
return CurrentStatus switch {
|
||||
Status.Starting => "STARTING",
|
||||
Status.Ready => "READY",
|
||||
Status.Stopping => "STOPPING",
|
||||
Status.Stopped => "STOPPED",
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? messageCount;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
||||
private ServerManager.Status serverStatus;
|
||||
|
||||
public string ServerStatusText => serverStatus switch {
|
||||
ServerManager.Status.Starting => "STARTING",
|
||||
ServerManager.Status.Started => "READY",
|
||||
ServerManager.Status.Stopping => "STOPPING",
|
||||
ServerManager.Status.Stopped => "STOPPED",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private readonly State state;
|
||||
private readonly IDisposable serverCountSubscription;
|
||||
private readonly IDisposable channelCountSubscription;
|
||||
private readonly IDisposable messageCountSubscription;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public StatusBarModel() : this(new DatabaseStatistics()) {}
|
||||
public StatusBarModel() : this(State.Dummy) {}
|
||||
|
||||
public StatusBarModel(DatabaseStatistics databaseStatistics) {
|
||||
this.DatabaseStatistics = databaseStatistics;
|
||||
public StatusBarModel(State state) {
|
||||
this.state = state;
|
||||
|
||||
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
|
||||
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
|
||||
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
|
||||
|
||||
state.Server.StatusChanged += OnServerStatusChanged;
|
||||
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
Starting,
|
||||
Ready,
|
||||
Stopping,
|
||||
Stopped
|
||||
public void Dispose() {
|
||||
serverCountSubscription.Dispose();
|
||||
channelCountSubscription.Dispose();
|
||||
messageCountSubscription.Dispose();
|
||||
|
||||
state.Server.StatusChanged -= OnServerStatusChanged;
|
||||
}
|
||||
|
||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
|
||||
}
|
||||
}
|
||||
|
@@ -5,12 +5,13 @@
|
||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.MainWindow"
|
||||
x:DataType="main:MainWindowModel"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="800" Height="500"
|
||||
Width="820" Height="520"
|
||||
MinWidth="520" MinHeight="300"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Closed="OnClosed">
|
||||
Closing="OnClosing">
|
||||
|
||||
<Design.DataContext>
|
||||
<main:MainWindowModel />
|
||||
|
@@ -1,36 +1,47 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using Avalonia;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using DHT.Utils.Logging;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MainWindow : Window {
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class MainWindow : Window {
|
||||
private static readonly Log Log = Log.ForType<MainWindow>();
|
||||
|
||||
[UsedImplicitly]
|
||||
public MainWindow() {
|
||||
InitializeComponent(Arguments.Empty);
|
||||
InitializeComponent();
|
||||
DataContext = new MainWindowModel(this, Arguments.Empty);
|
||||
}
|
||||
|
||||
internal MainWindow(Arguments args) {
|
||||
InitializeComponent(args);
|
||||
}
|
||||
|
||||
private void InitializeComponent(Arguments args) {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
InitializeComponent();
|
||||
DataContext = new MainWindowModel(this, args);
|
||||
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
public void OnClosed(object? sender, EventArgs e) {
|
||||
if (DataContext is IDisposable disposable) {
|
||||
disposable.Dispose();
|
||||
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||
e.Cancel = true;
|
||||
Closing -= OnClosing;
|
||||
|
||||
try {
|
||||
await Dispose();
|
||||
} finally {
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Dispose() {
|
||||
if (DataContext is MainWindowModel model) {
|
||||
try {
|
||||
await model.DisposeAsync();
|
||||
} catch (Exception ex) {
|
||||
Log.Error("Caught exception while disposing window: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
||||
@@ -41,5 +52,4 @@ namespace DHT.Desktop.Main {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,32 +1,37 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Screens;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
sealed class MainWindowModel : BaseModel, IDisposable {
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||
private const string DefaultTitle = "Discord History Tracker";
|
||||
|
||||
public string Title { get; private set; } = DefaultTitle;
|
||||
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
||||
|
||||
public UserControl CurrentScreen { get; private set; }
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private string title = DefaultTitle;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private UserControl currentScreen;
|
||||
|
||||
private readonly WelcomeScreen welcomeScreen;
|
||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||
|
||||
private MainContentScreen? mainContentScreen;
|
||||
private MainContentScreenModel? mainContentScreenModel;
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
private IDatabaseFile? db;
|
||||
private State? state;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||
@@ -35,10 +40,10 @@ namespace DHT.Desktop.Main {
|
||||
this.window = window;
|
||||
|
||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||
CurrentScreen = welcomeScreen;
|
||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||
|
||||
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||
currentScreen = welcomeScreen;
|
||||
|
||||
var dbFile = args.DatabaseFile;
|
||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||
@@ -62,55 +67,61 @@ namespace DHT.Desktop.Main {
|
||||
}
|
||||
|
||||
if (args.ServerPort != null) {
|
||||
ServerManager.Port = args.ServerPort.Value;
|
||||
ServerConfiguration.Port = args.ServerPort.Value;
|
||||
}
|
||||
|
||||
if (args.ServerToken != null) {
|
||||
ServerManager.Token = args.ServerToken;
|
||||
ServerConfiguration.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();
|
||||
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
||||
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
|
||||
|
||||
await DisposeState();
|
||||
|
||||
state = new State(db);
|
||||
|
||||
try {
|
||||
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||
} catch (Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||
}
|
||||
|
||||
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 = new MainContentScreenModel(window, state);
|
||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||
CurrentScreen = mainContentScreen;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(CurrentScreen));
|
||||
OnPropertyChanged(nameof(Title));
|
||||
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
||||
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||
|
||||
window.Focus();
|
||||
}
|
||||
|
||||
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||
if (mainContentScreenModel != null) {
|
||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||
mainContentScreenModel.Dispose();
|
||||
mainContentScreenModel = null;
|
||||
}
|
||||
|
||||
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||
welcomeScreenModel.CloseDatabase();
|
||||
await DisposeState();
|
||||
|
||||
Title = DefaultTitle;
|
||||
CurrentScreen = welcomeScreen;
|
||||
|
||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
welcomeScreenModel.Dispose();
|
||||
private async Task DisposeState() {
|
||||
if (state != null) {
|
||||
await state.DisposeAsync();
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
mainContentScreenModel?.Dispose();
|
||||
db?.Dispose();
|
||||
db = null;
|
||||
}
|
||||
await DisposeState();
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,8 @@
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.Pages.AdvancedPage">
|
||||
x:Class="DHT.Desktop.Main.Pages.AdvancedPage"
|
||||
x:DataType="pages:AdvancedPageModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:AdvancedPageModel />
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class AdvancedPage : UserControl {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class AdvancedPage : UserControl {
|
||||
public AdvancedPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,39 +1,36 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Server;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed class AdvancedPageModel : IDisposable {
|
||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly State state;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||
public AdvancedPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
|
||||
public AdvancedPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
this.state = state;
|
||||
|
||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
|
||||
}
|
||||
|
||||
public void Initialize() {
|
||||
ServerConfigurationModel.Initialize();
|
||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerConfigurationModel.Dispose();
|
||||
}
|
||||
|
||||
public async void VacuumDatabase() {
|
||||
db.Vacuum();
|
||||
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
|
||||
}
|
||||
public async Task VacuumDatabase() {
|
||||
const string Title = "Vacuum Database";
|
||||
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
|
||||
await Dialog.ShowOk(window, Title, "Done.");
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,8 @@
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage">
|
||||
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
|
||||
x:DataType="pages:AttachmentsPageModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:AttachmentsPageModel />
|
||||
@@ -32,22 +33,22 @@
|
||||
<StackPanel Orientation="Vertical" Spacing="20">
|
||||
<DockPanel>
|
||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
|
||||
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
|
||||
<TextBlock Text="{Binding DownloadMessage}" MinWidth="100" Margin="10 0 0 0" VerticalAlignment="Center" TextAlignment="Right" DockPanel.Dock="Left" />
|
||||
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
|
||||
</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">
|
||||
<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>
|
||||
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
||||
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding Size, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding Size, Mode=OneWay, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Expander>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding HasFailedDownloads}">Retry Failed Downloads</Button>
|
||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class AttachmentsPage : UserControl {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class AttachmentsPage : UserControl {
|
||||
public AttachmentsPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,35 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Aggregations;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Download;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
||||
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed partial class AttachmentsPageModel : ObservableObject, IDisposable {
|
||||
private static readonly Log Log = Log.ForType<AttachmentsPageModel>();
|
||||
|
||||
private static readonly DownloadItemFilter EnqueuedItemFilter = new () {
|
||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
||||
DownloadStatus.Enqueued
|
||||
DownloadStatus.Enqueued,
|
||||
DownloadStatus.Downloading
|
||||
}
|
||||
};
|
||||
|
||||
private bool isThreadDownloadButtonEnabled = true;
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isToggleDownloadButtonEnabled = true;
|
||||
|
||||
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
|
||||
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
||||
|
||||
public bool IsToggleDownloadButtonEnabled {
|
||||
get => isThreadDownloadButtonEnabled;
|
||||
set => Change(ref isThreadDownloadButtonEnabled, value);
|
||||
}
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||
private bool isRetryingFailedDownloads = false;
|
||||
|
||||
public string DownloadMessage { get; set; } = "";
|
||||
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||
private bool hasFailedDownloads;
|
||||
|
||||
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && hasFailedDownloads;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private string downloadMessage = "";
|
||||
|
||||
public double DownloadProgress => totalItemsToDownloadCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / totalItemsToDownloadCount.Value;
|
||||
|
||||
public AttachmentFilterPanelModel FilterModel { get; }
|
||||
|
||||
@@ -38,71 +53,161 @@ namespace DHT.Desktop.Main.Pages {
|
||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||
|
||||
public List<StatisticsRow> StatisticsRows {
|
||||
get {
|
||||
return new List<StatisticsRow> {
|
||||
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
|
||||
|
||||
public bool IsDownloading => state.Downloader.IsDownloading;
|
||||
|
||||
private readonly State state;
|
||||
private readonly ThrottledTask<int> enqueueDownloadItemsTask;
|
||||
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
||||
|
||||
private readonly IDisposable attachmentCountSubscription;
|
||||
private readonly IDisposable downloadCountSubscription;
|
||||
|
||||
private IDisposable? finishedItemsSubscription;
|
||||
private int doneItemsCount;
|
||||
private int totalEnqueuedItemCount;
|
||||
private int? totalItemsToDownloadCount;
|
||||
|
||||
public AttachmentsPageModel() : this(State.Dummy) {}
|
||||
|
||||
public AttachmentsPageModel(State state) {
|
||||
this.state = state;
|
||||
|
||||
FilterModel = new AttachmentFilterPanelModel(state);
|
||||
|
||||
StatisticsRows = [
|
||||
statisticsEnqueued,
|
||||
statisticsDownloaded,
|
||||
statisticsFailed,
|
||||
statisticsSkipped
|
||||
};
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
public bool IsDownloading => downloadThread != null;
|
||||
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
||||
enqueueDownloadItemsTask = new ThrottledTask<int>(OnItemsEnqueued, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
|
||||
private BackgroundDownloadThread? downloadThread;
|
||||
attachmentCountSubscription = state.Db.Attachments.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnAttachmentCountChanged);
|
||||
downloadCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
|
||||
|
||||
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;
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||
attachmentCountSubscription.Dispose();
|
||||
downloadCountSubscription.Dispose();
|
||||
finishedItemsSubscription?.Dispose();
|
||||
|
||||
enqueueDownloadItemsTask.Dispose();
|
||||
downloadStatisticsTask.Dispose();
|
||||
|
||||
FilterModel.Dispose();
|
||||
DisposeDownloadThread();
|
||||
}
|
||||
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
||||
private void OnAttachmentCountChanged(long newAttachmentCount) {
|
||||
if (IsDownloading) {
|
||||
EnqueueDownloadItems();
|
||||
EnqueueDownloadItemsLater();
|
||||
}
|
||||
else {
|
||||
downloadStatisticsComputer.Recompute();
|
||||
}
|
||||
}
|
||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
||||
downloadStatisticsComputer.Recompute();
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnqueueDownloadItems() {
|
||||
private void OnDownloadCountChanged(long newDownloadCount) {
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
private async Task EnqueueDownloadItems() {
|
||||
OnItemsEnqueued(await state.Db.Downloads.EnqueueDownloadItems(CreateAttachmentFilter()));
|
||||
}
|
||||
|
||||
private void EnqueueDownloadItemsLater() {
|
||||
var filter = CreateAttachmentFilter();
|
||||
enqueueDownloadItemsTask.Post(cancellationToken => state.Db.Downloads.EnqueueDownloadItems(filter, cancellationToken));
|
||||
}
|
||||
|
||||
private void OnItemsEnqueued(int itemCount) {
|
||||
totalEnqueuedItemCount += itemCount;
|
||||
totalItemsToDownloadCount = totalEnqueuedItemCount;
|
||||
UpdateDownloadMessage();
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
private AttachmentFilter CreateAttachmentFilter() {
|
||||
var filter = FilterModel.CreateFilter();
|
||||
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
||||
db.EnqueueDownloadItems(filter);
|
||||
return filter;
|
||||
}
|
||||
|
||||
downloadStatisticsComputer.Recompute();
|
||||
public async Task OnClickToggleDownload() {
|
||||
IsToggleDownloadButtonEnabled = false;
|
||||
|
||||
if (IsDownloading) {
|
||||
await state.Downloader.Stop();
|
||||
|
||||
finishedItemsSubscription?.Dispose();
|
||||
finishedItemsSubscription = null;
|
||||
|
||||
RecomputeDownloadStatistics();
|
||||
|
||||
await state.Db.Downloads.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
|
||||
|
||||
doneItemsCount = 0;
|
||||
totalEnqueuedItemCount = 0;
|
||||
totalItemsToDownloadCount = null;
|
||||
UpdateDownloadMessage();
|
||||
}
|
||||
else {
|
||||
var finishedItems = await state.Downloader.Start();
|
||||
|
||||
finishedItemsSubscription = finishedItems.Select(static _ => true)
|
||||
.Buffer(TimeSpan.FromMilliseconds(100))
|
||||
.Select(static items => items.Count)
|
||||
.Where(static items => items > 0)
|
||||
.ObserveOn(AvaloniaScheduler.Instance)
|
||||
.Subscribe(OnItemsFinished);
|
||||
|
||||
await EnqueueDownloadItems();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
||||
OnPropertyChanged(nameof(IsDownloading));
|
||||
IsToggleDownloadButtonEnabled = true;
|
||||
}
|
||||
|
||||
private void OnItemsFinished(int finishedItemCount) {
|
||||
doneItemsCount += finishedItemCount;
|
||||
UpdateDownloadMessage();
|
||||
}
|
||||
|
||||
public async Task OnClickRetryFailedDownloads() {
|
||||
IsRetryingFailedDownloads = true;
|
||||
|
||||
try {
|
||||
var allExceptFailedFilter = new DownloadItemFilter {
|
||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
||||
DownloadStatus.Enqueued,
|
||||
DownloadStatus.Downloading,
|
||||
DownloadStatus.Success
|
||||
}
|
||||
};
|
||||
|
||||
await state.Db.Downloads.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
||||
|
||||
if (IsDownloading) {
|
||||
await EnqueueDownloadItems();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
} finally {
|
||||
IsRetryingFailedDownloads = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecomputeDownloadStatistics() {
|
||||
downloadStatisticsTask.Post(state.Db.Downloads.GetStatistics);
|
||||
}
|
||||
|
||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||
var hadFailedDownloads = HasFailedDownloads;
|
||||
|
||||
statisticsEnqueued.Items = statusStatistics.EnqueuedCount;
|
||||
statisticsEnqueued.Size = statusStatistics.EnqueuedSize;
|
||||
|
||||
@@ -115,89 +220,25 @@ namespace DHT.Desktop.Main.Pages {
|
||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||
statisticsSkipped.Size = statusStatistics.SkippedSize;
|
||||
|
||||
OnPropertyChanged(nameof(StatisticsRows));
|
||||
hasFailedDownloads = statusStatistics.FailedCount > 0;
|
||||
|
||||
if (hadFailedDownloads != HasFailedDownloads) {
|
||||
OnPropertyChanged(nameof(HasFailedDownloads));
|
||||
}
|
||||
|
||||
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
|
||||
UpdateDownloadMessage();
|
||||
}
|
||||
|
||||
private void UpdateDownloadMessage() {
|
||||
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
|
||||
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (totalItemsToDownloadCount?.Format() ?? "?") : "";
|
||||
|
||||
OnPropertyChanged(nameof(DownloadMessage));
|
||||
OnPropertyChanged(nameof(DownloadProgress));
|
||||
}
|
||||
|
||||
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
|
||||
++doneItemsCount;
|
||||
[ObservableObject]
|
||||
public sealed partial class StatisticsRow(string state) {
|
||||
public string State { get; } = state;
|
||||
|
||||
UpdateDownloadMessage();
|
||||
downloadStatisticsComputer.Recompute();
|
||||
}
|
||||
[ObservableProperty]
|
||||
private int items;
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
[ObservableProperty]
|
||||
private ulong? size;
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
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>
|
||||
<pages:DatabasePageModel />
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class DatabasePage : UserControl {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class DatabasePage : UserControl {
|
||||
public DatabasePage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,19 +7,23 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.File;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Dialogs.TextBox;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Import;
|
||||
using DHT.Server.Database.Sqlite.Utils;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DatabasePageModel : BaseModel {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed class DatabasePageModel {
|
||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||
|
||||
public IDatabaseFile Db { get; }
|
||||
@@ -29,14 +33,14 @@ namespace DHT.Desktop.Main.Pages {
|
||||
private readonly Window window;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
public DatabasePageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public DatabasePageModel(Window window, IDatabaseFile db) {
|
||||
public DatabasePageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.Db = db;
|
||||
this.Db = state.Db;
|
||||
}
|
||||
|
||||
public async void OpenDatabaseFolder() {
|
||||
public async Task OpenDatabaseFolder() {
|
||||
string file = Db.Path;
|
||||
string? folder = Path.GetDirectoryName(file);
|
||||
|
||||
@@ -67,74 +71,77 @@ namespace DHT.Desktop.Main.Pages {
|
||||
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;
|
||||
public async Task MergeWithDatabase() {
|
||||
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||
if (paths.Length > 0) {
|
||||
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||
|
||||
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);
|
||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||
|
||||
if (db == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
target.AddFrom(db);
|
||||
await target.AddFrom(db);
|
||||
return true;
|
||||
} finally {
|
||||
db.Dispose();
|
||||
await db.DisposeAsync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async void ImportLegacyArchive() {
|
||||
var fileDialog = new OpenFileDialog {
|
||||
Title = "Open Legacy DHT Archive",
|
||||
Directory = Path.GetDirectoryName(Db.Path),
|
||||
AllowMultiple = true
|
||||
};
|
||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||
private readonly ProgressDialog dialog;
|
||||
private readonly int total;
|
||||
private bool? decision;
|
||||
|
||||
string[]? paths = await fileDialog.ShowAsync(window);
|
||||
if (paths == null || paths.Length == 0) {
|
||||
return;
|
||||
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||
this.total = total;
|
||||
this.dialog = dialog;
|
||||
}
|
||||
|
||||
ProgressDialog progressDialog = new ProgressDialog();
|
||||
progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) {
|
||||
Title = "Legacy Archive Import"
|
||||
};
|
||||
public async Task<bool> CanUpgrade() {
|
||||
return decision ??= (total > 1
|
||||
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
||||
}
|
||||
|
||||
await progressDialog.ShowDialog(window);
|
||||
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 Task 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) {
|
||||
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||
@@ -186,7 +193,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
|
||||
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();
|
||||
var oldStatistics = await DatabaseStatistics.Take(target);
|
||||
|
||||
int successful = 0;
|
||||
int finished = 0;
|
||||
@@ -217,14 +224,26 @@ namespace DHT.Desktop.Main.Pages {
|
||||
return;
|
||||
}
|
||||
|
||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName));
|
||||
var newStatistics = await DatabaseStatistics.Take(target);
|
||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, 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;
|
||||
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
|
||||
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
|
||||
return new DatabaseStatistics(
|
||||
await db.Servers.Count(),
|
||||
await db.Channels.Count(),
|
||||
await db.Users.Count(),
|
||||
await db.Messages.Count()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
|
||||
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
|
||||
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
|
||||
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
|
||||
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
|
||||
|
||||
StringBuilder message = new StringBuilder();
|
||||
message.Append("Processed ");
|
||||
@@ -244,5 +263,4 @@ namespace DHT.Desktop.Main.Pages {
|
||||
|
||||
return message.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
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>
|
||||
<pages:DebugPageModel />
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class DebugPage : UserControl {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class DebugPage : UserControl {
|
||||
public DebugPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -6,26 +6,25 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DebugPageModel : BaseModel {
|
||||
sealed class DebugPageModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly State state;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public DebugPageModel(Window window, IDatabaseFile db) {
|
||||
public DebugPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public async void OnClickAddRandomDataToDatabase() {
|
||||
@@ -44,13 +43,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
return;
|
||||
}
|
||||
|
||||
ProgressDialog progressDialog = new ProgressDialog {
|
||||
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
|
||||
Title = "Generating Random Data"
|
||||
}
|
||||
};
|
||||
|
||||
await progressDialog.ShowDialog(window);
|
||||
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||
}
|
||||
|
||||
private const int BatchSize = 500;
|
||||
@@ -63,7 +56,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
var server = new DHT.Server.Data.Server {
|
||||
Id = RandomId(rand),
|
||||
Name = RandomName("s"),
|
||||
Type = ServerType.Server
|
||||
Type = ServerType.Server,
|
||||
};
|
||||
|
||||
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
||||
@@ -73,22 +66,19 @@ namespace DHT.Desktop.Main.Pages {
|
||||
ParentId = null,
|
||||
Position = i,
|
||||
Topic = RandomText(rand, 10),
|
||||
Nsfw = rand.Next(4) == 0
|
||||
Nsfw = rand.Next(4) == 0,
|
||||
}).ToArray();
|
||||
|
||||
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
||||
Id = RandomId(rand),
|
||||
Name = RandomName("u"),
|
||||
AvatarUrl = null,
|
||||
Discriminator = rand.Next(0, 9999).ToString()
|
||||
Discriminator = rand.Next(0, 9999).ToString(),
|
||||
}).ToArray();
|
||||
|
||||
db.AddServer(server);
|
||||
db.AddUsers(users);
|
||||
|
||||
foreach (var channel in channels) {
|
||||
db.AddChannel(channel);
|
||||
}
|
||||
await state.Db.Users.Add(users);
|
||||
await state.Db.Servers.Add([server]);
|
||||
await state.Db.Channels.Add(channels);
|
||||
|
||||
var now = DateTimeOffset.Now;
|
||||
int batchIndex = 0;
|
||||
@@ -97,7 +87,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
int hourOffset = batchIndex;
|
||||
|
||||
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;
|
||||
|
||||
var timeMillis = time.ToUnixTimeMilliseconds();
|
||||
@@ -111,13 +101,13 @@ namespace DHT.Desktop.Main.Pages {
|
||||
Timestamp = timeMillis,
|
||||
EditTimestamp = editMillis,
|
||||
RepliedToId = null,
|
||||
Attachments = ImmutableArray<Attachment>.Empty,
|
||||
Embeds = ImmutableArray<Embed>.Empty,
|
||||
Reactions = ImmutableArray<Reaction>.Empty
|
||||
Attachments = ImmutableList<Attachment>.Empty,
|
||||
Embeds = ImmutableList<Embed>.Empty,
|
||||
Reactions = ImmutableList<Reaction>.Empty,
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
db.AddMessages(messages);
|
||||
await state.Db.Messages.Add(messages);
|
||||
|
||||
messageCount -= BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||
@@ -138,7 +128,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||
}
|
||||
|
||||
private static readonly string[] RandomWords = {
|
||||
private static readonly string[] RandomWords = [
|
||||
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
||||
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
|
||||
@@ -162,7 +152,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
"watercress", "watermelon",
|
||||
"yam",
|
||||
"zucchini"
|
||||
};
|
||||
];
|
||||
|
||||
private static string RandomText(Random rand, int maxWords) {
|
||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
||||
@@ -171,10 +161,8 @@ namespace DHT.Desktop.Main.Pages {
|
||||
}
|
||||
}
|
||||
#else
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DebugPageModel : BaseModel {
|
||||
sealed class DebugPageModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
|
@@ -4,7 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
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>
|
||||
<pages:TrackingPageModel />
|
||||
@@ -15,7 +16,7 @@
|
||||
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
|
||||
</TextBlock>
|
||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
|
||||
</StackPanel>
|
||||
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
||||
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
|
||||
|
@@ -3,36 +3,30 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class TrackingPage : UserControl {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class TrackingPage : UserControl {
|
||||
private bool isCopyingScript;
|
||||
|
||||
public TrackingPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||
if (DataContext is TrackingPageModel model) {
|
||||
var button = this.FindControl<Button>("CopyTrackingScript");
|
||||
var originalText = button.Content;
|
||||
button.MinWidth = button.Bounds.Width;
|
||||
var originalText = CopyTrackingScript.Content;
|
||||
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
||||
|
||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||
isCopyingScript = true;
|
||||
button.Content = "Script Copied!";
|
||||
CopyTrackingScript.Content = "Script Copied!";
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
button.Content = originalText;
|
||||
CopyTrackingScript.Content = originalText;
|
||||
isCopyingScript = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,35 +1,39 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Discord;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class TrackingPageModel : BaseModel {
|
||||
private bool areDevToolsEnabled;
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
private bool AreDevToolsEnabled {
|
||||
get => areDevToolsEnabled;
|
||||
set {
|
||||
Change(ref areDevToolsEnabled, value);
|
||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
||||
}
|
||||
}
|
||||
sealed partial class TrackingPageModel : ObservableObject {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isCopyTrackingScriptButtonEnabled = true;
|
||||
|
||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||
private bool? areDevToolsEnabled = null;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||
|
||||
public string ToggleAppDevToolsButtonText {
|
||||
get {
|
||||
if (!AreDevToolsEnabled.HasValue) {
|
||||
return "Loading...";
|
||||
}
|
||||
|
||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||
return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,28 +44,25 @@ namespace DHT.Desktop.Main.Pages {
|
||||
|
||||
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));
|
||||
}
|
||||
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
public async Task<bool> OnClickCopyTrackingScript() {
|
||||
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
|
||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
|
||||
.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"));
|
||||
IsCopyTrackingScriptButtonEnabled = false;
|
||||
|
||||
var clipboard = Application.Current?.Clipboard;
|
||||
try {
|
||||
return await CopyTrackingScript();
|
||||
} finally {
|
||||
IsCopyTrackingScriptButtonEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CopyTrackingScript() {
|
||||
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.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;
|
||||
@@ -76,10 +77,26 @@ namespace DHT.Desktop.Main.Pages {
|
||||
}
|
||||
}
|
||||
|
||||
public async void OnClickToggleAppDevTools() {
|
||||
private async Task InitializeDevToolsToggle() {
|
||||
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
|
||||
|
||||
if (devToolsEnabled.HasValue) {
|
||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||
IsToggleAppDevToolsButtonEnabled = true;
|
||||
}
|
||||
else {
|
||||
IsToggleAppDevToolsButtonEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnClickToggleAppDevTools() {
|
||||
const string DialogTitle = "Discord App Settings File";
|
||||
|
||||
bool oldState = AreDevToolsEnabled;
|
||||
if (!AreDevToolsEnabled.HasValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool oldState = AreDevToolsEnabled.Value;
|
||||
bool newState = !oldState;
|
||||
|
||||
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||
@@ -113,5 +130,4 @@ namespace DHT.Desktop.Main.Pages {
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,8 @@
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.Pages.ViewerPage">
|
||||
x:Class="DHT.Desktop.Main.Pages.ViewerPage"
|
||||
x:DataType="pages:ViewerPageModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:ViewerPageModel />
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class ViewerPage : UserControl {
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class ViewerPage : UserControl {
|
||||
public ViewerPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -8,44 +7,48 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.File;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Export;
|
||||
using DHT.Server.Database.Export.Strategy;
|
||||
using DHT.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
||||
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||
public static readonly ConcurrentBag<string> TemporaryFiles = [];
|
||||
|
||||
private static readonly FilePickerFileType[] ViewerFileTypes = [
|
||||
FileDialogs.CreateFilter("Discord History Viewer", ["html"])
|
||||
];
|
||||
|
||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasFilters = false;
|
||||
|
||||
public bool HasFilters {
|
||||
get => hasFilters;
|
||||
set => Change(ref hasFilters, value);
|
||||
}
|
||||
|
||||
private MessageFilterPanelModel FilterModel { get; }
|
||||
public MessageFilterPanelModel FilterModel { get; }
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly State state;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
public ViewerPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public ViewerPageModel(Window window, IDatabaseFile db) {
|
||||
public ViewerPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
this.state = state;
|
||||
|
||||
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
|
||||
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
|
||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -57,6 +60,61 @@ namespace DHT.Desktop.Main.Pages {
|
||||
HasFilters = FilterModel.HasAnyFilters;
|
||||
}
|
||||
|
||||
public async void OnClickOpenViewer() {
|
||||
try {
|
||||
var fullPath = await PrepareTemporaryViewerFile();
|
||||
var strategy = new LiveViewerExportStrategy(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||
|
||||
await ProgressDialog.ShowIndeterminate(window, "Open Viewer", "Creating viewer...", _ => Task.Run(() => WriteViewerFile(fullPath, strategy)));
|
||||
|
||||
Process.Start(new ProcessStartInfo(fullPath) {
|
||||
UseShellExecute = true
|
||||
});
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, "Open Viewer", "Could not create or save viewer: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> PrepareTemporaryViewerFile() {
|
||||
return await Task.Run(() => {
|
||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
||||
string filenameBase = Path.GetFileNameWithoutExtension(state.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);
|
||||
|
||||
return fullPath;
|
||||
});
|
||||
}
|
||||
|
||||
public async void OnClickSaveViewer() {
|
||||
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||
Title = "Save Viewer",
|
||||
FileTypeChoices = ViewerFileTypes,
|
||||
SuggestedFileName = Path.GetFileNameWithoutExtension(state.Db.Path) + ".html",
|
||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(state.Db.Path)),
|
||||
});
|
||||
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ProgressDialog.ShowIndeterminate(window, "Save Viewer", "Creating viewer...", _ => Task.Run(() => WriteViewerFile(path, StandaloneViewerExportStrategy.Instance)));
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, "Save Viewer", "Could not create or save viewer: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
|
||||
const string ArchiveTag = "/*[ARCHIVE]*/";
|
||||
|
||||
@@ -70,7 +128,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
string jsonTempFile = path + ".tmp";
|
||||
|
||||
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
||||
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
|
||||
await ViewerJsonExport.Generate(jsonStream, strategy, state.Db, FilterModel.CreateFilter());
|
||||
|
||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
||||
jsonStream.Position = 0;
|
||||
@@ -94,57 +152,23 @@ namespace DHT.Desktop.Main.Pages {
|
||||
File.Delete(jsonTempFile);
|
||||
}
|
||||
|
||||
public async void OnClickOpenViewer() {
|
||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
||||
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 });
|
||||
}
|
||||
|
||||
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;
|
||||
if (!string.IsNullOrEmpty(path)) {
|
||||
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
public async void OnClickApplyFiltersToDatabase() {
|
||||
public async Task OnClickApplyFiltersToDatabase() {
|
||||
var filter = FilterModel.CreateFilter();
|
||||
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
||||
|
||||
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);
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
await ApplyFilterToDatabase(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?")) {
|
||||
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
|
||||
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,8 @@
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||
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>
|
||||
<screens:MainContentScreenModel />
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MainContentScreen : UserControl {
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class MainContentScreen : UserControl {
|
||||
public MainContentScreen() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,18 +1,12 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Server;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens {
|
||||
sealed class MainContentScreenModel : IDisposable {
|
||||
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
sealed class MainContentScreenModel : IDisposable {
|
||||
public DatabasePage DatabasePage { get; }
|
||||
private DatabasePageModel DatabasePageModel { get; }
|
||||
|
||||
@@ -48,72 +42,39 @@ namespace DHT.Desktop.Main.Screens {
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Window window;
|
||||
private readonly ServerManager serverManager;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.serverManager = new ServerManager(db);
|
||||
|
||||
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
||||
|
||||
DatabasePageModel = new DatabasePageModel(window, db);
|
||||
public MainContentScreenModel(Window window, State state) {
|
||||
DatabasePageModel = new DatabasePageModel(window, state);
|
||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||
|
||||
TrackingPageModel = new TrackingPageModel(window);
|
||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||
|
||||
AttachmentsPageModel = new AttachmentsPageModel(db);
|
||||
AttachmentsPageModel = new AttachmentsPageModel(state);
|
||||
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
||||
|
||||
ViewerPageModel = new ViewerPageModel(window, db);
|
||||
ViewerPageModel = new ViewerPageModel(window, state);
|
||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||
|
||||
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
|
||||
AdvancedPageModel = new AdvancedPageModel(window, state);
|
||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||
|
||||
#if DEBUG
|
||||
DebugPageModel = new DebugPageModel(window, db);
|
||||
DebugPageModel = new DebugPageModel(window, state);
|
||||
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();
|
||||
StatusBarModel = new StatusBarModel(state);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
AdvancedPageModel.Dispose();
|
||||
StatusBarModel.Dispose();
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||
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>
|
||||
<screens:WelcomeScreenModel />
|
||||
@@ -31,7 +32,7 @@
|
||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>
|
||||
<Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
||||
<Button Command="{Binding Exit}">Exit</Button>
|
||||
</StackPanel>
|
||||
|
@@ -1,16 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class WelcomeScreen : UserControl {
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class WelcomeScreen : UserControl {
|
||||
public WelcomeScreen() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,18 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Server.Database.Sqlite.Utils;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens {
|
||||
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
public string Version => Program.Version;
|
||||
|
||||
public IDatabaseFile? Db { get; private set; }
|
||||
public bool HasDatabase => Db != null;
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||
|
||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
@@ -25,49 +31,89 @@ namespace DHT.Desktop.Main.Screens {
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
public async void OpenOrCreateDatabase() {
|
||||
var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog();
|
||||
dialog.Directory = Path.GetDirectoryName(dbFilePath);
|
||||
|
||||
string? path = await dialog.ShowAsync(window);
|
||||
if (!string.IsNullOrWhiteSpace(path)) {
|
||||
public async Task OpenOrCreateDatabase() {
|
||||
IsOpenOrCreateDatabaseButtonEnabled = false;
|
||||
try {
|
||||
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||
if (path != null) {
|
||||
await OpenOrCreateDatabaseFromPath(path);
|
||||
}
|
||||
} finally {
|
||||
IsOpenOrCreateDatabaseButtonEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||
if (db != null) {
|
||||
DatabaseSelected?.Invoke(this, db);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CheckCanUpgradeDatabase() {
|
||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||
private readonly Window window;
|
||||
|
||||
public SchemaUpgradeCallbacks(Window window) {
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
public async Task<bool> CanUpgrade() {
|
||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||
}
|
||||
|
||||
public void CloseDatabase() {
|
||||
Dispose();
|
||||
OnPropertyChanged(nameof(Db));
|
||||
OnPropertyChanged(nameof(HasDatabase));
|
||||
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
||||
var reporter = new ProgressReporter(versionSteps, callbacks);
|
||||
await reporter.NextVersion();
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(800));
|
||||
await doUpgrade(reporter);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||
}
|
||||
|
||||
public async void ShowAboutDialog() {
|
||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
||||
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
||||
}
|
||||
|
||||
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||
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 async Task NextVersion() {
|
||||
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||
await HideChildren(0);
|
||||
}
|
||||
|
||||
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
||||
await callbacks[1].Update(message, finishedItems, totalItems);
|
||||
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 async Task ShowAboutDialog() {
|
||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
||||
}
|
||||
|
||||
public void Exit() {
|
||||
window.Close();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
Db?.Dispose();
|
||||
Db = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,17 @@
|
||||
using System.Globalization;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Resources;
|
||||
|
||||
namespace DHT.Desktop {
|
||||
static class Program {
|
||||
namespace DHT.Desktop;
|
||||
|
||||
static class Program {
|
||||
public static string Version { get; }
|
||||
public static CultureInfo Culture { get; }
|
||||
public static ResourceLoader Resources { get; }
|
||||
public static Arguments Arguments { get; }
|
||||
|
||||
static Program() {
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
@@ -24,16 +28,27 @@ namespace DHT.Desktop {
|
||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||
|
||||
Resources = new ResourceLoader(assembly);
|
||||
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
||||
}
|
||||
|
||||
public static void Main(string[] args) {
|
||||
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||
WindowsConsole.AllocConsole();
|
||||
}
|
||||
|
||||
try {
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
} finally {
|
||||
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||
WindowsConsole.FreeConsole();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
8
app/Desktop/Server/ServerConfiguration.cs
Normal file
8
app/Desktop/Server/ServerConfiguration.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using DHT.Server.Service;
|
||||
|
||||
namespace DHT.Desktop.Server;
|
||||
|
||||
static class ServerConfiguration {
|
||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
using System;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
|
||||
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;
|
||||
|
||||
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!");
|
||||
}
|
||||
|
||||
instance = this;
|
||||
}
|
||||
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public void Launch() {
|
||||
ServerLauncher.Relaunch(Port, Token, db);
|
||||
}
|
||||
|
||||
public void Relaunch(ushort port, string token) {
|
||||
Port = port;
|
||||
Token = token;
|
||||
Launch();
|
||||
}
|
||||
|
||||
public void Stop() {
|
||||
ServerLauncher.Stop();
|
||||
}
|
||||
|
||||
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>12</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>
|
6
app/NuGet.Config
Normal file
6
app/NuGet.Config
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<add key="chylex's repository" value="https://nuget.chylex.com/feed/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
5
app/Resources/Tracker/bootstrap.js
vendored
5
app/Resources/Tracker/bootstrap.js
vendored
@@ -64,9 +64,11 @@
|
||||
let action = null;
|
||||
|
||||
if (!DISCORD.hasMoreMessages()) {
|
||||
console.debug("[DHT] Reached first message.");
|
||||
action = SETTINGS.afterFirstMsg;
|
||||
}
|
||||
if (isNoAction(action) && !anyNewMessages) {
|
||||
console.debug("[DHT] No new messages.");
|
||||
action = SETTINGS.afterSavedMsg;
|
||||
}
|
||||
|
||||
@@ -121,7 +123,7 @@
|
||||
onTrackingContinued(false);
|
||||
}
|
||||
else {
|
||||
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);
|
||||
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||
onTrackingContinued(anyNewMessages);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -156,3 +158,4 @@
|
||||
GUI.showSettings();
|
||||
}
|
||||
})();
|
||||
/*[DEBUGGER]*/
|
||||
|
@@ -1,5 +1,23 @@
|
||||
// noinspection JSUnresolvedVariable
|
||||
// noinspection LocalVariableNamingConventionJS
|
||||
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() {
|
||||
return DOM.queryReactClass("messagesWrapper");
|
||||
}
|
||||
@@ -9,7 +27,7 @@ class DISCORD {
|
||||
}
|
||||
|
||||
static getMessageElements() {
|
||||
return this.getMessageOuterElement().querySelectorAll("[class*='message-']");
|
||||
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
|
||||
}
|
||||
|
||||
static hasMoreMessages() {
|
||||
@@ -28,46 +46,11 @@ class DISCORD {
|
||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||
*/
|
||||
static setupMessageCallback(callback) {
|
||||
let skipsLeft = 0;
|
||||
let waitForCleanup = false;
|
||||
const previousMessages = new Set();
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (skipsLeft > 0) {
|
||||
--skipsLeft;
|
||||
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();
|
||||
const onMessageElementsChanged = function() {
|
||||
const messages = DISCORD.getMessages();
|
||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
||||
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
@@ -79,24 +62,74 @@ class DISCORD {
|
||||
}
|
||||
|
||||
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 { null | { message: DiscordMessage, channel: Object } }
|
||||
* Returns the message from a message element.
|
||||
* @returns { null | DiscordMessage } }
|
||||
*/
|
||||
static getMessageElementProps(ele) {
|
||||
static getMessageFromElement(ele) {
|
||||
const props = DOM.getReactProps(ele);
|
||||
|
||||
if (props.children && props.children.length) {
|
||||
for (let i = 3; i < props.children.length; i++) {
|
||||
const childProps = props.children[i].props;
|
||||
if (props && Array.isArray(props.children)) {
|
||||
for (const child of props.children) {
|
||||
if (!(child instanceof Object)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (childProps && "message" in childProps && "channel" in childProps) {
|
||||
return childProps;
|
||||
const childProps = child.props;
|
||||
if (childProps instanceof Object && "message" in childProps) {
|
||||
return childProps.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,10 +146,10 @@ class DISCORD {
|
||||
|
||||
for (const ele of this.getMessageElements()) {
|
||||
try {
|
||||
const props = this.getMessageElementProps(ele);
|
||||
const message = this.getMessageFromElement(ele);
|
||||
|
||||
if (props != null) {
|
||||
messages.push(props.message);
|
||||
if (message != null) {
|
||||
messages.push(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
||||
@@ -137,7 +170,7 @@ class DISCORD {
|
||||
*/
|
||||
static getSelectedChannel() {
|
||||
try {
|
||||
let obj;
|
||||
let obj = null;
|
||||
|
||||
try {
|
||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
||||
@@ -148,15 +181,6 @@ class DISCORD {
|
||||
}
|
||||
} catch (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") {
|
||||
@@ -168,7 +192,7 @@ class DISCORD {
|
||||
if (dms) {
|
||||
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);
|
||||
|
||||
if (node) {
|
||||
@@ -185,8 +209,8 @@ class DISCORD {
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
switch (obj.type) {
|
||||
case 1: type = "DM"; break;
|
||||
case 3: type = "GROUP"; break;
|
||||
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
|
||||
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
@@ -199,7 +223,7 @@ class DISCORD {
|
||||
else if (obj.guild_id) {
|
||||
let guild;
|
||||
|
||||
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent-']")).children) {
|
||||
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;
|
||||
@@ -224,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;
|
||||
}
|
||||
else {
|
||||
@@ -251,10 +275,10 @@ class DISCORD {
|
||||
|
||||
if (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;
|
||||
|
||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-")) {
|
||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -281,7 +305,7 @@ class DISCORD {
|
||||
let nextChannel = null;
|
||||
|
||||
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];
|
||||
break;
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ class DOM {
|
||||
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
||||
*/
|
||||
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>
|
||||
<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", "switch", "Switch to Next Channel")}
|
||||
<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", "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>`;
|
||||
|
@@ -174,12 +174,10 @@ const STATE = (function() {
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {String} channelId
|
||||
* @param {DiscordMessage[]} discordMessageArray
|
||||
*/
|
||||
async addDiscordMessages(channelId, discordMessageArray) {
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
discordMessageArray = discordMessageArray.filter(msg => (msg.type === 0 || msg.type === 19 || msg.type === 21) && msg.state === "SENT");
|
||||
async addDiscordMessages(discordMessageArray) {
|
||||
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");
|
||||
|
||||
if (discordMessageArray.length === 0) {
|
||||
return false;
|
||||
|
@@ -1,7 +1,8 @@
|
||||
const DISCORD = (function() {
|
||||
const regex = {
|
||||
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
||||
formatItalic: /(.)?([_*])([\s\S]+?)\2(?!\2)/g,
|
||||
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
||||
formatItalic2: /_([\s\S]+?)_(?!_)\b/g,
|
||||
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
||||
formatStrike: /~~([\s\S]+?)~~(?!~)/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.formatBold, "<b>$1</b>")
|
||||
.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>");
|
||||
}
|
||||
|
||||
|
@@ -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,5 +1,6 @@
|
||||
namespace DHT.Server.Data.Aggregations {
|
||||
public sealed class DownloadStatusStatistics {
|
||||
namespace DHT.Server.Data.Aggregations;
|
||||
|
||||
public sealed class DownloadStatusStatistics {
|
||||
public int EnqueuedCount { get; internal set; }
|
||||
public ulong EnqueuedSize { get; internal set; }
|
||||
|
||||
@@ -11,5 +12,4 @@ namespace DHT.Server.Data.Aggregations {
|
||||
|
||||
public int SkippedCount { get; internal set; }
|
||||
public ulong SkippedSize { get; internal set; }
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,12 @@
|
||||
namespace DHT.Server.Data {
|
||||
public readonly struct Attachment {
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
public readonly struct Attachment {
|
||||
public ulong Id { get; internal init; }
|
||||
public string Name { get; internal init; }
|
||||
public string? Type { get; internal init; }
|
||||
public string Url { 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,5 +1,6 @@
|
||||
namespace DHT.Server.Data {
|
||||
public readonly struct Channel {
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
public readonly struct Channel {
|
||||
public ulong Id { get; init; }
|
||||
public ulong Server { get; init; }
|
||||
public string Name { get; init; }
|
||||
@@ -7,5 +8,4 @@ namespace DHT.Server.Data {
|
||||
public int? Position { get; init; }
|
||||
public string? Topic { get; init; }
|
||||
public bool? Nsfw { get; init; }
|
||||
}
|
||||
}
|
||||
|
@@ -1,30 +1,33 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using DHT.Server.Download;
|
||||
|
||||
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);
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
public readonly struct Download {
|
||||
internal static Download NewSuccess(DownloadItem item, byte[] data) {
|
||||
return new Download(item.NormalizedUrl, item.DownloadUrl, DownloadStatus.Success, (ulong) Math.Max(data.LongLength, 0), data);
|
||||
}
|
||||
|
||||
internal static Download NewFailure(string url, HttpStatusCode? statusCode, ulong size) {
|
||||
return new Download(url, statusCode.HasValue ? (DownloadStatus) (int) statusCode : DownloadStatus.GenericError, size);
|
||||
internal static Download NewFailure(DownloadItem item, HttpStatusCode? statusCode, ulong size) {
|
||||
return new Download(item.NormalizedUrl, item.DownloadUrl, statusCode.HasValue ? (DownloadStatus) (int) statusCode : DownloadStatus.GenericError, size);
|
||||
}
|
||||
|
||||
public string Url { get; }
|
||||
public string NormalizedUrl { get; }
|
||||
public string DownloadUrl { get; }
|
||||
public DownloadStatus Status { get; }
|
||||
public ulong Size { get; }
|
||||
public byte[]? Data { get; }
|
||||
|
||||
internal Download(string url, DownloadStatus status, ulong size, byte[]? data = null) {
|
||||
Url = url;
|
||||
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, ulong size, byte[]? data = null) {
|
||||
NormalizedUrl = normalizedUrl;
|
||||
DownloadUrl = downloadUrl;
|
||||
Status = status;
|
||||
Size = size;
|
||||
Data = data;
|
||||
}
|
||||
|
||||
internal Download WithData(byte[] data) {
|
||||
return new Download(Url, Status, Size, data);
|
||||
}
|
||||
return new Download(NormalizedUrl, DownloadUrl, Status, Size, data);
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,13 @@
|
||||
using System.Net;
|
||||
|
||||
namespace DHT.Server.Data {
|
||||
/// <summary>
|
||||
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
||||
/// </summary>
|
||||
public enum DownloadStatus {
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
||||
/// </summary>
|
||||
public enum DownloadStatus {
|
||||
Enqueued = 0,
|
||||
GenericError = 1,
|
||||
Downloading = 2,
|
||||
Success = HttpStatusCode.OK
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
namespace DHT.Server.Data {
|
||||
public readonly struct DownloadedAttachment {
|
||||
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 {
|
||||
public readonly struct Embed {
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
public readonly struct Embed {
|
||||
public string Json { get; internal init; }
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace DHT.Server.Data {
|
||||
[Flags]
|
||||
public enum EmojiFlags : ushort {
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
[Flags]
|
||||
public enum EmojiFlags : ushort {
|
||||
None = 0,
|
||||
Animated = 0b1
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
namespace DHT.Server.Data.Filters {
|
||||
public sealed class AttachmentFilter {
|
||||
namespace DHT.Server.Data.Filters;
|
||||
|
||||
public sealed class AttachmentFilter {
|
||||
public ulong? MaxBytes { get; set; } = null;
|
||||
|
||||
public DownloadItemRules? DownloadItemRule { get; set; } = null;
|
||||
@@ -11,5 +12,4 @@ namespace DHT.Server.Data.Filters {
|
||||
OnlyNotPresent,
|
||||
OnlyPresent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DHT.Server.Data.Filters {
|
||||
public sealed class DownloadItemFilter {
|
||||
public HashSet<DownloadStatus>? IncludeStatuses { get; set; } = null;
|
||||
public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
|
||||
namespace DHT.Server.Data.Filters;
|
||||
|
||||
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 {
|
||||
public enum FilterRemovalMode {
|
||||
namespace DHT.Server.Data.Filters;
|
||||
|
||||
public enum FilterRemovalMode {
|
||||
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