mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 10:31:41 +02:00
Compare commits
73 Commits
Author | SHA1 | Date | |
---|---|---|---|
039c55eb1e
|
|||
a54242de8a
|
|||
578e51dc17
|
|||
8e2ec4dfe2
|
|||
3431f091ad
|
|||
a988003bdd
|
|||
5561f574cf
|
|||
8fd4561721
|
|||
9fe68be3f4
|
|||
90dac674eb
|
|||
1ebf15b039
|
|||
86acef1a22
|
|||
277e241183
|
|||
3b41ea7b5f
|
|||
6ce0ef7d55
|
|||
fd09ac496e
|
|||
9ca56bd910
|
|||
3e891e19c3
|
|||
9341988017
|
|||
461e403733
|
|||
c03e2d328d
|
|||
f3723ee43b
|
|||
8f7b566db7
|
|||
70a2a01ec3
|
|||
c31155738e
|
|||
c23fac465f
|
|||
51a2ac2d66
|
|||
a5e8366f1b
|
|||
3b698dbf33
|
|||
dc2c2d7ce8
|
|||
bb5634adc4
|
|||
d26e16eadf
|
|||
8f5f6065d8
|
|||
ad299bf762
|
|||
f70bbd53d9
|
|||
ae821f738e
|
|||
ab7f5d0a41
|
|||
1bddde7ccd
|
|||
58259c0bb4
|
|||
a84a453990
|
|||
563c644f48
|
|||
f8331a571e
|
|||
1ed26a16ea
|
|||
72c13cbb58
|
|||
e420add646
|
|||
6f1149ad5e
|
|||
b9899922e0
|
|||
6a2933ea0a
|
|||
be5c76c3bd
|
|||
217c1f9e10
|
|||
725ab7accf
|
|||
9a7a2cffc2
|
|||
6d3db23f80
|
|||
4bc9626013
|
|||
8002236c1f
|
|||
c4fe6c4391
|
|||
ebfe972a98
|
|||
20aac4c47a
|
|||
35308e0995
|
|||
f7f32c3f6a
|
|||
4dc781b35c
|
|||
849ef18adb
|
|||
77aa15e557
|
|||
47b106503d
|
|||
bde4cb06f4
|
|||
d772f7ed71
|
|||
0662af9b1a
|
|||
03fd730139
|
|||
d362c96b80
|
|||
9f34c9dffa
|
|||
cacf43d1d8
|
|||
edc23d616d
|
|||
db191f87fd
|
1
.idea/Discord-History-Tracker.iml
generated
1
.idea/Discord-History-Tracker.iml
generated
@@ -8,6 +8,7 @@
|
||||
<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
52
.vscode/tasks.json
vendored
@@ -1,52 +0,0 @@
|
||||
{
|
||||
// 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": []
|
||||
}
|
||||
]
|
||||
}
|
59
README.md
59
README.md
@@ -1,48 +1,55 @@
|
||||
# Welcome
|
||||
|
||||
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.
|
||||
For instructions on how to **use Discord History Tracker**, visit the [official website](https://dht.chylex.com).
|
||||
|
||||
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 **creating your own version** from the source code, continue reading the [build instructions](#Build-Instructions) below.
|
||||
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.
|
||||
|
||||
# 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).
|
||||
|
||||
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**
|
||||
Folder organization:
|
||||
* `app/` contains a Visual Studio solution for the desktop app
|
||||
* `lib/` contains utilities required to build the project
|
||||
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
||||
|
||||
To start editing source code for the desktop app, install the [.NET 5 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/5.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
||||
|
||||
### Building
|
||||
|
||||
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 `Debug` version of the desktop app, there are no additional requirements.
|
||||
|
||||
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
|
||||
To build a `Release` version of the desktop app, follow the instructions for your operating system.
|
||||
|
||||
You can tweak the build process using the following flags:
|
||||
* `python build.py --nominify` to disable [minification](#Minification)
|
||||
#### Release – Windows (64-bit)
|
||||
|
||||
### Minification
|
||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH`
|
||||
2. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
|
||||
|
||||
The build process automatically minifies JS using `UglifyJS@3`, and CSS using a custom minifier.
|
||||
The `lib/` folder contains an installation of [Node](https://nodejs.org/en) and [uglify-js](https://www.npmjs.com/package/uglify-js), which are used to minify the tracking script. This installation will only work on 64-bit Windows; building on 32-bit Windows is not supported, but you can try.
|
||||
|
||||
* 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
|
||||
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
||||
|
||||
#### Release – Other Operating Systems
|
||||
|
||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3
|
||||
- On Debian and derivatives, you can install `python-is-python3`
|
||||
- On other distributions, you can create a link manually, for ex. `ln -s /usr/bin/python3 /usr/bin/python`
|
||||
- If you don't want `python` to mean Python 3, then edit `Desktop.csproj` and change `python` to `python3`
|
||||
2. Install [Node + npm](https://nodejs.org/en)
|
||||
3. Install [uglify-js](https://www.npmjs.com/package/uglify-js) globally (`npm install -g uglify-js`)
|
||||
4. Install the `zip` package from your repository
|
||||
|
||||
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
|
||||
|
||||
#### Distribution
|
||||
|
||||
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 5 to be installed.
|
||||
|
||||
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking `DiscordHistoryTracker`. I tried using Python to re-create the archives with correct file permissions, but found that Linux `zip` tools could not see them. The only working solution is building the Windows + portable version on Windows, and Linux + Mac version on Linux.
|
||||
|
@@ -4,18 +4,21 @@
|
||||
<option name="projectPerEditor">
|
||||
<map>
|
||||
<entry key="Desktop/App.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/CheckBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/MessageDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<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/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/FilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.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/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/WelcomeScreen.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" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
|
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectTasksOptions">
|
||||
<enabled-global>
|
||||
<option value="UglifyJS (Tracker)" />
|
||||
</enabled-global>
|
||||
</component>
|
||||
</project>
|
@@ -1,74 +1,41 @@
|
||||
<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">
|
||||
|
||||
<Application.Styles>
|
||||
|
||||
<FluentTheme Mode="Light" />
|
||||
|
||||
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
</Style>
|
||||
|
||||
<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="Expander">
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:disabled"><!-- TODO bug in Avalonia (https://github.com/AvaloniaUI/Avalonia/pull/7792) -->
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextControlForegroundDisabled}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Template">
|
||||
@@ -108,6 +75,30 @@
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
|
||||
<Style.Animations>
|
||||
<Animation FillMode="Both" Duration="0:0:0.0625">
|
||||
<KeyFrame Cue="0%">
|
||||
<Setter Property="RotateTransform.Angle" Value="180" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="100%">
|
||||
<Setter Property="RotateTransform.Angle" Value="180" />
|
||||
</KeyFrame>
|
||||
</Animation>
|
||||
</Style.Animations>
|
||||
</Style>
|
||||
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
|
||||
<Style.Animations>
|
||||
<Animation FillMode="Both" Duration="0:0:0.0625">
|
||||
<KeyFrame Cue="0%">
|
||||
<Setter Property="RotateTransform.Angle" Value="0" />
|
||||
</KeyFrame>
|
||||
<KeyFrame Cue="100%">
|
||||
<Setter Property="RotateTransform.Angle" Value="0" />
|
||||
</KeyFrame>
|
||||
</Animation>
|
||||
</Style.Animations>
|
||||
</Style>
|
||||
|
||||
</Application.Styles>
|
||||
|
||||
@@ -115,9 +106,61 @@
|
||||
|
||||
<common:NumberValueConverter x:Key="NumberValueConverter" />
|
||||
|
||||
<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">15</Thickness>
|
||||
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FCFCFC" />
|
||||
<SolidColorBrush x:Key="ExpanderBorderBrush" Color="#697DAB" />
|
||||
<SolidColorBrush x:Key="ExpanderBackground" Color="#697DAB" />
|
||||
<SolidColorBrush x:Key="ExpanderForeground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="ExpanderDropDownBorderBrush" Color="#697DAB" />
|
||||
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FFFFFF" />
|
||||
|
||||
</Application.Resources>
|
||||
|
||||
|
@@ -4,7 +4,7 @@ using Avalonia.Markup.Xaml;
|
||||
using DHT.Desktop.Main;
|
||||
|
||||
namespace DHT.Desktop {
|
||||
public class App : Application {
|
||||
sealed class App : Application {
|
||||
public override void Initialize() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using DHT.Server.Logging;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop {
|
||||
public class Arguments {
|
||||
sealed class Arguments {
|
||||
private static readonly Log Log = Log.ForType<Arguments>();
|
||||
|
||||
public static Arguments Empty => new(Array.Empty<string>());
|
||||
|
||||
public string? DatabaseFile { get; }
|
||||
@@ -13,12 +15,25 @@ namespace DHT.Desktop {
|
||||
for (int i = 0; i < args.Length; i++) {
|
||||
string key = args[i];
|
||||
|
||||
if (i >= args.Length - 1) {
|
||||
switch (key) {
|
||||
case "-debug":
|
||||
Log.IsDebugEnabled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
string value;
|
||||
|
||||
if (i == 0 && !key.StartsWith('-')) {
|
||||
value = key;
|
||||
key = "-db";
|
||||
}
|
||||
else if (i >= args.Length - 1) {
|
||||
Log.Warn("Missing value for command line argument: " + key);
|
||||
continue;
|
||||
}
|
||||
|
||||
string value = args[++i];
|
||||
else {
|
||||
value = args[++i];
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "-db":
|
||||
|
@@ -3,14 +3,16 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Exceptions;
|
||||
using DHT.Server.Database.Sqlite;
|
||||
using DHT.Server.Logging;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Common {
|
||||
public static class DatabaseGui {
|
||||
static class DatabaseGui {
|
||||
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
||||
|
||||
private const string DatabaseFileInitialName = "archive.dht";
|
||||
|
||||
private static readonly List<FileDialogFilter> DatabaseFileDialogFilter = new() {
|
||||
@@ -54,11 +56,11 @@ namespace DHT.Desktop.Common {
|
||||
}
|
||||
|
||||
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?");
|
||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
||||
}
|
||||
|
||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped. Do you want to proceed with the upgrade?");
|
||||
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?");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,12 +3,12 @@ using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace DHT.Desktop.Common {
|
||||
public class NumberValueConverter : IValueConverter {
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) {
|
||||
return string.Format(Program.Culture, "{0:n0}", value);
|
||||
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) {
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
namespace DHT.Desktop.Common {
|
||||
public static class TextFormat {
|
||||
static class TextFormat {
|
||||
public static string Format(this int number) {
|
||||
return number.ToString("N0", Program.Culture);
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
||||
<RootNamespace>DHT.Desktop</RootNamespace>
|
||||
<PackageId>DiscordHistoryTracker</PackageId>
|
||||
<Authors>chylex</Authors>
|
||||
@@ -11,56 +12,61 @@
|
||||
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
||||
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
||||
<Version>33.0.0.0</Version>
|
||||
<AssemblyVersion>$(Version)</AssemblyVersion>
|
||||
<FileVersion>$(Version)</FileVersion>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
|
||||
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
|
||||
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="0.10.10" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.10" />
|
||||
<PackageReference Include="Avalonia" Version="0.10.13" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.13" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.13" Condition=" '$(Configuration)' == 'Debug' " />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Server\Server.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.10" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Update="Windows\MainWindow.axaml.cs">
|
||||
<DependentUpon>MainWindow.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Update="Dialogs\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>
|
||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Resources/icon.ico" />
|
||||
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js">
|
||||
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<Visible>false</Visible>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="../Resources/Tracker/scripts.min/**">
|
||||
<EmbeddedResource Include="../Resources/Tracker/scripts/**" Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<Visible>false</Visible>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="../Resources/Tracker/styles/**">
|
||||
<LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<Visible>false</Visible>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource 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>
|
||||
<Target Name="MinifyResources" BeforeTargets="PrepareForBuild" Condition=" '$(Configuration)' == 'Release' ">
|
||||
<PropertyGroup>
|
||||
<MinifiedResourceDir>$(ProjectDir)bin/.res/scripts</MinifiedResourceDir>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<UpToDateCheckInput Include="$(ProjectDir)../Resources/Tracker/scripts/**" Visible="false" />
|
||||
<EmbeddedResource Include="$(MinifiedResourceDir)/discord.js" LogicalName="Tracker\scripts\discord.js" Visible="false" />
|
||||
<EmbeddedResource Include="$(MinifiedResourceDir)/dom.js" LogicalName="Tracker\scripts\dom.js" Visible="false" />
|
||||
<EmbeddedResource Include="$(MinifiedResourceDir)/gui.js" LogicalName="Tracker\scripts\gui.js" Visible="false" />
|
||||
<EmbeddedResource Include="$(MinifiedResourceDir)/settings.js" LogicalName="Tracker\scripts\settings.js" Visible="false" />
|
||||
<EmbeddedResource Include="$(MinifiedResourceDir)/state.js" LogicalName="Tracker\scripts\state.js" Visible="false" />
|
||||
</ItemGroup>
|
||||
<RemoveDir Directories="$(ProjectDir)bin/.res/scripts" />
|
||||
<Exec Command="python $(ProjectDir)../Resources/minify.py" WorkingDirectory="$(ProjectDir)../Resources" IgnoreExitCode="false" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
@@ -2,16 +2,16 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:dialogs="clr-namespace:DHT.Desktop.Dialogs"
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
|
||||
mc:Ignorable="d" d:DesignWidth="500"
|
||||
x:Class="DHT.Desktop.Dialogs.CheckBoxDialog"
|
||||
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="500" SizeToContent="Height" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Window.DataContext>
|
||||
<dialogs:CheckBoxDialogModel />
|
||||
<namespace:CheckBoxDialogModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Styles>
|
@@ -1,10 +1,13 @@
|
||||
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 {
|
||||
public class CheckBoxDialog : Window {
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class CheckBoxDialog : Window {
|
||||
public CheckBoxDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
@@ -2,10 +2,10 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs {
|
||||
public class CheckBoxDialogModel : BaseModel {
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
class CheckBoxDialogModel : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
||||
@@ -28,8 +28,8 @@ namespace DHT.Desktop.Dialogs {
|
||||
|
||||
private bool pauseCheckEvents = false;
|
||||
|
||||
public bool AreAllSelected => Items.All(item => item.Checked);
|
||||
public bool AreNoneSelected => Items.All(item => !item.Checked);
|
||||
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);
|
||||
@@ -57,10 +57,10 @@ namespace DHT.Desktop.Dialogs {
|
||||
}
|
||||
}
|
||||
|
||||
public class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||
|
||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(item => item.Checked);
|
||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
||||
|
||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||
this.Items = new List<CheckBoxItem<T>>(items);
|
@@ -1,7 +1,7 @@
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs {
|
||||
public class CheckBoxItem : BaseModel {
|
||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
||||
class CheckBoxItem : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace DHT.Desktop.Dialogs {
|
||||
}
|
||||
}
|
||||
|
||||
public class CheckBoxItem<T> : CheckBoxItem {
|
||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||
public new T Item { get; }
|
||||
|
||||
public CheckBoxItem(T item) {
|
@@ -2,8 +2,8 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace DHT.Desktop.Dialogs {
|
||||
public static class Dialog {
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
static class Dialog {
|
||||
public static async Task ShowOk(Window owner, string title, string message) {
|
||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
|
||||
namespace DHT.Desktop.Dialogs {
|
||||
public static class DialogResult {
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
static class DialogResult {
|
||||
public enum All {
|
||||
Ok,
|
||||
Yes,
|
@@ -2,16 +2,16 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:dialogs="clr-namespace:DHT.Desktop.Dialogs"
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
||||
mc:Ignorable="d" d:DesignWidth="500"
|
||||
x:Class="DHT.Desktop.Dialogs.MessageDialog"
|
||||
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="500" SizeToContent="Height" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Window.DataContext>
|
||||
<dialogs:MessageDialogModel />
|
||||
<namespace:MessageDialogModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Styles>
|
@@ -1,10 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Dialogs {
|
||||
public class MessageDialog : Window {
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MessageDialog : Window {
|
||||
public MessageDialog() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
@@ -1,5 +1,5 @@
|
||||
namespace DHT.Desktop.Dialogs {
|
||||
public class MessageDialogModel {
|
||||
namespace DHT.Desktop.Dialogs.Message {
|
||||
sealed class MessageDialogModel {
|
||||
public string Title { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
|
@@ -1,7 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Dialogs {
|
||||
public interface IProgressCallback {
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
interface IProgressCallback {
|
||||
Task Update(string message, int finishedItems, int totalItems);
|
||||
}
|
||||
}
|
@@ -2,9 +2,9 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:dialogs="clr-namespace:DHT.Desktop.Dialogs"
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
||||
mc:Ignorable="d" d:DesignWidth="500"
|
||||
x:Class="DHT.Desktop.Dialogs.ProgressDialog"
|
||||
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Opened="Loaded"
|
||||
@@ -13,7 +13,7 @@
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Window.DataContext>
|
||||
<dialogs:ProgressDialogModel />
|
||||
<namespace:ProgressDialogModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Styles>
|
@@ -1,12 +1,14 @@
|
||||
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 {
|
||||
public class ProgressDialog : Window {
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class ProgressDialog : Window {
|
||||
private bool isFinished = false;
|
||||
|
||||
public ProgressDialog() {
|
@@ -2,10 +2,10 @@ using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs {
|
||||
public class ProgressDialogModel : BaseModel {
|
||||
namespace DHT.Desktop.Dialogs.Progress {
|
||||
sealed class ProgressDialogModel : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
private string message = "";
|
||||
@@ -46,7 +46,7 @@ namespace DHT.Desktop.Dialogs {
|
||||
|
||||
public delegate Task TaskRunner(IProgressCallback callback);
|
||||
|
||||
private class Callback : IProgressCallback {
|
||||
private sealed class Callback : IProgressCallback {
|
||||
private readonly ProgressDialogModel model;
|
||||
|
||||
public Callback(ProgressDialogModel model) {
|
119
app/Desktop/Discord/DiscordAppSettings.cs
Normal file
119
app/Desktop/Discord/DiscordAppSettings.cs
Normal file
@@ -0,0 +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.Threading.Tasks;
|
||||
using DHT.Utils.Logging;
|
||||
using static System.Environment.SpecialFolder;
|
||||
using static System.Environment.SpecialFolderOption;
|
||||
|
||||
namespace DHT.Desktop.Discord {
|
||||
static class DiscordAppSettings {
|
||||
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
||||
|
||||
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
||||
|
||||
public static string JsonFilePath { get; }
|
||||
private static string JsonBackupFilePath { get; }
|
||||
|
||||
[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 {
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
File.Delete(JsonBackupFilePath);
|
||||
} catch (Exception e) {
|
||||
Log.Error("Cannot delete backup file.");
|
||||
Log.Error(e);
|
||||
}
|
||||
|
||||
return SettingsJsonResult.Success;
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
|
||||
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
|
||||
}
|
||||
|
||||
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
|
||||
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
||||
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
}
|
||||
}
|
10
app/Desktop/Discord/SettingsJsonResult.cs
Normal file
10
app/Desktop/Discord/SettingsJsonResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace DHT.Desktop.Discord {
|
||||
enum SettingsJsonResult {
|
||||
Success,
|
||||
AlreadySet,
|
||||
FileNotFound,
|
||||
ReadError,
|
||||
InvalidJson,
|
||||
WriteError
|
||||
}
|
||||
}
|
@@ -1,9 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
public class AboutWindow : Window {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class AboutWindow : Window {
|
||||
public AboutWindow() {
|
||||
InitializeComponent();
|
||||
#if DEBUG
|
||||
|
@@ -1,7 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
public class AboutWindowModel {
|
||||
sealed class AboutWindowModel {
|
||||
public void ShowOfficialWebsite() {
|
||||
OpenUrl("https://dht.chylex.com");
|
||||
}
|
||||
|
@@ -15,6 +15,9 @@
|
||||
<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>
|
||||
|
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
public class FilterPanel : UserControl {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class FilterPanel : UserControl {
|
||||
private CalendarDatePicker StartDatePicker => this.FindControl<CalendarDatePicker>("StartDatePicker");
|
||||
private CalendarDatePicker EndDatePicker => this.FindControl<CalendarDatePicker>("EndDatePicker");
|
||||
|
||||
|
@@ -6,14 +6,15 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs;
|
||||
using DHT.Desktop.Models;
|
||||
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 {
|
||||
public class FilterPanelModel : BaseModel {
|
||||
sealed class FilterPanelModel : BaseModel, IDisposable {
|
||||
private static readonly HashSet<string> FilterProperties = new () {
|
||||
nameof(FilterByDate),
|
||||
nameof(StartDate),
|
||||
@@ -57,7 +58,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
}
|
||||
|
||||
public HashSet<ulong> IncludedChannels {
|
||||
get => includedChannels ?? db.GetAllChannels().Select(channel => channel.Id).ToHashSet();
|
||||
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
|
||||
set => Change(ref includedChannels, value);
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
}
|
||||
|
||||
public HashSet<ulong> IncludedUsers {
|
||||
get => includedUsers ?? db.GetAllUsers().Select(user => user.Id).ToHashSet();
|
||||
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
|
||||
set => Change(ref includedUsers, value);
|
||||
}
|
||||
|
||||
@@ -102,6 +103,10 @@ namespace DHT.Desktop.Main.Controls {
|
||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||
FilterPropertyChanged?.Invoke(sender, e);
|
||||
@@ -125,7 +130,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
}
|
||||
|
||||
public async void OpenChannelFilterDialog() {
|
||||
var servers = db.GetAllServers().ToDictionary(server => server.Id);
|
||||
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var included = IncludedChannels;
|
||||
|
||||
@@ -206,7 +211,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
|
||||
if (FilterByDate) {
|
||||
filter.StartDate = StartDate;
|
||||
filter.EndDate = EndDate;
|
||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
||||
}
|
||||
|
||||
if (FilterByChannel) {
|
||||
@@ -221,7 +226,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
}
|
||||
|
||||
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
|
||||
items.Sort((item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||
|
||||
var model = new CheckBoxDialogModel<ulong>(items) {
|
||||
Title = title
|
||||
@@ -230,7 +235,7 @@ namespace DHT.Desktop.Main.Controls {
|
||||
var dialog = new CheckBoxDialog { DataContext = model };
|
||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||
|
||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(item => item.Item).ToHashSet() : null;
|
||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
48
app/Desktop/Main/Controls/ServerConfigurationPanel.axaml
Normal file
48
app/Desktop/Main/Controls/ServerConfigurationPanel.axaml
Normal file
@@ -0,0 +1,48 @@
|
||||
<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">
|
||||
|
||||
<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 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>
|
||||
<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>
|
16
app/Desktop/Main/Controls/ServerConfigurationPanel.axaml.cs
Normal file
16
app/Desktop/Main/Controls/ServerConfigurationPanel.axaml.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class ServerConfigurationPanel : UserControl {
|
||||
public ServerConfigurationPanel() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
116
app/Desktop/Main/Controls/ServerConfigurationPanelModel.cs
Normal file
116
app/Desktop/Main/Controls/ServerConfigurationPanelModel.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
||||
private string inputPort;
|
||||
|
||||
public string InputPort {
|
||||
get => inputPort;
|
||||
set {
|
||||
Change(ref inputPort, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
private string inputToken;
|
||||
|
||||
public string InputToken {
|
||||
get => inputToken;
|
||||
set {
|
||||
Change(ref inputToken, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
||||
|
||||
private bool isToggleServerButtonEnabled = true;
|
||||
|
||||
public bool IsToggleServerButtonEnabled {
|
||||
get => isToggleServerButtonEnabled;
|
||||
set => Change(ref isToggleServerButtonEnabled, value);
|
||||
}
|
||||
|
||||
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
|
||||
|
||||
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
|
||||
|
||||
private readonly Window window;
|
||||
private readonly ServerManager serverManager;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||
|
||||
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
|
||||
this.window = window;
|
||||
this.serverManager = serverManager;
|
||||
this.inputPort = ServerManager.Port.ToString();
|
||||
this.inputToken = ServerManager.Token;
|
||||
}
|
||||
|
||||
public void Initialize() {
|
||||
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
|
||||
}
|
||||
|
||||
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
||||
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
||||
OnPropertyChanged(nameof(ToggleServerButtonText));
|
||||
IsToggleServerButtonEnabled = true;
|
||||
}
|
||||
|
||||
private void BeforeServerStart() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
||||
}
|
||||
|
||||
private void StartServer() {
|
||||
BeforeServerStart();
|
||||
serverManager.Launch();
|
||||
}
|
||||
|
||||
private void StopServer() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
||||
serverManager.Stop();
|
||||
}
|
||||
|
||||
public void OnClickToggleServerButton() {
|
||||
if (serverManager.IsRunning) {
|
||||
StopServer();
|
||||
}
|
||||
else {
|
||||
StartServer();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnClickRandomizeToken() {
|
||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||
}
|
||||
|
||||
public async void OnClickApplyChanges() {
|
||||
if (!ushort.TryParse(InputPort, out ushort port)) {
|
||||
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
||||
return;
|
||||
}
|
||||
|
||||
BeforeServerStart();
|
||||
serverManager.Relaunch(port, InputToken);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
|
||||
public void OnClickCancelChanges() {
|
||||
InputPort = ServerManager.Port.ToString();
|
||||
InputToken = ServerManager.Token;
|
||||
}
|
||||
}
|
||||
}
|
@@ -11,32 +11,35 @@
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Background>
|
||||
<SolidColorBrush>#546A9F</SolidColorBrush>
|
||||
<SolidColorBrush>#3C4F79</SolidColorBrush>
|
||||
</UserControl.Background>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="StackPanel > TextBlock">
|
||||
<Setter Property="Foreground" Value="#E0E0E0" />
|
||||
<Setter Property="Foreground" Value="#E7E7E7" />
|
||||
</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 0" />
|
||||
<Setter Property="Stroke" Value="#3B5287" />
|
||||
<Setter Property="Margin" Value="14 1" />
|
||||
<Setter Property="Stroke" Value="#697899" />
|
||||
<Setter Property="StrokeThickness" Value="2" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="4 3">
|
||||
<StackPanel Orientation="Horizontal" Margin="6 3">
|
||||
<StackPanel Orientation="Vertical" Width="65">
|
||||
<TextBlock Classes="label">Status</TextBlock>
|
||||
<TextBlock FontSize="12" Margin="0 2 0 0" Text="{Binding StatusText}" />
|
||||
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
|
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
public class StatusBar : UserControl {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class StatusBar : UserControl {
|
||||
public StatusBar() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls {
|
||||
public class StatusBarModel : BaseModel {
|
||||
sealed class StatusBarModel : BaseModel {
|
||||
public DatabaseStatistics DatabaseStatistics { get; }
|
||||
|
||||
private Status status = Status.Stopped;
|
||||
|
@@ -1,91 +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: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>
|
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
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 {
|
||||
public 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 void Initialize() {
|
||||
TrackingPageModel.Initialize();
|
||||
}
|
||||
|
||||
private void TrackingPageModelOnServerStatusChanged(object? sender, StatusBarModel.Status e) {
|
||||
StatusBarModel.CurrentStatus = e;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
TrackingPageModel.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
@@ -5,18 +5,18 @@
|
||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.MainWindow"
|
||||
Title="Discord History Tracker"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="800" Height="500"
|
||||
MinWidth="480" MinHeight="240"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
MinWidth="500" MinHeight="275"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Closed="OnClosed">
|
||||
|
||||
<Design.DataContext>
|
||||
<main:MainWindowModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Panel>
|
||||
<ContentPresenter Content="{Binding WelcomeScreen}" IsVisible="{Binding ShowWelcomeScreen}" />
|
||||
<ContentPresenter Content="{Binding MainContentScreen}" IsVisible="{Binding ShowMainContentScreen}" />
|
||||
<ContentPresenter Content="{Binding CurrentScreen}" />
|
||||
</Panel>
|
||||
</Window>
|
||||
|
@@ -1,16 +1,19 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
public class MainWindow : Window {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MainWindow : Window {
|
||||
[UsedImplicitly]
|
||||
public MainWindow() {
|
||||
InitializeComponent(Arguments.Empty);
|
||||
}
|
||||
|
||||
public MainWindow(Arguments args) {
|
||||
internal MainWindow(Arguments args) {
|
||||
InitializeComponent(args);
|
||||
}
|
||||
|
||||
@@ -22,5 +25,11 @@ namespace DHT.Desktop.Main {
|
||||
this.AttachDevTools();
|
||||
#endif
|
||||
}
|
||||
|
||||
public void OnClosed(object? sender, EventArgs e) {
|
||||
if (DataContext is IDisposable disposable) {
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,21 +4,25 @@ using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Screens;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
public class MainWindowModel : BaseModel {
|
||||
public WelcomeScreen WelcomeScreen { get; }
|
||||
private WelcomeScreenModel WelcomeScreenModel { get; }
|
||||
sealed class MainWindowModel : BaseModel, IDisposable {
|
||||
private const string DefaultTitle = "Discord History Tracker";
|
||||
|
||||
public MainContentScreen? MainContentScreen { get; private set; }
|
||||
private MainContentScreenModel? MainContentScreenModel { get; set; }
|
||||
public string Title { get; private set; } = DefaultTitle;
|
||||
|
||||
public bool ShowWelcomeScreen => db == null;
|
||||
public bool ShowMainContentScreen => db != null;
|
||||
public UserControl CurrentScreen { get; private set; }
|
||||
|
||||
private readonly WelcomeScreen welcomeScreen;
|
||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||
|
||||
private MainContentScreen? mainContentScreen;
|
||||
private MainContentScreenModel? mainContentScreenModel;
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
@@ -30,10 +34,11 @@ namespace DHT.Desktop.Main {
|
||||
public MainWindowModel(Window window, Arguments args) {
|
||||
this.window = window;
|
||||
|
||||
WelcomeScreenModel = new WelcomeScreenModel(window);
|
||||
WelcomeScreen = new WelcomeScreen { DataContext = WelcomeScreenModel };
|
||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||
CurrentScreen = welcomeScreen;
|
||||
|
||||
WelcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
||||
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
||||
|
||||
var dbFile = args.DatabaseFile;
|
||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||
@@ -46,7 +51,7 @@ namespace DHT.Desktop.Main {
|
||||
}
|
||||
|
||||
if (File.Exists(dbFile)) {
|
||||
await WelcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
||||
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
||||
}
|
||||
else {
|
||||
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
||||
@@ -57,45 +62,55 @@ namespace DHT.Desktop.Main {
|
||||
}
|
||||
|
||||
if (args.ServerPort != null) {
|
||||
TrackingPageModel.ServerPort = args.ServerPort.ToString()!;
|
||||
ServerManager.Port = args.ServerPort.Value;
|
||||
}
|
||||
|
||||
if (args.ServerToken != null) {
|
||||
TrackingPageModel.ServerToken = args.ServerToken;
|
||||
ServerManager.Token = args.ServerToken;
|
||||
}
|
||||
}
|
||||
|
||||
private void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(WelcomeScreenModel.Db)) {
|
||||
if (MainContentScreenModel != null) {
|
||||
MainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||
MainContentScreenModel.Dispose();
|
||||
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;
|
||||
db = welcomeScreenModel.Db;
|
||||
|
||||
if (db == null) {
|
||||
MainContentScreenModel = null;
|
||||
MainContentScreen = null;
|
||||
Title = DefaultTitle;
|
||||
mainContentScreenModel = null;
|
||||
mainContentScreen = null;
|
||||
CurrentScreen = welcomeScreen;
|
||||
}
|
||||
else {
|
||||
MainContentScreenModel = new MainContentScreenModel(window, db);
|
||||
MainContentScreenModel.Initialize();
|
||||
MainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||
MainContentScreen = new MainContentScreen { DataContext = MainContentScreenModel };
|
||||
OnPropertyChanged(nameof(MainContentScreen));
|
||||
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
||||
mainContentScreenModel = new MainContentScreenModel(window, db);
|
||||
await mainContentScreenModel.Initialize();
|
||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||
CurrentScreen = mainContentScreen;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ShowWelcomeScreen));
|
||||
OnPropertyChanged(nameof(ShowMainContentScreen));
|
||||
OnPropertyChanged(nameof(CurrentScreen));
|
||||
OnPropertyChanged(nameof(Title));
|
||||
|
||||
window.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||
WelcomeScreenModel.CloseDatabase();
|
||||
welcomeScreenModel.CloseDatabase();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
welcomeScreenModel.Dispose();
|
||||
mainContentScreenModel?.Dispose();
|
||||
db?.Dispose();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
25
app/Desktop/Main/Pages/AdvancedPage.axaml
Normal file
25
app/Desktop/Main/Pages/AdvancedPage.axaml
Normal file
@@ -0,0 +1,25 @@
|
||||
<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">
|
||||
|
||||
<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>
|
16
app/Desktop/Main/Pages/AdvancedPage.axaml.cs
Normal file
16
app/Desktop/Main/Pages/AdvancedPage.axaml.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class AdvancedPage : UserControl {
|
||||
public AdvancedPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
39
app/Desktop/Main/Pages/AdvancedPageModel.cs
Normal file
39
app/Desktop/Main/Pages/AdvancedPageModel.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||
|
||||
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
|
||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
|
||||
}
|
||||
|
||||
public void Initialize() {
|
||||
ServerConfigurationModel.Initialize();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerConfigurationModel.Dispose();
|
||||
}
|
||||
|
||||
public async void VacuumDatabase() {
|
||||
db.Vacuum();
|
||||
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
|
||||
}
|
||||
}
|
||||
}
|
@@ -19,7 +19,7 @@
|
||||
<StackPanel Spacing="10">
|
||||
<DockPanel>
|
||||
<Button Command="{Binding CloseDatabase}" DockPanel.Dock="Right">Close Database</Button>
|
||||
<TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsEnabled="True" />
|
||||
<TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsReadOnly="True" />
|
||||
</DockPanel>
|
||||
<WrapPanel>
|
||||
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
|
||||
|
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
public class DatabasePage : UserControl {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class DatabasePage : UserControl {
|
||||
public DatabasePage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
@@ -5,14 +5,16 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs;
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Logging;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
public class DatabasePageModel : BaseModel {
|
||||
sealed class DatabasePageModel : BaseModel {
|
||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||
|
||||
public IDatabaseFile Db { get; }
|
||||
|
||||
public event EventHandler? DatabaseClosed;
|
||||
@@ -73,7 +75,6 @@ namespace DHT.Desktop.Main.Pages {
|
||||
}
|
||||
|
||||
public void CloseDatabase() {
|
||||
ServerLauncher.Stop();
|
||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -91,6 +92,8 @@ namespace DHT.Desktop.Main.Pages {
|
||||
}
|
||||
|
||||
var oldStatistics = target.Statistics.Clone();
|
||||
var oldMessageCount = target.CountMessages();
|
||||
|
||||
int successful = 0;
|
||||
int finished = 0;
|
||||
|
||||
@@ -131,7 +134,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
var newStatistics = target.Statistics;
|
||||
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
||||
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
||||
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
|
||||
long newMessages = target.CountMessages() - oldMessageCount;
|
||||
|
||||
StringBuilder message = new StringBuilder();
|
||||
message.Append("Processed ");
|
||||
|
45
app/Desktop/Main/Pages/DebugPage.axaml
Normal file
45
app/Desktop/Main/Pages/DebugPage.axaml
Normal file
@@ -0,0 +1,45 @@
|
||||
<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">
|
||||
|
||||
<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>
|
16
app/Desktop/Main/Pages/DebugPage.axaml.cs
Normal file
16
app/Desktop/Main/Pages/DebugPage.axaml.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class DebugPage : UserControl {
|
||||
public DebugPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
}
|
185
app/Desktop/Main/Pages/DebugPageModel.cs
Normal file
185
app/Desktop/Main/Pages/DebugPageModel.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
#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.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DebugPageModel : BaseModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public DebugPageModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
ProgressDialog progressDialog = new ProgressDialog {
|
||||
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
|
||||
Title = "Generating Random Data"
|
||||
}
|
||||
};
|
||||
|
||||
await progressDialog.ShowDialog(window);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
db.AddServer(server);
|
||||
db.AddUsers(users);
|
||||
|
||||
foreach (var channel in channels) {
|
||||
db.AddChannel(channel);
|
||||
}
|
||||
|
||||
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 = ImmutableArray<Attachment>.Empty,
|
||||
Embeds = ImmutableArray<Embed>.Empty,
|
||||
Reactions = ImmutableArray<Reaction>.Empty
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
db.AddMessages(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
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DebugPageModel : BaseModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
|
||||
public void OnClickAddRandomDataToDatabase() {}
|
||||
}
|
||||
}
|
||||
#endif
|
@@ -10,51 +10,17 @@
|
||||
<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 (Ctrl+Shift+I), or your browser with Discord open.
|
||||
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 Orientation="Horizontal" Spacing="10">
|
||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
||||
<Button Command="{Binding OnClickToggleButton}" Content="{Binding ToggleButtonText}" IsEnabled="{Binding IsToggleButtonEnabled}" />
|
||||
</StackPanel>
|
||||
<Expander Header="Advanced 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 5 0 0">
|
||||
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
|
||||
</TextBlock>
|
||||
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
|
@@ -1,11 +1,13 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
public class TrackingPage : UserControl {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class TrackingPage : UserControl {
|
||||
private bool isCopyingScript;
|
||||
|
||||
public TrackingPage() {
|
||||
@@ -22,9 +24,7 @@ namespace DHT.Desktop.Main.Pages {
|
||||
var originalText = button.Content;
|
||||
button.MinWidth = button.Bounds.Width;
|
||||
|
||||
await model.OnClickCopyTrackingScript();
|
||||
|
||||
if (!isCopyingScript) {
|
||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||
isCopyingScript = true;
|
||||
button.Content = "Script Copied!";
|
||||
|
||||
|
@@ -3,145 +3,115 @@ using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Desktop.Resources;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Logging;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Discord;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
public class TrackingPageModel : BaseModel, IDisposable {
|
||||
internal static string ServerPort { get; set; } = ServerUtils.FindAvailablePort(50000, 60000).ToString();
|
||||
internal static string ServerToken { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||
sealed class TrackingPageModel : BaseModel {
|
||||
private bool areDevToolsEnabled;
|
||||
|
||||
private string inputPort = ServerPort;
|
||||
|
||||
public string InputPort {
|
||||
get => inputPort;
|
||||
private bool AreDevToolsEnabled {
|
||||
get => areDevToolsEnabled;
|
||||
set {
|
||||
Change(ref inputPort, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
Change(ref areDevToolsEnabled, value);
|
||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
||||
}
|
||||
}
|
||||
|
||||
private string inputToken = ServerToken;
|
||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
||||
|
||||
public string InputToken {
|
||||
get => inputToken;
|
||||
set {
|
||||
Change(ref inputToken, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
public string ToggleAppDevToolsButtonText {
|
||||
get {
|
||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMadeChanges => ServerPort != InputPort || ServerToken != InputToken;
|
||||
|
||||
private bool isToggleButtonEnabled = true;
|
||||
|
||||
public bool IsToggleButtonEnabled {
|
||||
get => isToggleButtonEnabled;
|
||||
set => Change(ref isToggleButtonEnabled, value);
|
||||
}
|
||||
|
||||
public string ToggleButtonText => ServerLauncher.IsRunning ? "Pause Tracking" : "Resume Tracking";
|
||||
|
||||
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
|
||||
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public TrackingPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
public TrackingPageModel() : this(null!) {}
|
||||
|
||||
public TrackingPageModel(Window window, IDatabaseFile db) {
|
||||
public TrackingPageModel(Window window) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public void Initialize() {
|
||||
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
||||
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
||||
|
||||
if (int.TryParse(ServerPort, out int port)) {
|
||||
string token = ServerToken;
|
||||
ServerLauncher.Relaunch(port, token, db);
|
||||
public async Task Initialize() {
|
||||
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();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
public async Task<bool> OnClickCopyTrackingScript() {
|
||||
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
|
||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
|
||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
|
||||
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
||||
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
|
||||
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
|
||||
|
||||
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.");
|
||||
var clipboard = Application.Current?.Clipboard;
|
||||
if (clipboard == null) {
|
||||
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
||||
return false;
|
||||
}
|
||||
|
||||
IsToggleButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
||||
ServerLauncher.Relaunch(port, InputToken, db);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void StopServer() {
|
||||
IsToggleButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
||||
ServerLauncher.Stop();
|
||||
}
|
||||
|
||||
public async Task<bool> OnClickToggleButton() {
|
||||
if (ServerLauncher.IsRunning) {
|
||||
StopServer();
|
||||
try {
|
||||
await clipboard.SetTextAsync(script);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return await StartServer();
|
||||
} catch {
|
||||
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnClickCopyTrackingScript() {
|
||||
string bootstrap = await ResourceLoader.ReadTextAsync("Tracker/bootstrap.js");
|
||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerPort + ";")
|
||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerToken))
|
||||
.Replace("/*[IMPORTS]*/", await ResourceLoader.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
||||
.Replace("/*[CSS-CONTROLLER]*/", await ResourceLoader.ReadTextAsync("Tracker/styles/controller.css"))
|
||||
.Replace("/*[CSS-SETTINGS]*/", await ResourceLoader.ReadTextAsync("Tracker/styles/settings.css"));
|
||||
public async void OnClickToggleAppDevTools() {
|
||||
const string DialogTitle = "Discord App Settings File";
|
||||
|
||||
await Application.Current.Clipboard.SetTextAsync(script);
|
||||
}
|
||||
bool oldState = AreDevToolsEnabled;
|
||||
bool newState = !oldState;
|
||||
|
||||
public void OnClickRandomizeToken() {
|
||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||
}
|
||||
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 void OnClickApplyChanges() {
|
||||
if (await StartServer()) {
|
||||
ServerPort = InputPort;
|
||||
ServerToken = InputToken;
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnClickCancelChanges() {
|
||||
InputPort = ServerPort;
|
||||
InputToken = ServerToken;
|
||||
}
|
||||
|
||||
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
||||
ServerStatusChanged?.Invoke(this, ServerLauncher.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
||||
OnPropertyChanged(nameof(ToggleButtonText));
|
||||
IsToggleButtonEnabled = true;
|
||||
}
|
||||
|
||||
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(window, "Server Error", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,9 @@
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.Pages.ViewerPage">
|
||||
x:Class="DHT.Desktop.Main.Pages.ViewerPage"
|
||||
AttachedToVisualTree="OnAttachedToVisualTree"
|
||||
DetachedFromVisualTree="OnDetachedFromVisualTree">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:ViewerPageModel />
|
||||
|
@@ -1,8 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
public class ViewerPage : UserControl {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class ViewerPage : UserControl {
|
||||
public ViewerPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
@@ -10,5 +13,17 @@ namespace DHT.Desktop.Main.Pages {
|
||||
private void InitializeComponent() {
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) {
|
||||
if (DataContext is ViewerPageModel model) {
|
||||
model.SetPageVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) {
|
||||
if (DataContext is ViewerPageModel model) {
|
||||
model.SetPageVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,16 +7,16 @@ using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Desktop.Resources;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Export;
|
||||
using DHT.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
public class ViewerPageModel : BaseModel {
|
||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
||||
public string ExportedMessageText { get; private set; } = "";
|
||||
|
||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||
@@ -34,6 +34,8 @@ namespace DHT.Desktop.Main.Pages {
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
private bool isPageVisible = false;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
@@ -41,10 +43,21 @@ namespace DHT.Desktop.Main.Pages {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
|
||||
this.FilterModel = new FilterPanelModel(window, db);
|
||||
this.FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||
this.db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
UpdateStatistics();
|
||||
FilterModel = new FilterPanelModel(window, db);
|
||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||
FilterModel.Dispose();
|
||||
}
|
||||
|
||||
public void SetPageVisible(bool isPageVisible) {
|
||||
this.isPageVisible = isPageVisible;
|
||||
if (isPageVisible) {
|
||||
UpdateStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
@@ -53,22 +66,26 @@ namespace DHT.Desktop.Main.Pages {
|
||||
}
|
||||
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
||||
if (isPageVisible && 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).";
|
||||
var filter = FilterModel.CreateFilter();
|
||||
var allMessagesCount = db.Statistics.TotalMessages?.Format() ?? "?";
|
||||
var filteredMessagesCount = filter.IsEmpty ? allMessagesCount : db.CountMessages(filter).Format();
|
||||
|
||||
ExportedMessageText = "Will export " + filteredMessagesCount + " out of " + allMessagesCount + " message(s).";
|
||||
OnPropertyChanged(nameof(ExportedMessageText));
|
||||
}
|
||||
|
||||
private async Task<string> GenerateViewerContents() {
|
||||
string json = ViewerJsonExport.Generate(db, FilterModel.CreateFilter());
|
||||
|
||||
string index = await ResourceLoader.ReadTextAsync("Viewer/index.html");
|
||||
string viewer = index.Replace("/*[JS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
||||
.Replace("/*[CSS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/styles/", '\n'))
|
||||
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;
|
||||
}
|
||||
|
114
app/Desktop/Main/Screens/MainContentScreen.axaml
Normal file
114
app/Desktop/Main/Screens/MainContentScreen.axaml
Normal file
@@ -0,0 +1,114 @@
|
||||
<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">
|
||||
|
||||
<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">Advanced</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" />
|
||||
</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="TabViewer" Header="Viewer" Grid.Row="2">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding ViewerPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabAdvanced" Header="Advanced" Grid.Row="4">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding AdvancedPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabDebug" Header="Debug" Grid.Row="5" IsVisible="{Binding HasDebugPage}">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding DebugPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</UserControl>
|
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
public class MainContentScreen : UserControl {
|
||||
namespace DHT.Desktop.Main.Screens {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class MainContentScreen : UserControl {
|
||||
public MainContentScreen() {
|
||||
InitializeComponent();
|
||||
}
|
112
app/Desktop/Main/Screens/MainContentScreenModel.cs
Normal file
112
app/Desktop/Main/Screens/MainContentScreenModel.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens {
|
||||
sealed class MainContentScreenModel : IDisposable {
|
||||
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Window window;
|
||||
private readonly ServerManager serverManager;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.serverManager = new ServerManager(db);
|
||||
|
||||
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
||||
|
||||
DatabasePageModel = new DatabasePageModel(window, db);
|
||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||
|
||||
TrackingPageModel = new TrackingPageModel(window);
|
||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||
|
||||
ViewerPageModel = new ViewerPageModel(window, db);
|
||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||
|
||||
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
|
||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||
|
||||
#if DEBUG
|
||||
DebugPageModel = new DebugPageModel(window, db);
|
||||
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
||||
#else
|
||||
DebugPage = null;
|
||||
#endif
|
||||
|
||||
StatusBarModel = new StatusBarModel(db.Statistics);
|
||||
|
||||
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
|
||||
DatabaseClosed += OnDatabaseClosed;
|
||||
|
||||
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
|
||||
}
|
||||
|
||||
public async Task Initialize() {
|
||||
await TrackingPageModel.Initialize();
|
||||
AdvancedPageModel.Initialize();
|
||||
serverManager.Launch();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
|
||||
ViewerPageModel.Dispose();
|
||||
serverManager.Dispose();
|
||||
}
|
||||
|
||||
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
|
||||
StatusBarModel.CurrentStatus = e;
|
||||
}
|
||||
|
||||
private void OnDatabaseClosed(object? sender, EventArgs e) {
|
||||
serverManager.Stop();
|
||||
}
|
||||
|
||||
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
@@ -2,12 +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:main="clr-namespace:DHT.Desktop.Main"
|
||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.WelcomeScreen">
|
||||
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen">
|
||||
|
||||
<Design.DataContext>
|
||||
<main:WelcomeScreenModel />
|
||||
<screens:WelcomeScreenModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Background>
|
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
public class WelcomeScreen : UserControl {
|
||||
namespace DHT.Desktop.Main.Screens {
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed class WelcomeScreen : UserControl {
|
||||
public WelcomeScreen() {
|
||||
InitializeComponent();
|
||||
}
|
@@ -3,12 +3,12 @@ using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs;
|
||||
using DHT.Desktop.Models;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main {
|
||||
public class WelcomeScreenModel : BaseModel {
|
||||
namespace DHT.Desktop.Main.Screens {
|
||||
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
||||
public string Version => Program.Version;
|
||||
|
||||
public IDatabaseFile? Db { get; private set; }
|
||||
@@ -52,8 +52,7 @@ namespace DHT.Desktop.Main {
|
||||
}
|
||||
|
||||
public void CloseDatabase() {
|
||||
Db = null;
|
||||
|
||||
Dispose();
|
||||
OnPropertyChanged(nameof(Db));
|
||||
OnPropertyChanged(nameof(HasDatabase));
|
||||
}
|
||||
@@ -65,5 +64,10 @@ namespace DHT.Desktop.Main {
|
||||
public void Exit() {
|
||||
window.Close();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
Db?.Dispose();
|
||||
Db = null;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,14 +1,18 @@
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using DHT.Utils.Resources;
|
||||
|
||||
namespace DHT.Desktop {
|
||||
internal static class Program {
|
||||
static class Program {
|
||||
public static string Version { get; }
|
||||
public static CultureInfo Culture { get; }
|
||||
public static ResourceLoader Resources { get; }
|
||||
|
||||
static Program() {
|
||||
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "";
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
||||
while (Version.EndsWith(".0")) {
|
||||
Version = Version[..^2];
|
||||
}
|
||||
@@ -18,6 +22,8 @@ namespace DHT.Desktop {
|
||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||
|
||||
Resources = new ResourceLoader(assembly);
|
||||
}
|
||||
|
||||
public static void Main(string[] args) {
|
||||
|
50
app/Desktop/Server/ServerManager.cs
Normal file
50
app/Desktop/Server/ServerManager.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
|
||||
namespace DHT.Desktop.Server {
|
||||
sealed class ServerManager : IDisposable {
|
||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||
|
||||
private static ServerManager? instance;
|
||||
|
||||
public bool IsRunning => ServerLauncher.IsRunning;
|
||||
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
public ServerManager(IDatabaseFile db) {
|
||||
if (db != DummyDatabaseFile.Instance) {
|
||||
if (instance != null) {
|
||||
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
|
||||
}
|
||||
|
||||
instance = this;
|
||||
}
|
||||
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public void Launch() {
|
||||
ServerLauncher.Relaunch(Port, Token, db);
|
||||
}
|
||||
|
||||
public void Relaunch(ushort port, string token) {
|
||||
Port = port;
|
||||
Token = token;
|
||||
Launch();
|
||||
}
|
||||
|
||||
public void Stop() {
|
||||
ServerLauncher.Stop();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
Stop();
|
||||
|
||||
if (instance == this) {
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Desktop", "Desktop\Desktop.
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{7F94B470-B06F-43C0-9655-6592A9AE2D92}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Utils", "Utils\Utils.csproj", "{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -18,5 +20,9 @@ Global
|
||||
{7F94B470-B06F-43C0-9655-6592A9AE2D92}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7F94B470-B06F-43C0-9655-6592A9AE2D92}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7F94B470-B06F-43C0-9655-6592A9AE2D92}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
19
app/Resources/Tracker/bootstrap.js
vendored
19
app/Resources/Tracker/bootstrap.js
vendored
@@ -42,6 +42,10 @@
|
||||
stopTrackingDelayed(() => isSending = false);
|
||||
};
|
||||
|
||||
const isNoAction = function(action) {
|
||||
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||
};
|
||||
|
||||
const onTrackingContinued = function(anyNewMessages) {
|
||||
if (!STATE.isTracking()) {
|
||||
return;
|
||||
@@ -59,14 +63,14 @@
|
||||
if (SETTINGS.autoscroll) {
|
||||
let action = null;
|
||||
|
||||
if (!anyNewMessages) {
|
||||
action = SETTINGS.afterSavedMsg;
|
||||
}
|
||||
else if (!DISCORD.hasMoreMessages()) {
|
||||
if (!DISCORD.hasMoreMessages()) {
|
||||
action = SETTINGS.afterFirstMsg;
|
||||
}
|
||||
if (isNoAction(action) && !anyNewMessages) {
|
||||
action = SETTINGS.afterSavedMsg;
|
||||
}
|
||||
|
||||
if (action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING) {
|
||||
if (isNoAction(action)) {
|
||||
DISCORD.loadOlderMessages();
|
||||
}
|
||||
else if (action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE || (action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel())) {
|
||||
@@ -113,8 +117,8 @@
|
||||
|
||||
try {
|
||||
if (!messages.length) {
|
||||
DISCORD.loadOlderMessages();
|
||||
isSending = false;
|
||||
onTrackingContinued(false);
|
||||
}
|
||||
else {
|
||||
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);
|
||||
@@ -125,8 +129,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const callbackTimer = DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||
window.DHT_ON_UNLOAD.push(() => window.clearTimeout(callbackTimer));
|
||||
DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||
|
||||
STATE.onTrackingStateChanged(enabled => {
|
||||
if (enabled) {
|
||||
|
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
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}}
|
@@ -1,17 +0,0 @@
|
||||
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 +0,0 @@
|
||||
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 +0,0 @@
|
||||
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"}}}();
|
@@ -26,14 +26,13 @@ class DISCORD {
|
||||
|
||||
/**
|
||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||
* Returns a setInterval handle.
|
||||
*/
|
||||
static setupMessageCallback(callback) {
|
||||
let skipsLeft = 0;
|
||||
let waitForCleanup = false;
|
||||
const previousMessages = new Set();
|
||||
|
||||
return window.setInterval(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (skipsLeft > 0) {
|
||||
--skipsLeft;
|
||||
return;
|
||||
@@ -68,14 +67,7 @@ class DISCORD {
|
||||
}
|
||||
|
||||
const messages = this.getMessages();
|
||||
let hasChanged = false;
|
||||
|
||||
for (const message of messages) {
|
||||
if (!previousMessages.has(message.id)) {
|
||||
hasChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
|
||||
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
@@ -88,6 +80,8 @@ class DISCORD {
|
||||
|
||||
callback(messages);
|
||||
}, 200);
|
||||
|
||||
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,16 +133,27 @@ class DISCORD {
|
||||
try {
|
||||
let obj;
|
||||
|
||||
for (const ele of this.getMessageElements()) {
|
||||
const props = this.getMessageElementProps(ele);
|
||||
try {
|
||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
||||
if (child && child.props && child.props.channel) {
|
||||
obj = child.props.channel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
||||
|
||||
if (props != null) {
|
||||
obj = props.channel;
|
||||
break;
|
||||
for (const ele of this.getMessageElements()) {
|
||||
const props = this.getMessageElementProps(ele);
|
||||
|
||||
if (props != null) {
|
||||
obj = props.channel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!obj) {
|
||||
if (!obj || typeof obj.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -157,7 +162,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) {
|
||||
@@ -188,7 +193,7 @@ class DISCORD {
|
||||
else if (obj.guild_id) {
|
||||
const server = {
|
||||
"id": obj.guild_id,
|
||||
"name": document.querySelector("nav header > h1").innerText,
|
||||
"name": document.querySelector("nav header h1[class*='name-']").innerText,
|
||||
"type": "SERVER"
|
||||
};
|
||||
|
||||
@@ -214,7 +219,7 @@ class DISCORD {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error("[DHT] Error retrieving selected channel.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -227,42 +232,38 @@ class DISCORD {
|
||||
|
||||
if (dms) {
|
||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
||||
const nextChannel = currentChannel && currentChannel.nextElementSibling;
|
||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel-']");
|
||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
||||
|
||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-") || !("href" in nextChannel) || !nextChannel.href.includes("/@me/")) {
|
||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-")) {
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
nextChannel.click();
|
||||
nextChannel.scrollIntoView(true);
|
||||
return true;
|
||||
|
||||
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
||||
if (!nextChannelLink) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextChannelLink.click();
|
||||
nextChannelLink.scrollIntoView(true);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
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']");
|
||||
|
||||
const channelListEle = document.getElementById("channels");
|
||||
if (!channelListEle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), isValidChannel);
|
||||
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);
|
||||
let nextChannel = null;
|
||||
|
||||
for (let index = 0; index < allChannels.length - 1; index++) {
|
||||
if (allChannels[index].children[0].className.includes("modeSelected")) {
|
||||
nextChannel = allChannels[index + 1];
|
||||
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
||||
if (allTextChannels[index].className.includes("selected-")) {
|
||||
nextChannel = allTextChannels[index + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -271,7 +272,7 @@ class DISCORD {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextChannelLink = nextChannel.querySelector("a[href^='/channels/']");
|
||||
const nextChannelLink = getLinkElement(nextChannel);
|
||||
if (!nextChannelLink) {
|
||||
return false;
|
||||
}
|
||||
|
@@ -55,7 +55,8 @@ const GUI = (function() {
|
||||
controller.ui.btnClose.addEventListener("click", () => {
|
||||
this.hideController();
|
||||
window.DHT_ON_UNLOAD.forEach(f => f());
|
||||
window.DHT_LOADED = false;
|
||||
delete window.DHT_ON_UNLOAD;
|
||||
delete window.DHT_LOADED;
|
||||
});
|
||||
|
||||
STATE.onTrackingStateChanged(isTracking => {
|
||||
|
27
app/Resources/minify.py
Normal file
27
app/Resources/minify.py
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
if os.name == "nt":
|
||||
uglifyjs = os.path.abspath("../../lib/uglifyjs.cmd")
|
||||
else:
|
||||
uglifyjs = "uglifyjs"
|
||||
|
||||
if shutil.which(uglifyjs) is None:
|
||||
print("Cannot find executable: {0}".format(uglifyjs))
|
||||
sys.exit(1)
|
||||
|
||||
input_dir = os.path.abspath("./Tracker/scripts")
|
||||
output_dir = os.path.abspath("../Desktop/bin/.res/scripts")
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
for file in glob.glob(input_dir + "/*.js"):
|
||||
name = os.path.basename(file)
|
||||
print("Minifying {0}...".format(name))
|
||||
os.system("{0} {1} -o {2}/{3}".format(uglifyjs, file, output_dir, name))
|
||||
|
||||
print("Done!")
|
@@ -1,9 +1,9 @@
|
||||
namespace DHT.Server.Data {
|
||||
public readonly struct Attachment {
|
||||
public ulong Id { get; init; }
|
||||
public string Name { get; init; }
|
||||
public string? Type { get; init; }
|
||||
public string Url { get; init; }
|
||||
public ulong Size { get; init; }
|
||||
public ulong Id { get; internal init; }
|
||||
public string Name { get; internal init; }
|
||||
public string? Type { get; internal init; }
|
||||
public string Url { get; internal init; }
|
||||
public ulong Size { get; internal init; }
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
namespace DHT.Server.Data {
|
||||
public readonly struct Embed {
|
||||
public string Json { get; init; }
|
||||
public string Json { get; internal init; }
|
||||
}
|
||||
}
|
||||
|
@@ -2,12 +2,18 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DHT.Server.Data.Filters {
|
||||
public class MessageFilter {
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public sealed class MessageFilter {
|
||||
public DateTime? StartDate { get; set; } = null;
|
||||
public DateTime? EndDate { get; set; } = null;
|
||||
|
||||
public HashSet<ulong>? ChannelIds { get; set; } = null;
|
||||
public HashSet<ulong>? UserIds { get; set; } = null;
|
||||
public HashSet<ulong>? MessageIds { get; set; } = null;
|
||||
|
||||
public bool IsEmpty => StartDate == null &&
|
||||
EndDate == null &&
|
||||
ChannelIds == null &&
|
||||
UserIds == null &&
|
||||
MessageIds == null;
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
namespace DHT.Server.Data {
|
||||
public readonly struct Reaction {
|
||||
public ulong? EmojiId { get; init; }
|
||||
public string? EmojiName { get; init; }
|
||||
public EmojiFlags EmojiFlags { get; init; }
|
||||
public int Count { get; init; }
|
||||
public ulong? EmojiId { get; internal init; }
|
||||
public string? EmojiName { get; internal init; }
|
||||
public EmojiFlags EmojiFlags { get; internal init; }
|
||||
public int Count { get; internal init; }
|
||||
}
|
||||
}
|
||||
|
@@ -24,7 +24,7 @@ namespace DHT.Server.Data {
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToJsonViewerString(ServerType? type) {
|
||||
internal static string ToJsonViewerString(ServerType? type) {
|
||||
return type switch {
|
||||
ServerType.Server => "server",
|
||||
ServerType.Group => "group",
|
||||
|
@@ -1,38 +1,30 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Server.Database {
|
||||
public class DatabaseStatistics : INotifyPropertyChanged {
|
||||
public sealed class DatabaseStatistics : BaseModel {
|
||||
private long totalServers;
|
||||
private long totalChannels;
|
||||
private long totalUsers;
|
||||
private long totalMessages;
|
||||
private long? totalMessages;
|
||||
|
||||
public long TotalServers {
|
||||
get => totalServers;
|
||||
internal set => Change(out totalServers, value);
|
||||
internal set => Change(ref totalServers, value);
|
||||
}
|
||||
|
||||
public long TotalChannels {
|
||||
get => totalChannels;
|
||||
internal set => Change(out totalChannels, value);
|
||||
internal set => Change(ref totalChannels, value);
|
||||
}
|
||||
|
||||
public long TotalUsers {
|
||||
get => totalUsers;
|
||||
internal set => Change(out totalUsers, value);
|
||||
internal set => Change(ref totalUsers, value);
|
||||
}
|
||||
|
||||
public long TotalMessages {
|
||||
public long? TotalMessages {
|
||||
get => totalMessages;
|
||||
internal set => Change(out totalMessages, value);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Change<T>(out T field, T value, [CallerMemberName] string? propertyName = null) {
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
internal set => Change(ref totalMessages, value);
|
||||
}
|
||||
|
||||
public DatabaseStatistics Clone() {
|
||||
|
@@ -1,10 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
|
||||
namespace DHT.Server.Database {
|
||||
public class DummyDatabaseFile : IDatabaseFile {
|
||||
public sealed class DummyDatabaseFile : IDatabaseFile {
|
||||
public static DummyDatabaseFile Instance { get; } = new();
|
||||
|
||||
public string Path => "";
|
||||
@@ -42,8 +41,8 @@ namespace DHT.Server.Database {
|
||||
|
||||
public void RemoveMessages(MessageFilter filter, MessageFilterRemovalMode mode) {}
|
||||
|
||||
public void Dispose() {
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
public void Vacuum() {}
|
||||
|
||||
public void Dispose() {}
|
||||
}
|
||||
}
|
||||
|
@@ -2,11 +2,11 @@ using System;
|
||||
using DHT.Server.Database.Sqlite;
|
||||
|
||||
namespace DHT.Server.Database.Exceptions {
|
||||
public class DatabaseTooNewException : Exception {
|
||||
public sealed class DatabaseTooNewException : Exception {
|
||||
public int DatabaseVersion { get; }
|
||||
public int CurrentVersion => Schema.Version;
|
||||
|
||||
public DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + Schema.Version) {
|
||||
internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + Schema.Version) {
|
||||
this.DatabaseVersion = databaseVersion;
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace DHT.Server.Database.Exceptions {
|
||||
public class InvalidDatabaseVersionException : Exception {
|
||||
public sealed class InvalidDatabaseVersionException : Exception {
|
||||
public string Version { get; }
|
||||
|
||||
public InvalidDatabaseVersionException(string version) : base("Invalid database version: " + version) {
|
||||
internal InvalidDatabaseVersionException(string version) : base("Invalid database version: " + version) {
|
||||
this.Version = version;
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Server.Database.Export {
|
||||
public static class ViewerJsonExport {
|
||||
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
|
||||
|
||||
public static string Generate(IDatabaseFile db, MessageFilter? filter = null) {
|
||||
var perf = Log.Start();
|
||||
|
||||
var includedUserIds = new HashSet<ulong>();
|
||||
var includedChannelIds = new HashSet<ulong>();
|
||||
var includedServerIds = new HashSet<ulong>();
|
||||
@@ -27,23 +31,29 @@ namespace DHT.Server.Database.Export {
|
||||
}
|
||||
}
|
||||
|
||||
var opts = new JsonSerializerOptions();
|
||||
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
|
||||
|
||||
var users = GenerateUserList(db, includedUserIds, out var userindex, out var userIndices);
|
||||
var servers = GenerateServerList(db, includedServerIds, out var serverindex);
|
||||
var channels = GenerateChannelList(includedChannels, serverindex);
|
||||
|
||||
return JsonSerializer.Serialize(new {
|
||||
perf.Step("Collect database data");
|
||||
|
||||
var opts = new JsonSerializerOptions();
|
||||
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
|
||||
|
||||
var json = JsonSerializer.Serialize(new {
|
||||
meta = new { users, userindex, servers, channels },
|
||||
data = GenerateMessageList(includedMessages, userIndices)
|
||||
}, opts);
|
||||
|
||||
perf.Step("Serialize to JSON");
|
||||
perf.End();
|
||||
return json;
|
||||
}
|
||||
|
||||
private static dynamic GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, int> userIndices) {
|
||||
var users = new Dictionary<string, dynamic>();
|
||||
private static object GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) {
|
||||
var users = new Dictionary<string, object>();
|
||||
userindex = new List<string>();
|
||||
userIndices = new Dictionary<ulong, int>();
|
||||
userIndices = new Dictionary<ulong, object>();
|
||||
|
||||
foreach (var user in db.GetAllUsers()) {
|
||||
var id = user.Id;
|
||||
@@ -51,15 +61,16 @@ namespace DHT.Server.Database.Export {
|
||||
continue;
|
||||
}
|
||||
|
||||
dynamic obj = new ExpandoObject();
|
||||
obj.name = user.Name;
|
||||
var obj = new Dictionary<string, object> {
|
||||
["name"] = user.Name
|
||||
};
|
||||
|
||||
if (user.AvatarUrl != null) {
|
||||
obj.avatar = user.AvatarUrl;
|
||||
obj["avatar"] = user.AvatarUrl;
|
||||
}
|
||||
|
||||
if (user.Discriminator != null) {
|
||||
obj.tag = user.Discriminator;
|
||||
obj["tag"] = user.Discriminator;
|
||||
}
|
||||
|
||||
var idStr = id.ToString();
|
||||
@@ -71,9 +82,9 @@ namespace DHT.Server.Database.Export {
|
||||
return users;
|
||||
}
|
||||
|
||||
private static dynamic GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, int> serverIndices) {
|
||||
var servers = new List<dynamic>();
|
||||
serverIndices = new Dictionary<ulong, int>();
|
||||
private static object GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, object> serverIndices) {
|
||||
var servers = new List<object>();
|
||||
serverIndices = new Dictionary<ulong, object>();
|
||||
|
||||
foreach (var server in db.GetAllServers()) {
|
||||
var id = server.Id;
|
||||
@@ -82,37 +93,38 @@ namespace DHT.Server.Database.Export {
|
||||
}
|
||||
|
||||
serverIndices[id] = servers.Count;
|
||||
servers.Add(new {
|
||||
name = server.Name,
|
||||
type = ServerTypes.ToJsonViewerString(server.Type)
|
||||
servers.Add(new Dictionary<string, object> {
|
||||
["name"] = server.Name,
|
||||
["type"] = ServerTypes.ToJsonViewerString(server.Type)
|
||||
});
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
private static dynamic GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
|
||||
var channels = new Dictionary<string, dynamic>();
|
||||
private static object GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, object> serverIndices) {
|
||||
var channels = new Dictionary<string, object>();
|
||||
|
||||
foreach (var channel in includedChannels) {
|
||||
dynamic obj = new ExpandoObject();
|
||||
obj.server = serverIndices[channel.Server];
|
||||
obj.name = channel.Name;
|
||||
var obj = new Dictionary<string, object> {
|
||||
["server"] = serverIndices[channel.Server],
|
||||
["name"] = channel.Name
|
||||
};
|
||||
|
||||
if (channel.ParentId != null) {
|
||||
obj.parent = channel.ParentId;
|
||||
obj["parent"] = channel.ParentId;
|
||||
}
|
||||
|
||||
if (channel.Position != null) {
|
||||
obj.position = channel.Position;
|
||||
obj["position"] = channel.Position;
|
||||
}
|
||||
|
||||
if (channel.Topic != null) {
|
||||
obj.topic = channel.Topic;
|
||||
obj["topic"] = channel.Topic;
|
||||
}
|
||||
|
||||
if (channel.Nsfw != null) {
|
||||
obj.nsfw = channel.Nsfw;
|
||||
obj["nsfw"] = channel.Nsfw;
|
||||
}
|
||||
|
||||
channels[channel.Id.ToString()] = obj;
|
||||
@@ -121,54 +133,55 @@ namespace DHT.Server.Database.Export {
|
||||
return channels;
|
||||
}
|
||||
|
||||
private static dynamic GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices) {
|
||||
var data = new Dictionary<string, Dictionary<string, dynamic>>();
|
||||
private static object GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, object> userIndices) {
|
||||
var data = new Dictionary<string, Dictionary<string, object>>();
|
||||
|
||||
foreach (var grouping in includedMessages.GroupBy(message => message.Channel)) {
|
||||
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
|
||||
var channel = grouping.Key.ToString();
|
||||
var channelData = new Dictionary<string, dynamic>();
|
||||
var channelData = new Dictionary<string, object>();
|
||||
|
||||
foreach (var message in grouping) {
|
||||
dynamic obj = new ExpandoObject();
|
||||
obj.u = userIndices[message.Sender];
|
||||
obj.t = message.Timestamp;
|
||||
var obj = new Dictionary<string, object> {
|
||||
["u"] = userIndices[message.Sender],
|
||||
["t"] = message.Timestamp
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(message.Text)) {
|
||||
obj.m = message.Text;
|
||||
obj["m"] = message.Text;
|
||||
}
|
||||
|
||||
if (message.EditTimestamp != null) {
|
||||
obj.te = message.EditTimestamp;
|
||||
obj["te"] = message.EditTimestamp;
|
||||
}
|
||||
|
||||
if (message.RepliedToId != null) {
|
||||
obj.r = message.RepliedToId.Value;
|
||||
obj["r"] = message.RepliedToId.Value;
|
||||
}
|
||||
|
||||
if (!message.Attachments.IsEmpty) {
|
||||
obj.a = message.Attachments.Select(attachment => new {
|
||||
obj["a"] = message.Attachments.Select(static attachment => new {
|
||||
url = attachment.Url
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (!message.Embeds.IsEmpty) {
|
||||
obj.e = message.Embeds.Select(embed => embed.Json).ToArray();
|
||||
obj["e"] = message.Embeds.Select(static embed => embed.Json).ToArray();
|
||||
}
|
||||
|
||||
if (!message.Reactions.IsEmpty) {
|
||||
obj.re = message.Reactions.Select(reaction => {
|
||||
dynamic r = new ExpandoObject();
|
||||
obj["re"] = message.Reactions.Select(static reaction => {
|
||||
var r = new Dictionary<string, object>();
|
||||
|
||||
if (reaction.EmojiId != null) {
|
||||
r.id = reaction.EmojiId.Value;
|
||||
r["id"] = reaction.EmojiId.Value;
|
||||
}
|
||||
|
||||
if (reaction.EmojiName != null) {
|
||||
r.n = reaction.EmojiName;
|
||||
r["n"] = reaction.EmojiName;
|
||||
}
|
||||
|
||||
r.a = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
|
||||
r.c = reaction.Count;
|
||||
r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
|
||||
r["c"] = reaction.Count;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DHT.Server.Database.Export {
|
||||
public class ViewerJsonSnowflakeSerializer : JsonConverter<ulong> {
|
||||
sealed class ViewerJsonSnowflakeSerializer : JsonConverter<ulong> {
|
||||
public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
||||
return ulong.Parse(reader.GetString()!);
|
||||
}
|
||||
|
@@ -21,5 +21,7 @@ namespace DHT.Server.Database {
|
||||
int CountMessages(MessageFilter? filter = null);
|
||||
List<Message> GetMessages(MessageFilter? filter = null);
|
||||
void RemoveMessages(MessageFilter filter, MessageFilterRemovalMode mode);
|
||||
|
||||
void Vacuum();
|
||||
}
|
||||
}
|
||||
|
@@ -1,32 +1,29 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Database.Exceptions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using DHT.Server.Database.Sqlite.Utils;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite {
|
||||
internal class Schema {
|
||||
internal const int Version = 2;
|
||||
sealed class Schema {
|
||||
internal const int Version = 3;
|
||||
|
||||
private readonly SqliteConnection conn;
|
||||
private static readonly Log Log = Log.ForType<Schema>();
|
||||
|
||||
public Schema(SqliteConnection conn) {
|
||||
private readonly ISqliteConnection conn;
|
||||
|
||||
public Schema(ISqliteConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
private SqliteCommand Sql(string sql) {
|
||||
var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private void Execute(string sql) {
|
||||
Sql(sql).ExecuteNonQuery();
|
||||
conn.Command(sql).ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public async Task<bool> Setup(Func<Task<bool>> checkCanUpgradeSchemas) {
|
||||
Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
|
||||
|
||||
var dbVersionStr = Sql("SELECT value FROM metadata WHERE key = 'version'").ExecuteScalar();
|
||||
var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
|
||||
if (dbVersionStr == null) {
|
||||
InitializeSchemas();
|
||||
}
|
||||
@@ -74,9 +71,7 @@ namespace DHT.Server.Database.Sqlite {
|
||||
sender_id INTEGER NOT NULL,
|
||||
channel_id INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
edit_timestamp INTEGER,
|
||||
replied_to_id INTEGER)");
|
||||
timestamp INTEGER NOT NULL)");
|
||||
|
||||
Execute(@"CREATE TABLE attachments (
|
||||
message_id INTEGER NOT NULL,
|
||||
@@ -97,6 +92,9 @@ namespace DHT.Server.Database.Sqlite {
|
||||
emoji_flags INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL)");
|
||||
|
||||
CreateMessageEditTimestampTable();
|
||||
CreateMessageRepliedToTable();
|
||||
|
||||
Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)");
|
||||
Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)");
|
||||
Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)");
|
||||
@@ -104,12 +102,50 @@ namespace DHT.Server.Database.Sqlite {
|
||||
Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
|
||||
}
|
||||
|
||||
private void CreateMessageEditTimestampTable() {
|
||||
Execute(@"CREATE TABLE edit_timestamps (
|
||||
message_id INTEGER PRIMARY KEY NOT NULL,
|
||||
edit_timestamp INTEGER NOT NULL)");
|
||||
}
|
||||
|
||||
private void CreateMessageRepliedToTable() {
|
||||
Execute(@"CREATE TABLE replied_to (
|
||||
message_id INTEGER PRIMARY KEY NOT NULL,
|
||||
replied_to_id INTEGER NOT NULL)");
|
||||
}
|
||||
|
||||
private void UpgradeSchemas(int dbVersion) {
|
||||
var perf = Log.Start("from version " + dbVersion);
|
||||
|
||||
Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
|
||||
|
||||
if (dbVersion <= 1) {
|
||||
Execute("ALTER TABLE channels ADD parent_id INTEGER");
|
||||
perf.Step("Upgrade to version 2");
|
||||
}
|
||||
|
||||
if (dbVersion <= 2) {
|
||||
CreateMessageEditTimestampTable();
|
||||
CreateMessageRepliedToTable();
|
||||
|
||||
Execute(@"INSERT INTO edit_timestamps (message_id, edit_timestamp)
|
||||
SELECT message_id, edit_timestamp FROM messages
|
||||
WHERE edit_timestamp IS NOT NULL");
|
||||
|
||||
Execute(@"INSERT INTO replied_to (message_id, replied_to_id)
|
||||
SELECT message_id, replied_to_id FROM messages
|
||||
WHERE replied_to_id IS NOT NULL");
|
||||
|
||||
Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
|
||||
Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
|
||||
|
||||
perf.Step("Upgrade to version 3");
|
||||
|
||||
Execute("VACUUM");
|
||||
perf.Step("Vacuum");
|
||||
}
|
||||
|
||||
perf.End();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,104 +3,134 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Collections;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database.Sqlite.Utils;
|
||||
using DHT.Utils.Collections;
|
||||
using DHT.Utils.Logging;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite {
|
||||
public class SqliteDatabaseFile : IDatabaseFile {
|
||||
public sealed class SqliteDatabaseFile : IDatabaseFile {
|
||||
private const int DefaultPoolSize = 5;
|
||||
|
||||
public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, Func<Task<bool>> checkCanUpgradeSchemas) {
|
||||
string connectionString = new SqliteConnectionStringBuilder {
|
||||
var connectionString = new SqliteConnectionStringBuilder {
|
||||
DataSource = path,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate
|
||||
}.ToString();
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
};
|
||||
|
||||
var conn = new SqliteConnection(connectionString);
|
||||
conn.Open();
|
||||
var pool = new SqliteConnectionPool(connectionString, DefaultPoolSize);
|
||||
|
||||
return await new Schema(conn).Setup(checkCanUpgradeSchemas) ? new SqliteDatabaseFile(path, conn) : null;
|
||||
using (var conn = pool.Take()) {
|
||||
if (!await new Schema(conn).Setup(checkCanUpgradeSchemas)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new SqliteDatabaseFile(path, pool);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public DatabaseStatistics Statistics { get; }
|
||||
|
||||
private readonly SqliteConnection conn;
|
||||
private readonly Log log;
|
||||
private readonly SqliteConnectionPool pool;
|
||||
private readonly SqliteMessageStatisticsThread messageStatisticsThread;
|
||||
|
||||
private SqliteDatabaseFile(string path, SqliteConnectionPool pool) {
|
||||
this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
|
||||
this.pool = pool;
|
||||
this.messageStatisticsThread = new SqliteMessageStatisticsThread(pool, UpdateMessageStatistics);
|
||||
|
||||
private SqliteDatabaseFile(string path, SqliteConnection conn) {
|
||||
this.conn = conn;
|
||||
this.Path = path;
|
||||
this.Statistics = new DatabaseStatistics();
|
||||
UpdateServerStatistics();
|
||||
UpdateChannelStatistics();
|
||||
UpdateUserStatistics();
|
||||
UpdateMessageStatistics();
|
||||
|
||||
using (var conn = pool.Take()) {
|
||||
UpdateServerStatistics(conn);
|
||||
UpdateChannelStatistics(conn);
|
||||
UpdateUserStatistics(conn);
|
||||
}
|
||||
|
||||
messageStatisticsThread.RequestUpdate();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
conn.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
messageStatisticsThread.Dispose();
|
||||
pool.Dispose();
|
||||
}
|
||||
|
||||
public void AddServer(Data.Server server) {
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Upsert("servers", new[] {
|
||||
"id", "name", "type"
|
||||
("id", SqliteType.Integer),
|
||||
("name", SqliteType.Text),
|
||||
("type", SqliteType.Text)
|
||||
});
|
||||
|
||||
var serverParams = cmd.Parameters;
|
||||
serverParams.AddAndSet(":id", server.Id);
|
||||
serverParams.AddAndSet(":name", server.Name);
|
||||
serverParams.AddAndSet(":type", ServerTypes.ToString(server.Type));
|
||||
cmd.Set(":id", server.Id);
|
||||
cmd.Set(":name", server.Name);
|
||||
cmd.Set(":type", ServerTypes.ToString(server.Type));
|
||||
cmd.ExecuteNonQuery();
|
||||
UpdateServerStatistics();
|
||||
UpdateServerStatistics(conn);
|
||||
}
|
||||
|
||||
public List<Data.Server> GetAllServers() {
|
||||
var perf = log.Start();
|
||||
var list = new List<Data.Server>();
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT id, name, type FROM servers");
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
list.Add(new Data.Server {
|
||||
Id = (ulong) reader.GetInt64(0),
|
||||
Id = reader.GetUint64(0),
|
||||
Name = reader.GetString(1),
|
||||
Type = ServerTypes.FromString(reader.GetString(2))
|
||||
});
|
||||
}
|
||||
|
||||
perf.End();
|
||||
return list;
|
||||
}
|
||||
|
||||
public void AddChannel(Channel channel) {
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Upsert("channels", new[] {
|
||||
"id", "server", "name", "parent_id", "position", "topic", "nsfw"
|
||||
("id", SqliteType.Integer),
|
||||
("server", SqliteType.Integer),
|
||||
("name", SqliteType.Text),
|
||||
("parent_id", SqliteType.Integer),
|
||||
("position", SqliteType.Integer),
|
||||
("topic", SqliteType.Text),
|
||||
("nsfw", SqliteType.Integer)
|
||||
});
|
||||
|
||||
var channelParams = cmd.Parameters;
|
||||
channelParams.AddAndSet(":id", channel.Id);
|
||||
channelParams.AddAndSet(":server", channel.Server);
|
||||
channelParams.AddAndSet(":name", channel.Name);
|
||||
channelParams.AddAndSet(":parent_id", channel.ParentId);
|
||||
channelParams.AddAndSet(":position", channel.Position);
|
||||
channelParams.AddAndSet(":topic", channel.Topic);
|
||||
channelParams.AddAndSet(":nsfw", channel.Nsfw);
|
||||
cmd.Set(":id", channel.Id);
|
||||
cmd.Set(":server", channel.Server);
|
||||
cmd.Set(":name", channel.Name);
|
||||
cmd.Set(":parent_id", channel.ParentId);
|
||||
cmd.Set(":position", channel.Position);
|
||||
cmd.Set(":topic", channel.Topic);
|
||||
cmd.Set(":nsfw", channel.Nsfw);
|
||||
cmd.ExecuteNonQuery();
|
||||
UpdateChannelStatistics();
|
||||
UpdateChannelStatistics(conn);
|
||||
}
|
||||
|
||||
public List<Channel> GetAllChannels() {
|
||||
var list = new List<Channel>();
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
list.Add(new Channel {
|
||||
Id = (ulong) reader.GetInt64(0),
|
||||
Server = (ulong) reader.GetInt64(1),
|
||||
Id = reader.GetUint64(0),
|
||||
Server = reader.GetUint64(1),
|
||||
Name = reader.GetString(2),
|
||||
ParentId = reader.IsDBNull(3) ? null : (ulong) reader.GetInt64(3),
|
||||
ParentId = reader.IsDBNull(3) ? null : reader.GetUint64(3),
|
||||
Position = reader.IsDBNull(4) ? null : reader.GetInt32(4),
|
||||
Topic = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
Nsfw = reader.IsDBNull(6) ? null : reader.GetBoolean(6)
|
||||
@@ -111,163 +141,175 @@ namespace DHT.Server.Database.Sqlite {
|
||||
}
|
||||
|
||||
public void AddUsers(User[] users) {
|
||||
using var conn = pool.Take();
|
||||
using var tx = conn.BeginTransaction();
|
||||
using var cmd = conn.Upsert("users", new[] {
|
||||
"id", "name", "avatar_url", "discriminator"
|
||||
("id", SqliteType.Integer),
|
||||
("name", SqliteType.Text),
|
||||
("avatar_url", SqliteType.Text),
|
||||
("discriminator", SqliteType.Text)
|
||||
});
|
||||
|
||||
var userParams = cmd.Parameters;
|
||||
userParams.Add(":id", SqliteType.Integer);
|
||||
userParams.Add(":name", SqliteType.Text);
|
||||
userParams.Add(":avatar_url", SqliteType.Text);
|
||||
userParams.Add(":discriminator", SqliteType.Text);
|
||||
|
||||
foreach (var user in users) {
|
||||
userParams.Set(":id", user.Id);
|
||||
userParams.Set(":name", user.Name);
|
||||
userParams.Set(":avatar_url", user.AvatarUrl);
|
||||
userParams.Set(":discriminator", user.Discriminator);
|
||||
cmd.Set(":id", user.Id);
|
||||
cmd.Set(":name", user.Name);
|
||||
cmd.Set(":avatar_url", user.AvatarUrl);
|
||||
cmd.Set(":discriminator", user.Discriminator);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
UpdateUserStatistics();
|
||||
UpdateUserStatistics(conn);
|
||||
}
|
||||
|
||||
public List<User> GetAllUsers() {
|
||||
var perf = log.Start();
|
||||
var list = new List<User>();
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users");
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
list.Add(new User {
|
||||
Id = (ulong) reader.GetInt64(0),
|
||||
Id = reader.GetUint64(0),
|
||||
Name = reader.GetString(1),
|
||||
AvatarUrl = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Discriminator = reader.IsDBNull(3) ? null : reader.GetString(3)
|
||||
});
|
||||
}
|
||||
|
||||
perf.End();
|
||||
return list;
|
||||
}
|
||||
|
||||
public void AddMessages(Message[] messages) {
|
||||
static SqliteCommand DeleteByMessageId(ISqliteConnection conn, string tableName) {
|
||||
return conn.Delete(tableName, ("message_id", SqliteType.Integer));
|
||||
}
|
||||
|
||||
static void ExecuteDeleteByMessageId(SqliteCommand cmd, object id) {
|
||||
cmd.Set(":message_id", id);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var tx = conn.BeginTransaction();
|
||||
|
||||
using var messageCmd = conn.Upsert("messages", new[] {
|
||||
"message_id", "sender_id", "channel_id", "text", "timestamp", "edit_timestamp", "replied_to_id"
|
||||
("message_id", SqliteType.Integer),
|
||||
("sender_id", SqliteType.Integer),
|
||||
("channel_id", SqliteType.Integer),
|
||||
("text", SqliteType.Text),
|
||||
("timestamp", SqliteType.Integer)
|
||||
});
|
||||
|
||||
using var deleteEditTimestampCmd = DeleteByMessageId(conn, "edit_timestamps");
|
||||
using var deleteRepliedToCmd = DeleteByMessageId(conn, "replied_to");
|
||||
|
||||
using var deleteAttachmentsCmd = DeleteByMessageId(conn, "attachments");
|
||||
using var deleteEmbedsCmd = DeleteByMessageId(conn, "embeds");
|
||||
using var deleteReactionsCmd = DeleteByMessageId(conn, "reactions");
|
||||
|
||||
using var editTimestampCmd = conn.Insert("edit_timestamps", new [] {
|
||||
("message_id", SqliteType.Integer),
|
||||
("edit_timestamp", SqliteType.Integer)
|
||||
});
|
||||
|
||||
using var repliedToCmd = conn.Insert("replied_to", new [] {
|
||||
("message_id", SqliteType.Integer),
|
||||
("replied_to_id", SqliteType.Integer)
|
||||
});
|
||||
|
||||
using var deleteAttachmentsCmd = conn.Command("DELETE FROM attachments WHERE message_id = :message_id");
|
||||
using var attachmentCmd = conn.Insert("attachments", new[] {
|
||||
"message_id", "attachment_id", "name", "type", "url", "size"
|
||||
("message_id", SqliteType.Integer),
|
||||
("attachment_id", SqliteType.Integer),
|
||||
("name", SqliteType.Text),
|
||||
("type", SqliteType.Text),
|
||||
("url", SqliteType.Text),
|
||||
("size", SqliteType.Integer)
|
||||
});
|
||||
|
||||
using var deleteEmbedsCmd = conn.Command("DELETE FROM embeds WHERE message_id = :message_id");
|
||||
using var embedCmd = conn.Insert("embeds", new[] {
|
||||
"message_id", "json"
|
||||
("message_id", SqliteType.Integer),
|
||||
("json", SqliteType.Text)
|
||||
});
|
||||
|
||||
using var deleteReactionsCmd = conn.Command("DELETE FROM reactions WHERE message_id = :message_id");
|
||||
using var reactionCmd = conn.Insert("reactions", new[] {
|
||||
"message_id", "emoji_id", "emoji_name", "emoji_flags", "count"
|
||||
("message_id", SqliteType.Integer),
|
||||
("emoji_id", SqliteType.Integer),
|
||||
("emoji_name", SqliteType.Text),
|
||||
("emoji_flags", SqliteType.Integer),
|
||||
("count", SqliteType.Integer)
|
||||
});
|
||||
|
||||
var messageParams = messageCmd.Parameters;
|
||||
messageParams.Add(":message_id", SqliteType.Integer);
|
||||
messageParams.Add(":sender_id", SqliteType.Integer);
|
||||
messageParams.Add(":channel_id", SqliteType.Integer);
|
||||
messageParams.Add(":text", SqliteType.Text);
|
||||
messageParams.Add(":timestamp", SqliteType.Integer);
|
||||
messageParams.Add(":edit_timestamp", SqliteType.Integer);
|
||||
messageParams.Add(":replied_to_id", SqliteType.Integer);
|
||||
|
||||
var deleteAttachmentsParams = deleteAttachmentsCmd.Parameters;
|
||||
deleteAttachmentsParams.Add(":message_id", SqliteType.Integer);
|
||||
|
||||
var attachmentParams = attachmentCmd.Parameters;
|
||||
attachmentParams.Add(":message_id", SqliteType.Integer);
|
||||
attachmentParams.Add(":attachment_id", SqliteType.Integer);
|
||||
attachmentParams.Add(":name", SqliteType.Text);
|
||||
attachmentParams.Add(":type", SqliteType.Text);
|
||||
attachmentParams.Add(":url", SqliteType.Text);
|
||||
attachmentParams.Add(":size", SqliteType.Integer);
|
||||
|
||||
var deleteEmbedsParams = deleteEmbedsCmd.Parameters;
|
||||
deleteEmbedsParams.Add(":message_id", SqliteType.Integer);
|
||||
|
||||
var embedParams = embedCmd.Parameters;
|
||||
embedParams.Add(":message_id", SqliteType.Integer);
|
||||
embedParams.Add(":json", SqliteType.Text);
|
||||
|
||||
var deleteReactionsParams = deleteReactionsCmd.Parameters;
|
||||
deleteReactionsParams.Add(":message_id", SqliteType.Integer);
|
||||
|
||||
var reactionParams = reactionCmd.Parameters;
|
||||
reactionParams.Add(":message_id", SqliteType.Integer);
|
||||
reactionParams.Add(":emoji_id", SqliteType.Integer);
|
||||
reactionParams.Add(":emoji_name", SqliteType.Text);
|
||||
reactionParams.Add(":emoji_flags", SqliteType.Integer);
|
||||
reactionParams.Add(":count", SqliteType.Integer);
|
||||
|
||||
foreach (var message in messages) {
|
||||
object messageId = message.Id;
|
||||
|
||||
messageParams.Set(":message_id", messageId);
|
||||
messageParams.Set(":sender_id", message.Sender);
|
||||
messageParams.Set(":channel_id", message.Channel);
|
||||
messageParams.Set(":text", message.Text);
|
||||
messageParams.Set(":timestamp", message.Timestamp);
|
||||
messageParams.Set(":edit_timestamp", message.EditTimestamp);
|
||||
messageParams.Set(":replied_to_id", message.RepliedToId);
|
||||
messageCmd.Set(":message_id", messageId);
|
||||
messageCmd.Set(":sender_id", message.Sender);
|
||||
messageCmd.Set(":channel_id", message.Channel);
|
||||
messageCmd.Set(":text", message.Text);
|
||||
messageCmd.Set(":timestamp", message.Timestamp);
|
||||
messageCmd.ExecuteNonQuery();
|
||||
|
||||
deleteAttachmentsParams.Set(":message_id", messageId);
|
||||
deleteAttachmentsCmd.ExecuteNonQuery();
|
||||
ExecuteDeleteByMessageId(deleteEditTimestampCmd, messageId);
|
||||
ExecuteDeleteByMessageId(deleteRepliedToCmd, messageId);
|
||||
|
||||
deleteEmbedsParams.Set(":message_id", messageId);
|
||||
deleteEmbedsCmd.ExecuteNonQuery();
|
||||
ExecuteDeleteByMessageId(deleteAttachmentsCmd, messageId);
|
||||
ExecuteDeleteByMessageId(deleteEmbedsCmd, messageId);
|
||||
ExecuteDeleteByMessageId(deleteReactionsCmd, messageId);
|
||||
|
||||
deleteReactionsParams.Set(":message_id", messageId);
|
||||
deleteReactionsCmd.ExecuteNonQuery();
|
||||
if (message.EditTimestamp is {} timestamp) {
|
||||
editTimestampCmd.Set(":message_id", messageId);
|
||||
editTimestampCmd.Set(":edit_timestamp", timestamp);
|
||||
editTimestampCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (message.RepliedToId is {} repliedToId) {
|
||||
repliedToCmd.Set(":message_id", messageId);
|
||||
repliedToCmd.Set(":replied_to_id", repliedToId);
|
||||
repliedToCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!message.Attachments.IsEmpty) {
|
||||
foreach (var attachment in message.Attachments) {
|
||||
attachmentParams.Set(":message_id", messageId);
|
||||
attachmentParams.Set(":attachment_id", attachment.Id);
|
||||
attachmentParams.Set(":name", attachment.Name);
|
||||
attachmentParams.Set(":type", attachment.Type);
|
||||
attachmentParams.Set(":url", attachment.Url);
|
||||
attachmentParams.Set(":size", attachment.Size);
|
||||
attachmentCmd.Set(":message_id", messageId);
|
||||
attachmentCmd.Set(":attachment_id", attachment.Id);
|
||||
attachmentCmd.Set(":name", attachment.Name);
|
||||
attachmentCmd.Set(":type", attachment.Type);
|
||||
attachmentCmd.Set(":url", attachment.Url);
|
||||
attachmentCmd.Set(":size", attachment.Size);
|
||||
attachmentCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.Embeds.IsEmpty) {
|
||||
foreach (var embed in message.Embeds) {
|
||||
embedParams.Set(":message_id", messageId);
|
||||
embedParams.Set(":json", embed.Json);
|
||||
embedCmd.Set(":message_id", messageId);
|
||||
embedCmd.Set(":json", embed.Json);
|
||||
embedCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.Reactions.IsEmpty) {
|
||||
foreach (var reaction in message.Reactions) {
|
||||
reactionParams.Set(":message_id", messageId);
|
||||
reactionParams.Set(":emoji_id", reaction.EmojiId);
|
||||
reactionParams.Set(":emoji_name", reaction.EmojiName);
|
||||
reactionParams.Set(":emoji_flags", (int) reaction.EmojiFlags);
|
||||
reactionParams.Set(":count", reaction.Count);
|
||||
reactionCmd.Set(":message_id", messageId);
|
||||
reactionCmd.Set(":emoji_id", reaction.EmojiId);
|
||||
reactionCmd.Set(":emoji_name", reaction.EmojiName);
|
||||
reactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
|
||||
reactionCmd.Set(":count", reaction.Count);
|
||||
reactionCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
UpdateMessageStatistics();
|
||||
messageStatisticsThread.RequestUpdate();
|
||||
}
|
||||
|
||||
public int CountMessages(MessageFilter? filter = null) {
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause());
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
@@ -275,32 +317,39 @@ namespace DHT.Server.Database.Sqlite {
|
||||
}
|
||||
|
||||
public List<Message> GetMessages(MessageFilter? filter = null) {
|
||||
var perf = log.Start();
|
||||
var list = new List<Message>();
|
||||
|
||||
var attachments = GetAllAttachments();
|
||||
var embeds = GetAllEmbeds();
|
||||
var reactions = GetAllReactions();
|
||||
|
||||
var list = new List<Message>();
|
||||
|
||||
using var cmd = conn.Command("SELECT message_id, sender_id, channel_id, text, timestamp, edit_timestamp, replied_to_id FROM messages" + filter.GenerateWhereClause());
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command(@"
|
||||
SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, et.edit_timestamp, rt.replied_to_id
|
||||
FROM messages m
|
||||
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
|
||||
LEFT JOIN replied_to rt ON m.message_id = rt.message_id" + filter.GenerateWhereClause("m"));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
ulong id = (ulong) reader.GetInt64(0);
|
||||
ulong id = reader.GetUint64(0);
|
||||
|
||||
list.Add(new Message {
|
||||
Id = id,
|
||||
Sender = (ulong) reader.GetInt64(1),
|
||||
Channel = (ulong) reader.GetInt64(2),
|
||||
Sender = reader.GetUint64(1),
|
||||
Channel = reader.GetUint64(2),
|
||||
Text = reader.GetString(3),
|
||||
Timestamp = reader.GetInt64(4),
|
||||
EditTimestamp = reader.IsDBNull(5) ? null : reader.GetInt64(5),
|
||||
RepliedToId = reader.IsDBNull(6) ? null : (ulong) reader.GetInt64(6),
|
||||
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
|
||||
Attachments = attachments.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Attachment>.Empty,
|
||||
Embeds = embeds.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Embed>.Empty,
|
||||
Reactions = reactions.GetListOrNull(id)?.ToImmutableArray() ?? ImmutableArray<Reaction>.Empty
|
||||
});
|
||||
}
|
||||
|
||||
perf.End();
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -310,33 +359,38 @@ namespace DHT.Server.Database.Sqlite {
|
||||
return;
|
||||
}
|
||||
|
||||
var perf = log.Start();
|
||||
|
||||
// Rider is being stupid...
|
||||
StringBuilder build = new StringBuilder()
|
||||
.Append("DELETE ")
|
||||
.Append("FROM messages")
|
||||
.Append(whereClause);
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command(build.ToString());
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
UpdateMessageStatistics();
|
||||
UpdateMessageStatistics(conn);
|
||||
perf.End();
|
||||
}
|
||||
|
||||
private MultiDictionary<ulong, Attachment> GetAllAttachments() {
|
||||
var dict = new MultiDictionary<ulong, Attachment>();
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT message_id, attachment_id, name, type, url, size FROM attachments");
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
ulong messageId = (ulong) reader.GetInt64(0);
|
||||
ulong messageId = reader.GetUint64(0);
|
||||
|
||||
dict.Add(messageId, new Attachment {
|
||||
Id = (ulong) reader.GetInt64(1),
|
||||
Id = reader.GetUint64(1),
|
||||
Name = reader.GetString(2),
|
||||
Type = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Url = reader.GetString(4),
|
||||
Size = (ulong) reader.GetInt64(5)
|
||||
Size = reader.GetUint64(5)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -346,11 +400,12 @@ namespace DHT.Server.Database.Sqlite {
|
||||
private MultiDictionary<ulong, Embed> GetAllEmbeds() {
|
||||
var dict = new MultiDictionary<ulong, Embed>();
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT message_id, json FROM embeds");
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
ulong messageId = (ulong) reader.GetInt64(0);
|
||||
ulong messageId = reader.GetUint64(0);
|
||||
|
||||
dict.Add(messageId, new Embed {
|
||||
Json = reader.GetString(1)
|
||||
@@ -363,14 +418,15 @@ namespace DHT.Server.Database.Sqlite {
|
||||
private MultiDictionary<ulong, Reaction> GetAllReactions() {
|
||||
var dict = new MultiDictionary<ulong, Reaction>();
|
||||
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("SELECT message_id, emoji_id, emoji_name, emoji_flags, count FROM reactions");
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read()) {
|
||||
ulong messageId = (ulong) reader.GetInt64(0);
|
||||
ulong messageId = reader.GetUint64(0);
|
||||
|
||||
dict.Add(messageId, new Reaction {
|
||||
EmojiId = reader.IsDBNull(1) ? null : (ulong) reader.GetInt64(1),
|
||||
EmojiId = reader.IsDBNull(1) ? null : reader.GetUint64(1),
|
||||
EmojiName = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
EmojiFlags = (EmojiFlags) reader.GetInt16(3),
|
||||
Count = reader.GetInt32(4)
|
||||
@@ -380,24 +436,26 @@ namespace DHT.Server.Database.Sqlite {
|
||||
return dict;
|
||||
}
|
||||
|
||||
private void UpdateServerStatistics() {
|
||||
using var cmd = conn.Command("SELECT COUNT(*) FROM servers");
|
||||
Statistics.TotalServers = cmd.ExecuteScalar() as long? ?? 0;
|
||||
public void Vacuum() {
|
||||
using var conn = pool.Take();
|
||||
using var cmd = conn.Command("VACUUM");
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private void UpdateChannelStatistics() {
|
||||
using var cmd = conn.Command("SELECT COUNT(*) FROM channels");
|
||||
Statistics.TotalChannels = cmd.ExecuteScalar() as long? ?? 0;
|
||||
private void UpdateServerStatistics(ISqliteConnection conn) {
|
||||
Statistics.TotalServers = conn.SelectScalar("SELECT COUNT(*) FROM servers") as long? ?? 0;
|
||||
}
|
||||
|
||||
private void UpdateUserStatistics() {
|
||||
using var cmd = conn.Command("SELECT COUNT(*) FROM users");
|
||||
Statistics.TotalUsers = cmd.ExecuteScalar() as long? ?? 0;
|
||||
private void UpdateChannelStatistics(ISqliteConnection conn) {
|
||||
Statistics.TotalChannels = conn.SelectScalar("SELECT COUNT(*) FROM channels") as long? ?? 0;
|
||||
}
|
||||
|
||||
private void UpdateMessageStatistics() {
|
||||
using var cmd = conn.Command("SELECT COUNT(*) FROM messages");
|
||||
Statistics.TotalMessages = cmd.ExecuteScalar() as long? ?? 0L;
|
||||
private void UpdateUserStatistics(ISqliteConnection conn) {
|
||||
Statistics.TotalUsers = conn.SelectScalar("SELECT COUNT(*) FROM users") as long? ?? 0;
|
||||
}
|
||||
|
||||
private void UpdateMessageStatistics(ISqliteConnection conn) {
|
||||
Statistics.TotalMessages = conn.SelectScalar("SELECT COUNT(*) FROM messages") as long? ?? 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,32 +3,36 @@ using System.Collections.Generic;
|
||||
using DHT.Server.Data.Filters;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite {
|
||||
public static class SqliteMessageFilter {
|
||||
public static string GenerateWhereClause(this MessageFilter? filter, bool invert = false) {
|
||||
static class SqliteMessageFilter {
|
||||
public static string GenerateWhereClause(this MessageFilter? filter, string? tableAlias = null, bool invert = false) {
|
||||
if (filter == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (tableAlias != null) {
|
||||
tableAlias += ".";
|
||||
}
|
||||
|
||||
List<string> conditions = new();
|
||||
|
||||
if (filter.StartDate != null) {
|
||||
conditions.Add("timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds());
|
||||
conditions.Add(tableAlias + "timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
if (filter.EndDate != null) {
|
||||
conditions.Add("timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds());
|
||||
conditions.Add(tableAlias + "timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
if (filter.ChannelIds != null) {
|
||||
conditions.Add("channel_id IN (" + string.Join(",", filter.ChannelIds) + ")");
|
||||
conditions.Add(tableAlias + "channel_id IN (" + string.Join(",", filter.ChannelIds) + ")");
|
||||
}
|
||||
|
||||
if (filter.UserIds != null) {
|
||||
conditions.Add("sender_id IN (" + string.Join(",", filter.UserIds) + ")");
|
||||
conditions.Add(tableAlias + "sender_id IN (" + string.Join(",", filter.UserIds) + ")");
|
||||
}
|
||||
|
||||
if (filter.MessageIds != null) {
|
||||
conditions.Add("message_id IN (" + string.Join(",", filter.MessageIds) + ")");
|
||||
conditions.Add(tableAlias + "message_id IN (" + string.Join(",", filter.MessageIds) + ")");
|
||||
}
|
||||
|
||||
if (conditions.Count == 0) {
|
||||
|
54
app/Server/Database/Sqlite/SqliteMessageStatisticsThread.cs
Normal file
54
app/Server/Database/Sqlite/SqliteMessageStatisticsThread.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using DHT.Server.Database.Sqlite.Utils;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite {
|
||||
sealed class SqliteMessageStatisticsThread : IDisposable {
|
||||
private readonly SqliteConnectionPool pool;
|
||||
private readonly Action<ISqliteConnection> action;
|
||||
|
||||
private readonly CancellationTokenSource cancellationTokenSource = new();
|
||||
private readonly CancellationToken cancellationToken;
|
||||
|
||||
private readonly AutoResetEvent requestEvent = new (false);
|
||||
|
||||
public SqliteMessageStatisticsThread(SqliteConnectionPool pool, Action<ISqliteConnection> action) {
|
||||
this.pool = pool;
|
||||
this.action = action;
|
||||
|
||||
this.cancellationToken = cancellationTokenSource.Token;
|
||||
|
||||
var thread = new Thread(RunThread) {
|
||||
Name = "DHT message statistics thread",
|
||||
IsBackground = true
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
try {
|
||||
cancellationTokenSource.Cancel();
|
||||
} catch (ObjectDisposedException) {}
|
||||
}
|
||||
|
||||
public void RequestUpdate() {
|
||||
try {
|
||||
requestEvent.Set();
|
||||
} catch (ObjectDisposedException) {}
|
||||
}
|
||||
|
||||
private void RunThread() {
|
||||
try {
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
if (requestEvent.WaitOne(TimeSpan.FromMilliseconds(100))) {
|
||||
using var conn = pool.Take();
|
||||
action(conn);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancellationTokenSource.Dispose();
|
||||
requestEvent.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,40 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite {
|
||||
public static class SqliteUtils {
|
||||
public static SqliteCommand Command(this SqliteConnection conn, string sql) {
|
||||
var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
public static SqliteCommand Insert(this SqliteConnection conn, string tableName, string[] columns) {
|
||||
string columnNames = string.Join(',', columns);
|
||||
string columnParams = string.Join(',', columns.Select(c => ':' + c));
|
||||
|
||||
return conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" +
|
||||
"VALUES (" + columnParams + ")");
|
||||
}
|
||||
|
||||
public static SqliteCommand Upsert(this SqliteConnection conn, string tableName, string[] columns) {
|
||||
string columnNames = string.Join(',', columns);
|
||||
string columnParams = string.Join(',', columns.Select(c => ':' + c));
|
||||
string columnUpdates = string.Join(',', columns.Skip(1).Select(c => c + " = excluded." + c));
|
||||
|
||||
return conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" +
|
||||
"VALUES (" + columnParams + ")" +
|
||||
"ON CONFLICT (" + columns[0] + ")" +
|
||||
"DO UPDATE SET " + columnUpdates);
|
||||
}
|
||||
|
||||
public static void AddAndSet(this SqliteParameterCollection parameters, string key, object? value) {
|
||||
parameters.AddWithValue(key, value ?? DBNull.Value);
|
||||
}
|
||||
|
||||
public static void Set(this SqliteParameterCollection parameters, string key, object? value) {
|
||||
parameters[key].Value = value ?? DBNull.Value;
|
||||
}
|
||||
}
|
||||
}
|
8
app/Server/Database/Sqlite/Utils/ISqliteConnection.cs
Normal file
8
app/Server/Database/Sqlite/Utils/ISqliteConnection.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite.Utils {
|
||||
interface ISqliteConnection : IDisposable {
|
||||
SqliteConnection InnerConnection { get; }
|
||||
}
|
||||
}
|
116
app/Server/Database/Sqlite/Utils/SqliteConnectionPool.cs
Normal file
116
app/Server/Database/Sqlite/Utils/SqliteConnectionPool.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using DHT.Utils.Logging;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite.Utils {
|
||||
sealed class SqliteConnectionPool : IDisposable {
|
||||
private static string GetConnectionString(SqliteConnectionStringBuilder connectionStringBuilder) {
|
||||
connectionStringBuilder.Pooling = false;
|
||||
return connectionStringBuilder.ToString();
|
||||
}
|
||||
|
||||
private readonly object monitor = new ();
|
||||
private volatile bool isDisposed;
|
||||
|
||||
private readonly BlockingCollection<PooledConnection> free = new (new ConcurrentStack<PooledConnection>());
|
||||
private readonly List<PooledConnection> used;
|
||||
|
||||
public SqliteConnectionPool(SqliteConnectionStringBuilder connectionStringBuilder, int poolSize) {
|
||||
var connectionString = GetConnectionString(connectionStringBuilder);
|
||||
|
||||
for (int i = 0; i < poolSize; i++) {
|
||||
var conn = new SqliteConnection(connectionString);
|
||||
conn.Open();
|
||||
|
||||
var pooledConn = new PooledConnection(this, conn);
|
||||
|
||||
using (var cmd = pooledConn.Command("PRAGMA journal_mode=WAL")) {
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
free.Add(pooledConn);
|
||||
}
|
||||
|
||||
used = new List<PooledConnection>(poolSize);
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() {
|
||||
if (isDisposed) {
|
||||
throw new ObjectDisposedException(nameof(SqliteConnectionPool));
|
||||
}
|
||||
}
|
||||
|
||||
public ISqliteConnection Take() {
|
||||
PooledConnection? conn = null;
|
||||
|
||||
while (conn == null) {
|
||||
ThrowIfDisposed();
|
||||
lock (monitor) {
|
||||
if (free.TryTake(out conn, TimeSpan.FromMilliseconds(100))) {
|
||||
used.Add(conn);
|
||||
break;
|
||||
}
|
||||
else {
|
||||
Log.ForType<SqliteConnectionPool>().Warn("Thread " + Thread.CurrentThread.ManagedThreadId + " is starving for connections.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
private void Return(PooledConnection conn) {
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (monitor) {
|
||||
if (used.Remove(conn)) {
|
||||
free.Add(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
if (isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDisposed = true;
|
||||
|
||||
lock (monitor) {
|
||||
while (free.TryTake(out var conn)) {
|
||||
Close(conn.InnerConnection);
|
||||
}
|
||||
|
||||
foreach (var conn in used) {
|
||||
Close(conn.InnerConnection);
|
||||
}
|
||||
|
||||
free.Dispose();
|
||||
used.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Close(SqliteConnection conn) {
|
||||
conn.Close();
|
||||
conn.Dispose();
|
||||
}
|
||||
|
||||
private sealed class PooledConnection : ISqliteConnection {
|
||||
public SqliteConnection InnerConnection { get; }
|
||||
|
||||
private readonly SqliteConnectionPool pool;
|
||||
|
||||
public PooledConnection(SqliteConnectionPool pool, SqliteConnection conn) {
|
||||
this.pool = pool;
|
||||
this.InnerConnection = conn;
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() {
|
||||
pool.Return(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
67
app/Server/Database/Sqlite/Utils/SqliteExtensions.cs
Normal file
67
app/Server/Database/Sqlite/Utils/SqliteExtensions.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace DHT.Server.Database.Sqlite.Utils {
|
||||
static class SqliteExtensions {
|
||||
public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
|
||||
var cmd = conn.InnerConnection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
|
||||
return conn.InnerConnection.BeginTransaction();
|
||||
}
|
||||
|
||||
public static object? SelectScalar(this ISqliteConnection conn, string sql) {
|
||||
using var cmd = conn.Command(sql);
|
||||
return cmd.ExecuteScalar();
|
||||
}
|
||||
|
||||
public static SqliteCommand Insert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) {
|
||||
string columnNames = string.Join(',', columns.Select(static c => c.Name));
|
||||
string columnParams = string.Join(',', columns.Select(static c => ':' + c.Name));
|
||||
|
||||
var cmd = conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" +
|
||||
"VALUES (" + columnParams + ")");
|
||||
|
||||
CreateParameters(cmd, columns);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
public static SqliteCommand Upsert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) {
|
||||
string columnNames = string.Join(',', columns.Select(static c => c.Name));
|
||||
string columnParams = string.Join(',', columns.Select(static c => ':' + c.Name));
|
||||
string columnUpdates = string.Join(',', columns.Skip(1).Select(static c => c.Name + " = excluded." + c.Name));
|
||||
|
||||
var cmd = conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" +
|
||||
"VALUES (" + columnParams + ")" +
|
||||
"ON CONFLICT (" + columns[0].Name + ")" +
|
||||
"DO UPDATE SET " + columnUpdates);
|
||||
|
||||
CreateParameters(cmd, columns);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
public static SqliteCommand Delete(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type) column) {
|
||||
var cmd = conn.Command("DELETE FROM " + tableName + " WHERE " + column.Name + " = :" + column.Name);
|
||||
CreateParameters(cmd, new [] { column });
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static void CreateParameters(SqliteCommand cmd, (string Name, SqliteType Type)[] columns) {
|
||||
foreach (var (name, type) in columns) {
|
||||
cmd.Parameters.Add(":" + name, type);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Set(this SqliteCommand cmd, string key, object? value) {
|
||||
cmd.Parameters[key].Value = value ?? DBNull.Value;
|
||||
}
|
||||
|
||||
public static ulong GetUint64(this SqliteDataReader reader, int ordinal) {
|
||||
return (ulong) reader.GetInt64(ordinal);
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,13 +3,16 @@ using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Logging;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Http;
|
||||
using DHT.Utils.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
|
||||
namespace DHT.Server.Endpoints {
|
||||
public abstract class BaseEndpoint {
|
||||
abstract class BaseEndpoint {
|
||||
private static readonly Log Log = Log.ForType<BaseEndpoint>();
|
||||
|
||||
protected IDatabaseFile Db { get; }
|
||||
private readonly ServerParameters parameters;
|
||||
|
||||
|
@@ -3,12 +3,12 @@ using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Json;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DHT.Server.Endpoints {
|
||||
public class TrackChannelEndpoint : BaseEndpoint {
|
||||
sealed class TrackChannelEndpoint : BaseEndpoint {
|
||||
public TrackChannelEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
||||
|
||||
protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
@@ -7,12 +8,13 @@ using System.Threading.Tasks;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Json;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Collections;
|
||||
using DHT.Utils.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DHT.Server.Endpoints {
|
||||
public class TrackMessagesEndpoint : BaseEndpoint {
|
||||
sealed class TrackMessagesEndpoint : BaseEndpoint {
|
||||
public TrackMessagesEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
||||
|
||||
protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) {
|
||||
@@ -53,12 +55,16 @@ namespace DHT.Server.Endpoints {
|
||||
Reactions = json.HasKey("reactions") ? ReadReactions(json.RequireArray("reactions", path + ".reactions"), path + ".reactions[]").ToImmutableArray() : ImmutableArray<Reaction>.Empty
|
||||
};
|
||||
|
||||
[SuppressMessage("ReSharper", "ConvertToLambdaExpression")]
|
||||
private static IEnumerable<Attachment> ReadAttachments(JsonElement.ArrayEnumerator array, string path) => array.Select(ele => new Attachment {
|
||||
Id = ele.RequireSnowflake("id", path),
|
||||
Name = ele.RequireString("name", path),
|
||||
Type = ele.HasKey("type") ? ele.RequireString("type", path) : null,
|
||||
Url = ele.RequireString("url", path),
|
||||
Size = (ulong) ele.RequireLong("size", path)
|
||||
}).DistinctByKeyStable(static attachment => {
|
||||
// Some Discord messages have duplicate attachments with the same id for unknown reasons.
|
||||
return attachment.Id;
|
||||
});
|
||||
|
||||
private static IEnumerable<Embed> ReadEmbeds(JsonElement.ArrayEnumerator array, string path) => array.Select(ele => new Embed {
|
||||
|
@@ -3,12 +3,12 @@ using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Json;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DHT.Server.Endpoints {
|
||||
public class TrackUsersEndpoint : BaseEndpoint {
|
||||
sealed class TrackUsersEndpoint : BaseEndpoint {
|
||||
public TrackUsersEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {}
|
||||
|
||||
protected override async Task<(HttpStatusCode, object?)> Respond(HttpContext ctx) {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user