mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 19:31:42 +02:00
Compare commits
1 Commits
v41.1
...
app-raw-me
Author | SHA1 | Date | |
---|---|---|---|
dc5cd83da9
|
1
.idea/Discord-History-Tracker.iml
generated
1
.idea/Discord-History-Tracker.iml
generated
@@ -8,7 +8,6 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/.vscode" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/bld" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/lib" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/app" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.8" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
52
.vscode/tasks.json
vendored
Normal file
52
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Build Raw",
|
||||
"type": "shell",
|
||||
"command": "python ./build.py --nominify --copytracker",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"echo": true,
|
||||
"focus": false,
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Build Minified",
|
||||
"type": "shell",
|
||||
"command": "python ./build.py --copytracker",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"echo": true,
|
||||
"focus": false,
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Build Website",
|
||||
"type": "shell",
|
||||
"command": "python ./build.py --website",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"echo": true,
|
||||
"focus": false,
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
51
README.md
51
README.md
@@ -1,45 +1,48 @@
|
||||
# Welcome
|
||||
|
||||
For instructions on how to **use Discord History Tracker**, visit the [official website](https://dht.chylex.com).
|
||||
All you need to **use Discord History Tracker** is either an up-to-date browser, or the [Discord desktop client](https://discord.com/download). Visit the [official website](https://dht.chylex.com) for instructions.
|
||||
|
||||
To **report an issue or suggestion**, first please see the [issues](https://github.com/chylex/Discord-History-Tracker/issues) page and make sure someone else hasn't already created a similar issue report. If you do find an existing issue, comment on it or add a reaction. Otherwise, either click [New Issue](https://github.com/chylex/Discord-History-Tracker/issues/new), or contact me via email [contact@chylex.com](mailto:contact@chylex.com) or Twitter [@chylexmc](https://twitter.com/chylexmc).
|
||||
|
||||
If you are interested in **building from source code**, continue reading the [build instructions](#Build-Instructions) below.
|
||||
|
||||
This branch is dedicated to the Discord History Tracker desktop app. If you are looking for the older browser-only version, visit the [master-browser-only](https://github.com/chylex/Discord-History-Tracker/tree/master-browser-only) branch.
|
||||
If you are interested in **creating your own version** from the source code, continue reading the [build instructions](#Build-Instructions) below.
|
||||
|
||||
# Build Instructions
|
||||
|
||||
Follow the steps below to create your own version of Discord History Tracker.
|
||||
|
||||
### Setup
|
||||
|
||||
Fork the repository and clone it to your computer (if you've never used git, you can download the [GitHub Desktop](https://desktop.github.com) client to get started quickly).
|
||||
|
||||
Folder organization:
|
||||
* `app/` contains a Visual Studio solution for the desktop app
|
||||
Now you can modify the source code:
|
||||
* `src/tracker/` contains JS files that are automatically combined into the **tracker bookmark/script**
|
||||
* `src/viewer/` contains HTML, CSS, JS files that are then combined into the **offline viewer page**
|
||||
* `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 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
|
||||
|
||||
To build a `Debug` version of the desktop app, there are no additional requirements.
|
||||
After you've done changes to the source code, you will need to build it. Before that, download and install:
|
||||
* (**required**) [Python 3](https://www.python.org/downloads)
|
||||
* Use to run the build script
|
||||
* (optional) [Node + npm](https://nodejs.org/en) & command line [uglify-js](https://www.npmjs.com/package/uglify-js)
|
||||
* Not required on Windows
|
||||
* Only required for optional [JS minification](#Minification) on Linux/Mac
|
||||
|
||||
To build a `Release` version of the desktop app, follow the instructions for your operating system.
|
||||
Now open the folder that contains `build.py` in a command line, and run `python build.py` to create a build with default settings. The following files will be created:
|
||||
* `bld/track.js` is the raw tracker script that can be pasted into a browser console
|
||||
* `bld/track.html` is the tracker script but sanitized for inclusion in HTML (see `web/index.php` for examples)
|
||||
* `bld/viewer.html` is the complete offline viewer
|
||||
|
||||
#### Release – Windows (64-bit)
|
||||
You can tweak the build process using the following flags:
|
||||
* `python build.py --nominify` to disable [minification](#Minification)
|
||||
|
||||
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)
|
||||
### Minification
|
||||
|
||||
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
||||
The build process automatically minifies JS using `UglifyJS@3`, and CSS using a custom minifier.
|
||||
|
||||
#### Release – Other Operating Systems
|
||||
|
||||
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 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 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.
|
||||
* If the `--nominify` flag is used, minification will be completely disabled
|
||||
* If `uglify-js` is not available from the command line, JS minification will be skipped
|
||||
* When building on Windows 64-bit, the build script will use the included Node runner and packages
|
||||
* When building on Windows 32-bit, you will need to download [Node 32-bit](https://nodejs.org/en/download) and replace the included one in `lib/`
|
||||
* When building on Linux/Mac, the build script will attempt to find `uglifyjs` in the command line
|
||||
|
@@ -7,22 +7,16 @@
|
||||
<entry key="Desktop/Dialogs/CheckBox/CheckBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/Message/MessageDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/DownloadItemFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/FilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/DebugPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/DownloadsPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Screens/WelcomeScreen.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/WelcomeScreen.axaml" value="Desktop/Desktop.csproj" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
@@ -29,6 +29,9 @@
|
||||
<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$/.artifacts/bin/Desktop/debug/DiscordHistoryTracker.exe" />
|
||||
<option name="EXE_PATH" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0/DiscordHistoryTracker.exe" />
|
||||
<option name="PROGRAM_PARAMETERS" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.artifacts/bin/Desktop/debug" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0" />
|
||||
<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="net8.0" />
|
||||
<option name="PROJECT_TFM" value="net5.0" />
|
||||
<method v="2">
|
||||
<option name="Build" />
|
||||
</method>
|
||||
|
23
app/.idea/.idea.DiscordHistoryTracker/.idea/runConfigurations/Minify.xml
generated
Normal file
23
app/.idea/.idea.DiscordHistoryTracker/.idea/runConfigurations/Minify.xml
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
<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>
|
8
app/.idea/.idea.DiscordHistoryTracker/.idea/watcherTasks.xml
generated
Normal file
8
app/.idea/.idea.DiscordHistoryTracker/.idea/watcherTasks.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectTasksOptions">
|
||||
<enabled-global>
|
||||
<option value="UglifyJS (Tracker)" />
|
||||
</enabled-global>
|
||||
</component>
|
||||
</project>
|
@@ -1,135 +1,123 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
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"
|
||||
RequestedThemeVariant="Light">
|
||||
x:Class="DHT.Desktop.App">
|
||||
|
||||
<Application.Styles>
|
||||
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" />
|
||||
|
||||
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
</Style>
|
||||
<FluentTheme Mode="Light" />
|
||||
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Padding" Value="10 7 10 6" />
|
||||
<Setter Property="Background" Value="#DDDDDD" />
|
||||
<Setter Property="BorderBrush" Value="#999999" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="TextBlock.Foreground" Value="#060606" />
|
||||
<Setter Property="Background" Value="#DDDDDD" />
|
||||
<Setter Property="BorderBrush" Value="#999999" />
|
||||
</Style>
|
||||
<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#E7E7E7" />
|
||||
<Setter Property="BorderBrush" Value="#A2A2A2" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button:pressed">
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
<Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#EBEBEB" />
|
||||
<Setter Property="BorderBrush" Value="#A5A5A5" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="TextBlock.Foreground" Value="#7A7A7A" />
|
||||
<Setter Property="Background" Value="#E9E9E9" />
|
||||
<Setter Property="BorderBrush" Value="#BFBFBF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="BorderBrush" Value="#999999" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="SelectionBrush" Value="#72C0FF" />
|
||||
<Setter Property="Background" Value="#F4F4F4" />
|
||||
<Setter Property="Padding" Value="6 0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:pointerover /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="#999999" />
|
||||
<Setter Property="Background" Value="#F8F8F8" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="#546A9F" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Background" Value="#FBFBFB" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:disabled /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="#999999" />
|
||||
<Setter Property="Background" Value="#BBBBBB" />
|
||||
</Style>
|
||||
<Style Selector="TextBox /template/ TextBlock#PART_Watermark">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:error DataValidationErrors">
|
||||
<Style.Resources>
|
||||
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
|
||||
<ContentPresenter Name="PART_ContentPresenter"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
||||
</ControlTemplate>
|
||||
</Style.Resources>
|
||||
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Expander">
|
||||
<Setter Property="MinHeight" Value="40" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border x:Name="ToggleButtonBackground">
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="35">
|
||||
<Border x:Name="ExpandCollapseChevronBorder"
|
||||
Grid.Column="0"
|
||||
Width="35"
|
||||
Height="35"
|
||||
Margin="2,0"
|
||||
RenderTransformOrigin="50%,50%">
|
||||
<Path x:Name="ExpandCollapseChevron"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
RenderTransformOrigin="50%,50%"
|
||||
Stretch="None"
|
||||
Stroke="{DynamicResource ExpanderChevronForeground}"
|
||||
StrokeThickness="1" />
|
||||
<Border.RenderTransform>
|
||||
<RotateTransform />
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
<ContentPresenter x:Name="PART_ContentPresenter"
|
||||
Grid.Column="1"
|
||||
Margin="0"
|
||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalContentAlignment="Center"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
TextBlock.Foreground="{DynamicResource ExpanderForeground}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</Application.Styles>
|
||||
|
||||
<Application.Resources>
|
||||
|
||||
<common:NumberValueConverter x:Key="NumberValueConverter" />
|
||||
<common:BytesValueConverter x:Key="BytesValueConverter" />
|
||||
|
||||
<system:Double x:Key="ControlContentThemeFontSize">14</system:Double>
|
||||
<CornerRadius x:Key="ControlCornerRadius">0</CornerRadius>
|
||||
|
||||
<Color x:Key="SystemAccentColor">#3C5F95</Color>
|
||||
<Color x:Key="SystemAccentColorDark1">#3C5F95</Color>
|
||||
<Color x:Key="SystemAccentColorDark2">#3C5F95</Color>
|
||||
<Color x:Key="SystemAccentColorDark3">#3C5F95</Color>
|
||||
<Color x:Key="SystemAccentColorLight1">#3C5F95</Color>
|
||||
<Color x:Key="SystemAccentColorLight2">#3C5F95</Color>
|
||||
<Color x:Key="SystemAccentColorLight3">#3C5F95</Color>
|
||||
|
||||
<system:Double x:Key="ScrollBarSize">14</system:Double>
|
||||
<TransformOperations x:Key="VerticalSmallScrollThumbScaleTransform">scaleX(0.5) translateX(-3px)</TransformOperations>
|
||||
<TransformOperations x:Key="HorizontalSmallScrollThumbScaleTransform">scaleY(0.5) translateY(-3px)</TransformOperations>
|
||||
<SolidColorBrush x:Key="ScrollBarPanningThumbBackground" Color="#8F8F8F" />
|
||||
|
||||
<SolidColorBrush x:Key="ButtonBorderBrush" Color="#3C5F95" />
|
||||
<SolidColorBrush x:Key="ButtonBorderBrushPointerOver" Color="#0E2B59" />
|
||||
<SolidColorBrush x:Key="ButtonBorderBrushPressed" Color="#061742" />
|
||||
<SolidColorBrush x:Key="ButtonBorderBrushDisabled" Color="#9B9B9B" />
|
||||
<SolidColorBrush x:Key="ButtonBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ButtonBackgroundPointerOver" Color="#F6F9FD" />
|
||||
<SolidColorBrush x:Key="ButtonBackgroundPressed" Color="#EDF3FD" />
|
||||
<SolidColorBrush x:Key="ButtonBackgroundDisabled" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ButtonForeground" Color="#112961" />
|
||||
<SolidColorBrush x:Key="ButtonForegroundPointerOver" Color="#050E41" />
|
||||
<SolidColorBrush x:Key="ButtonForegroundPressed" Color="#010320" />
|
||||
<SolidColorBrush x:Key="ButtonForegroundDisabled" Color="#8C8C8C" />
|
||||
|
||||
<SolidColorBrush x:Key="TextControlBorderBrush" Color="#515151" />
|
||||
<SolidColorBrush x:Key="TextControlBorderBrushPointerOver" Color="#000000" />
|
||||
<SolidColorBrush x:Key="TextControlBorderBrushFocused" Color="#3C5F95" />
|
||||
<SolidColorBrush x:Key="TextControlBorderBrushDisabled" Color="#9B9B9B" />
|
||||
<SolidColorBrush x:Key="TextControlBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="TextControlBackgroundPointerOver" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="TextControlBackgroundFocused" Color="#F8FCFF" />
|
||||
<SolidColorBrush x:Key="TextControlBackgroundDisabled" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="TextControlForeground" Color="#000000" />
|
||||
<SolidColorBrush x:Key="TextControlForegroundPointerOver" Color="#000000" />
|
||||
<SolidColorBrush x:Key="TextControlForegroundFocused" Color="#000000" />
|
||||
<SolidColorBrush x:Key="TextControlForegroundDisabled" Color="#8C8C8C" />
|
||||
<SolidColorBrush x:Key="TextControlSelectionHighlightColor" Color="#DEE9F8" />
|
||||
<SolidColorBrush x:Key="TextControlPlaceholderForeground" Color="#AAAAAA" />
|
||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
|
||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
|
||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
|
||||
|
||||
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
|
||||
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
|
||||
<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="ExpanderChevronForegroundPointerOver" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="#FFFFFF" />
|
||||
<Thickness x:Key="ExpanderContentPadding">15</Thickness>
|
||||
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FCFCFC" />
|
||||
|
||||
</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 {
|
||||
public override void Initialize() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted() {
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||
desktop.MainWindow = new MainWindow(Program.Arguments);
|
||||
namespace DHT.Desktop {
|
||||
sealed class App : Application {
|
||||
public override void Initialize() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
public override void OnFrameworkInitializationCompleted() {
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args));
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,72 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop;
|
||||
namespace DHT.Desktop {
|
||||
sealed class Arguments {
|
||||
private static readonly Log Log = Log.ForType<Arguments>();
|
||||
|
||||
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 string? DatabaseFile { get; }
|
||||
public ushort? ServerPort { get; }
|
||||
public string? ServerToken { get; }
|
||||
|
||||
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(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 == FirstArgument && !key.StartsWith('-')) {
|
||||
value = key;
|
||||
key = "-db";
|
||||
}
|
||||
else if (i >= args.Count - 1) {
|
||||
Log.Warn("Missing value for command line argument: " + key);
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
value = args[++i];
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "-db":
|
||||
DatabaseFile = value;
|
||||
continue;
|
||||
|
||||
case "-port": {
|
||||
if (ushort.TryParse(value, out var port)) {
|
||||
ServerPort = port;
|
||||
}
|
||||
else {
|
||||
Log.Warn("Invalid port number: " + value);
|
||||
}
|
||||
public Arguments(string[] args) {
|
||||
for (int i = 0; i < args.Length; i++) {
|
||||
string key = args[i];
|
||||
|
||||
if (i >= args.Length - 1) {
|
||||
Log.Warn("Missing value for command line argument: " + key);
|
||||
continue;
|
||||
}
|
||||
|
||||
case "-token":
|
||||
ServerToken = value;
|
||||
continue;
|
||||
string value = args[++i];
|
||||
|
||||
default:
|
||||
Log.Warn("Unknown command line argument: " + key);
|
||||
break;
|
||||
switch (key) {
|
||||
case "-db":
|
||||
DatabaseFile = value;
|
||||
continue;
|
||||
|
||||
case "-port": {
|
||||
if (ushort.TryParse(value, out var port)) {
|
||||
ServerPort = port;
|
||||
}
|
||||
else {
|
||||
Log.Warn("Invalid port number: " + value);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
case "-token":
|
||||
ServerToken = value;
|
||||
continue;
|
||||
|
||||
default:
|
||||
Log.Warn("Unknown command line argument: " + key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace DHT.Desktop.Common;
|
||||
|
||||
sealed class BytesValueConverter : IValueConverter {
|
||||
private sealed class Unit {
|
||||
private readonly string label;
|
||||
private readonly string numberFormat;
|
||||
|
||||
public Unit(string label, int decimalPlaces) {
|
||||
this.label = label;
|
||||
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
||||
}
|
||||
|
||||
public string Format(double size) {
|
||||
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
public static string Convert(ulong size) {
|
||||
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
||||
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
||||
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||
}
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
if (value is long size and >= 0L) {
|
||||
return Convert((ulong) size);
|
||||
}
|
||||
else if (value is ulong usize) {
|
||||
return Convert(usize);
|
||||
}
|
||||
else {
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
@@ -3,67 +3,64 @@ 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.Schema;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Common;
|
||||
namespace DHT.Desktop.Common {
|
||||
static class DatabaseGui {
|
||||
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
||||
|
||||
static class DatabaseGui {
|
||||
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
||||
private const string DatabaseFileInitialName = "archive.dht";
|
||||
|
||||
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 async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
||||
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||
Title = "Open Database File",
|
||||
FileTypeFilter = DatabaseFileDialogFilter,
|
||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||
AllowMultiple = true
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
||||
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||
Title = "Open or Create Database File",
|
||||
FileTypeChoices = DatabaseFileDialogFilter,
|
||||
SuggestedFileName = DatabaseFileInitialName,
|
||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||
ShowOverwritePrompt = false
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||
IDatabaseFile? file = null;
|
||||
|
||||
try {
|
||||
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) {
|
||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
|
||||
} catch (Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
|
||||
public static OpenFileDialog NewOpenDatabaseFileDialog() {
|
||||
return new OpenFileDialog {
|
||||
Title = "Open Database File",
|
||||
InitialFileName = DatabaseFileInitialName,
|
||||
Filters = DatabaseFileDialogFilter
|
||||
};
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
public static SaveFileDialog NewOpenOrCreateDatabaseFileDialog() {
|
||||
return new SaveFileDialog {
|
||||
Title = "Open or Create Database File",
|
||||
InitialFileName = DatabaseFileInitialName,
|
||||
Filters = DatabaseFileDialogFilter
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
||||
}
|
||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
|
||||
IDatabaseFile? file = null;
|
||||
|
||||
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?");
|
||||
try {
|
||||
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
|
||||
} catch (InvalidDatabaseVersionException ex) {
|
||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
||||
} catch (DatabaseTooNewException ex) {
|
||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
|
||||
} catch (Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT. Do you want to proceed with the upgrade?");
|
||||
}
|
||||
|
||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped. Do you want to proceed with the upgrade?");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,14 +2,14 @@ using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace DHT.Desktop.Common;
|
||||
namespace DHT.Desktop.Common {
|
||||
sealed class NumberValueConverter : IValueConverter {
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
return string.Format(Program.Culture, "{0:n0}", value);
|
||||
}
|
||||
|
||||
sealed class NumberValueConverter : IValueConverter {
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
throw new NotSupportedException();
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,19 +1,19 @@
|
||||
namespace DHT.Desktop.Common;
|
||||
namespace DHT.Desktop.Common {
|
||||
static class TextFormat {
|
||||
public static string Format(this int number) {
|
||||
return number.ToString("N0", Program.Culture);
|
||||
}
|
||||
|
||||
static class TextFormat {
|
||||
public static string Format(this int number) {
|
||||
return number.ToString("N0", Program.Culture);
|
||||
}
|
||||
public static string Format(this long number) {
|
||||
return number.ToString("N0", Program.Culture);
|
||||
}
|
||||
|
||||
public static string Format(this long number) {
|
||||
return number.ToString("N0", Program.Culture);
|
||||
}
|
||||
public static string Pluralize(this int number, string singular) {
|
||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||
}
|
||||
|
||||
public static string Pluralize(this int number, string singular) {
|
||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||
}
|
||||
|
||||
public static string Pluralize(this long number, string singular) {
|
||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||
public static string Pluralize(this long number, string singular) {
|
||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,49 +1,76 @@
|
||||
<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>
|
||||
<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>
|
||||
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
||||
<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="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>
|
||||
<PackageReference Include="Avalonia" Version="0.10.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.12" />
|
||||
<ProjectReference Include="..\Server\Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Resources/icon.ico" />
|
||||
<EmbeddedResource Include="Resources/tracker-loader.js">
|
||||
<LogicalName>tracker-loader.js</LogicalName>
|
||||
<Compile Update="Windows\MainWindow.axaml.cs">
|
||||
<DependentUpon>MainWindow.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Update="Dialogs\CheckBox\CheckBoxDialog.axaml.cs">
|
||||
<DependentUpon>CheckBoxDialog.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Update="Dialogs\Progress\ProgressDialog.axaml.cs">
|
||||
<DependentUpon>ProgressDialog.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Update="Dialogs\Message\MessageDialog.axaml.cs">
|
||||
<DependentUpon>MessageDialog.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<UpToDateCheckInput Remove="Pages\DatabasePage.axaml" />
|
||||
<UpToDateCheckInput Remove="Pages\TrackingPage.axaml" />
|
||||
<UpToDateCheckInput Remove="Pages\ViewerPage.axaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js">
|
||||
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="../Resources/Tracker/scripts.min/**">
|
||||
<LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="../Resources/Tracker/styles/**">
|
||||
<LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="../Resources/Viewer/**">
|
||||
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<Visible>false</Visible>
|
||||
</EmbeddedResource>
|
||||
<AvaloniaResource Include="Resources/icon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@@ -5,7 +5,6 @@
|
||||
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"
|
||||
@@ -35,17 +34,17 @@
|
||||
|
||||
<StackPanel Margin="20">
|
||||
<ScrollViewer MaxHeight="400">
|
||||
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<ItemsControl Items="{Binding Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding IsChecked}">
|
||||
<CheckBox IsChecked="{Binding Checked}">
|
||||
<Label>
|
||||
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
||||
</Label>
|
||||
</CheckBox>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<Panel Classes="buttons">
|
||||
<WrapPanel Classes="left">
|
||||
|
@@ -1,21 +1,31 @@
|
||||
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;
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class CheckBoxDialog : Window {
|
||||
public CheckBoxDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class CheckBoxDialog : Window {
|
||||
public CheckBoxDialog() {
|
||||
InitializeComponent();
|
||||
}
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Ok);
|
||||
}
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Ok);
|
||||
}
|
||||
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Cancel);
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -2,69 +2,69 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
class CheckBoxDialogModel : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
class CheckBoxDialogModel : ObservableObject {
|
||||
public string Title { get; init; } = "";
|
||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
||||
|
||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
||||
public IReadOnlyList<CheckBoxItem> Items {
|
||||
get => items;
|
||||
|
||||
public IReadOnlyList<CheckBoxItem> Items {
|
||||
get => items;
|
||||
protected set {
|
||||
foreach (var item in items) {
|
||||
item.PropertyChanged -= OnItemPropertyChanged;
|
||||
}
|
||||
|
||||
protected set {
|
||||
foreach (var item in items) {
|
||||
item.PropertyChanged -= OnItemPropertyChanged;
|
||||
}
|
||||
items = value;
|
||||
|
||||
items = value;
|
||||
|
||||
foreach (var item in items) {
|
||||
item.PropertyChanged += OnItemPropertyChanged;
|
||||
foreach (var item in items) {
|
||||
item.PropertyChanged += OnItemPropertyChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool pauseCheckEvents = false;
|
||||
private bool pauseCheckEvents = false;
|
||||
|
||||
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
||||
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
||||
public bool AreAllSelected => Items.All(static item => item.Checked);
|
||||
public bool AreNoneSelected => Items.All(static item => !item.Checked);
|
||||
|
||||
public void SelectAll() => SetAllChecked(true);
|
||||
public void SelectNone() => SetAllChecked(false);
|
||||
public void SelectAll() => SetAllChecked(true);
|
||||
public void SelectNone() => SetAllChecked(false);
|
||||
|
||||
private void SetAllChecked(bool isChecked) {
|
||||
pauseCheckEvents = true;
|
||||
private void SetAllChecked(bool isChecked) {
|
||||
pauseCheckEvents = true;
|
||||
|
||||
foreach (var item in Items) {
|
||||
item.IsChecked = isChecked;
|
||||
}
|
||||
foreach (var item in Items) {
|
||||
item.Checked = isChecked;
|
||||
}
|
||||
|
||||
pauseCheckEvents = false;
|
||||
UpdateBulkButtons();
|
||||
}
|
||||
|
||||
private void UpdateBulkButtons() {
|
||||
OnPropertyChanged(nameof(AreAllSelected));
|
||||
OnPropertyChanged(nameof(AreNoneSelected));
|
||||
}
|
||||
|
||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
||||
pauseCheckEvents = false;
|
||||
UpdateBulkButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||
private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||
|
||||
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;
|
||||
|
||||
private void UpdateBulkButtons() {
|
||||
OnPropertyChanged(nameof(AreAllSelected));
|
||||
OnPropertyChanged(nameof(AreNoneSelected));
|
||||
}
|
||||
|
||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
|
||||
UpdateBulkButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||
|
||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
||||
|
||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||
this.Items = new List<CheckBoxItem<T>>(items);
|
||||
base.Items = this.Items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,20 +1,24 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
class CheckBoxItem : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
partial class CheckBoxItem : ObservableObject {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
private bool isChecked = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isChecked = false;
|
||||
}
|
||||
public bool Checked {
|
||||
get => isChecked;
|
||||
set => Change(ref isChecked, value);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||
public new T Item { get; }
|
||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||
public new T Item { get; }
|
||||
|
||||
public CheckBoxItem(T item) {
|
||||
this.Item = item;
|
||||
base.Item = item;
|
||||
public CheckBoxItem(T item) {
|
||||
this.Item = item;
|
||||
base.Item = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,36 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.File;
|
||||
|
||||
static class FileDialogs {
|
||||
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
||||
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
||||
}
|
||||
|
||||
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
||||
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
||||
}
|
||||
|
||||
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
||||
return new FilePickerFileType(name) {
|
||||
Patterns = extensions.Select(static ext => "*." + ext).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
||||
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
||||
}
|
||||
|
||||
private static string ToLocalPath(this IStorageFile file) {
|
||||
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
||||
}
|
||||
|
||||
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
|
||||
return files.Select(ToLocalPath).ToArray();
|
||||
}
|
||||
}
|
@@ -2,73 +2,73 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Message;
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
static class Dialog {
|
||||
public static async Task ShowOk(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
||||
return;
|
||||
}
|
||||
|
||||
static class Dialog {
|
||||
public static async Task ShowOk(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
||||
return;
|
||||
await new MessageDialog {
|
||||
DataContext = new MessageDialogModel {
|
||||
Title = title,
|
||||
Message = message,
|
||||
IsOkVisible = true
|
||||
}
|
||||
}.ShowDialog<DialogResult.All>(owner);
|
||||
}
|
||||
|
||||
await new MessageDialog {
|
||||
DataContext = new MessageDialogModel {
|
||||
Title = title,
|
||||
Message = message,
|
||||
IsOkVisible = true
|
||||
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
||||
}
|
||||
}.ShowDialog<DialogResult.All>(owner);
|
||||
}
|
||||
|
||||
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
||||
var result = await new MessageDialog {
|
||||
DataContext = new MessageDialogModel {
|
||||
Title = title,
|
||||
Message = message,
|
||||
IsOkVisible = true,
|
||||
IsCancelVisible = true
|
||||
}
|
||||
}.ShowDialog<DialogResult.All?>(owner);
|
||||
|
||||
return result.ToOkCancel();
|
||||
}
|
||||
|
||||
var result = await new MessageDialog {
|
||||
DataContext = new MessageDialogModel {
|
||||
Title = title,
|
||||
Message = message,
|
||||
IsOkVisible = true,
|
||||
IsCancelVisible = true
|
||||
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
||||
}
|
||||
}.ShowDialog<DialogResult.All?>(owner);
|
||||
|
||||
return result.ToOkCancel();
|
||||
}
|
||||
var result = await new MessageDialog {
|
||||
DataContext = new MessageDialogModel {
|
||||
Title = title,
|
||||
Message = message,
|
||||
IsYesVisible = true,
|
||||
IsNoVisible = true
|
||||
}
|
||||
}.ShowDialog<DialogResult.All?>(owner);
|
||||
|
||||
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
||||
return result.ToYesNo();
|
||||
}
|
||||
|
||||
var result = await new MessageDialog {
|
||||
DataContext = new MessageDialogModel {
|
||||
Title = title,
|
||||
Message = message,
|
||||
IsYesVisible = true,
|
||||
IsNoVisible = true
|
||||
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
||||
}
|
||||
}.ShowDialog<DialogResult.All?>(owner);
|
||||
|
||||
return result.ToYesNo();
|
||||
}
|
||||
var result = await new MessageDialog {
|
||||
DataContext = new MessageDialogModel {
|
||||
Title = title,
|
||||
Message = message,
|
||||
IsYesVisible = true,
|
||||
IsNoVisible = true,
|
||||
IsCancelVisible = true
|
||||
}
|
||||
}.ShowDialog<DialogResult.All?>(owner);
|
||||
|
||||
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
||||
return result.ToYesNoCancel();
|
||||
}
|
||||
|
||||
var result = await new MessageDialog {
|
||||
DataContext = new MessageDialogModel {
|
||||
Title = title,
|
||||
Message = message,
|
||||
IsYesVisible = true,
|
||||
IsNoVisible = true,
|
||||
IsCancelVisible = true
|
||||
}
|
||||
}.ShowDialog<DialogResult.All?>(owner);
|
||||
|
||||
return result.ToYesNoCancel();
|
||||
}
|
||||
}
|
||||
|
@@ -1,59 +1,59 @@
|
||||
using System;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Message;
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
static class DialogResult {
|
||||
public enum All {
|
||||
Ok,
|
||||
Yes,
|
||||
No,
|
||||
Cancel
|
||||
}
|
||||
|
||||
static class DialogResult {
|
||||
public enum All {
|
||||
Ok,
|
||||
Yes,
|
||||
No,
|
||||
Cancel
|
||||
}
|
||||
public enum OkCancel {
|
||||
Closed,
|
||||
Ok,
|
||||
Cancel
|
||||
}
|
||||
|
||||
public enum OkCancel {
|
||||
Closed,
|
||||
Ok,
|
||||
Cancel
|
||||
}
|
||||
public enum YesNo {
|
||||
Closed,
|
||||
Yes,
|
||||
No
|
||||
}
|
||||
|
||||
public enum YesNo {
|
||||
Closed,
|
||||
Yes,
|
||||
No
|
||||
}
|
||||
public enum YesNoCancel {
|
||||
Closed,
|
||||
Yes,
|
||||
No,
|
||||
Cancel
|
||||
}
|
||||
|
||||
public enum YesNoCancel {
|
||||
Closed,
|
||||
Yes,
|
||||
No,
|
||||
Cancel
|
||||
}
|
||||
public static OkCancel ToOkCancel(this All? result) {
|
||||
return result switch {
|
||||
null => OkCancel.Closed,
|
||||
All.Ok => OkCancel.Ok,
|
||||
All.Cancel => OkCancel.Cancel,
|
||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
|
||||
};
|
||||
}
|
||||
|
||||
public static OkCancel ToOkCancel(this All? result) {
|
||||
return result switch {
|
||||
null => OkCancel.Closed,
|
||||
All.Ok => OkCancel.Ok,
|
||||
All.Cancel => OkCancel.Cancel,
|
||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
|
||||
};
|
||||
}
|
||||
public static YesNo ToYesNo(this All? result) {
|
||||
return result switch {
|
||||
null => YesNo.Closed,
|
||||
All.Yes => YesNo.Yes,
|
||||
All.No => YesNo.No,
|
||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
|
||||
};
|
||||
}
|
||||
|
||||
public static YesNo ToYesNo(this All? result) {
|
||||
return result switch {
|
||||
null => YesNo.Closed,
|
||||
All.Yes => YesNo.Yes,
|
||||
All.No => YesNo.No,
|
||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
|
||||
};
|
||||
}
|
||||
|
||||
public static YesNoCancel ToYesNoCancel(this All? result) {
|
||||
return result switch {
|
||||
null => YesNoCancel.Closed,
|
||||
All.Yes => YesNoCancel.Yes,
|
||||
All.No => YesNoCancel.No,
|
||||
All.Cancel => YesNoCancel.Cancel,
|
||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
|
||||
};
|
||||
public static YesNoCancel ToYesNoCancel(this All? result) {
|
||||
return result switch {
|
||||
null => YesNoCancel.Closed,
|
||||
All.Yes => YesNoCancel.Yes,
|
||||
All.No => YesNoCancel.No,
|
||||
All.Cancel => YesNoCancel.Cancel,
|
||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,6 @@
|
||||
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,28 +1,37 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Message;
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MessageDialog : Window {
|
||||
public MessageDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class MessageDialog : Window {
|
||||
public MessageDialog() {
|
||||
InitializeComponent();
|
||||
}
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.Ok);
|
||||
}
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.Ok);
|
||||
}
|
||||
|
||||
public void ClickYes(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.Yes);
|
||||
}
|
||||
public void ClickYes(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.Yes);
|
||||
}
|
||||
|
||||
public void ClickNo(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.No);
|
||||
}
|
||||
public void ClickNo(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.No);
|
||||
}
|
||||
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.Cancel);
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.All.Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,11 @@
|
||||
namespace DHT.Desktop.Dialogs.Message;
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
sealed class MessageDialogModel {
|
||||
public string Title { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
|
||||
sealed class MessageDialogModel {
|
||||
public string Title { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
|
||||
public bool IsOkVisible { get; init; } = false;
|
||||
public bool IsYesVisible { get; init; } = false;
|
||||
public bool IsNoVisible { get; init; } = false;
|
||||
public bool IsCancelVisible { get; init; } = false;
|
||||
public bool IsOkVisible { get; init; } = false;
|
||||
public bool IsYesVisible { get; init; } = false;
|
||||
public bool IsNoVisible { get; init; } = false;
|
||||
public bool IsCancelVisible { get; init; } = false;
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
interface IProgressCallback {
|
||||
Task Update(string message, int finishedItems, int totalItems);
|
||||
Task UpdateIndeterminate(string message);
|
||||
Task Hide();
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
interface IProgressCallback {
|
||||
Task Update(string message, int finishedItems, int totalItems);
|
||||
}
|
||||
}
|
||||
|
@@ -5,10 +5,9 @@
|
||||
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="OnOpened"
|
||||
Opened="Loaded"
|
||||
Closing="OnClosing"
|
||||
Width="500" SizeToContent="Height" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
@@ -32,18 +31,12 @@
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<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 IsIndeterminate="{Binding IsIndeterminate}" Value="{Binding Progress}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
<StackPanel Margin="20">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
||||
</DockPanel>
|
||||
<ProgressBar Value="{Binding Progress}" />
|
||||
</StackPanel>
|
||||
|
||||
</Window>
|
||||
|
@@ -1,89 +1,40 @@
|
||||
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;
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class ProgressDialog : Window {
|
||||
private bool isFinished = false;
|
||||
|
||||
[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);
|
||||
public ProgressDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void OnClosing(object? sender, CancelEventArgs e) {
|
||||
e.Cancel = !isFinished;
|
||||
}
|
||||
|
||||
public void Loaded(object? sender, EventArgs e) {
|
||||
if (DataContext is ProgressDialogModel model) {
|
||||
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public void OnOpened(object? sender, EventArgs e) {
|
||||
if (DataContext is ProgressDialogModel model) {
|
||||
progressTask = Task.Run(model.StartTask);
|
||||
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
private void OnFinished(Task task) {
|
||||
isFinished = true;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||
e.Cancel = !isFinished;
|
||||
}
|
||||
|
||||
private void OnFinished(Task task) {
|
||||
isFinished = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
public async Task ShowProgressDialog(Window owner) {
|
||||
await ShowDialog(owner);
|
||||
await progressTask;
|
||||
}
|
||||
}
|
||||
|
@@ -1,63 +1,65 @@
|
||||
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;
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
sealed class ProgressDialogModel : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
sealed class ProgressDialogModel {
|
||||
public string Title { get; init; } = "";
|
||||
private string message = "";
|
||||
|
||||
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
||||
|
||||
private readonly TaskRunner? task;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ProgressDialogModel() {}
|
||||
|
||||
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(Items.Select(static item => new Callback(item)).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
||||
|
||||
private sealed class Callback : IProgressCallback {
|
||||
private readonly ProgressItem item;
|
||||
|
||||
public Callback(ProgressItem item) {
|
||||
this.item = item;
|
||||
public string Message {
|
||||
get => message;
|
||||
private set => Change(ref message, value);
|
||||
}
|
||||
|
||||
public async Task Update(string message, int finishedItems, int totalItems) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||
item.Message = message;
|
||||
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
||||
item.IsIndeterminate = false;
|
||||
});
|
||||
private string items = "";
|
||||
|
||||
public string Items {
|
||||
get => items;
|
||||
private set => Change(ref items, value);
|
||||
}
|
||||
|
||||
public async Task UpdateIndeterminate(string message) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||
item.Message = message;
|
||||
item.Items = string.Empty;
|
||||
item.Progress = 0;
|
||||
item.IsIndeterminate = true;
|
||||
});
|
||||
private int progress = 0;
|
||||
|
||||
public int Progress {
|
||||
get => progress;
|
||||
private set => Change(ref progress, value);
|
||||
}
|
||||
|
||||
public Task Hide() {
|
||||
return Update(string.Empty, 0, 0);
|
||||
private readonly TaskRunner? task;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ProgressDialogModel() {}
|
||||
|
||||
public ProgressDialogModel(TaskRunner task) {
|
||||
this.task = task;
|
||||
}
|
||||
|
||||
internal async Task StartTask() {
|
||||
if (task != null) {
|
||||
await task(new Callback(this));
|
||||
}
|
||||
}
|
||||
|
||||
public delegate Task TaskRunner(IProgressCallback callback);
|
||||
|
||||
private sealed class Callback : IProgressCallback {
|
||||
private readonly ProgressDialogModel model;
|
||||
|
||||
public Callback(ProgressDialogModel model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||
model.Message = message;
|
||||
model.Items = finishedItems.Format() + " / " + totalItems.Format();
|
||||
model.Progress = 100 * finishedItems / totalItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
}
|
@@ -1,57 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns: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"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Window.DataContext>
|
||||
<namespace:TextBoxDialogModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Panel.buttons">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons > WrapPanel.right">
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons Button">
|
||||
<Setter Property="MinWidth" Value="80" />
|
||||
<Setter Property="Margin" Value="8 0 0 0" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<StackPanel Margin="20">
|
||||
<ScrollViewer MaxHeight="400">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
|
||||
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0 5 25 0">
|
||||
<TextBox Name="Input" Text="{Binding Value}" Width="180" VerticalAlignment="Top" DockPanel.Dock="Right" />
|
||||
<Label Target="Input" VerticalAlignment="Center" DockPanel.Dock="Left">
|
||||
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
||||
</Label>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<Panel Classes="buttons">
|
||||
<WrapPanel Classes="right">
|
||||
<Button Click="ClickOk" IsEnabled="{Binding !HasErrors}">OK</Button>
|
||||
<Button Click="ClickCancel">Cancel</Button>
|
||||
</WrapPanel>
|
||||
</Panel>
|
||||
</StackPanel>
|
||||
|
||||
</Window>
|
@@ -1,21 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class TextBoxDialog : Window {
|
||||
public TextBoxDialog() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Ok);
|
||||
}
|
||||
|
||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||
Close(DialogResult.OkCancel.Cancel);
|
||||
}
|
||||
}
|
@@ -1,47 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
class TextBoxDialogModel : ObservableObject {
|
||||
public string Title { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
|
||||
|
||||
public IReadOnlyList<TextBoxItem> Items {
|
||||
get => items;
|
||||
|
||||
protected set {
|
||||
foreach (var item in items) {
|
||||
item.ErrorsChanged -= OnItemErrorsChanged;
|
||||
}
|
||||
|
||||
items = value;
|
||||
|
||||
foreach (var item in items) {
|
||||
item.ErrorsChanged += OnItemErrorsChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
||||
|
||||
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||
OnPropertyChanged(nameof(HasErrors));
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||
|
||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||
|
||||
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
||||
this.Items = new List<TextBoxItem<T>>(items);
|
||||
base.Items = this.Items;
|
||||
}
|
||||
}
|
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
||||
public bool IsValid => ValidityCheck(Value);
|
||||
|
||||
private string value = string.Empty;
|
||||
|
||||
public string Value {
|
||||
get => this.value;
|
||||
set {
|
||||
SetProperty(ref this.value, value);
|
||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable GetErrors(string? propertyName) {
|
||||
if (propertyName == nameof(Value) && !IsValid) {
|
||||
yield return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasErrors => !IsValid;
|
||||
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
||||
}
|
||||
|
||||
sealed class TextBoxItem<T> : TextBoxItem {
|
||||
public new T Item { get; }
|
||||
|
||||
public TextBoxItem(T item) {
|
||||
this.Item = item;
|
||||
base.Item = item;
|
||||
}
|
||||
}
|
@@ -1,122 +1,119 @@
|
||||
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;
|
||||
namespace DHT.Desktop.Discord {
|
||||
static class DiscordAppSettings {
|
||||
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
||||
|
||||
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";
|
||||
|
||||
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
||||
public static string JsonFilePath { get; }
|
||||
private static string JsonBackupFilePath { get; }
|
||||
|
||||
public static string JsonFilePath { get; }
|
||||
private static string JsonBackupFilePath { get; }
|
||||
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
||||
static DiscordAppSettings() {
|
||||
string rootFolder;
|
||||
|
||||
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
||||
static DiscordAppSettings() {
|
||||
string rootFolder;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
|
||||
rootFolder = Path.Combine(Environment.GetFolderPath(UserProfile, DoNotVerify), "Library", "Application Support", "Discord");
|
||||
}
|
||||
else {
|
||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
||||
}
|
||||
|
||||
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
||||
JsonBackupFilePath = JsonFilePath + ".bak";
|
||||
}
|
||||
|
||||
public static async Task<bool?> AreDevToolsEnabled() {
|
||||
try {
|
||||
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(JsonObject json) {
|
||||
return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
|
||||
}
|
||||
|
||||
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
||||
JsonObject json;
|
||||
|
||||
try {
|
||||
json = await ReadSettingsJson();
|
||||
} catch (FileNotFoundException) {
|
||||
return SettingsJsonResult.FileNotFound;
|
||||
} catch (JsonException) {
|
||||
return SettingsJsonResult.InvalidJson;
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
return SettingsJsonResult.ReadError;
|
||||
}
|
||||
|
||||
if (enable == AreDevToolsEnabled(json)) {
|
||||
return SettingsJsonResult.AlreadySet;
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
json[JsonKeyDevTools] = true;
|
||||
}
|
||||
else {
|
||||
json.Remove(JsonKeyDevTools);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!File.Exists(JsonBackupFilePath)) {
|
||||
File.Copy(JsonFilePath, JsonBackupFilePath);
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
|
||||
rootFolder = Path.Combine(Environment.GetFolderPath(UserProfile, DoNotVerify), "Library", "Application Support", "Discord");
|
||||
}
|
||||
else {
|
||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
||||
}
|
||||
|
||||
await WriteSettingsJson(json);
|
||||
} catch (Exception e) {
|
||||
Log.Error("An error occurred when writing settings file.");
|
||||
Log.Error(e);
|
||||
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
||||
JsonBackupFilePath = JsonFilePath + ".bak";
|
||||
}
|
||||
|
||||
if (File.Exists(JsonBackupFilePath)) {
|
||||
try {
|
||||
File.Move(JsonBackupFilePath, JsonFilePath, true);
|
||||
Log.Info("Restored settings file from backup.");
|
||||
} catch (Exception e2) {
|
||||
Log.Error("Cannot restore settings file from backup.");
|
||||
Log.Error(e2);
|
||||
public static async Task<bool?> AreDevToolsEnabled() {
|
||||
try {
|
||||
return AreDevToolsEnabled(await ReadSettingsJson());
|
||||
} catch (Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) {
|
||||
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True };
|
||||
}
|
||||
|
||||
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
||||
Dictionary<string, object?> json;
|
||||
|
||||
try {
|
||||
json = await ReadSettingsJson();
|
||||
} catch (FileNotFoundException) {
|
||||
return SettingsJsonResult.FileNotFound;
|
||||
} catch (JsonException) {
|
||||
return SettingsJsonResult.InvalidJson;
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
return SettingsJsonResult.ReadError;
|
||||
}
|
||||
|
||||
if (enable == AreDevToolsEnabled(json)) {
|
||||
return SettingsJsonResult.AlreadySet;
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
json[JsonKeyDevTools] = true;
|
||||
}
|
||||
else {
|
||||
json.Remove(JsonKeyDevTools);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!File.Exists(JsonBackupFilePath)) {
|
||||
File.Copy(JsonFilePath, JsonBackupFilePath);
|
||||
}
|
||||
|
||||
await WriteSettingsJson(json);
|
||||
} catch (Exception e) {
|
||||
Log.Error("An error occurred when writing settings file.");
|
||||
Log.Error(e);
|
||||
|
||||
if (File.Exists(JsonBackupFilePath)) {
|
||||
try {
|
||||
File.Move(JsonBackupFilePath, JsonFilePath, true);
|
||||
Log.Info("Restored settings file from backup.");
|
||||
} catch (Exception e2) {
|
||||
Log.Error("Cannot restore settings file from backup.");
|
||||
Log.Error(e2);
|
||||
}
|
||||
}
|
||||
|
||||
return SettingsJsonResult.WriteError;
|
||||
}
|
||||
|
||||
return SettingsJsonResult.WriteError;
|
||||
try {
|
||||
File.Delete(JsonBackupFilePath);
|
||||
} catch (Exception e) {
|
||||
Log.Error("Cannot delete backup file.");
|
||||
Log.Error(e);
|
||||
}
|
||||
|
||||
return SettingsJsonResult.Success;
|
||||
}
|
||||
|
||||
try {
|
||||
File.Delete(JsonBackupFilePath);
|
||||
} catch (Exception e) {
|
||||
Log.Error("Cannot delete backup file.");
|
||||
Log.Error(e);
|
||||
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
|
||||
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
|
||||
}
|
||||
|
||||
return SettingsJsonResult.Success;
|
||||
}
|
||||
|
||||
private static async Task<JsonObject> ReadSettingsJson() {
|
||||
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
||||
}
|
||||
|
||||
private static async Task WriteSettingsJson(JsonObject json) {
|
||||
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
||||
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
|
||||
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
|
||||
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
||||
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +0,0 @@
|
||||
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 {
|
||||
Success,
|
||||
AlreadySet,
|
||||
FileNotFound,
|
||||
ReadError,
|
||||
InvalidJson,
|
||||
WriteError
|
||||
namespace DHT.Desktop.Discord {
|
||||
enum SettingsJsonResult {
|
||||
Success,
|
||||
AlreadySet,
|
||||
FileNotFound,
|
||||
ReadError,
|
||||
InvalidJson,
|
||||
WriteError
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,6 @@
|
||||
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"
|
||||
@@ -47,7 +46,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 8</TextBlock>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0">.NET 5</TextBlock>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
|
||||
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
||||
|
||||
|
@@ -1,11 +1,20 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main;
|
||||
namespace DHT.Desktop.Main {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class AboutWindow : Window {
|
||||
public AboutWindow() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class AboutWindow : Window {
|
||||
public AboutWindow() {
|
||||
InitializeComponent();
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,33 +1,33 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DHT.Desktop.Main;
|
||||
namespace DHT.Desktop.Main {
|
||||
sealed class AboutWindowModel {
|
||||
public void ShowOfficialWebsite() {
|
||||
OpenUrl("https://dht.chylex.com");
|
||||
}
|
||||
|
||||
sealed class AboutWindowModel {
|
||||
public void ShowOfficialWebsite() {
|
||||
OpenUrl("https://dht.chylex.com");
|
||||
}
|
||||
public void ShowSourceCode() {
|
||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||
}
|
||||
|
||||
public void ShowSourceCode() {
|
||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||
}
|
||||
public void ShowLibraryAvalonia() {
|
||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||
}
|
||||
|
||||
public void ShowLibraryAvalonia() {
|
||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||
}
|
||||
public void ShowLibrarySqlite() {
|
||||
OpenUrl("https://www.sqlite.org");
|
||||
}
|
||||
|
||||
public void ShowLibrarySqlite() {
|
||||
OpenUrl("https://www.sqlite.org");
|
||||
}
|
||||
public void ShowLibrarySqliteAdoNet() {
|
||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||
}
|
||||
|
||||
public void ShowLibrarySqliteAdoNet() {
|
||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||
}
|
||||
public void ShowLibraryNetCore() {
|
||||
OpenUrl("https://github.com/dotnet/core");
|
||||
}
|
||||
|
||||
public void ShowLibraryNetCore() {
|
||||
OpenUrl("https://github.com/dotnet/core");
|
||||
}
|
||||
|
||||
private static void OpenUrl(string url) {
|
||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
private static void OpenUrl(string url) {
|
||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,52 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.DownloadItemFilterPanel"
|
||||
x:DataType="controls:DownloadItemFilterPanelModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:DownloadItemFilterPanelModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Margin" Value="0 20 40 0" />
|
||||
<Setter Property="Spacing" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Grid > Label">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="Margin" Value="8 0 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Margin" Value="0 0 0 8" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding FilterStatisticsText}" />
|
||||
<WrapPanel>
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
|
||||
<ComboBox IsEnabled="{Binding LimitSize}" ItemsSource="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
@@ -1,11 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class DownloadItemFilterPanel : UserControl {
|
||||
public DownloadItemFilterPanel() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
@@ -1,124 +0,0 @@
|
||||
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.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposable {
|
||||
public sealed record Unit(string Name, uint Scale);
|
||||
|
||||
private static readonly Unit[] AllUnits = [
|
||||
new Unit("B", 1),
|
||||
new Unit("kB", 1024),
|
||||
new Unit("MB", 1024 * 1024)
|
||||
];
|
||||
|
||||
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 IEnumerable<Unit> Units => AllUnits;
|
||||
|
||||
private readonly State state;
|
||||
private readonly string verb;
|
||||
|
||||
private readonly RestartableTask<long> downloadItemCountTask;
|
||||
private long? matchingItemCount;
|
||||
|
||||
private readonly IDisposable downloadItemCountSubscription;
|
||||
private long? totalItemCount;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DownloadItemFilterPanelModel() : this(State.Dummy) {}
|
||||
|
||||
public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
|
||||
this.state = state;
|
||||
this.verb = verb;
|
||||
|
||||
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
|
||||
|
||||
UpdateFilterStatistics();
|
||||
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
downloadItemCountTask.Cancel();
|
||||
downloadItemCountSubscription.Dispose();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDownloadItemCountChanged(long newItemCount) {
|
||||
totalItemCount = newItemCount;
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
|
||||
|
||||
private void UpdateFilterStatistics() {
|
||||
var filter = CreateFilter();
|
||||
if (filter.IsEmpty) {
|
||||
downloadItemCountTask.Cancel();
|
||||
matchingItemCount = totalItemCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
else {
|
||||
matchingItemCount = null;
|
||||
UpdateFilterStatisticsText();
|
||||
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetMatchingCount(long matchingAttachmentCount) {
|
||||
this.matchingItemCount = matchingAttachmentCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
|
||||
private void UpdateFilterStatisticsText() {
|
||||
var matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
|
||||
var totalItemCountStr = totalItemCount?.Format() ?? "(...)";
|
||||
|
||||
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
|
||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||
}
|
||||
|
||||
public DownloadItemFilter CreateFilter() {
|
||||
DownloadItemFilter filter = new ();
|
||||
|
||||
if (LimitSize) {
|
||||
try {
|
||||
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||
} catch (ArithmeticException) {
|
||||
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||
}
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
57
app/Desktop/Main/Controls/FilterPanel.axaml
Normal file
57
app/Desktop/Main/Controls/FilterPanel.axaml
Normal file
@@ -0,0 +1,57 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.FilterPanel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:FilterPanelModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Margin" Value="0 20 40 0" />
|
||||
<Setter Property="Spacing" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="Grid > Label">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Grid > CalendarDatePicker">
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="IsTodayHighlighted" Value="True" />
|
||||
<Setter Property="SelectedDateFormat" Value="Short" />
|
||||
</Style>
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Margin" Value="0 0 0 8" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<WrapPanel>
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox>
|
||||
<Grid ColumnDefinitions="Auto, 4, 125" RowDefinitions="Auto, 4, Auto" Margin="4 0">
|
||||
<Label Grid.Row="0" Grid.Column="0">From:</Label>
|
||||
<CalendarDatePicker Grid.Row="0" Grid.Column="2" x:Name="StartDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" />
|
||||
<Label Grid.Row="2" Grid.Column="0">To:</Label>
|
||||
<CalendarDatePicker Grid.Row="2" Grid.Column="2" x:Name="EndDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding FilterByChannel}">Filter by Channel</CheckBox>
|
||||
<Button Command="{Binding OpenChannelFilterDialog}" IsEnabled="{Binding FilterByChannel}">Select Channels...</Button>
|
||||
<TextBlock Text="{Binding ChannelFilterLabel}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding FilterByUser}">Filter by User</CheckBox>
|
||||
<Button Command="{Binding OpenUserFilterDialog}" IsEnabled="{Binding FilterByUser}">Select Users...</Button>
|
||||
<TextBlock Text="{Binding UserFilterLabel}" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
|
||||
</UserControl>
|
34
app/Desktop/Main/Controls/FilterPanel.axaml.cs
Normal file
34
app/Desktop/Main/Controls/FilterPanel.axaml.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class FilterPanel : UserControl {
|
||||
private CalendarDatePicker StartDatePicker => this.FindControl<CalendarDatePicker>("StartDatePicker");
|
||||
private CalendarDatePicker EndDatePicker => this.FindControl<CalendarDatePicker>("EndDatePicker");
|
||||
|
||||
public FilterPanel() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
var culture = Program.Culture;
|
||||
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
||||
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
|
||||
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
|
||||
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
|
||||
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
|
||||
}
|
||||
}
|
||||
|
||||
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
||||
if (DataContext is FilterPanelModel model) {
|
||||
model.StartDate = StartDatePicker.SelectedDate;
|
||||
model.EndDate = EndDatePicker.SelectedDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
237
app/Desktop/Main/Controls/FilterPanelModel.cs
Normal file
237
app/Desktop/Main/Controls/FilterPanelModel.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.CheckBox;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
sealed class FilterPanelModel : BaseModel {
|
||||
private static readonly HashSet<string> FilterProperties = new () {
|
||||
nameof(FilterByDate),
|
||||
nameof(StartDate),
|
||||
nameof(EndDate),
|
||||
nameof(FilterByChannel),
|
||||
nameof(IncludedChannels),
|
||||
nameof(FilterByUser),
|
||||
nameof(IncludedUsers)
|
||||
};
|
||||
|
||||
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
||||
|
||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||
|
||||
private bool filterByDate = false;
|
||||
private DateTime? startDate = null;
|
||||
private DateTime? endDate = null;
|
||||
private bool filterByChannel = false;
|
||||
private HashSet<ulong>? includedChannels = null;
|
||||
private bool filterByUser = false;
|
||||
private HashSet<ulong>? includedUsers = null;
|
||||
|
||||
public bool FilterByDate {
|
||||
get => filterByDate;
|
||||
set => Change(ref filterByDate, value);
|
||||
}
|
||||
|
||||
public DateTime? StartDate {
|
||||
get => startDate;
|
||||
set => Change(ref startDate, value);
|
||||
}
|
||||
|
||||
public DateTime? EndDate {
|
||||
get => endDate;
|
||||
set => Change(ref endDate, value);
|
||||
}
|
||||
|
||||
public bool FilterByChannel {
|
||||
get => filterByChannel;
|
||||
set => Change(ref filterByChannel, value);
|
||||
}
|
||||
|
||||
public HashSet<ulong> IncludedChannels {
|
||||
get => includedChannels ?? db.GetAllChannels().Select(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);
|
||||
}
|
||||
|
||||
private string channelFilterLabel = "";
|
||||
|
||||
public string ChannelFilterLabel {
|
||||
get => channelFilterLabel;
|
||||
set => Change(ref channelFilterLabel, value);
|
||||
}
|
||||
|
||||
private string userFilterLabel = "";
|
||||
|
||||
public string UserFilterLabel {
|
||||
get => userFilterLabel;
|
||||
set => Change(ref userFilterLabel, value);
|
||||
}
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public FilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public FilterPanelModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
|
||||
UpdateChannelFilterLabel();
|
||||
UpdateUserFilterLabel();
|
||||
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||
FilterPropertyChanged?.Invoke(sender, e);
|
||||
}
|
||||
|
||||
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
||||
UpdateChannelFilterLabel();
|
||||
}
|
||||
else if (e.PropertyName is nameof(FilterByUser) or nameof(IncludedUsers)) {
|
||||
UpdateUserFilterLabel();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
||||
UpdateChannelFilterLabel();
|
||||
}
|
||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
||||
UpdateUserFilterLabel();
|
||||
}
|
||||
}
|
||||
|
||||
public async void OpenChannelFilterDialog() {
|
||||
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var included = IncludedChannels;
|
||||
|
||||
foreach (var channel in db.GetAllChannels()) {
|
||||
var channelId = channel.Id;
|
||||
var channelName = channel.Name;
|
||||
|
||||
string title;
|
||||
if (servers.TryGetValue(channel.Server, out var server)) {
|
||||
var titleBuilder = new StringBuilder();
|
||||
var serverType = server.Type;
|
||||
|
||||
titleBuilder.Append('[')
|
||||
.Append(ServerTypes.ToString(serverType))
|
||||
.Append("] ");
|
||||
|
||||
if (serverType == ServerType.DirectMessage) {
|
||||
titleBuilder.Append(channelName);
|
||||
}
|
||||
else {
|
||||
titleBuilder.Append(server.Name)
|
||||
.Append(" - ")
|
||||
.Append(channelName);
|
||||
}
|
||||
|
||||
title = titleBuilder.ToString();
|
||||
}
|
||||
else {
|
||||
title = channelName;
|
||||
}
|
||||
|
||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||
Title = title,
|
||||
Checked = included.Contains(channelId)
|
||||
});
|
||||
}
|
||||
|
||||
var result = await OpenIdFilterDialog(window, "Included Channels", items);
|
||||
if (result != null) {
|
||||
IncludedChannels = result;
|
||||
}
|
||||
}
|
||||
|
||||
public async void OpenUserFilterDialog() {
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var included = IncludedUsers;
|
||||
|
||||
foreach (var user in db.GetAllUsers()) {
|
||||
var name = user.Name;
|
||||
var discriminator = user.Discriminator;
|
||||
|
||||
items.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||
Title = discriminator == null ? name : name + " #" + discriminator,
|
||||
Checked = included.Contains(user.Id)
|
||||
});
|
||||
}
|
||||
|
||||
var result = await OpenIdFilterDialog(window, "Included Users", items);
|
||||
if (result != null) {
|
||||
IncludedUsers = result;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateChannelFilterLabel() {
|
||||
long total = db.Statistics.TotalChannels;
|
||||
long included = FilterByChannel ? IncludedChannels.Count : total;
|
||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
||||
}
|
||||
|
||||
private void UpdateUserFilterLabel() {
|
||||
long total = db.Statistics.TotalUsers;
|
||||
long included = FilterByUser ? IncludedUsers.Count : total;
|
||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
||||
}
|
||||
|
||||
public MessageFilter CreateFilter() {
|
||||
MessageFilter filter = new();
|
||||
|
||||
if (FilterByDate) {
|
||||
filter.StartDate = StartDate;
|
||||
filter.EndDate = EndDate;
|
||||
}
|
||||
|
||||
if (FilterByChannel) {
|
||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
||||
}
|
||||
|
||||
if (FilterByUser) {
|
||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
|
||||
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||
|
||||
var model = new CheckBoxDialogModel<ulong>(items) {
|
||||
Title = title
|
||||
};
|
||||
|
||||
var dialog = new CheckBoxDialog { DataContext = model };
|
||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||
|
||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,64 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
|
||||
x:DataType="controls:MessageFilterPanelModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:MessageFilterPanelModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Margin" Value="0 20 40 0" />
|
||||
<Setter Property="Spacing" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Grid > Label">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Grid > CalendarDatePicker">
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="IsTodayHighlighted" Value="True" />
|
||||
<Setter Property="SelectedDateFormat" Value="Short" />
|
||||
</Style>
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Margin" Value="0 0 0 8" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding FilterStatisticsText}" />
|
||||
<WrapPanel>
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox>
|
||||
<Grid ColumnDefinitions="Auto, 4, 125" RowDefinitions="Auto, 4, Auto" Margin="4 0">
|
||||
<Label Grid.Row="0" Grid.Column="0">From:</Label>
|
||||
<CalendarDatePicker Grid.Row="0" Grid.Column="2" x:Name="StartDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" />
|
||||
<Label Grid.Row="2" Grid.Column="0">To:</Label>
|
||||
<CalendarDatePicker Grid.Row="2" Grid.Column="2" x:Name="EndDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding FilterByChannel}">Filter by Channel</CheckBox>
|
||||
<Button Command="{Binding OpenChannelFilterDialog}" IsEnabled="{Binding FilterByChannel}">Select Channels...</Button>
|
||||
<TextBlock Text="{Binding ChannelFilterLabel}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding FilterByUser}">Filter by User</CheckBox>
|
||||
<Button Command="{Binding OpenUserFilterDialog}" IsEnabled="{Binding FilterByUser}">Select Users...</Button>
|
||||
<TextBlock Text="{Binding UserFilterLabel}" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
@@ -1,26 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class MessageFilterPanel : UserControl {
|
||||
public MessageFilterPanel() {
|
||||
InitializeComponent();
|
||||
|
||||
var culture = Program.Culture;
|
||||
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
||||
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
|
||||
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
|
||||
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
|
||||
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
|
||||
}
|
||||
}
|
||||
|
||||
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
||||
if (DataContext is MessageFilterPanelModel model) {
|
||||
model.StartDate = StartDatePicker.SelectedDate;
|
||||
model.EndDate = EndDatePicker.SelectedDate;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,311 +0,0 @@
|
||||
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.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
private static readonly HashSet<string> FilterProperties = [
|
||||
nameof(FilterByDate),
|
||||
nameof(StartDate),
|
||||
nameof(EndDate),
|
||||
nameof(FilterByChannel),
|
||||
nameof(IncludedChannels),
|
||||
nameof(FilterByUser),
|
||||
nameof(IncludedUsers)
|
||||
];
|
||||
|
||||
public string FilterStatisticsText { get; private set; } = "";
|
||||
|
||||
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
||||
|
||||
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;
|
||||
|
||||
[ObservableProperty]
|
||||
private string channelFilterLabel = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string userFilterLabel = "";
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
private readonly string verb;
|
||||
|
||||
private readonly RestartableTask<long> exportedMessageCountTask;
|
||||
private long? exportedMessageCount;
|
||||
|
||||
private readonly IDisposable messageCountSubscription;
|
||||
private long? totalMessageCount;
|
||||
|
||||
private readonly IDisposable channelCountSubscription;
|
||||
private long? totalChannelCount;
|
||||
|
||||
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.state = state;
|
||||
this.verb = verb;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
exportedMessageCountTask.Cancel();
|
||||
|
||||
messageCountSubscription.Dispose();
|
||||
channelCountSubscription.Dispose();
|
||||
userCountSubscription.Dispose();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||
UpdateFilterStatistics();
|
||||
FilterPropertyChanged?.Invoke(sender, e);
|
||||
}
|
||||
|
||||
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
||||
UpdateChannelFilterLabel();
|
||||
}
|
||||
else if (e.PropertyName is nameof(FilterByUser) or nameof(IncludedUsers)) {
|
||||
UpdateUserFilterLabel();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMessageCountChanged(long newMessageCount) {
|
||||
totalMessageCount = newMessageCount;
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
|
||||
private void OnChannelCountChanged(long newChannelCount) {
|
||||
totalChannelCount = newChannelCount;
|
||||
UpdateChannelFilterLabel();
|
||||
}
|
||||
|
||||
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) {
|
||||
exportedMessageCountTask.Cancel();
|
||||
exportedMessageCount = totalMessageCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
else {
|
||||
exportedMessageCount = null;
|
||||
UpdateFilterStatisticsText();
|
||||
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetExportedMessageCount(long exportedMessageCount) {
|
||||
this.exportedMessageCount = exportedMessageCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
|
||||
private void UpdateFilterStatisticsText() {
|
||||
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
||||
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
||||
|
||||
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
|
||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||
}
|
||||
|
||||
public async Task OpenChannelFilterDialog() {
|
||||
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
||||
|
||||
await foreach (var channel in state.Db.Channels.Get()) {
|
||||
var channelId = channel.Id;
|
||||
var channelName = channel.Name;
|
||||
|
||||
string title;
|
||||
if (servers.TryGetValue(channel.Server, out var server)) {
|
||||
var titleBuilder = new StringBuilder();
|
||||
var serverType = server.Type;
|
||||
|
||||
titleBuilder.Append('[')
|
||||
.Append(ServerTypes.ToString(serverType))
|
||||
.Append("] ");
|
||||
|
||||
if (serverType == ServerType.DirectMessage) {
|
||||
titleBuilder.Append(channelName);
|
||||
}
|
||||
else {
|
||||
titleBuilder.Append(server.Name)
|
||||
.Append(" - ")
|
||||
.Append(channelName);
|
||||
}
|
||||
|
||||
title = titleBuilder.ToString();
|
||||
}
|
||||
else {
|
||||
title = channelName;
|
||||
}
|
||||
|
||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||
Title = title,
|
||||
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId)
|
||||
});
|
||||
}
|
||||
|
||||
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 Task OpenUserFilterDialog() {
|
||||
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
|
||||
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
||||
|
||||
await foreach (var user in state.Db.Users.Get()) {
|
||||
var name = user.Name;
|
||||
var discriminator = user.Discriminator;
|
||||
|
||||
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||
Title = discriminator == null ? name : name + " #" + discriminator,
|
||||
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
||||
});
|
||||
}
|
||||
|
||||
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 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) {
|
||||
Title = title
|
||||
};
|
||||
|
||||
var dialog = new CheckBoxDialog { DataContext = model };
|
||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||
|
||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
|
||||
x:DataType="controls:ServerConfigurationPanelModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:ServerConfigurationPanelModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Orientation" Value="Vertical" />
|
||||
<Setter Property="Margin" Value="0 0 10 10" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel>
|
||||
<Button Command="{Binding OnClickToggleServerButton}" Content="{Binding ToggleServerButtonText}" IsEnabled="{Binding IsToggleServerButtonEnabled}" />
|
||||
<TextBlock TextWrapping="Wrap" Margin="0 15">
|
||||
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script again.
|
||||
</TextBlock>
|
||||
<WrapPanel>
|
||||
<StackPanel>
|
||||
<Label Target="Port">Port</Label>
|
||||
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<Label Target="Token">Token</Label>
|
||||
<TextBox x:Name="Token" Width="200" Text="{Binding InputToken}" />
|
||||
</StackPanel>
|
||||
<StackPanel VerticalAlignment="Bottom">
|
||||
<Button Command="{Binding OnClickRandomizeToken}">Randomize Token</Button>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickApplyChanges}">Apply & Restart</Button>
|
||||
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickCancelChanges}">Cancel</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
@@ -1,11 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class ServerConfigurationPanel : UserControl {
|
||||
public ServerConfigurationPanel() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
@@ -1,118 +0,0 @@
|
||||
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;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
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;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||
private string inputToken;
|
||||
|
||||
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isToggleServerButtonEnabled = true;
|
||||
|
||||
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
||||
|
||||
private readonly Window window;
|
||||
private readonly ServerManager server;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public ServerConfigurationPanelModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.server = state.Server;
|
||||
this.inputPort = ServerConfiguration.Port.ToString();
|
||||
this.inputToken = ServerConfiguration.Token;
|
||||
|
||||
server.StatusChanged += OnServerStatusChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
server.StatusChanged -= OnServerStatusChanged;
|
||||
}
|
||||
|
||||
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 async Task StopServer() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
|
||||
try {
|
||||
await server.Stop();
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||
}
|
||||
|
||||
UpdateServerStatus();
|
||||
IsToggleServerButtonEnabled = true;
|
||||
}
|
||||
|
||||
public async Task OnClickToggleServerButton() {
|
||||
if (server.IsRunning) {
|
||||
await StopServer();
|
||||
}
|
||||
else {
|
||||
await StartServer();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnClickRandomizeToken() {
|
||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
ServerConfiguration.Port = port;
|
||||
ServerConfiguration.Token = inputToken;
|
||||
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
|
||||
await StartServer();
|
||||
}
|
||||
|
||||
public void OnClickCancelChanges() {
|
||||
InputPort = ServerConfiguration.Port.ToString();
|
||||
InputToken = ServerConfiguration.Token;
|
||||
}
|
||||
}
|
@@ -4,58 +4,54 @@
|
||||
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:DataType="controls:StatusBarModel">
|
||||
x:Class="DHT.Desktop.Main.Controls.StatusBar">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:StatusBarModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Background>
|
||||
<SolidColorBrush>#3C4F79</SolidColorBrush>
|
||||
<SolidColorBrush>#546A9F</SolidColorBrush>
|
||||
</UserControl.Background>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="StackPanel > TextBlock">
|
||||
<Setter Property="Foreground" Value="#E7E7E7" />
|
||||
<Setter Property="Foreground" Value="#E0E0E0" />
|
||||
</Style>
|
||||
<Style Selector="StackPanel > TextBlock.label">
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiLight" />
|
||||
</Style>
|
||||
<Style Selector="StackPanel > TextBlock.value">
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="FontWeight" Value="SemiLight" />
|
||||
<Setter Property="TextAlignment" Value="Right" />
|
||||
<Setter Property="Margin" Value="0 1" />
|
||||
</Style>
|
||||
<Style Selector="StackPanel > Rectangle">
|
||||
<Setter Property="Margin" Value="14 1" />
|
||||
<Setter Property="Stroke" Value="#697899" />
|
||||
<Setter Property="Margin" Value="14 0" />
|
||||
<Setter Property="Stroke" Value="#3B5287" />
|
||||
<Setter Property="StrokeThickness" Value="2" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="6 3">
|
||||
<StackPanel Orientation="Horizontal" Margin="4 3">
|
||||
<StackPanel Orientation="Vertical" Width="65">
|
||||
<TextBlock Classes="label">Status</TextBlock>
|
||||
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
|
||||
<TextBlock FontSize="12" Margin="0 2 0 0" Text="{Binding StatusText}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Servers</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding ServerCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Channels</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding ChannelCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Messages</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding MessageCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
@@ -1,11 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class StatusBar : UserControl {
|
||||
public StatusBar() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class StatusBar : UserControl {
|
||||
public StatusBar() {
|
||||
InitializeComponent();
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,63 +1,45 @@
|
||||
using System;
|
||||
using System.Reactive.Linq;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
sealed class StatusBarModel : BaseModel {
|
||||
public DatabaseStatistics DatabaseStatistics { get; }
|
||||
|
||||
sealed partial class StatusBarModel : ObservableObject, IDisposable {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? serverCount;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? channelCount;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? messageCount;
|
||||
private Status status = Status.Stopped;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
||||
private ServerManager.Status serverStatus;
|
||||
public Status CurrentStatus {
|
||||
get => status;
|
||||
set {
|
||||
status = value;
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
}
|
||||
}
|
||||
|
||||
public string ServerStatusText => serverStatus switch {
|
||||
ServerManager.Status.Starting => "STARTING",
|
||||
ServerManager.Status.Started => "READY",
|
||||
ServerManager.Status.Stopping => "STOPPING",
|
||||
ServerManager.Status.Stopped => "STOPPED",
|
||||
_ => ""
|
||||
};
|
||||
public string StatusText {
|
||||
get {
|
||||
return CurrentStatus switch {
|
||||
Status.Starting => "STARTING",
|
||||
Status.Ready => "READY",
|
||||
Status.Stopping => "STOPPING",
|
||||
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()) {}
|
||||
|
||||
[Obsolete("Designer")]
|
||||
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 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);
|
||||
public enum Status {
|
||||
Starting,
|
||||
Ready,
|
||||
Stopping,
|
||||
Stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
91
app/Desktop/Main/MainContentScreen.axaml
Normal file
91
app/Desktop/Main/MainContentScreen.axaml
Normal file
@@ -0,0 +1,91 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.MainContentScreen">
|
||||
|
||||
<Design.DataContext>
|
||||
<main:MainContentScreenModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="TabControl ItemsPresenter > WrapPanel">
|
||||
<Setter Property="Background" Value="#546A9F" />
|
||||
</Style>
|
||||
<Style Selector="TabItem">
|
||||
<Setter Property="Foreground" Value="#E9E9E9" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
<Style Selector="TabItem[TabStripPlacement=Left] /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Margin" Value="5 0" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:pointerover">
|
||||
<Setter Property="Background" Value="#1F2E45" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:pointerover /template/ Border">
|
||||
<Setter Property="Background" Value="#1F2E45" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:pointerover > TextBlock">
|
||||
<Setter Property="Foreground" Value="#E9E9E9" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected:pointerover /template/ Border">
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected:pointerover > TextBlock">
|
||||
<Setter Property="Foreground" Value="#1A2234" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected">
|
||||
<Setter Property="Foreground" Value="#1A2234" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:disabled > TextBlock">
|
||||
<Setter Property="Foreground" Value="#B2B2B2" />
|
||||
<Setter Property="TextDecorations" Value="Strikethrough" />
|
||||
</Style>
|
||||
<Style Selector="TabItem.first">
|
||||
<Setter Property="Margin" Value="0 13 0 0" />
|
||||
</Style>
|
||||
<Style Selector="TabControl">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="ContentPresenter.page">
|
||||
<Setter Property="Margin" Value="15 21" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<DockPanel>
|
||||
<TabControl x:Name="TabControl" TabStripPlacement="Left">
|
||||
<TabItem x:Name="TabDatabase" Header="Database" Classes="first">
|
||||
<DockPanel>
|
||||
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding DatabasePage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabTracking" Header="Tracking">
|
||||
<DockPanel>
|
||||
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabViewer" Header="Viewer">
|
||||
<DockPanel>
|
||||
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding ViewerPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</UserControl>
|
16
app/Desktop/Main/MainContentScreen.axaml.cs
Normal file
16
app/Desktop/Main/MainContentScreen.axaml.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MainContentScreen : UserControl {
|
||||
public MainContentScreen() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
61
app/Desktop/Main/MainContentScreenModel.cs
Normal file
61
app/Desktop/Main/MainContentScreenModel.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
sealed class MainContentScreenModel : IDisposable {
|
||||
public DatabasePage DatabasePage { get; }
|
||||
private DatabasePageModel DatabasePageModel { get; }
|
||||
|
||||
public TrackingPage TrackingPage { get; }
|
||||
private TrackingPageModel TrackingPageModel { get; }
|
||||
|
||||
public ViewerPage ViewerPage { get; }
|
||||
private ViewerPageModel ViewerPageModel { get; }
|
||||
|
||||
public StatusBarModel StatusBarModel { get; }
|
||||
|
||||
public event EventHandler? DatabaseClosed {
|
||||
add {
|
||||
DatabasePageModel.DatabaseClosed += value;
|
||||
}
|
||||
remove {
|
||||
DatabasePageModel.DatabaseClosed -= value;
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
||||
DatabasePageModel = new DatabasePageModel(window, db);
|
||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||
|
||||
TrackingPageModel = new TrackingPageModel(window, db);
|
||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||
|
||||
ViewerPageModel = new ViewerPageModel(window, db);
|
||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||
|
||||
StatusBarModel = new StatusBarModel(db.Statistics);
|
||||
TrackingPageModel.ServerStatusChanged += TrackingPageModelOnServerStatusChanged;
|
||||
StatusBarModel.CurrentStatus = ServerLauncher.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
|
||||
}
|
||||
|
||||
public async Task Initialize() {
|
||||
await TrackingPageModel.Initialize();
|
||||
}
|
||||
|
||||
private void TrackingPageModelOnServerStatusChanged(object? sender, StatusBarModel.Status e) {
|
||||
StatusBarModel.CurrentStatus = e;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
TrackingPageModel.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
@@ -5,19 +5,19 @@
|
||||
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="820" Height="520"
|
||||
MinWidth="520" MinHeight="300"
|
||||
Width="800" Height="500"
|
||||
MinWidth="480" MinHeight="240"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Closing="OnClosing">
|
||||
Closed="OnClosed">
|
||||
|
||||
<Design.DataContext>
|
||||
<main:MainWindowModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Panel>
|
||||
<ContentPresenter Content="{Binding CurrentScreen}" />
|
||||
<ContentPresenter Content="{Binding WelcomeScreen}" IsVisible="{Binding ShowWelcomeScreen}" />
|
||||
<ContentPresenter Content="{Binding MainContentScreen}" IsVisible="{Binding ShowMainContentScreen}" />
|
||||
</Panel>
|
||||
</Window>
|
||||
|
@@ -1,54 +1,34 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using DHT.Utils.Logging;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
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();
|
||||
DataContext = new MainWindowModel(this, Arguments.Empty);
|
||||
}
|
||||
|
||||
internal MainWindow(Arguments args) {
|
||||
InitializeComponent();
|
||||
DataContext = new MainWindowModel(this, args);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
namespace DHT.Desktop.Main {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MainWindow : Window {
|
||||
[UsedImplicitly]
|
||||
public MainWindow() {
|
||||
InitializeComponent(Arguments.Empty);
|
||||
}
|
||||
|
||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
||||
try {
|
||||
File.Delete(temporaryFile);
|
||||
} catch (Exception) {
|
||||
// ignored
|
||||
internal MainWindow(Arguments args) {
|
||||
InitializeComponent(args);
|
||||
}
|
||||
|
||||
private void InitializeComponent(Arguments args) {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
DataContext = new MainWindowModel(this, args);
|
||||
|
||||
#if DEBUG
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
public void OnClosed(object? sender, EventArgs e) {
|
||||
if (DataContext is IDisposable disposable) {
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,127 +1,113 @@
|
||||
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.Desktop.Main.Pages;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main;
|
||||
namespace DHT.Desktop.Main {
|
||||
sealed class MainWindowModel : BaseModel, IDisposable {
|
||||
private const string DefaultTitle = "Discord History Tracker";
|
||||
|
||||
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 WelcomeScreen WelcomeScreen { get; }
|
||||
private WelcomeScreenModel WelcomeScreenModel { get; }
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private string title = DefaultTitle;
|
||||
public MainContentScreen? MainContentScreen { get; private set; }
|
||||
private MainContentScreenModel? MainContentScreenModel { get; set; }
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private UserControl currentScreen;
|
||||
public bool ShowWelcomeScreen => db == null;
|
||||
public bool ShowMainContentScreen => db != null;
|
||||
|
||||
private readonly WelcomeScreen welcomeScreen;
|
||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||
private readonly Window window;
|
||||
|
||||
private MainContentScreenModel? mainContentScreenModel;
|
||||
private IDatabaseFile? db;
|
||||
|
||||
private readonly Window window;
|
||||
[Obsolete("Designer")]
|
||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||
|
||||
private State? state;
|
||||
public MainWindowModel(Window window, Arguments args) {
|
||||
this.window = window;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||
WelcomeScreenModel = new WelcomeScreenModel(window);
|
||||
WelcomeScreen = new WelcomeScreen { DataContext = WelcomeScreenModel };
|
||||
|
||||
public MainWindowModel(Window window, Arguments args) {
|
||||
this.window = window;
|
||||
WelcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
||||
|
||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||
var dbFile = args.DatabaseFile;
|
||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
||||
window.Opened -= OnWindowOpened;
|
||||
|
||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||
currentScreen = welcomeScreen;
|
||||
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
var dbFile = args.DatabaseFile;
|
||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
||||
window.Opened -= OnWindowOpened;
|
||||
|
||||
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
await Task.Delay(500);
|
||||
if (File.Exists(dbFile)) {
|
||||
await WelcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
||||
}
|
||||
else {
|
||||
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(dbFile)) {
|
||||
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
||||
}
|
||||
else {
|
||||
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
||||
}
|
||||
window.Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
window.Opened += OnWindowOpened;
|
||||
if (args.ServerPort != null) {
|
||||
TrackingPageModel.ServerPort = args.ServerPort.ToString()!;
|
||||
}
|
||||
|
||||
if (args.ServerToken != null) {
|
||||
TrackingPageModel.ServerToken = args.ServerToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.ServerPort != null) {
|
||||
ServerConfiguration.Port = args.ServerPort.Value;
|
||||
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(WelcomeScreenModel.Db)) {
|
||||
if (MainContentScreenModel != null) {
|
||||
MainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||
MainContentScreenModel.Dispose();
|
||||
}
|
||||
|
||||
db?.Dispose();
|
||||
db = WelcomeScreenModel.Db;
|
||||
|
||||
if (db == null) {
|
||||
Title = DefaultTitle;
|
||||
MainContentScreenModel = null;
|
||||
MainContentScreen = null;
|
||||
}
|
||||
else {
|
||||
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
||||
MainContentScreenModel = new MainContentScreenModel(window, db);
|
||||
await MainContentScreenModel.Initialize();
|
||||
MainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||
MainContentScreen = new MainContentScreen { DataContext = MainContentScreenModel };
|
||||
OnPropertyChanged(nameof(MainContentScreen));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ShowWelcomeScreen));
|
||||
OnPropertyChanged(nameof(ShowMainContentScreen));
|
||||
OnPropertyChanged(nameof(Title));
|
||||
|
||||
window.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (args.ServerToken != null) {
|
||||
ServerConfiguration.Token = args.ServerToken;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||
WelcomeScreenModel.CloseDatabase();
|
||||
}
|
||||
|
||||
mainContentScreenModel = new MainContentScreenModel(window, state);
|
||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||
|
||||
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;
|
||||
public void Dispose() {
|
||||
MainContentScreenModel?.Dispose();
|
||||
db?.Dispose();
|
||||
}
|
||||
|
||||
await DisposeState();
|
||||
|
||||
Title = DefaultTitle;
|
||||
CurrentScreen = welcomeScreen;
|
||||
|
||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||
}
|
||||
|
||||
private async Task DisposeState() {
|
||||
if (state != null) {
|
||||
await state.DisposeAsync();
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
mainContentScreenModel?.Dispose();
|
||||
await DisposeState();
|
||||
}
|
||||
}
|
||||
|
@@ -1,26 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns: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:DataType="pages:AdvancedPageModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:AdvancedPageModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<Expander Header="Internal Server Configuration" IsExpanded="True">
|
||||
<controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" />
|
||||
</Expander>
|
||||
<Expander Header="Database Tools" IsExpanded="True">
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<TextBlock TextWrapping="Wrap">Recreates the database to remove any traces of deleted data, which frees up space and prevents forensic recovery.</TextBlock>
|
||||
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
</UserControl>
|
@@ -1,11 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class AdvancedPage : UserControl {
|
||||
public AdvancedPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
@@ -1,36 +0,0 @@
|
||||
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.Server;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed class AdvancedPageModel : IDisposable {
|
||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public AdvancedPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public AdvancedPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
|
||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerConfigurationModel.Dispose();
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
@@ -4,8 +4,7 @@
|
||||
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:DataType="pages:DatabasePageModel">
|
||||
x:Class="DHT.Desktop.Main.Pages.DatabasePage">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:DatabasePageModel />
|
||||
@@ -25,7 +24,6 @@
|
||||
<WrapPanel>
|
||||
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
|
||||
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
|
||||
<Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
@@ -1,11 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class DatabasePage : UserControl {
|
||||
public DatabasePage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class DatabasePage : UserControl {
|
||||
public DatabasePage() {
|
||||
InitializeComponent();
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,266 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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.Schema;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DatabasePageModel : BaseModel {
|
||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||
|
||||
sealed class DatabasePageModel {
|
||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||
public IDatabaseFile Db { get; }
|
||||
|
||||
public IDatabaseFile Db { get; }
|
||||
public event EventHandler? DatabaseClosed;
|
||||
|
||||
public event EventHandler? DatabaseClosed;
|
||||
private readonly Window window;
|
||||
|
||||
private readonly Window window;
|
||||
[Obsolete("Designer")]
|
||||
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DatabasePageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public DatabasePageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.Db = state.Db;
|
||||
}
|
||||
|
||||
public async Task OpenDatabaseFolder() {
|
||||
string file = Db.Path;
|
||||
string? folder = Path.GetDirectoryName(file);
|
||||
|
||||
if (folder == null) {
|
||||
return;
|
||||
public DatabasePageModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.Db = db;
|
||||
}
|
||||
|
||||
switch (Environment.OSVersion.Platform) {
|
||||
case PlatformID.Win32NT:
|
||||
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
||||
break;
|
||||
public async void OpenDatabaseFolder() {
|
||||
string file = Db.Path;
|
||||
string? folder = Path.GetDirectoryName(file);
|
||||
|
||||
case PlatformID.Unix:
|
||||
Process.Start("xdg-open", new string[] { folder });
|
||||
break;
|
||||
|
||||
case PlatformID.MacOSX:
|
||||
Process.Start("open", new string[] { folder });
|
||||
break;
|
||||
|
||||
default:
|
||||
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void CloseDatabase() {
|
||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async 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));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||
|
||||
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||
|
||||
if (db == null) {
|
||||
return false;
|
||||
if (folder == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await target.AddFrom(db);
|
||||
return true;
|
||||
} finally {
|
||||
await db.DisposeAsync();
|
||||
}
|
||||
});
|
||||
}
|
||||
switch (Environment.OSVersion.Platform) {
|
||||
case PlatformID.Win32NT:
|
||||
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
||||
break;
|
||||
|
||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||
private readonly ProgressDialog dialog;
|
||||
private readonly int total;
|
||||
private bool? decision;
|
||||
case PlatformID.Unix:
|
||||
Process.Start("xdg-open", new string[] { folder });
|
||||
break;
|
||||
|
||||
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||
this.total = total;
|
||||
this.dialog = dialog;
|
||||
}
|
||||
case PlatformID.MacOSX:
|
||||
Process.Start("open", new string[] { folder });
|
||||
break;
|
||||
|
||||
public async Task<bool> CanUpgrade() {
|
||||
return decision ??= (total > 1
|
||||
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
||||
}
|
||||
|
||||
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||
return doUpgrade(new NullReporter());
|
||||
}
|
||||
|
||||
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||
public Task NextVersion() {
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task MainWork(string message, int finishedItems, int totalItems) {
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SubWork(string message, int finishedItems, int totalItems) {
|
||||
return Task.CompletedTask;
|
||||
default:
|
||||
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
public async void MergeWithDatabase() {
|
||||
var fileDialog = DatabaseGui.NewOpenDatabaseFileDialog();
|
||||
fileDialog.Directory = Path.GetDirectoryName(Db.Path);
|
||||
fileDialog.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) {
|
||||
var fakeSnowflake = new FakeSnowflake();
|
||||
|
||||
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
||||
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
|
||||
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||
Dictionary<DHT.Server.Data.Server, ulong>? result = await Dispatcher.UIThread.InvokeAsync(() => AskForServerIds(dialog, servers));
|
||||
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
||||
static bool IsValidSnowflake(string value) {
|
||||
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||
}
|
||||
|
||||
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||
|
||||
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
||||
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
||||
ValidityCheck = IsValidSnowflake
|
||||
});
|
||||
}
|
||||
|
||||
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
||||
Title = "Imported Server IDs",
|
||||
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
|
||||
};
|
||||
|
||||
var dialog = new TextBoxDialog { DataContext = model };
|
||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||
|
||||
if (result != DialogResult.OkCancel.Ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return model.ValidItems
|
||||
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
||||
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
||||
}
|
||||
|
||||
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
||||
int total = paths.Length;
|
||||
var oldStatistics = await DatabaseStatistics.Take(target);
|
||||
|
||||
int successful = 0;
|
||||
int finished = 0;
|
||||
|
||||
foreach (string path in paths) {
|
||||
await callback.Update(Path.GetFileName(path), finished, total);
|
||||
++finished;
|
||||
|
||||
if (!File.Exists(path)) {
|
||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
||||
continue;
|
||||
string[]? paths = await fileDialog.ShowAsync(window);
|
||||
if (paths == null || paths.Length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await performImport(path)) {
|
||||
++successful;
|
||||
ProgressDialog progressDialog = new ProgressDialog();
|
||||
progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) {
|
||||
Title = "Database Merge"
|
||||
};
|
||||
|
||||
await progressDialog.ShowDialog(window);
|
||||
}
|
||||
|
||||
public void CloseDatabase() {
|
||||
ServerLauncher.Stop();
|
||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||
int total = paths.Length;
|
||||
|
||||
DialogResult.YesNo? upgradeResult = null;
|
||||
|
||||
async Task<bool> CheckCanUpgradeDatabase() {
|
||||
upgradeResult ??= total > 1
|
||||
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
|
||||
|
||||
return DialogResult.YesNo.Yes == upgradeResult;
|
||||
}
|
||||
|
||||
var oldStatistics = target.Statistics.Clone();
|
||||
int successful = 0;
|
||||
int finished = 0;
|
||||
|
||||
foreach (string path in paths) {
|
||||
await callback.Update(Path.GetFileName(path), finished, total);
|
||||
++finished;
|
||||
|
||||
if (!File.Exists(path)) {
|
||||
await Dialog.ShowOk(dialog, "Database Error", "Database '" + Path.GetFileName(path) + "' no longer exists.");
|
||||
continue;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
||||
|
||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
|
||||
if (db == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
target.AddFrom(db);
|
||||
} catch (Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(dialog, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be merged: " + ex.Message);
|
||||
continue;
|
||||
} finally {
|
||||
db.Dispose();
|
||||
}
|
||||
|
||||
++successful;
|
||||
}
|
||||
|
||||
await callback.Update("Done", finished, total);
|
||||
|
||||
if (successful == 0) {
|
||||
await Dialog.ShowOk(dialog, "Database Merge", "Nothing was merged.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newStatistics = target.Statistics;
|
||||
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
||||
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
||||
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
|
||||
|
||||
StringBuilder message = new StringBuilder();
|
||||
message.Append("Processed ");
|
||||
|
||||
if (successful == total) {
|
||||
message.Append(successful.Pluralize("database file"));
|
||||
}
|
||||
else {
|
||||
message.Append(successful.Format()).Append(" out of ").Append(total.Pluralize("database file"));
|
||||
}
|
||||
|
||||
message.Append(" and added:\n\n \u2022 ");
|
||||
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
||||
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
||||
message.Append(newMessages.Pluralize("message"));
|
||||
|
||||
await Dialog.ShowOk(dialog, "Database Merge", message.ToString());
|
||||
}
|
||||
|
||||
await callback.Update("Done", finished, total);
|
||||
|
||||
if (successful == 0) {
|
||||
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newStatistics = await DatabaseStatistics.Take(target);
|
||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
||||
}
|
||||
|
||||
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 ");
|
||||
|
||||
if (successfulItems == totalItems) {
|
||||
message.Append(successfulItems.Pluralize(itemName));
|
||||
}
|
||||
else {
|
||||
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
||||
}
|
||||
|
||||
message.Append(" and added:\n\n \u2022 ");
|
||||
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
||||
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
||||
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
||||
message.Append(newMessages.Pluralize("message"));
|
||||
|
||||
return message.ToString();
|
||||
}
|
||||
}
|
||||
|
@@ -1,46 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.Pages.DebugPage"
|
||||
x:DataType="pages:DebugPageModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:DebugPageModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Orientation" Value="Vertical" />
|
||||
<Setter Property="Margin" Value="0 0 10 10" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<Expander Header="Generate Random Data" IsExpanded="True">
|
||||
<WrapPanel>
|
||||
<StackPanel>
|
||||
<Label Target="Channels">Channels</Label>
|
||||
<TextBox x:Name="Channels" Width="100" Text="{Binding GenerateChannels}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<Label Target="Users">Users</Label>
|
||||
<TextBox x:Name="Users" Width="100" Text="{Binding GenerateUsers}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<Label Target="Messages">Messages</Label>
|
||||
<TextBox x:Name="Messages" Width="100" Text="{Binding GenerateMessages}" />
|
||||
</StackPanel>
|
||||
<StackPanel VerticalAlignment="Bottom">
|
||||
<Button Command="{Binding OnClickAddRandomDataToDatabase}">Add to Database</Button>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
</UserControl>
|
@@ -1,11 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class DebugPage : UserControl {
|
||||
public DebugPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
@@ -1,173 +0,0 @@
|
||||
#if DEBUG
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
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.Service;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
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 State state;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public DebugPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public async void OnClickAddRandomDataToDatabase() {
|
||||
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||
}
|
||||
|
||||
private const int BatchSize = 500;
|
||||
|
||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
||||
|
||||
var rand = new Random();
|
||||
var server = new DHT.Server.Data.Server {
|
||||
Id = RandomId(rand),
|
||||
Name = RandomName("s"),
|
||||
Type = ServerType.Server,
|
||||
};
|
||||
|
||||
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
||||
Id = RandomId(rand),
|
||||
Server = server.Id,
|
||||
Name = RandomName("c"),
|
||||
ParentId = null,
|
||||
Position = i,
|
||||
Topic = RandomText(rand, 10),
|
||||
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(),
|
||||
}).ToArray();
|
||||
|
||||
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;
|
||||
|
||||
while (messageCount > 0) {
|
||||
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? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
||||
|
||||
var timeMillis = time.ToUnixTimeMilliseconds();
|
||||
var editMillis = edit?.ToUnixTimeMilliseconds();
|
||||
|
||||
return new Message {
|
||||
Id = (ulong) timeMillis,
|
||||
Sender = RandomBiasedIndex(rand, users).Id,
|
||||
Channel = RandomBiasedIndex(rand, channels).Id,
|
||||
Text = RandomText(rand, 100),
|
||||
Timestamp = timeMillis,
|
||||
EditTimestamp = editMillis,
|
||||
RepliedToId = null,
|
||||
Attachments = ImmutableList<Attachment>.Empty,
|
||||
Embeds = ImmutableList<Embed>.Empty,
|
||||
Reactions = ImmutableList<Reaction>.Empty,
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
await state.Db.Messages.Add(messages);
|
||||
|
||||
messageCount -= BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong RandomId(Random rand) {
|
||||
ulong h = unchecked((ulong) rand.Next());
|
||||
ulong l = unchecked((ulong) rand.Next());
|
||||
return (h << 32) | l;
|
||||
}
|
||||
|
||||
private static string RandomName(string prefix) {
|
||||
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
||||
}
|
||||
|
||||
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||
}
|
||||
|
||||
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",
|
||||
"daikon", "date", "dewberry", "durian",
|
||||
"edamame", "eggplant", "elderberry", "endive",
|
||||
"fig",
|
||||
"garlic", "ginger", "gooseberry", "grape", "grapefruit", "guava",
|
||||
"honeysuckle", "horseradish", "huckleberry",
|
||||
"jackfruit", "jicama",
|
||||
"kale", "kiwi", "kohlrabi", "kumquat",
|
||||
"leek", "lemon", "lentil", "lettuce", "lime",
|
||||
"mandarin", "mango", "mushroom", "myrtle",
|
||||
"nectarine", "nut",
|
||||
"olive", "okra", "onion", "orange",
|
||||
"papaya", "parsnip", "pawpaw", "peach", "pear", "pea", "pepper", "persimmon", "pineapple", "plum", "plantain", "pomegranate", "pomelo", "potato", "prune", "pumpkin",
|
||||
"quandong", "quinoa",
|
||||
"radicchio", "radish", "raisin", "raspberry", "redcurrant", "rhubarb", "rutabaga",
|
||||
"spinach", "strawberry", "squash",
|
||||
"tamarind", "tangerine", "tomatillo", "tomato", "turnip",
|
||||
"vanilla",
|
||||
"watercress", "watermelon",
|
||||
"yam",
|
||||
"zucchini"
|
||||
];
|
||||
|
||||
private static string RandomText(Random rand, int maxWords) {
|
||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DebugPageModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
|
||||
public void OnClickAddRandomDataToDatabase() {}
|
||||
}
|
||||
}
|
||||
#endif
|
@@ -1,51 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns: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.DownloadsPage"
|
||||
x:DataType="pages:DownloadsPageModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:DownloadsPageModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Expander">
|
||||
<Setter Property="Margin" Value="0 5 0 0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader:nth-child(2)">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader:nth-child(3)">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style Selector="DataGridCell.right">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Right" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Orientation="Vertical" Spacing="20">
|
||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
|
||||
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
||||
<StackPanel Orientation="Vertical" Spacing="12">
|
||||
<Expander Header="Download Status" IsExpanded="True">
|
||||
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" />
|
||||
<DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Expander>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
@@ -1,11 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class DownloadsPage : UserControl {
|
||||
public DownloadsPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
@@ -1,186 +0,0 @@
|
||||
using System;
|
||||
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.Aggregations;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Download;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed partial class DownloadsPageModel : ObservableObject, IDisposable {
|
||||
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isToggleDownloadButtonEnabled = true;
|
||||
|
||||
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||
private bool isRetryingFailedDownloads = false;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||
private bool hasFailedDownloads;
|
||||
|
||||
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private string downloadMessage = "";
|
||||
|
||||
public DownloadItemFilterPanelModel FilterModel { get; }
|
||||
|
||||
private readonly StatisticsRow statisticsPending = new ("Pending");
|
||||
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||
|
||||
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
|
||||
|
||||
public bool IsDownloading => state.Downloader.IsDownloading;
|
||||
|
||||
private readonly State state;
|
||||
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
||||
private readonly IDisposable downloadItemCountSubscription;
|
||||
|
||||
private IDisposable? finishedItemsSubscription;
|
||||
private DownloadItemFilter? currentDownloadFilter;
|
||||
|
||||
public DownloadsPageModel() : this(State.Dummy) {}
|
||||
|
||||
public DownloadsPageModel(State state) {
|
||||
this.state = state;
|
||||
|
||||
FilterModel = new DownloadItemFilterPanelModel(state);
|
||||
|
||||
StatisticsRows = [
|
||||
statisticsPending,
|
||||
statisticsDownloaded,
|
||||
statisticsFailed,
|
||||
statisticsSkipped
|
||||
];
|
||||
|
||||
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
|
||||
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
finishedItemsSubscription?.Dispose();
|
||||
|
||||
downloadItemCountSubscription.Dispose();
|
||||
downloadStatisticsTask.Dispose();
|
||||
|
||||
FilterModel.Dispose();
|
||||
}
|
||||
|
||||
private void OnDownloadCountChanged(long newDownloadCount) {
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
public async Task OnClickToggleDownload() {
|
||||
IsToggleDownloadButtonEnabled = false;
|
||||
|
||||
if (IsDownloading) {
|
||||
await state.Downloader.Stop();
|
||||
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||
|
||||
finishedItemsSubscription?.Dispose();
|
||||
finishedItemsSubscription = null;
|
||||
|
||||
currentDownloadFilter = null;
|
||||
}
|
||||
else {
|
||||
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||
|
||||
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
||||
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
|
||||
}
|
||||
|
||||
RecomputeDownloadStatistics();
|
||||
|
||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
||||
OnPropertyChanged(nameof(IsDownloading));
|
||||
IsToggleDownloadButtonEnabled = true;
|
||||
}
|
||||
|
||||
private void OnItemFinished(DownloadItem item) {
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
public async Task OnClickRetryFailedDownloads() {
|
||||
IsRetryingFailedDownloads = true;
|
||||
|
||||
try {
|
||||
await state.Db.Downloads.RetryFailed();
|
||||
RecomputeDownloadStatistics();
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
} finally {
|
||||
IsRetryingFailedDownloads = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecomputeDownloadStatistics() {
|
||||
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||
statisticsPending.Items = statusStatistics.PendingCount;
|
||||
statisticsPending.Size = statusStatistics.PendingTotalSize;
|
||||
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
|
||||
|
||||
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
||||
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
|
||||
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
|
||||
|
||||
statisticsFailed.Items = statusStatistics.FailedCount;
|
||||
statisticsFailed.Size = statusStatistics.FailedTotalSize;
|
||||
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
|
||||
|
||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
|
||||
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
|
||||
|
||||
HasFailedDownloads = statusStatistics.FailedCount > 0;
|
||||
}
|
||||
|
||||
[ObservableObject]
|
||||
public sealed partial class StatisticsRow(string state) {
|
||||
public string State { get; } = state;
|
||||
|
||||
[ObservableProperty]
|
||||
private int items;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||
private ulong? size;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||
private bool hasFilesWithUnknownSize;
|
||||
|
||||
public string SizeText {
|
||||
get {
|
||||
if (size == null) {
|
||||
return "-";
|
||||
}
|
||||
else if (hasFilesWithUnknownSize) {
|
||||
return "\u2265 " + BytesValueConverter.Convert(size.Value);
|
||||
}
|
||||
else {
|
||||
return BytesValueConverter.Convert(size.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -4,21 +4,58 @@
|
||||
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:DataType="pages:TrackingPageModel">
|
||||
x:Class="DHT.Desktop.Main.Pages.TrackingPage">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:TrackingPageModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Orientation" Value="Vertical" />
|
||||
<Setter Property="Margin" Value="0 0 10 10" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock TextWrapping="Wrap">
|
||||
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" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
|
||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
||||
<Button Command="{Binding OnClickToggleTrackingButton}" Content="{Binding ToggleTrackingButtonText}" IsEnabled="{Binding IsToggleButtonEnabled}" />
|
||||
</StackPanel>
|
||||
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
||||
<Expander Header="Advanced Tracking Settings">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock TextWrapping="Wrap">
|
||||
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy and apply the tracking script again.
|
||||
</TextBlock>
|
||||
<WrapPanel>
|
||||
<StackPanel>
|
||||
<Label Target="Port">Port</Label>
|
||||
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<Label Target="Token">Token</Label>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBox x:Name="Token" Width="200" Text="{Binding InputToken}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel VerticalAlignment="Bottom">
|
||||
<Button Command="{Binding OnClickRandomizeToken}">Randomize Token</Button>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickApplyChanges}">Apply & Restart</Button>
|
||||
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickCancelChanges}">Cancel</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
<TextBlock TextWrapping="Wrap" Margin="0 15 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.
|
||||
</TextBlock>
|
||||
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
||||
|
@@ -3,29 +3,35 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class TrackingPage : UserControl {
|
||||
private bool isCopyingScript;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class TrackingPage : UserControl {
|
||||
private bool isCopyingScript;
|
||||
public TrackingPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public TrackingPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||
if (DataContext is TrackingPageModel model) {
|
||||
var originalText = CopyTrackingScript.Content;
|
||||
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
||||
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;
|
||||
|
||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||
isCopyingScript = true;
|
||||
CopyTrackingScript.Content = "Script Copied!";
|
||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||
isCopyingScript = true;
|
||||
button.Content = "Script Copied!";
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
CopyTrackingScript.Content = originalText;
|
||||
isCopyingScript = false;
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
button.Content = originalText;
|
||||
isCopyingScript = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,133 +1,230 @@
|
||||
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.Desktop.Main.Controls;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class TrackingPageModel : BaseModel, IDisposable {
|
||||
private static readonly Log Log = Log.ForType<TrackingPageModel>();
|
||||
|
||||
sealed partial class TrackingPageModel : ObservableObject {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isCopyTrackingScriptButtonEnabled = true;
|
||||
internal static string ServerPort { get; set; } = ServerUtils.FindAvailablePort(50000, 60000).ToString();
|
||||
internal static string ServerToken { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||
private bool? areDevToolsEnabled = null;
|
||||
private string inputPort = ServerPort;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||
public string InputPort {
|
||||
get => inputPort;
|
||||
set {
|
||||
Change(ref inputPort, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
public string ToggleAppDevToolsButtonText {
|
||||
get {
|
||||
if (!AreDevToolsEnabled.HasValue) {
|
||||
return "Loading...";
|
||||
private string inputToken = ServerToken;
|
||||
|
||||
public string InputToken {
|
||||
get => inputToken;
|
||||
set {
|
||||
Change(ref inputToken, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMadeChanges => ServerPort != InputPort || ServerToken != InputToken;
|
||||
|
||||
private bool isToggleTrackingButtonEnabled = true;
|
||||
|
||||
public bool IsToggleButtonEnabled {
|
||||
get => isToggleTrackingButtonEnabled;
|
||||
set => Change(ref isToggleTrackingButtonEnabled, value);
|
||||
}
|
||||
|
||||
public string ToggleTrackingButtonText => ServerLauncher.IsRunning ? "Pause Tracking" : "Resume Tracking";
|
||||
|
||||
private bool areDevToolsEnabled;
|
||||
|
||||
private bool AreDevToolsEnabled {
|
||||
get => areDevToolsEnabled;
|
||||
set {
|
||||
Change(ref areDevToolsEnabled, value);
|
||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
||||
|
||||
public string ToggleAppDevToolsButtonText {
|
||||
get {
|
||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public TrackingPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public TrackingPageModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public async Task Initialize() {
|
||||
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
||||
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
||||
|
||||
if (int.TryParse(ServerPort, out int port)) {
|
||||
string token = ServerToken;
|
||||
ServerLauncher.Relaunch(port, token, db);
|
||||
}
|
||||
|
||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||
return "Unavailable";
|
||||
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
||||
if (devToolsEnabled.HasValue) {
|
||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||
}
|
||||
else {
|
||||
IsToggleAppDevToolsButtonEnabled = false;
|
||||
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
|
||||
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
|
||||
ServerLauncher.Stop();
|
||||
}
|
||||
|
||||
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
||||
ServerStatusChanged?.Invoke(this, ServerLauncher.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
||||
OnPropertyChanged(nameof(ToggleTrackingButtonText));
|
||||
IsToggleButtonEnabled = true;
|
||||
}
|
||||
|
||||
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(window, "Server Error", ex.Message);
|
||||
}
|
||||
|
||||
private async Task<bool> StartServer() {
|
||||
if (!int.TryParse(InputPort, out int port) || port is < 0 or > 65535) {
|
||||
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public TrackingPageModel() : this(null!) {}
|
||||
|
||||
public TrackingPageModel(Window window) {
|
||||
this.window = window;
|
||||
|
||||
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
public async Task<bool> OnClickCopyTrackingScript() {
|
||||
IsCopyTrackingScriptButtonEnabled = false;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await clipboard.SetTextAsync(script);
|
||||
IsToggleButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
||||
ServerLauncher.Relaunch(port, InputToken, db);
|
||||
return true;
|
||||
} catch {
|
||||
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
if (!AreDevToolsEnabled.HasValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool oldState = AreDevToolsEnabled.Value;
|
||||
bool newState = !oldState;
|
||||
private void StopServer() {
|
||||
IsToggleButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
||||
ServerLauncher.Stop();
|
||||
}
|
||||
|
||||
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||
case SettingsJsonResult.Success:
|
||||
AreDevToolsEnabled = newState;
|
||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
||||
break;
|
||||
public async Task<bool> OnClickToggleTrackingButton() {
|
||||
if (ServerLauncher.IsRunning) {
|
||||
StopServer();
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return await StartServer();
|
||||
}
|
||||
}
|
||||
|
||||
case SettingsJsonResult.AlreadySet:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
|
||||
AreDevToolsEnabled = newState;
|
||||
break;
|
||||
public async Task<bool> OnClickCopyTrackingScript() {
|
||||
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
|
||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerPort + ";")
|
||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerToken))
|
||||
.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"));
|
||||
|
||||
case SettingsJsonResult.FileNotFound:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||
break;
|
||||
var clipboard = Application.Current?.Clipboard;
|
||||
if (clipboard == null) {
|
||||
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
||||
return false;
|
||||
}
|
||||
|
||||
case SettingsJsonResult.ReadError:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||
break;
|
||||
try {
|
||||
await clipboard.SetTextAsync(script);
|
||||
return true;
|
||||
} catch {
|
||||
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
case SettingsJsonResult.InvalidJson:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||
break;
|
||||
public void OnClickRandomizeToken() {
|
||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||
}
|
||||
|
||||
case SettingsJsonResult.WriteError:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||
break;
|
||||
public async void OnClickApplyChanges() {
|
||||
if (await StartServer()) {
|
||||
ServerPort = InputPort;
|
||||
ServerToken = InputToken;
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
public void OnClickCancelChanges() {
|
||||
InputPort = ServerPort;
|
||||
InputToken = ServerToken;
|
||||
}
|
||||
|
||||
public async void OnClickToggleAppDevTools() {
|
||||
const string DialogTitle = "Discord App Settings File";
|
||||
|
||||
bool oldState = AreDevToolsEnabled;
|
||||
bool newState = !oldState;
|
||||
|
||||
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||
case SettingsJsonResult.Success:
|
||||
AreDevToolsEnabled = newState;
|
||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
||||
break;
|
||||
|
||||
case SettingsJsonResult.AlreadySet:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
|
||||
AreDevToolsEnabled = newState;
|
||||
break;
|
||||
|
||||
case SettingsJsonResult.FileNotFound:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||
break;
|
||||
|
||||
case SettingsJsonResult.ReadError:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||
break;
|
||||
|
||||
case SettingsJsonResult.InvalidJson:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||
break;
|
||||
|
||||
case SettingsJsonResult.WriteError:
|
||||
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,8 +5,7 @@
|
||||
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:DataType="pages:ViewerPageModel">
|
||||
x:Class="DHT.Desktop.Main.Pages.ViewerPage">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:ViewerPageModel />
|
||||
@@ -14,16 +13,17 @@
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Expander">
|
||||
<Setter Property="Margin" Value="0 5 0 0" />
|
||||
<Setter Property="Margin" Value="0 25 0 0" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Orientation="Vertical" Spacing="20">
|
||||
<StackPanel Orientation="Vertical">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
||||
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
|
||||
<Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button>
|
||||
</StackPanel>
|
||||
<controls:MessageFilterPanel DataContext="{Binding FilterModel}" />
|
||||
<TextBlock Text="{Binding ExportedMessageText}" Margin="0 20 0 0" />
|
||||
<controls:FilterPanel DataContext="{Binding FilterModel}" />
|
||||
<Expander Header="Database Tools">
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<StackPanel Orientation="Vertical" Spacing="4">
|
||||
|
@@ -1,11 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class ViewerPage : UserControl {
|
||||
public ViewerPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class ViewerPage : UserControl {
|
||||
public ViewerPage() {
|
||||
InitializeComponent();
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,89 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
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.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class ViewerPageModel : BaseModel {
|
||||
public string ExportedMessageText { get; private set; } = "";
|
||||
|
||||
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||
public static readonly ConcurrentBag<string> TemporaryFiles = [];
|
||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||
|
||||
private static readonly FilePickerFileType[] ViewerFileTypes = [
|
||||
FileDialogs.CreateFilter("Discord History Viewer", ["html"])
|
||||
];
|
||||
private bool hasFilters = false;
|
||||
|
||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasFilters = false;
|
||||
|
||||
public MessageFilterPanelModel FilterModel { get; }
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ViewerPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public ViewerPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
|
||||
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
|
||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
FilterModel.Dispose();
|
||||
}
|
||||
|
||||
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
HasFilters = FilterModel.HasAnyFilters;
|
||||
}
|
||||
|
||||
public async void OnClickOpenViewer() {
|
||||
try {
|
||||
var fullPath = await PrepareTemporaryViewerFile();
|
||||
|
||||
string jsConstants = $"""
|
||||
window.DHT_SERVER_URL = "{HttpUtility.JavaScriptStringEncode("http://127.0.0.1:" + ServerConfiguration.Port)}";
|
||||
window.DHT_SERVER_TOKEN = "{HttpUtility.JavaScriptStringEncode(ServerConfiguration.Token)}";
|
||||
""";
|
||||
|
||||
await ProgressDialog.ShowIndeterminate(window, "Open Viewer", "Creating viewer...", _ => Task.Run(() => WriteViewerFile(fullPath, jsConstants)));
|
||||
|
||||
Process.Start(new ProcessStartInfo(fullPath) {
|
||||
UseShellExecute = true
|
||||
});
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, "Open Viewer", "Could not create or save viewer: " + e.Message);
|
||||
public bool HasFilters {
|
||||
get => hasFilters;
|
||||
set => Change(ref hasFilters, value);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
private FilterPanelModel FilterModel { get; }
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public ViewerPageModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
|
||||
this.FilterModel = new FilterPanelModel(window, db);
|
||||
this.FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||
this.db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
UpdateStatistics();
|
||||
}
|
||||
|
||||
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
UpdateStatistics();
|
||||
HasFilters = FilterModel.HasAnyFilters;
|
||||
}
|
||||
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
||||
UpdateStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStatistics() {
|
||||
ExportedMessageText = "Will export " + db.CountMessages(FilterModel.CreateFilter()).Format() + " out of " + db.Statistics.TotalMessages.Format() + " message(s).";
|
||||
OnPropertyChanged(nameof(ExportedMessageText));
|
||||
}
|
||||
|
||||
private async Task<string> GenerateViewerContents() {
|
||||
string json = ViewerJsonExport.Generate(db, FilterModel.CreateFilter());
|
||||
|
||||
string index = await Resources.ReadTextAsync("Viewer/index.html");
|
||||
string viewer = index.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
||||
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'))
|
||||
.Replace("/*[ARCHIVE]*/", HttpUtility.JavaScriptStringEncode(json));
|
||||
return viewer;
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -91,88 +84,44 @@ sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
||||
}
|
||||
|
||||
TemporaryFiles.Add(fullPath);
|
||||
|
||||
Directory.CreateDirectory(rootPath);
|
||||
await File.WriteAllTextAsync(fullPath, await GenerateViewerContents());
|
||||
|
||||
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;
|
||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
try {
|
||||
await ProgressDialog.ShowIndeterminate(window, "Save Viewer", "Creating viewer...", _ => Task.Run(() => WriteViewerFile(path, string.Empty)));
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, "Save Viewer", "Could not create or save viewer: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteViewerFile(string path, string jsConstants) {
|
||||
const string ArchiveTag = "/*[ARCHIVE]*/";
|
||||
|
||||
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
||||
string viewerTemplate = indexFile.Replace("/*[CONSTANTS]*/", jsConstants)
|
||||
.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
||||
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
|
||||
|
||||
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
|
||||
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
|
||||
|
||||
string jsonTempFile = path + ".tmp";
|
||||
|
||||
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
||||
await ViewerJsonExport.Generate(jsonStream, state.Db, FilterModel.CreateFilter());
|
||||
|
||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
||||
jsonStream.Position = 0;
|
||||
|
||||
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
|
||||
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
|
||||
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
|
||||
|
||||
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
|
||||
int readBytes;
|
||||
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
|
||||
string jsonChunk = new string(jsonBuffer, 0, readBytes);
|
||||
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
|
||||
public async void OnClickSaveViewer() {
|
||||
var dialog = new SaveFileDialog {
|
||||
Title = "Save Viewer",
|
||||
InitialFileName = "archive.html",
|
||||
Directory = Path.GetDirectoryName(db.Path),
|
||||
Filters = new List<FileDialogFilter> {
|
||||
new() {
|
||||
Name = "Discord History Viewer",
|
||||
Extensions = { "html" }
|
||||
}
|
||||
}
|
||||
}.ShowAsync(window);
|
||||
|
||||
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
|
||||
string? path = await dialog;
|
||||
if (!string.IsNullOrEmpty(path)) {
|
||||
await File.WriteAllTextAsync(path, await GenerateViewerContents());
|
||||
}
|
||||
}
|
||||
|
||||
File.Delete(jsonTempFile);
|
||||
}
|
||||
public async void OnClickApplyFiltersToDatabase() {
|
||||
var filter = FilterModel.CreateFilter();
|
||||
|
||||
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", 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);
|
||||
if (DatabaseToolFilterModeKeep) {
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
db.RemoveMessages(filter, MessageFilterRemovalMode.KeepMatching);
|
||||
}
|
||||
}
|
||||
else if (DatabaseToolFilterModeRemove) {
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
db.RemoveMessages(filter, MessageFilterRemovalMode.RemoveMatching);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (DatabaseToolFilterModeRemove) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@@ -1,120 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
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:DataType="screens:MainContentScreenModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<screens:MainContentScreenModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Border.statusBar">
|
||||
<Setter Property="Background" Value="#3C4F79" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.invisibleTabItem">
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
<Setter Property="FontWeight" Value="SemiLight" />
|
||||
<Setter Property="Margin" Value="17 0" />
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="TabControl ItemsPresenter > Grid">
|
||||
<Setter Property="Background" Value="#546A9F" />
|
||||
</Style>
|
||||
<Style Selector="TabItem">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Foreground" Value="#E9E9E9" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
<Style Selector="TabItem[TabStripPlacement=Left] /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Margin" Value="5 0" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:pointerover">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Background" Value="#455785" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:pointerover /template/ Border">
|
||||
<Setter Property="Background" Value="#455785" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:pointerover > TextBlock">
|
||||
<Setter Property="Foreground" Value="#E9E9E9" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected:pointerover /template/ Border">
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected:pointerover > TextBlock">
|
||||
<Setter Property="Foreground" Value="#1A2234" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected">
|
||||
<Setter Property="Foreground" Value="#1A2234" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="TabItem:disabled > TextBlock">
|
||||
<Setter Property="Foreground" Value="#B2B2B2" />
|
||||
<Setter Property="TextDecorations" Value="Strikethrough" />
|
||||
</Style>
|
||||
<Style Selector="TabItem.first">
|
||||
<Setter Property="Margin" Value="0 13 0 0" />
|
||||
</Style>
|
||||
<Style Selector="TabControl">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="ContentPresenter.page">
|
||||
<Setter Property="Margin" Value="15 21 20 21" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<DockPanel>
|
||||
<Border Classes="statusBar" DockPanel.Dock="Bottom">
|
||||
<DockPanel>
|
||||
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Downloads</TextBlock>
|
||||
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<TabControl x:Name="TabControl" TabStripPlacement="Left" DockPanel.Dock="Top">
|
||||
<TabControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Grid ColumnDefinitions="Auto" RowDefinitions="Auto,Auto,Auto,Auto,*,Auto,Auto" />
|
||||
</ItemsPanelTemplate>
|
||||
</TabControl.ItemsPanel>
|
||||
<TabItem x:Name="TabDatabase" Header="Database" Classes="first" Grid.Row="0">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding DatabasePage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabTracking" Header="Tracking" Grid.Row="1">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabDownloads" Header="Downloads" Grid.Row="2">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding DownloadsPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding ViewerPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabAdvanced" Header="Advanced" Grid.Row="5">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding AdvancedPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabDebug" Header="Debug" Grid.Row="6" IsVisible="{Binding HasDebugPage}">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding DebugPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</UserControl>
|
@@ -1,11 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class MainContentScreen : UserControl {
|
||||
public MainContentScreen() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
@@ -1,80 +0,0 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using DHT.Server;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
sealed class MainContentScreenModel : IDisposable {
|
||||
public DatabasePage DatabasePage { get; }
|
||||
private DatabasePageModel DatabasePageModel { get; }
|
||||
|
||||
public TrackingPage TrackingPage { get; }
|
||||
private TrackingPageModel TrackingPageModel { get; }
|
||||
|
||||
public DownloadsPage DownloadsPage { get; }
|
||||
private DownloadsPageModel DownloadsPageModel { get; }
|
||||
|
||||
public ViewerPage ViewerPage { get; }
|
||||
private ViewerPageModel ViewerPageModel { get; }
|
||||
|
||||
public AdvancedPage AdvancedPage { get; }
|
||||
private AdvancedPageModel AdvancedPageModel { get; }
|
||||
|
||||
public DebugPage? DebugPage { get; }
|
||||
|
||||
#if DEBUG
|
||||
public bool HasDebugPage => true;
|
||||
private DebugPageModel DebugPageModel { get; }
|
||||
#else
|
||||
public bool HasDebugPage => false;
|
||||
#endif
|
||||
|
||||
public StatusBarModel StatusBarModel { get; }
|
||||
|
||||
public event EventHandler? DatabaseClosed {
|
||||
add {
|
||||
DatabasePageModel.DatabaseClosed += value;
|
||||
}
|
||||
remove {
|
||||
DatabasePageModel.DatabaseClosed -= value;
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
||||
|
||||
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 };
|
||||
|
||||
DownloadsPageModel = new DownloadsPageModel(state);
|
||||
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
|
||||
|
||||
ViewerPageModel = new ViewerPageModel(window, state);
|
||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||
|
||||
AdvancedPageModel = new AdvancedPageModel(window, state);
|
||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||
|
||||
#if DEBUG
|
||||
DebugPageModel = new DebugPageModel(window, state);
|
||||
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
||||
#else
|
||||
DebugPage = null;
|
||||
#endif
|
||||
|
||||
StatusBarModel = new StatusBarModel(state);
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
DownloadsPageModel.Dispose();
|
||||
ViewerPageModel.Dispose();
|
||||
AdvancedPageModel.Dispose();
|
||||
StatusBarModel.Dispose();
|
||||
}
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class WelcomeScreen : UserControl {
|
||||
public WelcomeScreen() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
@@ -1,119 +0,0 @@
|
||||
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.Server.Database.Sqlite.Schema;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
public string Version => Program.Version;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||
|
||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
private string? dbFilePath;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public WelcomeScreenModel() : this(null!) {}
|
||||
|
||||
public WelcomeScreenModel(Window window) {
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
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) {
|
||||
dbFilePath = path;
|
||||
|
||||
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||
if (db != null) {
|
||||
DatabaseSelected?.Invoke(this, db);
|
||||
}
|
||||
}
|
||||
|
||||
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 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));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
@@ -2,13 +2,12 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
|
||||
x:DataType="screens:WelcomeScreenModel">
|
||||
x:Class="DHT.Desktop.Main.WelcomeScreen">
|
||||
|
||||
<Design.DataContext>
|
||||
<screens:WelcomeScreenModel />
|
||||
<main:WelcomeScreenModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Background>
|
||||
@@ -32,7 +31,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}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>
|
||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
||||
<Button Command="{Binding Exit}">Exit</Button>
|
||||
</StackPanel>
|
16
app/Desktop/Main/WelcomeScreen.axaml.cs
Normal file
16
app/Desktop/Main/WelcomeScreen.axaml.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class WelcomeScreen : UserControl {
|
||||
public WelcomeScreen() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
69
app/Desktop/Main/WelcomeScreenModel.cs
Normal file
69
app/Desktop/Main/WelcomeScreenModel.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
sealed class WelcomeScreenModel : BaseModel {
|
||||
public string Version => Program.Version;
|
||||
|
||||
public IDatabaseFile? Db { get; private set; }
|
||||
public bool HasDatabase => Db != null;
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
private string? dbFilePath;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public WelcomeScreenModel() : this(null!) {}
|
||||
|
||||
public WelcomeScreenModel(Window window) {
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
public async void OpenOrCreateDatabase() {
|
||||
var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog();
|
||||
dialog.Directory = Path.GetDirectoryName(dbFilePath);
|
||||
|
||||
string? path = await dialog.ShowAsync(window);
|
||||
if (!string.IsNullOrWhiteSpace(path)) {
|
||||
await OpenOrCreateDatabaseFromPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||
if (Db != null) {
|
||||
Db = null;
|
||||
}
|
||||
|
||||
dbFilePath = path;
|
||||
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
|
||||
|
||||
OnPropertyChanged(nameof(Db));
|
||||
OnPropertyChanged(nameof(HasDatabase));
|
||||
}
|
||||
|
||||
private async Task<bool> CheckCanUpgradeDatabase() {
|
||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||
}
|
||||
|
||||
public void CloseDatabase() {
|
||||
Db = null;
|
||||
|
||||
OnPropertyChanged(nameof(Db));
|
||||
OnPropertyChanged(nameof(HasDatabase));
|
||||
}
|
||||
|
||||
public async void ShowAboutDialog() {
|
||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
||||
}
|
||||
|
||||
public void Exit() {
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,54 +1,39 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Resources;
|
||||
|
||||
namespace DHT.Desktop;
|
||||
namespace DHT.Desktop {
|
||||
static class Program {
|
||||
public static string Version { get; }
|
||||
public static CultureInfo Culture { get; }
|
||||
public static ResourceLoader Resources { get; }
|
||||
|
||||
static 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();
|
||||
|
||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
||||
while (Version.EndsWith(".0")) {
|
||||
Version = Version[..^2];
|
||||
}
|
||||
|
||||
Culture = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
||||
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();
|
||||
static Program() {
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
||||
while (Version.EndsWith(".0")) {
|
||||
Version = Version[..^2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp() {
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
Culture = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||
|
||||
Resources = new ResourceLoader(assembly);
|
||||
}
|
||||
|
||||
public static void Main(string[] args) {
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp() {
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1 +0,0 @@
|
||||
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));
|
@@ -1,8 +0,0 @@
|
||||
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,49 +0,0 @@
|
||||
<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>
|
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<add key="chylex's repository" value="https://nuget.chylex.com/feed/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
21
app/Resources/Tracker/bootstrap.js
vendored
21
app/Resources/Tracker/bootstrap.js
vendored
@@ -42,10 +42,6 @@
|
||||
stopTrackingDelayed(() => isSending = false);
|
||||
};
|
||||
|
||||
const isNoAction = function(action) {
|
||||
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||
};
|
||||
|
||||
const onTrackingContinued = function(anyNewMessages) {
|
||||
if (!STATE.isTracking()) {
|
||||
return;
|
||||
@@ -63,16 +59,14 @@
|
||||
if (SETTINGS.autoscroll) {
|
||||
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.");
|
||||
if (!anyNewMessages) {
|
||||
action = SETTINGS.afterSavedMsg;
|
||||
}
|
||||
else if (!DISCORD.hasMoreMessages()) {
|
||||
action = SETTINGS.afterFirstMsg;
|
||||
}
|
||||
|
||||
if (isNoAction(action)) {
|
||||
if (action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING) {
|
||||
DISCORD.loadOlderMessages();
|
||||
}
|
||||
else if (action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE || (action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel())) {
|
||||
@@ -119,11 +113,11 @@
|
||||
|
||||
try {
|
||||
if (!messages.length) {
|
||||
DISCORD.loadOlderMessages();
|
||||
isSending = false;
|
||||
onTrackingContinued(false);
|
||||
}
|
||||
else {
|
||||
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);
|
||||
onTrackingContinued(anyNewMessages);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -158,4 +152,3 @@
|
||||
GUI.showSettings();
|
||||
}
|
||||
})();
|
||||
/*[DEBUGGER]*/
|
||||
|
1
app/Resources/Tracker/scripts.min/discord.js
Normal file
1
app/Resources/Tracker/scripts.min/discord.js
Normal file
File diff suppressed because one or more lines are too long
1
app/Resources/Tracker/scripts.min/dom.js
Normal file
1
app/Resources/Tracker/scripts.min/dom.js
Normal file
@@ -0,0 +1 @@
|
||||
class DOM{static id(id,parent){return(parent||document).getElementById(id)}static queryReactClass(cls,parent){return(parent||document).querySelector(`[class*="${cls}-"]`)}static createElement(tag,parent,id,html){const ele=document.createElement(tag);ele.id=id||"";ele.innerHTML=html||"";parent.appendChild(ele);return ele}static removeElement(ele){return ele.parentNode.removeChild(ele)}static createStyle(styles){return this.createElement("style",document.head,"",styles)}static saveToCookie(name,obj,expiresInSeconds){const expires=new Date(Date.now()+1e3*expiresInSeconds).toUTCString();document.cookie=name+"="+encodeURIComponent(JSON.stringify(obj))+";path=/;expires="+expires}static loadFromCookie(name){const value=document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)"+name+"\\s*\\=\\s*([^;]*).*$)|^.*$"),"$1");return value.length?JSON.parse(decodeURIComponent(value)):null}static getReactProps(ele){const keys=Object.keys(ele||{});let key=keys.find(key=>key.startsWith("__reactInternalInstance"));if(key){return ele[key].memoizedProps}key=keys.find(key=>key.startsWith("__reactProps$"));return key?ele[key]:null}}
|
17
app/Resources/Tracker/scripts.min/gui.js
Normal file
17
app/Resources/Tracker/scripts.min/gui.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const GUI=function(){let controller=null;let settings=null;const stateChangedEvent=()=>{if(settings){settings.ui.cbAutoscroll.checked=SETTINGS.autoscroll;settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked=true;settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked=true;const autoscrollDisabled=!SETTINGS.autoscroll;Object.values(settings.ui.optsAfterFirstMsg).forEach(ele=>ele.disabled=autoscrollDisabled);Object.values(settings.ui.optsAfterSavedMsg).forEach(ele=>ele.disabled=autoscrollDisabled)}};return{showController(){if(controller){return}const html=`
|
||||
<button id='dht-ctrl-close'>X</button>
|
||||
<button id='dht-ctrl-settings'>Settings</button>
|
||||
<button id='dht-ctrl-track'></button>
|
||||
<p id='dht-ctrl-status'>Waiting</p>`;controller={styles:DOM.createStyle(`/*[CSS-CONTROLLER]*/`),ele:DOM.createElement("div",document.body,"dht-ctrl",html)};controller.ui={btnSettings:DOM.id("dht-ctrl-settings"),btnTrack:DOM.id("dht-ctrl-track"),btnClose:DOM.id("dht-ctrl-close"),textStatus:DOM.id("dht-ctrl-status")};controller.ui.btnSettings.addEventListener("click",()=>{this.showSettings()});controller.ui.btnTrack.addEventListener("click",()=>{const isTracking=!STATE.isTracking();STATE.setIsTracking(isTracking);if(!isTracking){controller.ui.textStatus.innerText="Stopped"}});controller.ui.btnClose.addEventListener("click",()=>{this.hideController();window.DHT_ON_UNLOAD.forEach(f=>f());window.DHT_LOADED=false});STATE.onTrackingStateChanged(isTracking=>{controller.ui.btnTrack.innerText=isTracking?"Pause Tracking":"Start Tracking";controller.ui.btnSettings.disabled=isTracking});SETTINGS.onSettingsChanged(stateChangedEvent);stateChangedEvent()},hideController(){if(controller){DOM.removeElement(controller.ele);DOM.removeElement(controller.styles);controller=null}},showSettings(){if(settings){return}const radio=(type,id,label)=>"<label><input id='dht-cfg-"+type+"-"+id+"' name='dht-"+type+"' type='radio'> "+label+"</label><br>";const html=`
|
||||
<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","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","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>`;settings={styles:DOM.createStyle(`/*[CSS-SETTINGS]*/`),overlay:DOM.createElement("div",document.body,"dht-cfg-overlay"),ele:DOM.createElement("div",document.body,"dht-cfg",html)};settings.overlay.addEventListener("click",()=>{this.hideSettings()});settings.ui={cbAutoscroll:DOM.id("dht-cfg-autoscroll"),optsAfterFirstMsg:{},optsAfterSavedMsg:{}};settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING]=DOM.id("dht-cfg-afm-nothing");settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE]=DOM.id("dht-cfg-afm-pause");settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH]=DOM.id("dht-cfg-afm-switch");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING]=DOM.id("dht-cfg-asm-nothing");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE]=DOM.id("dht-cfg-asm-pause");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH]=DOM.id("dht-cfg-asm-switch");settings.ui.cbAutoscroll.addEventListener("change",()=>{SETTINGS.autoscroll=settings.ui.cbAutoscroll.checked});Object.keys(settings.ui.optsAfterFirstMsg).forEach(key=>{settings.ui.optsAfterFirstMsg[key].addEventListener("click",()=>{SETTINGS.afterFirstMsg=key})});Object.keys(settings.ui.optsAfterSavedMsg).forEach(key=>{settings.ui.optsAfterSavedMsg[key].addEventListener("click",()=>{SETTINGS.afterSavedMsg=key})});stateChangedEvent()},hideSettings(){if(settings){DOM.removeElement(settings.overlay);DOM.removeElement(settings.ele);DOM.removeElement(settings.styles);settings=null}},setStatus(state){if(controller){controller.ui.textStatus.innerText=state}}}}();
|
1
app/Resources/Tracker/scripts.min/settings.js
Normal file
1
app/Resources/Tracker/scripts.min/settings.js
Normal file
@@ -0,0 +1 @@
|
||||
const CONSTANTS={AUTOSCROLL_ACTION_NOTHING:"optNothing",AUTOSCROLL_ACTION_PAUSE:"optPause",AUTOSCROLL_ACTION_SWITCH:"optSwitch"};let IS_FIRST_RUN=false;const SETTINGS=function(){const settingsChangedEvents=[];const saveSettings=function(){DOM.saveToCookie("DHT_SETTINGS",root,60*60*24*365*5)};const triggerSettingsChanged=function(property){for(const callback of settingsChangedEvents){callback(property)}saveSettings()};const defineTriggeringProperty=function(obj,property,value){const name="_"+property;Object.defineProperty(obj,property,{get:()=>obj[name],set:value=>{obj[name]=value;triggerSettingsChanged(property)}});obj[name]=value};let loaded=DOM.loadFromCookie("DHT_SETTINGS");if(!loaded){loaded={_autoscroll:true,_afterFirstMsg:CONSTANTS.AUTOSCROLL_ACTION_PAUSE,_afterSavedMsg:CONSTANTS.AUTOSCROLL_ACTION_PAUSE};IS_FIRST_RUN=true}const root={onSettingsChanged(callback){settingsChangedEvents.push(callback)}};defineTriggeringProperty(root,"autoscroll",loaded._autoscroll);defineTriggeringProperty(root,"afterFirstMsg",loaded._afterFirstMsg);defineTriggeringProperty(root,"afterSavedMsg",loaded._afterSavedMsg);if(IS_FIRST_RUN){saveSettings()}return root}();
|
1
app/Resources/Tracker/scripts.min/state.js
Normal file
1
app/Resources/Tracker/scripts.min/state.js
Normal file
@@ -0,0 +1 @@
|
||||
const STATE=function(){let serverPort=-1;let serverToken="";const post=function(endpoint,json){const aborter=new AbortController;const timeout=window.setTimeout(()=>aborter.abort(),5e3);return new Promise(async(resolve,reject)=>{let r;try{r=await fetch("http://127.0.0.1:"+serverPort+endpoint,{method:"POST",headers:{"Content-Type":"application/json","X-DHT-Token":serverToken},credentials:"omit",redirect:"error",body:JSON.stringify(json),signal:aborter.signal})}catch(e){if(e.name==="AbortError"){reject({status:"DISCONNECTED"});return}else{reject({status:"ERROR",message:e.message});return}}finally{window.clearTimeout(timeout)}if(r.status===200){resolve(r);return}try{const message=await r.text();reject({status:"ERROR",message:message})}catch(e){reject({status:"ERROR",message:e.message})}})};const trackingStateChangedListeners=[];let isTracking=false;const addedChannels=new Set;const addedUsers=new Set;return{setup(port,token){serverPort=port;serverToken=token},onTrackingStateChanged(callback){trackingStateChangedListeners.push(callback);callback(isTracking)},isTracking(){return isTracking},setIsTracking(state){if(isTracking!==state){isTracking=state;if(isTracking){addedChannels.clear();addedUsers.clear()}for(const callback of trackingStateChangedListeners){callback(isTracking)}}},async addDiscordChannel(serverInfo,channelInfo){if(addedChannels.has(channelInfo.id)){return}const server={id:serverInfo.id,name:serverInfo.name,type:serverInfo.type};const channel={id:channelInfo.id,name:channelInfo.name};if("extra"in channelInfo){const extra=channelInfo.extra;if("parent"in extra){channel.parent=extra.parent}channel.position=extra.position;channel.topic=extra.topic;channel.nsfw=extra.nsfw}await post("/track-channel",{server:server,channel:channel});addedChannels.add(channelInfo.id)},async addDiscordMessages(channelId,discordMessageArray){discordMessageArray=discordMessageArray.filter(msg=>(msg.type===0||msg.type===19||msg.type===21)&&msg.state==="SENT");if(discordMessageArray.length===0){return false}const userInfo={};let hasNewUsers=false;for(const msg of discordMessageArray){const user=msg.author;if(!addedUsers.has(user.id)){const obj={id:user.id,name:user.username};if(user.avatar){obj.avatar=user.avatar}if(!user.bot){obj.discriminator=user.discriminator}userInfo[user.id]=obj;hasNewUsers=true}}if(hasNewUsers){await post("/track-users",Object.values(userInfo));for(const id of Object.keys(userInfo)){addedUsers.add(id)}}const response=await post("/track-messages",discordMessageArray.map(msg=>{const obj={id:msg.id,sender:msg.author.id,channel:msg.channel_id,text:msg.content,timestamp:msg.timestamp.toDate().getTime()};if(msg.editedTimestamp!==null){obj.editTimestamp=msg.editedTimestamp.toDate().getTime()}if(msg.messageReference!==null){obj.repliedToId=msg.messageReference.message_id}if(msg.attachments.length>0){obj.attachments=msg.attachments.map(attachment=>{const mapped={id:attachment.id,name:attachment.filename,size:attachment.size,url:attachment.url};if(attachment.content_type){mapped.type=attachment.content_type}return mapped})}if(msg.embeds.length>0){obj.embeds=msg.embeds.map(embed=>{const mapped={};for(const key of Object.keys(embed)){if(key==="id"){continue}if(key==="rawTitle"){mapped["title"]=embed[key]}else if(key==="rawDescription"){mapped["description"]=embed[key]}else{mapped[key]=embed[key]}}return JSON.stringify(mapped)})}if(msg.reactions.length>0){obj.reactions=msg.reactions.map(reaction=>{const emoji=reaction.emoji;const mapped={count:reaction.count};if(emoji.id){mapped.id=emoji.id}if(emoji.name){mapped.name=emoji.name}if(emoji.animated){mapped.isAnimated=emoji.animated}return mapped})}return obj}));const anyNewMessages=await response.text();return anyNewMessages==="1"}}}();
|
@@ -1,23 +1,5 @@
|
||||
// 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");
|
||||
}
|
||||
@@ -27,7 +9,7 @@ class DISCORD {
|
||||
}
|
||||
|
||||
static getMessageElements() {
|
||||
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
|
||||
return this.getMessageOuterElement().querySelectorAll("[class*='message-']");
|
||||
}
|
||||
|
||||
static hasMoreMessages() {
|
||||
@@ -46,11 +28,53 @@ 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 onMessageElementsChanged = function() {
|
||||
const messages = DISCORD.getMessages();
|
||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
||||
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();
|
||||
let hasChanged = false;
|
||||
|
||||
for (const message of messages) {
|
||||
if (!previousMessages.has(message.id)) {
|
||||
hasChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
@@ -62,75 +86,23 @@ class DISCORD {
|
||||
}
|
||||
|
||||
callback(messages);
|
||||
};
|
||||
}, 200);
|
||||
|
||||
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);
|
||||
});
|
||||
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message from a message element.
|
||||
* @returns { null | DiscordMessage } }
|
||||
* Returns the property object of a message element.
|
||||
* @returns { null | { message: DiscordMessage, channel: Object } }
|
||||
*/
|
||||
static getMessageFromElement(ele) {
|
||||
static getMessageElementProps(ele) {
|
||||
const props = DOM.getReactProps(ele);
|
||||
|
||||
if (props && Array.isArray(props.children)) {
|
||||
for (const child of props.children) {
|
||||
if (!(child instanceof Object)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const childProps = child.props;
|
||||
if (childProps instanceof Object && "message" in childProps) {
|
||||
return childProps.message;
|
||||
}
|
||||
if (props.children && props.children.length >= 4) {
|
||||
const childProps = props.children[3].props;
|
||||
|
||||
if ("message" in childProps && "channel" in childProps) {
|
||||
return childProps;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,20 +117,16 @@ class DISCORD {
|
||||
const messages = [];
|
||||
|
||||
for (const ele of this.getMessageElements()) {
|
||||
try {
|
||||
const message = this.getMessageFromElement(ele);
|
||||
|
||||
if (message != null) {
|
||||
messages.push(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
||||
const props = this.getMessageElementProps(ele);
|
||||
|
||||
if (props != null) {
|
||||
messages.push(props.message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving messages.", e);
|
||||
console.error(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -170,20 +138,18 @@ class DISCORD {
|
||||
*/
|
||||
static getSelectedChannel() {
|
||||
try {
|
||||
let obj = null;
|
||||
let obj;
|
||||
|
||||
try {
|
||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
||||
if (child && child.props && child.props.channel) {
|
||||
obj = child.props.channel;
|
||||
break;
|
||||
}
|
||||
for (const ele of this.getMessageElements()) {
|
||||
const props = this.getMessageElementProps(ele);
|
||||
|
||||
if (props != null) {
|
||||
obj = props.channel;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
||||
}
|
||||
|
||||
if (!obj || typeof obj.id !== "string") {
|
||||
if (!obj) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -192,7 +158,7 @@ class DISCORD {
|
||||
if (dms) {
|
||||
let name;
|
||||
|
||||
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
|
||||
for (const ele of dms.querySelectorAll("[class*='channel-'] [class*='selected-'] [class^='name-'] *, [class*='channel-'][class*='selected-'] [class^='name-'] *")) {
|
||||
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
|
||||
|
||||
if (node) {
|
||||
@@ -209,8 +175,8 @@ class DISCORD {
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
switch (obj.type) {
|
||||
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
|
||||
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
|
||||
case 1: type = "DM"; break;
|
||||
case 3: type = "GROUP"; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
@@ -221,22 +187,9 @@ class DISCORD {
|
||||
return { server, channel };
|
||||
}
|
||||
else if (obj.guild_id) {
|
||||
let guild;
|
||||
|
||||
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
|
||||
if (child && child.props && child.props.guild) {
|
||||
guild = child.props.guild;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = {
|
||||
"id": guild.id,
|
||||
"name": guild.name,
|
||||
"id": obj.guild_id,
|
||||
"name": document.querySelector("nav header > h1").innerText,
|
||||
"type": "SERVER"
|
||||
};
|
||||
|
||||
@@ -248,7 +201,7 @@ class DISCORD {
|
||||
}
|
||||
};
|
||||
|
||||
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||
if (obj.parent_id) {
|
||||
channel["extra"]["parent"] = obj.parent_id;
|
||||
}
|
||||
else {
|
||||
@@ -262,7 +215,7 @@ class DISCORD {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving selected channel.", e);
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -275,38 +228,42 @@ class DISCORD {
|
||||
|
||||
if (dms) {
|
||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
|
||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
||||
const nextChannel = currentChannel && currentChannel.nextElementSibling;
|
||||
|
||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-") || !("href" in nextChannel) || !nextChannel.href.includes("/@me/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
||||
if (!nextChannelLink) {
|
||||
return false;
|
||||
else {
|
||||
nextChannel.click();
|
||||
nextChannel.scrollIntoView(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
nextChannelLink.click();
|
||||
nextChannelLink.scrollIntoView(true);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
const channelListEle = document.getElementById("channels");
|
||||
const channelIcons = [
|
||||
/* normal */ "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z",
|
||||
/* normal + thread */ "M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z",
|
||||
/* nsfw or private */ "M14 8C14 7.44772 13.5523 7 13 7H9.76001L10.3657 3.58738C10.4201 3.28107 10.1845 3 9.87344 3H8.88907C8.64664 3 8.43914 3.17391 8.39677 3.41262L7.76001 7H4.18011C3.93722 7 3.72946 7.17456 3.68759 7.41381L3.51259 8.41381C3.45905 8.71977 3.69449 9 4.00511 9H7.41001L6.35001 15H2.77011C2.52722 15 2.31946 15.1746 2.27759 15.4138L2.10259 16.4138C2.04905 16.7198 2.28449 17 2.59511 17H6.00001L5.39427 20.4126C5.3399 20.7189 5.57547 21 5.88657 21H6.87094C7.11337 21 7.32088 20.8261 7.36325 20.5874L8.00001 17H14L13.3943 20.4126C13.3399 20.7189 13.5755 21 13.8866 21H14.8709C15.1134 21 15.3209 20.8261 15.3632 20.5874L16 17H19.5799C19.8228 17 20.0306 16.8254 20.0724 16.5862L20.2474 15.5862C20.301 15.2802 20.0655 15 19.7549 15H16.35L16.6758 13.1558C16.7823 12.5529 16.3186 12 15.7063 12C15.2286 12 14.8199 12.3429 14.7368 12.8133L14.3504 15H8.35045L9.41045 9H13C13.5523 9 14 8.55228 14 8Z",
|
||||
/* nsfw + thread */ "M14.4 7C14.5326 7 14.64 7.10745 14.64 7.24V8.76C14.64 8.89255 14.5326 9 14.4 9H9.41045L8.35045 15H10.56V17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H14.4Z",
|
||||
/* private + thread */ "M15.44 6.99992C15.5725 6.99992 15.68 7.10737 15.68 7.23992V8.75992C15.68 8.89247 15.5725 8.99992 15.44 8.99992H9.41045L8.35045 14.9999H10.56V16.9999H8.00001L7.36325 20.5873C7.32088 20.826 7.11337 20.9999 6.87094 20.9999H5.88657C5.57547 20.9999 5.3399 20.7189 5.39427 20.4125L6.00001 16.9999H2.59511C2.28449 16.9999 2.04905 16.7197 2.10259 16.4137L2.27759 15.4137C2.31946 15.1745 2.52722 14.9999 2.77011 14.9999H6.35001L7.41001 8.99992H4.00511C3.69449 8.99992 3.45905 8.71969 3.51259 8.41373L3.68759 7.41373C3.72946 7.17448 3.93722 6.99992 4.18011 6.99992H7.76001L8.39677 3.41254C8.43914 3.17384 8.64664 2.99992 8.88907 2.99992H9.87344C10.1845 2.99992 10.4201 3.28099 10.3657 3.58731L9.76001 6.99992H15.44Z"
|
||||
];
|
||||
|
||||
const isValidChannelClass = cls => cls.includes("wrapper-") && !cls.includes("clickable-");
|
||||
const isValidChannelType = ele => channelIcons.some(icon => !!ele.querySelector("path[d=\"" + icon + "\"]"));
|
||||
const isValidChannel = ele => ele.childElementCount > 0 && isValidChannelClass(ele.children[0].className) && isValidChannelType(ele);
|
||||
|
||||
const channelListEle = document.querySelector("div[class*='sidebar'] > nav[class*='container'] > div[class*='scroller']");
|
||||
|
||||
if (!channelListEle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getLinkElement(channel) {
|
||||
return channel.querySelector("a[href^='/channels/'][role='link']");
|
||||
}
|
||||
|
||||
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
|
||||
const allChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), isValidChannel);
|
||||
let nextChannel = null;
|
||||
|
||||
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
||||
if (allTextChannels[index].className.includes("selected_")) {
|
||||
nextChannel = allTextChannels[index + 1];
|
||||
for (let index = 0; index < allChannels.length - 1; index++) {
|
||||
if (allChannels[index].children[0].className.includes("modeSelected")) {
|
||||
nextChannel = allChannels[index + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -315,7 +272,7 @@ class DISCORD {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextChannelLink = getLinkElement(nextChannel);
|
||||
const nextChannelLink = nextChannel.querySelector("a[href^='/channels/']");
|
||||
if (!nextChannelLink) {
|
||||
return false;
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user