mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 19:31:42 +02:00
Compare commits
173 Commits
v36.1.beta
...
http-serve
Author | SHA1 | Date | |
---|---|---|---|
3ecda02630
|
|||
295370a3d4
|
|||
312be6609d
|
|||
44b42657ef
|
|||
0d3600492e
|
|||
3b569ad5d6
|
|||
b2276600c7
|
|||
40269f591b
|
|||
dea3b272c0
|
|||
e66206b4a8
|
|||
780d5ae421
|
|||
38f79dee7d
|
|||
e30b305eb5
|
|||
fa17d0e224
|
|||
5741fad528
|
|||
e7c52fd1d5
|
|||
fb9b31175c
|
|||
f8e56ae367
|
|||
7ed31fbc8b
|
|||
e92aeb5a1b
|
|||
50d5bdba97
|
|||
295c32c7b4
|
|||
5e4bb5b44d
|
|||
96c19afa66
|
|||
a45f60b528
|
|||
62b97807b8
|
|||
0fff3e8eaf
|
|||
02b773db8f
|
|||
261a583b50
|
|||
2ecb658e42
|
|||
7a6dd23912
|
|||
60761d80ab
|
|||
e11db62015
|
|||
bfac9b91d9
|
|||
4215dc76f4
|
|||
86adda610b
|
|||
f1c4cd040e
|
|||
737ab43ea4
|
|||
0229c00259
|
|||
95361d2e01
|
|||
5b7312109b
|
|||
9f1db9a662
|
|||
d3e1bb901c
|
|||
9bd105f57e
|
|||
e1eae393c3
|
|||
fbed74529d
|
|||
8924aa7c06
|
|||
9738880ba7
|
|||
1700f99bf7
|
|||
84acf5f5d5
|
|||
ae56433836
|
|||
c3d4fa5532
|
|||
a6225b9721
|
|||
943163473a
|
|||
fa00df10d8
|
|||
f54465e5fe
|
|||
2d55ca4013
|
|||
0072e025f4
|
|||
383ee5c90e
|
|||
2b1c178f49
|
|||
16d7e5a6f8
|
|||
052d24d1e7
|
|||
b058f1fb77
|
|||
d4da64a5ed
|
|||
8de309a6c4
|
|||
de8d6a1e11
|
|||
d79e6f53b4
|
|||
70c04fc986
|
|||
c8d8d95daa
|
|||
daafdbbfaf
|
|||
07615de87a
|
|||
7fdc19880e
|
|||
67b9c12843
|
|||
9030a2f010
|
|||
a6dad6b4c7
|
|||
72b8fb7c14
|
|||
7173dc6cfc
|
|||
2c1e5a7603
|
|||
4929a19397
|
|||
c5f77872fe
|
|||
c9e50e1a80
|
|||
ef3e34066a
|
|||
37374eeb18
|
|||
23ddb45a0d
|
|||
9904a711f7
|
|||
d5720c8758
|
|||
89161e14b1
|
|||
9d208b026c
|
|||
119649ef9b
|
|||
0bc6232da7
|
|||
de266473c5
|
|||
e0f359c15b
|
|||
935f11d736
|
|||
f64141e768
|
|||
edea3470df
|
|||
031d521402
|
|||
0131f8cb50
|
|||
3bf5acfa65
|
|||
f603c861c5
|
|||
d2934f4d6a
|
|||
567253d147
|
|||
aa6555990c
|
|||
3d9d6a454a
|
|||
ee39780928
|
|||
7b58f973a0
|
|||
93fe018343
|
|||
4f5e27f651
|
|||
cbf81ec95a
|
|||
8a80cb8c20
|
|||
865deb356a
|
|||
069ab97196
|
|||
caab038eaa
|
|||
fb837374fc
|
|||
65d935cca1
|
|||
6e64c86d7a
|
|||
8aeb590bb3
|
|||
8dc1adc9f0
|
|||
ddf70b02e7
|
|||
ef59fd992e
|
|||
d044627fac
|
|||
a624745602 | |||
6da3c185e5 | |||
d4d14cab97
|
|||
095c9a061a
|
|||
d01f9ed218
|
|||
dd6f121059
|
|||
8bba33d815
|
|||
9eab8ac92a
|
|||
fe588686fc
|
|||
7392987165
|
|||
492dddb35d
|
|||
d35280a6a6
|
|||
ae8a34f938
|
|||
18f5823f2a
|
|||
37640c97b0
|
|||
3cc5c75c48
|
|||
2424a8ac8d
|
|||
e7705587c2
|
|||
bd7904b88e
|
|||
3db599e901
|
|||
69a68b463c
|
|||
b2389dd521
|
|||
610516de1f
|
|||
3a6b83e0ba
|
|||
22958536e7
|
|||
4db8c302d8
|
|||
7e7d140957
|
|||
8c68438fbb
|
|||
f625a39b4d
|
|||
7fd644449c
|
|||
e4a09515b0
|
|||
9ac9f2246f
|
|||
bbc734ba9b
|
|||
6837b05b0d
|
|||
c94808a15f
|
|||
739e87c5ab
|
|||
d463b407f4
|
|||
cd418f4871
|
|||
176a81e055 | |||
![]() |
1cf3e76644 | ||
33f5ab7cce
|
|||
b9a5664740
|
|||
845ac1b0fa
|
|||
1bead42a0e
|
|||
8f1c91b2cc
|
|||
9ae5ece24b
|
|||
053ab5b091
|
|||
71c628fdf8
|
|||
af621b8d46
|
|||
31fe6aed35
|
|||
c25426af55
|
|||
59129ba20a
|
|||
f7bfe052ca
|
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,3 +1 @@
|
|||||||
github: chylex
|
|
||||||
patreon: chylex
|
|
||||||
ko_fi: chylex
|
ko_fi: chylex
|
||||||
|
28
README.md
28
README.md
@@ -16,10 +16,9 @@ Fork the repository and clone it to your computer (if you've never used git, you
|
|||||||
|
|
||||||
Folder organization:
|
Folder organization:
|
||||||
* `app/` contains a Visual Studio solution for the desktop app
|
* `app/` contains a Visual Studio solution for the desktop app
|
||||||
* `lib/` contains utilities required to build the project
|
|
||||||
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
||||||
|
|
||||||
To start editing source code for the desktop app, install the [.NET 5 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/5.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
To start editing source code for the desktop app, install the [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
@@ -29,27 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
|
|||||||
|
|
||||||
#### Release – Windows (64-bit)
|
#### Release – Windows (64-bit)
|
||||||
|
|
||||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH`
|
1. Install Debian in WSL and open a terminal in the project folder.
|
||||||
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)
|
2. Run the `app/build.wsl.sh` script.
|
||||||
|
3. Read the [Distribution](#distribution) section below.
|
||||||
|
|
||||||
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.
|
Note: The build script expects `dotnet.exe` to be installed in `C:\Program Files\dotnet`.
|
||||||
|
|
||||||
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
|
||||||
|
|
||||||
#### Release – Other Operating Systems
|
#### Release – Other Operating Systems
|
||||||
|
|
||||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3
|
1. Install the `zip` package from your repository.
|
||||||
- On Debian and derivatives, you can install `python-is-python3`
|
2. Run the `app/build.sh` script.
|
||||||
- On other distributions, you can create a link manually, for ex. `ln -s /usr/bin/python3 /usr/bin/python`
|
3. Read the [Distribution](#distribution) section below.
|
||||||
- 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
|
#### Distribution
|
||||||
|
|
||||||
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 5 to be installed.
|
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires the ASP.NET Core Runtime 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.
|
|
||||||
|
2
app/.editorconfig
Normal file
2
app/.editorconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[*.cs]
|
||||||
|
propertychanged.auto_notify = false
|
@@ -9,15 +9,16 @@
|
|||||||
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/AttachmentFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Controls/DownloadItemFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.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/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
|
||||||
|
<entry key="Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Pages/AttachmentsPage.axaml" value="Desktop/Desktop.csproj" />
|
|
||||||
<entry key="Desktop/Main/Pages/DatabasePage.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/DebugPage.axaml" value="Desktop/Desktop.csproj" />
|
||||||
|
<entry key="Desktop/Main/Pages/DownloadsPage.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
|
||||||
|
@@ -13,10 +13,10 @@
|
|||||||
<CssCodeStyleSettings>
|
<CssCodeStyleSettings>
|
||||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||||
</CssCodeStyleSettings>
|
</CssCodeStyleSettings>
|
||||||
<DB2CodeStyleSettings version="6">
|
<DB2CodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</DB2CodeStyleSettings>
|
</DB2CodeStyleSettings>
|
||||||
<DerbyCodeStyleSettings version="6">
|
<DerbyCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</DerbyCodeStyleSettings>
|
</DerbyCodeStyleSettings>
|
||||||
<GoCodeStyleSettings>
|
<GoCodeStyleSettings>
|
||||||
@@ -26,13 +26,10 @@
|
|||||||
<option name="WRAP_FUNC_PARAMS" value="5" />
|
<option name="WRAP_FUNC_PARAMS" value="5" />
|
||||||
<option name="WRAP_FUNC_RESULT" value="5" />
|
<option name="WRAP_FUNC_RESULT" value="5" />
|
||||||
</GoCodeStyleSettings>
|
</GoCodeStyleSettings>
|
||||||
<H2CodeStyleSettings version="6">
|
<H2CodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</H2CodeStyleSettings>
|
</H2CodeStyleSettings>
|
||||||
<H2CodeStyleSettings version="6">
|
<HSQLCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
|
||||||
</H2CodeStyleSettings>
|
|
||||||
<HSQLCodeStyleSettings version="6">
|
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</HSQLCodeStyleSettings>
|
</HSQLCodeStyleSettings>
|
||||||
<HTMLCodeStyleSettings>
|
<HTMLCodeStyleSettings>
|
||||||
@@ -88,10 +85,10 @@
|
|||||||
<LessCodeStyleSettings>
|
<LessCodeStyleSettings>
|
||||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||||
</LessCodeStyleSettings>
|
</LessCodeStyleSettings>
|
||||||
<MSSQLCodeStyleSettings version="6">
|
<MSSQLCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</MSSQLCodeStyleSettings>
|
</MSSQLCodeStyleSettings>
|
||||||
<MySQLCodeStyleSettings version="6">
|
<MySQLCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</MySQLCodeStyleSettings>
|
</MySQLCodeStyleSettings>
|
||||||
<Objective-C>
|
<Objective-C>
|
||||||
@@ -101,7 +98,7 @@
|
|||||||
<option name="SPACE_BEFORE_INIT_LIST" value="true" />
|
<option name="SPACE_BEFORE_INIT_LIST" value="true" />
|
||||||
<option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" />
|
<option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" />
|
||||||
</Objective-C>
|
</Objective-C>
|
||||||
<OracleCodeStyleSettings version="6">
|
<OracleCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</OracleCodeStyleSettings>
|
</OracleCodeStyleSettings>
|
||||||
<PHPCodeStyleSettings>
|
<PHPCodeStyleSettings>
|
||||||
@@ -119,7 +116,7 @@
|
|||||||
<option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" />
|
<option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" />
|
||||||
<option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" />
|
<option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" />
|
||||||
</PHPCodeStyleSettings>
|
</PHPCodeStyleSettings>
|
||||||
<PostgresCodeStyleSettings version="6">
|
<PostgresCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</PostgresCodeStyleSettings>
|
</PostgresCodeStyleSettings>
|
||||||
<Properties>
|
<Properties>
|
||||||
@@ -146,13 +143,16 @@
|
|||||||
<option name="INDENT_WHEN_CASES" value="true" />
|
<option name="INDENT_WHEN_CASES" value="true" />
|
||||||
<option name="CHAIN_CALLS_ALIGNMENT" value="2" />
|
<option name="CHAIN_CALLS_ALIGNMENT" value="2" />
|
||||||
</Ruby>
|
</Ruby>
|
||||||
<SQLiteCodeStyleSettings version="6">
|
<SQLiteCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</SQLiteCodeStyleSettings>
|
</SQLiteCodeStyleSettings>
|
||||||
<ScssCodeStyleSettings>
|
<ScssCodeStyleSettings>
|
||||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||||
</ScssCodeStyleSettings>
|
</ScssCodeStyleSettings>
|
||||||
<SqlCodeStyleSettings version="6">
|
<Shell_Script>
|
||||||
|
<option name="KEEP_COLUMN_ALIGNMENT_PADDING" value="true" />
|
||||||
|
</Shell_Script>
|
||||||
|
<SqlCodeStyleSettings version="7">
|
||||||
<option name="KEYWORD_CASE" value="2" />
|
<option name="KEYWORD_CASE" value="2" />
|
||||||
<option name="TYPE_CASE" value="2" />
|
<option name="TYPE_CASE" value="2" />
|
||||||
<option name="CUSTOM_TYPE_CASE" value="2" />
|
<option name="CUSTOM_TYPE_CASE" value="2" />
|
||||||
@@ -176,9 +176,10 @@
|
|||||||
<option name="CONSTRAINT_WRAP_3" value="true" />
|
<option name="CONSTRAINT_WRAP_3" value="true" />
|
||||||
<option name="CONSTRAINT_WRAP_4" value="true" />
|
<option name="CONSTRAINT_WRAP_4" value="true" />
|
||||||
<option name="VIEW_INDENT_QUERY" value="true" />
|
<option name="VIEW_INDENT_QUERY" value="true" />
|
||||||
|
<option name="IMP_LOOP_LOOP_INDENT" value="false" />
|
||||||
<option name="EXPR_CASE_WHEN_WRAP" value="false" />
|
<option name="EXPR_CASE_WHEN_WRAP" value="false" />
|
||||||
</SqlCodeStyleSettings>
|
</SqlCodeStyleSettings>
|
||||||
<SybaseCodeStyleSettings version="6">
|
<SybaseCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</SybaseCodeStyleSettings>
|
</SybaseCodeStyleSettings>
|
||||||
<TypeScriptCodeStyleSettings version="0">
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
@@ -206,6 +207,11 @@
|
|||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Gherkin">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="Groovy">
|
<codeStyleSettings language="Groovy">
|
||||||
<option name="ELSE_ON_NEW_LINE" value="true" />
|
<option name="ELSE_ON_NEW_LINE" value="true" />
|
||||||
<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true" />
|
<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true" />
|
||||||
@@ -285,6 +291,13 @@
|
|||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Markdown">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="ObjectiveC">
|
<codeStyleSettings language="ObjectiveC">
|
||||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
@@ -345,7 +358,6 @@
|
|||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="Rust">
|
<codeStyleSettings language="Rust">
|
||||||
<option name="RIGHT_MARGIN" value="140" />
|
|
||||||
<option name="ALIGN_MULTILINE_CHAINED_METHODS" value="true" />
|
<option name="ALIGN_MULTILINE_CHAINED_METHODS" value="true" />
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
@@ -360,6 +372,7 @@
|
|||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="SCSS">
|
<codeStyleSettings language="SCSS">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
@@ -370,13 +383,6 @@
|
|||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="Shell Script">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="INDENT_SIZE" value="4" />
|
|
||||||
<option name="TAB_SIZE" value="4" />
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="TOML">
|
<codeStyleSettings language="TOML">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
@@ -430,6 +436,9 @@
|
|||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="liquid">
|
<codeStyleSettings language="liquid">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
@@ -445,4 +454,4 @@
|
|||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
@@ -91,7 +91,6 @@
|
|||||||
<inspection_tool class="ComparatorNotSerializable" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ComparatorNotSerializable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="CompareToUsesNonFinalVariable" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="CompareToUsesNonFinalVariable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="ComparisonOfShortAndChar" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ComparisonOfShortAndChar" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="ComposeMissingKeys" enabled="true" level="ERROR" enabled_by_default="true" />
|
|
||||||
<inspection_tool class="ConditionSignal" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ConditionSignal" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="ConditionalExpression" enabled="true" level="INFORMATION" enabled_by_default="true">
|
<inspection_tool class="ConditionalExpression" enabled="true" level="INFORMATION" enabled_by_default="true">
|
||||||
<option name="ignoreSimpleAssignmentsAndReturns" value="true" />
|
<option name="ignoreSimpleAssignmentsAndReturns" value="true" />
|
||||||
@@ -362,7 +361,7 @@
|
|||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="LocalVariableDeclarationSideOnly" enabled="true" level="ERROR" enabled_by_default="true" />
|
<inspection_tool class="LocalVariableDeclarationSideOnly" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||||
<inspection_tool class="LocalVariableNamingConventionJS" enabled="true" level="WARNING" enabled_by_default="true">
|
<inspection_tool class="LocalVariableNamingConventionJS" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
<option name="m_regex" value="[a-z][A-Za-z]*" />
|
<option name="m_regex" value="[a-z][A-Za-z0-9]*" />
|
||||||
<option name="m_minLength" value="0" />
|
<option name="m_minLength" value="0" />
|
||||||
<option name="m_maxLength" value="99" />
|
<option name="m_maxLength" value="99" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
|
@@ -1,22 +0,0 @@
|
|||||||
<component name="libraryTable">
|
|
||||||
<library name="Generated files" type="javaScript">
|
|
||||||
<properties>
|
|
||||||
<sourceFilesUrls>
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/discord.js" />
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/dom.js" />
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/gui.js" />
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/settings.js" />
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/state.js" />
|
|
||||||
</sourceFilesUrls>
|
|
||||||
</properties>
|
|
||||||
<CLASSES>
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/discord.js" />
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/dom.js" />
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/gui.js" />
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/settings.js" />
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/state.js" />
|
|
||||||
</CLASSES>
|
|
||||||
<JAVADOC />
|
|
||||||
<SOURCES />
|
|
||||||
</library>
|
|
||||||
</component>
|
|
@@ -1,8 +1,8 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project">
|
<configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project">
|
||||||
<option name="EXE_PATH" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0/DiscordHistoryTracker.exe" />
|
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Desktop/debug/DiscordHistoryTracker.exe" />
|
||||||
<option name="PROGRAM_PARAMETERS" value="" />
|
<option name="PROGRAM_PARAMETERS" value="" />
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0" />
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.artifacts/bin/Desktop/debug" />
|
||||||
<option name="PASS_PARENT_ENVS" value="1" />
|
<option name="PASS_PARENT_ENVS" value="1" />
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
<option name="USE_MONO" value="0" />
|
<option name="USE_MONO" value="0" />
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
|
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
|
||||||
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
|
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
|
||||||
<option name="PROJECT_KIND" value="DotNetCore" />
|
<option name="PROJECT_KIND" value="DotNetCore" />
|
||||||
<option name="PROJECT_TFM" value="net5.0" />
|
<option name="PROJECT_TFM" value="net8.0" />
|
||||||
<method v="2">
|
<method v="2">
|
||||||
<option name="Build" />
|
<option name="Build" />
|
||||||
</method>
|
</method>
|
||||||
|
@@ -1,23 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="Minify" type="PythonConfigurationType" factoryName="Python">
|
|
||||||
<module name="rider.module" />
|
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
|
||||||
<option name="PARENT_ENVS" value="true" />
|
|
||||||
<envs>
|
|
||||||
<env name="PYTHONUNBUFFERED" value="1" />
|
|
||||||
</envs>
|
|
||||||
<option name="SDK_HOME" value="" />
|
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
||||||
<option name="IS_MODULE_SDK" value="false" />
|
|
||||||
<option name="ADD_CONTENT_ROOTS" value="false" />
|
|
||||||
<option name="ADD_SOURCE_ROOTS" value="false" />
|
|
||||||
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/minify.py" />
|
|
||||||
<option name="PARAMETERS" value="" />
|
|
||||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
|
||||||
<option name="EMULATE_TERMINAL" value="false" />
|
|
||||||
<option name="MODULE_MODE" value="false" />
|
|
||||||
<option name="REDIRECT_INPUT" value="false" />
|
|
||||||
<option name="INPUT_FILE" value="" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
@@ -2,12 +2,13 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:common="clr-namespace:DHT.Desktop.Common"
|
xmlns:common="clr-namespace:DHT.Desktop.Common"
|
||||||
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
||||||
x:Class="DHT.Desktop.App">
|
x:Class="DHT.Desktop.App"
|
||||||
|
RequestedThemeVariant="Light">
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
|
|
||||||
<FluentTheme Mode="Light" />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml" />
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" />
|
||||||
|
|
||||||
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
|
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
|
||||||
<Setter Property="Cursor" Value="Hand" />
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
@@ -33,9 +34,6 @@
|
|||||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBox:disabled"><!-- TODO bug in Avalonia (https://github.com/AvaloniaUI/Avalonia/pull/7792) -->
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextControlForegroundDisabled}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBox:error DataValidationErrors">
|
<Style Selector="TextBox:error DataValidationErrors">
|
||||||
<Style.Resources>
|
<Style.Resources>
|
||||||
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
|
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
|
||||||
@@ -51,68 +49,26 @@
|
|||||||
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
|
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
|
<Style Selector="Expander">
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
<Setter Property="MinHeight" Value="40" />
|
||||||
<Setter Property="Template">
|
<Setter Property="Padding" Value="12" />
|
||||||
<ControlTemplate>
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Border x:Name="ToggleButtonBackground">
|
|
||||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="35">
|
|
||||||
<Border x:Name="ExpandCollapseChevronBorder"
|
|
||||||
Grid.Column="0"
|
|
||||||
Width="35"
|
|
||||||
Height="35"
|
|
||||||
Margin="2,0"
|
|
||||||
RenderTransformOrigin="50%,50%">
|
|
||||||
<Path x:Name="ExpandCollapseChevron"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
RenderTransformOrigin="50%,50%"
|
|
||||||
Stretch="None"
|
|
||||||
Stroke="{DynamicResource ExpanderChevronForeground}"
|
|
||||||
StrokeThickness="1" />
|
|
||||||
<Border.RenderTransform>
|
|
||||||
<RotateTransform />
|
|
||||||
</Border.RenderTransform>
|
|
||||||
</Border>
|
|
||||||
<ContentPresenter x:Name="PART_ContentPresenter"
|
|
||||||
Grid.Column="1"
|
|
||||||
Margin="0"
|
|
||||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
|
||||||
VerticalContentAlignment="Center"
|
|
||||||
Background="Transparent"
|
|
||||||
BorderBrush="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
Content="{TemplateBinding Content}"
|
|
||||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
|
||||||
TextBlock.Foreground="{DynamicResource ExpanderForeground}" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
|
|
||||||
<Style.Animations>
|
<Style Selector="Panel.buttons">
|
||||||
<Animation FillMode="Both" Duration="0:0:0.0625">
|
<Setter Property="Margin" Value="0 20 0 0" />
|
||||||
<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>
|
||||||
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
|
<Style Selector="Panel.buttons > WrapPanel.right">
|
||||||
<Style.Animations>
|
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||||
<Animation FillMode="Both" Duration="0:0:0.0625">
|
</Style>
|
||||||
<KeyFrame Cue="0%">
|
<Style Selector="Panel.buttons Button">
|
||||||
<Setter Property="RotateTransform.Angle" Value="0" />
|
<Setter Property="MinWidth" Value="80" />
|
||||||
</KeyFrame>
|
</Style>
|
||||||
<KeyFrame Cue="100%">
|
<Style Selector="Panel.buttons > WrapPanel.left Button">
|
||||||
<Setter Property="RotateTransform.Angle" Value="0" />
|
<Setter Property="Margin" Value="0 0 8 0" />
|
||||||
</KeyFrame>
|
</Style>
|
||||||
</Animation>
|
<Style Selector="Panel.buttons > WrapPanel.right Button">
|
||||||
</Style.Animations>
|
<Setter Property="Margin" Value="8 0 0 0" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
@@ -171,12 +127,26 @@
|
|||||||
|
|
||||||
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
|
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
|
||||||
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
|
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
|
||||||
<SolidColorBrush x:Key="ExpanderBorderBrush" Color="#697DAB" />
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />
|
||||||
<SolidColorBrush x:Key="ExpanderBackground" Color="#697DAB" />
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="#697DAB" />
|
||||||
<SolidColorBrush x:Key="ExpanderForeground" Color="#FFFFFF" />
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPressed" Color="#697DAB" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushDisabled" Color="#697DAB" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackground" Color="#697DAB" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPointerOver" Color="#536794" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPressed" Color="#47587F" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackgroundDisabled" Color="#697DAB" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForeground" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForegroundPointerOver" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForegroundPressed" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForegroundDisabled" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronBackground" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronBackgroundPointerOver" Color="#536794" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronBackgroundPressed" Color="#47587F" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronBackgroundDisabled" Color="Transparent" />
|
||||||
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
|
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
|
||||||
<SolidColorBrush x:Key="ExpanderDropDownBorderBrush" Color="#697DAB" />
|
<SolidColorBrush x:Key="ExpanderChevronForegroundPointerOver" Color="#FFFFFF" />
|
||||||
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FFFFFF" />
|
<SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="#FFFFFF" />
|
||||||
|
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|
||||||
|
@@ -3,18 +3,18 @@ using Avalonia.Controls.ApplicationLifetimes;
|
|||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using DHT.Desktop.Main;
|
using DHT.Desktop.Main;
|
||||||
|
|
||||||
namespace DHT.Desktop {
|
namespace DHT.Desktop;
|
||||||
sealed class App : Application {
|
|
||||||
public override void Initialize() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted() {
|
sealed class App : Application {
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
public override void Initialize() {
|
||||||
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args));
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
public override void OnFrameworkInitializationCompleted() {
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||||
|
desktop.MainWindow = new MainWindow(Program.Arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,64 +1,86 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop {
|
namespace DHT.Desktop;
|
||||||
sealed class Arguments {
|
|
||||||
private static readonly Log Log = Log.ForType<Arguments>();
|
|
||||||
|
|
||||||
public static Arguments Empty => new(Array.Empty<string>());
|
sealed class Arguments {
|
||||||
|
private static readonly Log Log = Log.ForType<Arguments>();
|
||||||
public string? DatabaseFile { get; }
|
|
||||||
public ushort? ServerPort { get; }
|
private const int FirstArgument = 1;
|
||||||
public string? ServerToken { get; }
|
|
||||||
|
public static Arguments Empty => new ([]);
|
||||||
public Arguments(string[] args) {
|
|
||||||
for (int i = 0; i < args.Length; i++) {
|
public bool Console { get; }
|
||||||
string key = args[i];
|
public string? DatabaseFile { get; }
|
||||||
|
public ushort? ServerPort { get; }
|
||||||
switch (key) {
|
public string? ServerToken { get; }
|
||||||
case "-debug":
|
public byte? ConcurrentDownloads { get; }
|
||||||
Log.IsDebugEnabled = true;
|
|
||||||
continue;
|
public Arguments(IReadOnlyList<string> args) {
|
||||||
}
|
for (int i = FirstArgument; i < args.Count; i++) {
|
||||||
|
string key = args[i];
|
||||||
string value;
|
|
||||||
|
switch (key) {
|
||||||
if (i == 0 && !key.StartsWith('-')) {
|
case "-debug":
|
||||||
value = key;
|
Log.IsDebugEnabled = true;
|
||||||
key = "-db";
|
continue;
|
||||||
}
|
|
||||||
else if (i >= args.Length - 1) {
|
case "-console":
|
||||||
Log.Warn("Missing value for command line argument: " + key);
|
Console = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string value;
|
||||||
|
|
||||||
|
if (i == FirstArgument && !key.StartsWith('-')) {
|
||||||
|
value = key;
|
||||||
|
key = "-db";
|
||||||
|
}
|
||||||
|
else if (i >= args.Count - 1) {
|
||||||
|
Log.Warn("Missing value for command line argument: " + key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
value = args[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "-db":
|
||||||
|
DatabaseFile = value;
|
||||||
|
continue;
|
||||||
|
|
||||||
|
case "-port": {
|
||||||
|
if (!ushort.TryParse(value, out ushort port)) {
|
||||||
|
Log.Warn("Invalid port number: " + value);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ServerPort = port;
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
value = args[++i];
|
case "-token":
|
||||||
}
|
ServerToken = value;
|
||||||
|
continue;
|
||||||
switch (key) {
|
|
||||||
case "-db":
|
case "-concurrentdownloads":
|
||||||
DatabaseFile = value;
|
if (!ulong.TryParse(value, out ulong concurrentDownloads) || concurrentDownloads == 0) {
|
||||||
continue;
|
Log.Warn("Invalid concurrent downloads count: " + value);
|
||||||
|
|
||||||
case "-port": {
|
|
||||||
if (ushort.TryParse(value, out var port)) {
|
|
||||||
ServerPort = port;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Log.Warn("Invalid port number: " + value);
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
else if (concurrentDownloads > 10) {
|
||||||
case "-token":
|
Log.Warn("Limiting concurrent downloads to 10");
|
||||||
ServerToken = value;
|
ConcurrentDownloads = 10;
|
||||||
continue;
|
}
|
||||||
|
else {
|
||||||
default:
|
ConcurrentDownloads = (byte) concurrentDownloads;
|
||||||
Log.Warn("Unknown command line argument: " + key);
|
}
|
||||||
break;
|
|
||||||
}
|
continue;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Log.Warn("Unknown command line argument: " + key);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,44 +2,52 @@ using System;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Avalonia.Data.Converters;
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
sealed class BytesValueConverter : IValueConverter {
|
|
||||||
private static readonly string[] Units = {
|
|
||||||
"B",
|
|
||||||
"kB",
|
|
||||||
"MB",
|
|
||||||
"GB",
|
|
||||||
"TB"
|
|
||||||
};
|
|
||||||
|
|
||||||
private const int Scale = 1000;
|
sealed class BytesValueConverter : IValueConverter {
|
||||||
|
private sealed class Unit {
|
||||||
private static string Convert(ulong size) {
|
private readonly string label;
|
||||||
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
private readonly string numberFormat;
|
||||||
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
|
||||||
if (unit == 0) {
|
public Unit(string label, int decimalPlaces) {
|
||||||
return string.Format(Program.Culture, "{0:n0}", size) + " " + Units[unit];
|
this.label = label;
|
||||||
}
|
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
||||||
else {
|
|
||||||
double humanReadableSize = size / Math.Pow(Scale, unit);
|
|
||||||
return string.Format(Program.Culture, "{0:n0}", humanReadableSize) + " " + Units[unit];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public string Format(double size) {
|
||||||
if (value is long size and >= 0L) {
|
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
||||||
return Convert((ulong) size);
|
|
||||||
}
|
|
||||||
else if (value is ulong usize) {
|
|
||||||
return Convert(usize);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly Unit[] Units = [
|
||||||
|
new Unit("B", decimalPlaces: 0),
|
||||||
|
new Unit("kB", decimalPlaces: 0),
|
||||||
|
new Unit("MB", decimalPlaces: 1),
|
||||||
|
new Unit("GB", decimalPlaces: 1),
|
||||||
|
new Unit("TB", decimalPlaces: 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
private const int Scale = 1000;
|
||||||
|
|
||||||
|
public static string Convert(ulong size) {
|
||||||
|
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
||||||
|
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
||||||
|
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
|
if (value is long size and >= 0L) {
|
||||||
|
return Convert((ulong) size);
|
||||||
|
}
|
||||||
|
else if (value is ulong usize) {
|
||||||
|
return Convert(usize);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,64 +3,67 @@ using System.Collections.Generic;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using DHT.Desktop.Dialogs.File;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Exceptions;
|
using DHT.Server.Database.Exceptions;
|
||||||
using DHT.Server.Database.Sqlite;
|
using DHT.Server.Database.Sqlite;
|
||||||
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
static class DatabaseGui {
|
|
||||||
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
|
||||||
|
|
||||||
private const string DatabaseFileInitialName = "archive.dht";
|
static class DatabaseGui {
|
||||||
|
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
||||||
private static readonly List<FileDialogFilter> DatabaseFileDialogFilter = new() {
|
|
||||||
new FileDialogFilter {
|
private const string DatabaseFileInitialName = "archive.dht";
|
||||||
Name = "Discord History Tracker Database",
|
|
||||||
Extensions = { "dht" }
|
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
|
||||||
}
|
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"]),
|
||||||
};
|
];
|
||||||
|
|
||||||
public static OpenFileDialog NewOpenDatabaseFileDialog() {
|
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
||||||
return new OpenFileDialog {
|
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
Title = "Open Database File",
|
Title = "Open Database File",
|
||||||
InitialFileName = DatabaseFileInitialName,
|
FileTypeFilter = DatabaseFileDialogFilter,
|
||||||
Filters = DatabaseFileDialogFilter
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
};
|
AllowMultiple = true,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
public static SaveFileDialog NewOpenOrCreateDatabaseFileDialog() {
|
|
||||||
return new SaveFileDialog {
|
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
||||||
Title = "Open or Create Database File",
|
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||||
InitialFileName = DatabaseFileInitialName,
|
Title = "Open or Create Database File",
|
||||||
Filters = DatabaseFileDialogFilter
|
FileTypeChoices = DatabaseFileDialogFilter,
|
||||||
};
|
SuggestedFileName = DatabaseFileInitialName,
|
||||||
}
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
|
ShowOverwritePrompt = false,
|
||||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
|
});
|
||||||
IDatabaseFile? file = null;
|
}
|
||||||
|
|
||||||
try {
|
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||||
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
|
IDatabaseFile? file = null;
|
||||||
} catch (InvalidDatabaseVersionException ex) {
|
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
try {
|
||||||
} catch (DatabaseTooNewException ex) {
|
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
|
} catch (InvalidDatabaseVersionException ex) {
|
||||||
} catch (Exception ex) {
|
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
||||||
Log.Error(ex);
|
} catch (DatabaseTooNewException ex) {
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
|
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
|
||||||
}
|
} catch (Exception ex) {
|
||||||
|
Log.Error(ex);
|
||||||
return file;
|
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
|
||||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
|
||||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
||||||
|
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
||||||
|
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,14 +2,14 @@ using System;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Avalonia.Data.Converters;
|
using Avalonia.Data.Converters;
|
||||||
|
|
||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
sealed class NumberValueConverter : IValueConverter {
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
|
||||||
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
sealed class NumberValueConverter : IValueConverter {
|
||||||
throw new NotSupportedException();
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
}
|
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
9
app/Desktop/Common/SystemUtils.cs
Normal file
9
app/Desktop/Common/SystemUtils.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Common;
|
||||||
|
|
||||||
|
static class SystemUtils {
|
||||||
|
public static void OpenUrl(string url) {
|
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
}
|
@@ -1,19 +1,19 @@
|
|||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
static class TextFormat {
|
|
||||||
public static string Format(this int number) {
|
|
||||||
return number.ToString("N0", Program.Culture);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Format(this long number) {
|
static class TextFormat {
|
||||||
return number.ToString("N0", Program.Culture);
|
public static string Format(this int number) {
|
||||||
}
|
return number.ToString("N0", Program.Culture);
|
||||||
|
}
|
||||||
public static string Pluralize(this int number, string singular) {
|
|
||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
public static string Format(this long number) {
|
||||||
}
|
return number.ToString("N0", Program.Culture);
|
||||||
|
}
|
||||||
public static string Pluralize(this long number, string singular) {
|
|
||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
public static string Pluralize(this int number, string singular) {
|
||||||
}
|
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Pluralize(this long number, string singular) {
|
||||||
|
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,77 +1,47 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>DHT.Desktop</RootNamespace>
|
||||||
|
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
||||||
|
<PackageId>DiscordHistoryTracker</PackageId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
|
||||||
<RootNamespace>DHT.Desktop</RootNamespace>
|
|
||||||
<PackageId>DiscordHistoryTracker</PackageId>
|
|
||||||
<Authors>chylex</Authors>
|
|
||||||
<Company>DiscordHistoryTracker</Company>
|
|
||||||
<Product>DiscordHistoryTracker</Product>
|
|
||||||
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
||||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
|
|
||||||
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
|
|
||||||
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
|
||||||
<DebugSymbols>true</DebugSymbols>
|
|
||||||
<DebugType>none</DebugType>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="0.10.14" />
|
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.14" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.14" />
|
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.14" Condition=" '$(Configuration)' == 'Debug' " />
|
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.3" Condition=" '$(Configuration)' == 'Debug' " />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||||
|
<PackageReference Include="PropertyChanged.SourceGenerator" Version="1.1.1">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Server\Server.csproj" />
|
<ProjectReference Include="..\Server\Server.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||||
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
|
|
||||||
<DependentUpon>CheckBoxDialog.axaml</DependentUpon>
|
|
||||||
<SubType>Code</SubType>
|
|
||||||
</Compile>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Resources/icon.ico" />
|
<AvaloniaResource Include="Resources/icon.ico" />
|
||||||
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js">
|
<EmbeddedResource Include="Resources/tracker-loader.js">
|
||||||
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
<LogicalName>tracker-loader.js</LogicalName>
|
||||||
<Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
|
||||||
<EmbeddedResource Include="../Resources/Tracker/scripts/**" Condition=" '$(Configuration)' == 'Debug' ">
|
|
||||||
<LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
|
||||||
<Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
|
||||||
<EmbeddedResource Include="../Resources/Tracker/styles/**">
|
|
||||||
<LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
|
||||||
<Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
|
||||||
<EmbeddedResource Include="../Resources/Viewer/**">
|
|
||||||
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
|
||||||
<Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Target Name="MinifyResources" BeforeTargets="PrepareForBuild" Condition=" '$(Configuration)' == 'Release' ">
|
|
||||||
<PropertyGroup>
|
|
||||||
<MinifiedResourceDir>$(ProjectDir)bin/.res/scripts</MinifiedResourceDir>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<UpToDateCheckInput Include="$(ProjectDir)../Resources/Tracker/scripts/**" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/discord.js" LogicalName="Tracker\scripts\discord.js" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/dom.js" LogicalName="Tracker\scripts\dom.js" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/gui.js" LogicalName="Tracker\scripts\gui.js" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/settings.js" LogicalName="Tracker\scripts\settings.js" Visible="false" />
|
|
||||||
<EmbeddedResource Include="$(MinifiedResourceDir)/state.js" LogicalName="Tracker\scripts\state.js" Visible="false" />
|
|
||||||
</ItemGroup>
|
|
||||||
<RemoveDir Directories="$(ProjectDir)bin/.res/scripts" />
|
|
||||||
<Exec Command="python $(ProjectDir)../Resources/minify.py" WorkingDirectory="$(ProjectDir)../Resources" IgnoreExitCode="false" />
|
|
||||||
</Target>
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
|
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
|
||||||
|
x:DataType="namespace:CheckBoxDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
@@ -14,30 +15,12 @@
|
|||||||
<namespace:CheckBoxDialogModel />
|
<namespace:CheckBoxDialogModel />
|
||||||
</Window.DataContext>
|
</Window.DataContext>
|
||||||
|
|
||||||
<Window.Styles>
|
|
||||||
<Style Selector="Panel.buttons">
|
|
||||||
<Setter Property="Margin" Value="0 20 0 0" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Panel.buttons > WrapPanel.right">
|
|
||||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Panel.buttons Button">
|
|
||||||
<Setter Property="MinWidth" Value="80" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Panel.buttons > WrapPanel.left Button">
|
|
||||||
<Setter Property="Margin" Value="0 0 8 0" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Panel.buttons > WrapPanel.right Button">
|
|
||||||
<Setter Property="Margin" Value="8 0 0 0" />
|
|
||||||
</Style>
|
|
||||||
</Window.Styles>
|
|
||||||
|
|
||||||
<StackPanel Margin="20">
|
<StackPanel Margin="20">
|
||||||
<ScrollViewer MaxHeight="400">
|
<ScrollViewer MaxHeight="400">
|
||||||
<ItemsRepeater Items="{Binding Items}">
|
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||||
<ItemsRepeater.ItemTemplate>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<CheckBox IsChecked="{Binding Checked}">
|
<CheckBox IsChecked="{Binding IsChecked}">
|
||||||
<Label>
|
<Label>
|
||||||
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
||||||
</Label>
|
</Label>
|
||||||
|
@@ -1,31 +1,21 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class CheckBoxDialog : Window {
|
|
||||||
public CheckBoxDialog() {
|
|
||||||
InitializeComponent();
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class CheckBoxDialog : Window {
|
||||||
}
|
public CheckBoxDialog() {
|
||||||
|
InitializeComponent();
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
}
|
||||||
Close(DialogResult.OkCancel.Ok);
|
|
||||||
}
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.OkCancel.Ok);
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
}
|
||||||
Close(DialogResult.OkCancel.Cancel);
|
|
||||||
}
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,70 +1,71 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DHT.Utils.Models;
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
class CheckBoxDialogModel : BaseModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
|
|
||||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
partial class CheckBoxDialogModel {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
public IReadOnlyList<CheckBoxItem> Items {
|
|
||||||
get => items;
|
private IReadOnlyList<CheckBoxItem> items = [];
|
||||||
|
|
||||||
protected set {
|
public IReadOnlyList<CheckBoxItem> Items {
|
||||||
foreach (var item in items) {
|
get => items;
|
||||||
item.PropertyChanged -= OnItemPropertyChanged;
|
|
||||||
}
|
protected set {
|
||||||
|
foreach (CheckBoxItem item in items) {
|
||||||
items = value;
|
item.PropertyChanged -= OnItemPropertyChanged;
|
||||||
|
|
||||||
foreach (var item in items) {
|
|
||||||
item.PropertyChanged += OnItemPropertyChanged;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
items = value;
|
||||||
private bool pauseCheckEvents = false;
|
|
||||||
|
foreach (CheckBoxItem item in items) {
|
||||||
public bool AreAllSelected => Items.All(static item => item.Checked);
|
item.PropertyChanged += OnItemPropertyChanged;
|
||||||
public bool AreNoneSelected => Items.All(static item => !item.Checked);
|
|
||||||
|
|
||||||
public void SelectAll() => SetAllChecked(true);
|
|
||||||
public void SelectNone() => SetAllChecked(false);
|
|
||||||
|
|
||||||
private void SetAllChecked(bool isChecked) {
|
|
||||||
pauseCheckEvents = true;
|
|
||||||
|
|
||||||
foreach (var item in Items) {
|
|
||||||
item.Checked = isChecked;
|
|
||||||
}
|
|
||||||
|
|
||||||
pauseCheckEvents = false;
|
|
||||||
UpdateBulkButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateBulkButtons() {
|
|
||||||
OnPropertyChanged(nameof(AreAllSelected));
|
|
||||||
OnPropertyChanged(nameof(AreNoneSelected));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
|
|
||||||
UpdateBulkButtons();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
private bool pauseCheckEvents = false;
|
||||||
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
|
||||||
|
[DependsOn(nameof(Items))]
|
||||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
||||||
|
|
||||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
[DependsOn(nameof(Items))]
|
||||||
this.Items = new List<CheckBoxItem<T>>(items);
|
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
||||||
base.Items = this.Items;
|
|
||||||
|
public void SelectAll() => SetAllChecked(true);
|
||||||
|
public void SelectNone() => SetAllChecked(false);
|
||||||
|
|
||||||
|
private void SetAllChecked(bool isChecked) {
|
||||||
|
pauseCheckEvents = true;
|
||||||
|
|
||||||
|
foreach (CheckBoxItem item in Items) {
|
||||||
|
item.IsChecked = isChecked;
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseCheckEvents = false;
|
||||||
|
UpdateBulkButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateBulkButtons() {
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Items)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
||||||
|
UpdateBulkButtons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||||
|
private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
|
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
|
||||||
|
|
||||||
|
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||||
|
this.Items = new List<CheckBoxItem<T>>(items);
|
||||||
|
base.Items = Items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,24 +1,20 @@
|
|||||||
using DHT.Utils.Models;
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
class CheckBoxItem : BaseModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
public object? Item { get; init; } = null;
|
|
||||||
|
|
||||||
private bool isChecked = false;
|
partial class CheckBoxItem {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool isChecked = false;
|
||||||
|
}
|
||||||
|
|
||||||
public bool Checked {
|
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||||
get => isChecked;
|
public new T Item { get; }
|
||||||
set => Change(ref isChecked, value);
|
|
||||||
}
|
public CheckBoxItem(T item) {
|
||||||
}
|
this.Item = item;
|
||||||
|
base.Item = item;
|
||||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
|
||||||
public new T Item { get; }
|
|
||||||
|
|
||||||
public CheckBoxItem(T item) {
|
|
||||||
this.Item = item;
|
|
||||||
base.Item = item;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
40
app/Desktop/Dialogs/File/FileDialogs.cs
Normal file
40
app/Desktop/Dialogs/File/FileDialogs.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.File;
|
||||||
|
|
||||||
|
static class FileDialogs {
|
||||||
|
public static async Task<string[]> OpenFolders(this IStorageProvider storageProvider, FolderPickerOpenOptions options) {
|
||||||
|
return (await storageProvider.OpenFolderPickerAsync(options)).ToLocalPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
||||||
|
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
||||||
|
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
||||||
|
return new FilePickerFileType(name) {
|
||||||
|
Patterns = extensions.Select(static ext => "*." + ext).ToArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
||||||
|
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToLocalPath(this IStorageItem itme) {
|
||||||
|
return itme.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ToLocalPaths(this IReadOnlyList<IStorageItem> items) {
|
||||||
|
return items.Select(ToLocalPath).ToArray();
|
||||||
|
}
|
||||||
|
}
|
@@ -2,73 +2,73 @@ using System.Threading.Tasks;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
static class Dialog {
|
|
||||||
public static async Task ShowOk(Window owner, string title, string message) {
|
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new MessageDialog {
|
static class Dialog {
|
||||||
DataContext = new MessageDialogModel {
|
public static async Task ShowOk(Window owner, string title, string message) {
|
||||||
Title = title,
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
Message = message,
|
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
||||||
IsOkVisible = true
|
return;
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All>(owner);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
await new MessageDialog {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
DataContext = new MessageDialogModel {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
Title = title,
|
||||||
}
|
Message = message,
|
||||||
|
IsOkVisible = true,
|
||||||
var result = await new MessageDialog {
|
},
|
||||||
DataContext = new MessageDialogModel {
|
}.ShowDialog<DialogResult.All>(owner);
|
||||||
Title = title,
|
}
|
||||||
Message = message,
|
|
||||||
IsOkVisible = true,
|
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
||||||
IsCancelVisible = true
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
}
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
|
||||||
|
|
||||||
return result.ToOkCancel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
var result = await new MessageDialog {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
DataContext = new MessageDialogModel {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
Title = title,
|
||||||
}
|
Message = message,
|
||||||
|
IsOkVisible = true,
|
||||||
var result = await new MessageDialog {
|
IsCancelVisible = true,
|
||||||
DataContext = new MessageDialogModel {
|
},
|
||||||
Title = title,
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
Message = message,
|
|
||||||
IsYesVisible = true,
|
return result.ToOkCancel();
|
||||||
IsNoVisible = true
|
}
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
||||||
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
return result.ToYesNo();
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
var result = await new MessageDialog {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
DataContext = new MessageDialogModel {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
Title = title,
|
||||||
}
|
Message = message,
|
||||||
|
IsYesVisible = true,
|
||||||
var result = await new MessageDialog {
|
IsNoVisible = true,
|
||||||
DataContext = new MessageDialogModel {
|
},
|
||||||
Title = title,
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
Message = message,
|
|
||||||
IsYesVisible = true,
|
return result.ToYesNo();
|
||||||
IsNoVisible = true,
|
}
|
||||||
IsCancelVisible = true
|
|
||||||
}
|
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
||||||
return result.ToYesNoCancel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var result = await new MessageDialog {
|
||||||
|
DataContext = new MessageDialogModel {
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
IsYesVisible = true,
|
||||||
|
IsNoVisible = true,
|
||||||
|
IsCancelVisible = true,
|
||||||
|
},
|
||||||
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
|
return result.ToYesNoCancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,59 +1,59 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
static class DialogResult {
|
|
||||||
public enum All {
|
|
||||||
Ok,
|
|
||||||
Yes,
|
|
||||||
No,
|
|
||||||
Cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum OkCancel {
|
static class DialogResult {
|
||||||
Closed,
|
public enum All {
|
||||||
Ok,
|
Ok,
|
||||||
Cancel
|
Yes,
|
||||||
}
|
No,
|
||||||
|
Cancel,
|
||||||
public enum YesNo {
|
}
|
||||||
Closed,
|
|
||||||
Yes,
|
public enum OkCancel {
|
||||||
No
|
Closed,
|
||||||
}
|
Ok,
|
||||||
|
Cancel,
|
||||||
public enum YesNoCancel {
|
}
|
||||||
Closed,
|
|
||||||
Yes,
|
public enum YesNo {
|
||||||
No,
|
Closed,
|
||||||
Cancel
|
Yes,
|
||||||
}
|
No,
|
||||||
|
}
|
||||||
public static OkCancel ToOkCancel(this All? result) {
|
|
||||||
return result switch {
|
public enum YesNoCancel {
|
||||||
null => OkCancel.Closed,
|
Closed,
|
||||||
All.Ok => OkCancel.Ok,
|
Yes,
|
||||||
All.Cancel => OkCancel.Cancel,
|
No,
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
|
Cancel,
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
public static OkCancel ToOkCancel(this All? result) {
|
||||||
public static YesNo ToYesNo(this All? result) {
|
return result switch {
|
||||||
return result switch {
|
null => OkCancel.Closed,
|
||||||
null => YesNo.Closed,
|
All.Ok => OkCancel.Ok,
|
||||||
All.Yes => YesNo.Yes,
|
All.Cancel => OkCancel.Cancel,
|
||||||
All.No => YesNo.No,
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel."),
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
|
};
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
public static YesNo ToYesNo(this All? result) {
|
||||||
public static YesNoCancel ToYesNoCancel(this All? result) {
|
return result switch {
|
||||||
return result switch {
|
null => YesNo.Closed,
|
||||||
null => YesNoCancel.Closed,
|
All.Yes => YesNo.Yes,
|
||||||
All.Yes => YesNoCancel.Yes,
|
All.No => YesNo.No,
|
||||||
All.No => YesNoCancel.No,
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no."),
|
||||||
All.Cancel => YesNoCancel.Cancel,
|
};
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
|
}
|
||||||
};
|
|
||||||
}
|
public static YesNoCancel ToYesNoCancel(this All? result) {
|
||||||
|
return result switch {
|
||||||
|
null => YesNoCancel.Closed,
|
||||||
|
All.Yes => YesNoCancel.Yes,
|
||||||
|
All.No => YesNoCancel.No,
|
||||||
|
All.Cancel => YesNoCancel.Cancel,
|
||||||
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel."),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
||||||
|
x:DataType="namespace:MessageDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
@@ -19,24 +20,18 @@
|
|||||||
<Setter Property="FontSize" Value="15" />
|
<Setter Property="FontSize" Value="15" />
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="WrapPanel">
|
|
||||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
|
||||||
<Setter Property="Margin" Value="0 8 0 0" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button">
|
|
||||||
<Setter Property="Margin" Value="8 0 0 0" />
|
|
||||||
<Setter Property="MinWidth" Value="80" />
|
|
||||||
</Style>
|
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<StackPanel Margin="20">
|
<StackPanel Margin="20">
|
||||||
<TextBlock Text="{Binding Message}" />
|
<TextBlock Text="{Binding Message}" />
|
||||||
<WrapPanel>
|
<Panel Classes="buttons">
|
||||||
<Button Click="ClickOk" IsVisible="{Binding IsOkVisible}">OK</Button>
|
<WrapPanel Classes="right">
|
||||||
<Button Click="ClickYes" IsVisible="{Binding IsYesVisible}">Yes</Button>
|
<Button Click="ClickOk" IsVisible="{Binding IsOkVisible}">OK</Button>
|
||||||
<Button Click="ClickNo" IsVisible="{Binding IsNoVisible}">No</Button>
|
<Button Click="ClickYes" IsVisible="{Binding IsYesVisible}">Yes</Button>
|
||||||
<Button Click="ClickCancel" IsVisible="{Binding IsCancelVisible}">Cancel</Button>
|
<Button Click="ClickNo" IsVisible="{Binding IsNoVisible}">No</Button>
|
||||||
</WrapPanel>
|
<Button Click="ClickCancel" IsVisible="{Binding IsCancelVisible}">Cancel</Button>
|
||||||
|
</WrapPanel>
|
||||||
|
</Panel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</Window>
|
</Window>
|
||||||
|
@@ -1,37 +1,28 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class MessageDialog : Window {
|
|
||||||
public MessageDialog() {
|
|
||||||
InitializeComponent();
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class MessageDialog : Window {
|
||||||
}
|
public MessageDialog() {
|
||||||
|
InitializeComponent();
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
}
|
||||||
Close(DialogResult.All.Ok);
|
|
||||||
}
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.All.Ok);
|
||||||
public void ClickYes(object? sender, RoutedEventArgs e) {
|
}
|
||||||
Close(DialogResult.All.Yes);
|
|
||||||
}
|
public void ClickYes(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.All.Yes);
|
||||||
public void ClickNo(object? sender, RoutedEventArgs e) {
|
}
|
||||||
Close(DialogResult.All.No);
|
|
||||||
}
|
public void ClickNo(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.All.No);
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
}
|
||||||
Close(DialogResult.All.Cancel);
|
|
||||||
}
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.All.Cancel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
sealed class MessageDialogModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
public string Message { get; init; } = "";
|
|
||||||
|
|
||||||
public bool IsOkVisible { get; init; } = false;
|
sealed class MessageDialogModel {
|
||||||
public bool IsYesVisible { get; init; } = false;
|
public string Title { get; init; } = "";
|
||||||
public bool IsNoVisible { get; init; } = false;
|
public string Message { get; init; } = "";
|
||||||
public bool IsCancelVisible { get; init; } = false;
|
|
||||||
}
|
public bool IsOkVisible { get; init; } = false;
|
||||||
|
public bool IsYesVisible { get; init; } = false;
|
||||||
|
public bool IsNoVisible { get; init; } = false;
|
||||||
|
public bool IsCancelVisible { get; init; } = false;
|
||||||
}
|
}
|
||||||
|
24
app/Desktop/Dialogs/Progress/DelayedProgressDialog.cs
Normal file
24
app/Desktop/Dialogs/Progress/DelayedProgressDialog.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
|
static class DelayedProgressDialog {
|
||||||
|
public static async ValueTask Await(Func<Task> taskProvider, TimeSpan delay, Window window, string progressDialogTitle, string progressDialogDescription) {
|
||||||
|
Dispatcher.UIThread.VerifyAccess();
|
||||||
|
|
||||||
|
Task task = Task.Run(taskProvider);
|
||||||
|
if (task.IsCompleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freeze the UI thread for a short while in case the task finishes quickly.
|
||||||
|
_ = Task.WhenAny(Task.Delay(delay), task).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (!task.IsCompleted) {
|
||||||
|
await ProgressDialog.ShowIndeterminate(window, progressDialogTitle, progressDialogDescription, _ => task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,7 +1,9 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress {
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
interface IProgressCallback {
|
|
||||||
Task Update(string message, int finishedItems, int totalItems);
|
interface IProgressCallback {
|
||||||
}
|
Task Update(string message, long finishedItems, long totalItems);
|
||||||
|
Task UpdateIndeterminate(string message);
|
||||||
|
Task Hide();
|
||||||
}
|
}
|
||||||
|
@@ -5,9 +5,10 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
|
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
|
||||||
|
x:DataType="namespace:ProgressDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Opened="Loaded"
|
Opened="OnOpened"
|
||||||
Closing="OnClosing"
|
Closing="OnClosing"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
WindowStartupLocation="CenterOwner">
|
WindowStartupLocation="CenterOwner">
|
||||||
@@ -31,12 +32,18 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Window.Styles>
|
</Window.Styles>
|
||||||
|
|
||||||
<StackPanel Margin="20">
|
<ItemsRepeater ItemsSource="{Binding Items}" Margin="0 10">
|
||||||
<DockPanel>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
<DataTemplate>
|
||||||
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
<StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
|
||||||
</DockPanel>
|
<DockPanel>
|
||||||
<ProgressBar Value="{Binding Progress}" />
|
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
||||||
</StackPanel>
|
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
||||||
|
</DockPanel>
|
||||||
|
<ProgressBar IsIndeterminate="{Binding IsIndeterminate}" Value="{Binding Progress}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsRepeater.ItemTemplate>
|
||||||
|
</ItemsRepeater>
|
||||||
|
|
||||||
</Window>
|
</Window>
|
||||||
|
@@ -1,40 +1,80 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress {
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class ProgressDialog : Window {
|
|
||||||
private bool isFinished = false;
|
|
||||||
|
|
||||||
public ProgressDialog() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
InitializeComponent();
|
public sealed partial class ProgressDialog : Window {
|
||||||
#if DEBUG
|
private static readonly TimeSpan MinimumShowDuration = TimeSpan.FromMilliseconds(500);
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
|
||||||
}
|
var dialog = new ProgressDialog();
|
||||||
|
dialog.DataContext = new ProgressDialogModel(title, async callbacks => await action(dialog, callbacks[0]));
|
||||||
private void InitializeComponent() {
|
await dialog.ShowProgressDialog(owner);
|
||||||
AvaloniaXamlLoader.Load(this);
|
}
|
||||||
}
|
|
||||||
|
internal static async Task<T> Show<T>(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task<T>> action) {
|
||||||
public void OnClosing(object? sender, CancelEventArgs e) {
|
var taskCompletionSource = new TaskCompletionSource<T>();
|
||||||
e.Cancel = !isFinished;
|
var dialog = new ProgressDialog();
|
||||||
}
|
|
||||||
|
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||||
public void Loaded(object? sender, EventArgs e) {
|
taskCompletionSource.SetResult(await action(dialog, callbacks[0]));
|
||||||
if (DataContext is ProgressDialogModel model) {
|
});
|
||||||
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
|
||||||
}
|
await dialog.ShowProgressDialog(owner);
|
||||||
}
|
return await taskCompletionSource.Task;
|
||||||
|
}
|
||||||
private void OnFinished(Task task) {
|
|
||||||
isFinished = true;
|
internal static Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
|
||||||
Close();
|
return Show(owner, title, async (dialog, callback) => {
|
||||||
|
await callback.UpdateIndeterminate(message);
|
||||||
|
await action(dialog);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
||||||
|
return Show<T>(owner, title, async (dialog, callback) => {
|
||||||
|
await callback.UpdateIndeterminate(message);
|
||||||
|
return await action(dialog);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool isFinished = false;
|
||||||
|
private DateTime startTime = DateTime.Now;
|
||||||
|
private Task progressTask = Task.CompletedTask;
|
||||||
|
|
||||||
|
public ProgressDialog() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnOpened(object? sender, EventArgs e) {
|
||||||
|
startTime = DateTime.Now;
|
||||||
|
|
||||||
|
if (DataContext is ProgressDialogModel model) {
|
||||||
|
progressTask = Task.Run(model.StartTask);
|
||||||
|
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
|
e.Cancel = !isFinished;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnFinished(Task task) {
|
||||||
|
isFinished = true;
|
||||||
|
|
||||||
|
TimeSpan elapsedTime = DateTime.Now - startTime;
|
||||||
|
if (elapsedTime < MinimumShowDuration) {
|
||||||
|
await Task.Delay(MinimumShowDuration - elapsedTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShowProgressDialog(Window owner) {
|
||||||
|
await ShowDialog(owner);
|
||||||
|
await progressTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,65 +1,63 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress {
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
sealed class ProgressDialogModel : BaseModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
|
|
||||||
private string message = "";
|
sealed class ProgressDialogModel {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
public string Message {
|
|
||||||
get => message;
|
public IReadOnlyList<ProgressItem> Items { get; } = [];
|
||||||
private set => Change(ref message, value);
|
|
||||||
|
private readonly TaskRunner? task;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public ProgressDialogModel() {}
|
||||||
|
|
||||||
|
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
|
||||||
|
this.Title = title;
|
||||||
|
this.task = task;
|
||||||
|
this.Items = Enumerable.Range(start: 0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task StartTask() {
|
||||||
|
if (task != null) {
|
||||||
|
await task(Items.Select(static item => new Callback(item)).ToArray());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private string items = "";
|
|
||||||
|
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
||||||
public string Items {
|
|
||||||
get => items;
|
private sealed class Callback : IProgressCallback {
|
||||||
private set => Change(ref items, value);
|
private readonly ProgressItem item;
|
||||||
|
|
||||||
|
public Callback(ProgressItem item) {
|
||||||
|
this.item = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int progress = 0;
|
public async Task Update(string message, long finishedItems, long totalItems) {
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||||
public int Progress {
|
item.Message = message;
|
||||||
get => progress;
|
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||||
private set => Change(ref progress, value);
|
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
||||||
|
item.IsIndeterminate = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly TaskRunner? task;
|
public async Task UpdateIndeterminate(string message) {
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||||
[Obsolete("Designer")]
|
item.Message = message;
|
||||||
public ProgressDialogModel() {}
|
item.Items = string.Empty;
|
||||||
|
item.Progress = 0;
|
||||||
public ProgressDialogModel(TaskRunner task) {
|
item.IsIndeterminate = true;
|
||||||
this.task = task;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task StartTask() {
|
public Task Hide() {
|
||||||
if (task != null) {
|
return Update(string.Empty, finishedItems: 0, totalItems: 0);
|
||||||
await task(new Callback(this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public delegate Task TaskRunner(IProgressCallback callback);
|
|
||||||
|
|
||||||
private sealed class Callback : IProgressCallback {
|
|
||||||
private readonly ProgressDialogModel model;
|
|
||||||
|
|
||||||
public Callback(ProgressDialogModel model) {
|
|
||||||
this.model = model;
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
|
||||||
model.Message = message;
|
|
||||||
model.Items = finishedItems.Format() + " / " + totalItems.Format();
|
|
||||||
model.Progress = 100 * finishedItems / totalItems;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
23
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
23
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
|
sealed partial class ProgressItem {
|
||||||
|
[Notify]
|
||||||
|
private string message = "";
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private string items = "";
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private long progress = 0L;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool isIndeterminate;
|
||||||
|
|
||||||
|
[DependsOn(nameof(Message))]
|
||||||
|
public bool IsVisible => !string.IsNullOrEmpty(Message);
|
||||||
|
|
||||||
|
[DependsOn(nameof(IsVisible))]
|
||||||
|
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||||
|
}
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
||||||
|
x:DataType="namespace:TextBoxDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
@@ -14,24 +15,11 @@
|
|||||||
<namespace:TextBoxDialogModel />
|
<namespace:TextBoxDialogModel />
|
||||||
</Window.DataContext>
|
</Window.DataContext>
|
||||||
|
|
||||||
<Window.Styles>
|
|
||||||
<Style Selector="Panel.buttons">
|
|
||||||
<Setter Property="Margin" Value="0 20 0 0" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Panel.buttons > WrapPanel.right">
|
|
||||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Panel.buttons Button">
|
|
||||||
<Setter Property="MinWidth" Value="80" />
|
|
||||||
<Setter Property="Margin" Value="8 0 0 0" />
|
|
||||||
</Style>
|
|
||||||
</Window.Styles>
|
|
||||||
|
|
||||||
<StackPanel Margin="20">
|
<StackPanel Margin="20">
|
||||||
<ScrollViewer MaxHeight="400">
|
<ScrollViewer MaxHeight="400">
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
|
||||||
<ItemsRepeater Items="{Binding Items}">
|
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||||
<ItemsRepeater.ItemTemplate>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<DockPanel Margin="0 5 25 0">
|
<DockPanel Margin="0 5 25 0">
|
||||||
|
@@ -1,31 +1,21 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox {
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class TextBoxDialog : Window {
|
|
||||||
public TextBoxDialog() {
|
|
||||||
InitializeComponent();
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class TextBoxDialog : Window {
|
||||||
}
|
public TextBoxDialog() {
|
||||||
|
InitializeComponent();
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
}
|
||||||
Close(DialogResult.OkCancel.Ok);
|
|
||||||
}
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.OkCancel.Ok);
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
}
|
||||||
Close(DialogResult.OkCancel.Cancel);
|
|
||||||
}
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,47 +1,47 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DHT.Utils.Models;
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox {
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
class TextBoxDialogModel : BaseModel {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
public string Description { get; init; } = "";
|
|
||||||
|
|
||||||
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
|
partial class TextBoxDialogModel {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
public IReadOnlyList<TextBoxItem> Items {
|
public string Description { get; init; } = "";
|
||||||
get => items;
|
|
||||||
|
private IReadOnlyList<TextBoxItem> items = [];
|
||||||
protected set {
|
|
||||||
foreach (var item in items) {
|
public IReadOnlyList<TextBoxItem> Items {
|
||||||
item.ErrorsChanged -= OnItemErrorsChanged;
|
get => items;
|
||||||
}
|
|
||||||
|
protected set {
|
||||||
items = value;
|
foreach (TextBoxItem item in items) {
|
||||||
|
item.ErrorsChanged -= OnItemErrorsChanged;
|
||||||
foreach (var item in items) {
|
}
|
||||||
item.ErrorsChanged += OnItemErrorsChanged;
|
|
||||||
}
|
items = value;
|
||||||
|
|
||||||
|
foreach (TextBoxItem item in items) {
|
||||||
|
item.ErrorsChanged += OnItemErrorsChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
|
||||||
|
|
||||||
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
|
||||||
OnPropertyChanged(nameof(HasErrors));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
[DependsOn(nameof(Items))]
|
||||||
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
||||||
|
|
||||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Items)));
|
||||||
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
}
|
||||||
this.Items = new List<TextBoxItem<T>>(items);
|
}
|
||||||
base.Items = this.Items;
|
|
||||||
}
|
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||||
|
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
|
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||||
|
|
||||||
|
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
||||||
|
this.Items = new List<TextBoxItem<T>>(items);
|
||||||
|
base.Items = this.Items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,42 +1,39 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using DHT.Utils.Models;
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox {
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
|
||||||
public string Title { get; init; } = "";
|
|
||||||
public object? Item { get; init; } = null;
|
|
||||||
|
|
||||||
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
|
||||||
public bool IsValid => ValidityCheck(Value);
|
|
||||||
|
|
||||||
private string value = string.Empty;
|
partial class TextBoxItem : INotifyDataErrorInfo {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
public string Value {
|
public object? Item { get; init; } = null;
|
||||||
get => this.value;
|
|
||||||
set {
|
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
||||||
Change(ref this.value, value);
|
public bool IsValid => ValidityCheck(Value);
|
||||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
|
||||||
}
|
[Notify]
|
||||||
}
|
private string value = string.Empty;
|
||||||
|
|
||||||
public IEnumerable GetErrors(string? propertyName) {
|
private void OnValueChanged() {
|
||||||
if (propertyName == nameof(Value) && !IsValid) {
|
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||||
yield return string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasErrors => !IsValid;
|
|
||||||
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class TextBoxItem<T> : TextBoxItem {
|
public IEnumerable GetErrors(string? propertyName) {
|
||||||
public new T Item { get; }
|
if (propertyName == nameof(Value) && !IsValid) {
|
||||||
|
yield return string.Empty;
|
||||||
public TextBoxItem(T item) {
|
|
||||||
this.Item = item;
|
|
||||||
base.Item = item;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasErrors => !IsValid;
|
||||||
|
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class TextBoxItem<T> : TextBoxItem {
|
||||||
|
public new T Item { get; }
|
||||||
|
|
||||||
|
public TextBoxItem(T item) {
|
||||||
|
this.Item = item;
|
||||||
|
base.Item = item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,119 +1,122 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using static System.Environment.SpecialFolder;
|
using static System.Environment.SpecialFolder;
|
||||||
using static System.Environment.SpecialFolderOption;
|
using static System.Environment.SpecialFolderOption;
|
||||||
|
|
||||||
namespace DHT.Desktop.Discord {
|
namespace DHT.Desktop.Discord;
|
||||||
static class DiscordAppSettings {
|
|
||||||
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
|
||||||
|
|
||||||
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
static class DiscordAppSettings {
|
||||||
|
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
||||||
public static string JsonFilePath { get; }
|
|
||||||
private static string JsonBackupFilePath { get; }
|
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
public static string JsonFilePath { get; }
|
||||||
static DiscordAppSettings() {
|
private static string JsonBackupFilePath { get; }
|
||||||
string rootFolder;
|
|
||||||
|
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
static DiscordAppSettings() {
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
string rootFolder;
|
||||||
}
|
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(UserProfile, DoNotVerify), "Library", "Application Support", "Discord");
|
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
||||||
}
|
|
||||||
else {
|
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
|
||||||
JsonBackupFilePath = JsonFilePath + ".bak";
|
|
||||||
}
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
|
||||||
public static async Task<bool?> AreDevToolsEnabled() {
|
rootFolder = Path.Combine(Environment.GetFolderPath(UserProfile, DoNotVerify), "Library", "Application Support", "Discord");
|
||||||
try {
|
|
||||||
return AreDevToolsEnabled(await ReadSettingsJson());
|
|
||||||
} catch (Exception) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) {
|
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
||||||
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
||||||
Dictionary<string, object?> json;
|
JsonBackupFilePath = JsonFilePath + ".bak";
|
||||||
|
}
|
||||||
try {
|
|
||||||
json = await ReadSettingsJson();
|
public static async Task<bool?> AreDevToolsEnabled() {
|
||||||
} catch (FileNotFoundException) {
|
try {
|
||||||
return SettingsJsonResult.FileNotFound;
|
JsonObject settingsJson = await ReadSettingsJson();
|
||||||
} catch (JsonException) {
|
return AreDevToolsEnabled(settingsJson);
|
||||||
return SettingsJsonResult.InvalidJson;
|
} catch (Exception e) {
|
||||||
} catch (Exception e) {
|
Log.Error("Cannot read settings file.");
|
||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
return SettingsJsonResult.ReadError;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
if (enable == AreDevToolsEnabled(json)) {
|
|
||||||
return SettingsJsonResult.AlreadySet;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enable) {
|
|
||||||
json[JsonKeyDevTools] = true;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
json.Remove(JsonKeyDevTools);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!File.Exists(JsonBackupFilePath)) {
|
|
||||||
File.Copy(JsonFilePath, JsonBackupFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await WriteSettingsJson(json);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error("An error occurred when writing settings file.");
|
|
||||||
Log.Error(e);
|
|
||||||
|
|
||||||
if (File.Exists(JsonBackupFilePath)) {
|
|
||||||
try {
|
|
||||||
File.Move(JsonBackupFilePath, JsonFilePath, true);
|
|
||||||
Log.Info("Restored settings file from backup.");
|
|
||||||
} catch (Exception e2) {
|
|
||||||
Log.Error("Cannot restore settings file from backup.");
|
|
||||||
Log.Error(e2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SettingsJsonResult.WriteError;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
File.Delete(JsonBackupFilePath);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error("Cannot delete backup file.");
|
|
||||||
Log.Error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SettingsJsonResult.Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
|
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
|
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
|
||||||
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool AreDevToolsEnabled(JsonObject json) {
|
||||||
|
return json.TryGetPropertyValue(JsonKeyDevTools, out JsonNode? node) && node?.GetValueKind() == JsonValueKind.True;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
||||||
|
JsonObject json;
|
||||||
|
|
||||||
|
try {
|
||||||
|
json = await ReadSettingsJson();
|
||||||
|
} catch (FileNotFoundException) {
|
||||||
|
return SettingsJsonResult.FileNotFound;
|
||||||
|
} catch (JsonException) {
|
||||||
|
return SettingsJsonResult.InvalidJson;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
return SettingsJsonResult.ReadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enable == AreDevToolsEnabled(json)) {
|
||||||
|
return SettingsJsonResult.AlreadySet;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enable) {
|
||||||
|
json[JsonKeyDevTools] = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
json.Remove(JsonKeyDevTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!File.Exists(JsonBackupFilePath)) {
|
||||||
|
File.Copy(JsonFilePath, JsonBackupFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteSettingsJson(json);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error("An error occurred when writing settings file.");
|
||||||
|
Log.Error(e);
|
||||||
|
|
||||||
|
if (File.Exists(JsonBackupFilePath)) {
|
||||||
|
try {
|
||||||
|
File.Move(JsonBackupFilePath, JsonFilePath, overwrite: true);
|
||||||
|
Log.Info("Restored settings file from backup.");
|
||||||
|
} catch (Exception e2) {
|
||||||
|
Log.Error("Cannot restore settings file from backup.");
|
||||||
|
Log.Error(e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SettingsJsonResult.WriteError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File.Delete(JsonBackupFilePath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error("Cannot delete backup file.");
|
||||||
|
Log.Error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SettingsJsonResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonObject> ReadSettingsJson() {
|
||||||
|
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteSettingsJson(JsonObject json) {
|
||||||
|
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
8
app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
Normal file
8
app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Discord;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
||||||
|
[JsonSerializable(typeof(JsonObject))]
|
||||||
|
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext;
|
@@ -1,10 +1,10 @@
|
|||||||
namespace DHT.Desktop.Discord {
|
namespace DHT.Desktop.Discord;
|
||||||
enum SettingsJsonResult {
|
|
||||||
Success,
|
enum SettingsJsonResult {
|
||||||
AlreadySet,
|
Success,
|
||||||
FileNotFound,
|
AlreadySet,
|
||||||
ReadError,
|
FileNotFound,
|
||||||
InvalidJson,
|
ReadError,
|
||||||
WriteError
|
InvalidJson,
|
||||||
}
|
WriteError,
|
||||||
}
|
}
|
||||||
|
@@ -3,11 +3,12 @@
|
|||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||||
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
|
mc:Ignorable="d" d:DesignWidth="510" d:DesignHeight="375"
|
||||||
x:Class="DHT.Desktop.Main.AboutWindow"
|
x:Class="DHT.Desktop.Main.AboutWindow"
|
||||||
|
x:DataType="main:AboutWindowModel"
|
||||||
Title="About Discord History Tracker"
|
Title="About Discord History Tracker"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="480" Height="295" CanResize="False"
|
Width="510" Height="375" CanResize="False"
|
||||||
WindowStartupLocation="CenterOwner">
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
@@ -15,10 +16,6 @@
|
|||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<Window.Styles>
|
<Window.Styles>
|
||||||
<Style Selector="StackPanel">
|
|
||||||
<Setter Property="Orientation" Value="Horizontal" />
|
|
||||||
<Setter Property="Spacing" Value="5" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock">
|
<Style Selector="TextBlock">
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
@@ -32,35 +29,45 @@
|
|||||||
|
|
||||||
<StackPanel Orientation="Vertical" Margin="20" Spacing="12">
|
<StackPanel Orientation="Vertical" Margin="20" Spacing="12">
|
||||||
|
|
||||||
<TextBlock VerticalAlignment="Center">
|
<StackPanel Orientation="Vertical" Spacing="3">
|
||||||
Discord History Tracker was created by chylex and released under the MIT license.
|
<TextBlock TextWrapping="Wrap">Discord History Tracker was created by chylex.</TextBlock>
|
||||||
</TextBlock>
|
<TextBlock>It is available under the MIT license.</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel>
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Command="{Binding ShowOfficialWebsite}">Official Website</Button>
|
<Button Command="{Binding ShowOfficialWebsite}">Official Website</Button>
|
||||||
|
<Button Command="{Binding ShowIssueTracker}">Issue Tracker</Button>
|
||||||
<Button Command="{Binding ShowSourceCode}">Source Code</Button>
|
<Button Command="{Binding ShowSourceCode}">Source Code</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="175,125,*" Margin="0 10 0 0">
|
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*,115,95" Margin="0 10 0 0">
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Third-Party Software</TextBlock>
|
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Third-Party Software</TextBlock>
|
||||||
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
|
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
|
||||||
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
|
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Grid.Column="0">.NET 5</TextBlock>
|
<TextBlock Grid.Row="2" Grid.Column="0">.NET</TextBlock>
|
||||||
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
|
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
|
||||||
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
||||||
|
|
||||||
<TextBlock Grid.Row="3" Grid.Column="0">Avalonia</TextBlock>
|
<TextBlock Grid.Row="3" Grid.Column="0">Avalonia</TextBlock>
|
||||||
<TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock>
|
<TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock>
|
||||||
<Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">NuGet</Button>
|
<Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">GitHub</Button>
|
||||||
|
|
||||||
<TextBlock Grid.Row="4" Grid.Column="0">SQLite</TextBlock>
|
<TextBlock Grid.Row="4" Grid.Column="0">Rx.NET</TextBlock>
|
||||||
<TextBlock Grid.Row="4" Grid.Column="1">Public Domain</TextBlock>
|
<TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock>
|
||||||
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
|
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button>
|
||||||
|
|
||||||
<TextBlock Grid.Row="5" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
|
<TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock>
|
||||||
<TextBlock Grid.Row="5" Grid.Column="1">Apache-2.0</TextBlock>
|
<TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock>
|
||||||
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button>
|
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Website</Button>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="6" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
|
||||||
|
<TextBlock Grid.Row="6" Grid.Column="1">Apache-2.0</TextBlock>
|
||||||
|
<Button Grid.Row="6" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="7" Grid.Column="0">PropertyChanged.SourceGenerator</TextBlock>
|
||||||
|
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
|
||||||
|
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryPropertyChangedSourceGenerator}">GitHub</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
@@ -1,20 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class AboutWindow : Window {
|
|
||||||
public AboutWindow() {
|
|
||||||
InitializeComponent();
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class AboutWindow : Window {
|
||||||
}
|
public AboutWindow() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,33 +1,41 @@
|
|||||||
using System.Diagnostics;
|
using DHT.Desktop.Common;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
sealed class AboutWindowModel {
|
|
||||||
public void ShowOfficialWebsite() {
|
|
||||||
OpenUrl("https://dht.chylex.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowSourceCode() {
|
sealed class AboutWindowModel {
|
||||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
public void ShowOfficialWebsite() {
|
||||||
}
|
SystemUtils.OpenUrl(Program.Website);
|
||||||
|
}
|
||||||
public void ShowLibraryAvalonia() {
|
|
||||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
public void ShowIssueTracker() {
|
||||||
}
|
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
||||||
|
}
|
||||||
public void ShowLibrarySqlite() {
|
|
||||||
OpenUrl("https://www.sqlite.org");
|
public void ShowSourceCode() {
|
||||||
}
|
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||||
|
}
|
||||||
public void ShowLibrarySqliteAdoNet() {
|
|
||||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
public void ShowLibraryNetCore() {
|
||||||
}
|
SystemUtils.OpenUrl("https://github.com/dotnet/core");
|
||||||
|
}
|
||||||
public void ShowLibraryNetCore() {
|
|
||||||
OpenUrl("https://github.com/dotnet/core");
|
public void ShowLibraryAvalonia() {
|
||||||
}
|
SystemUtils.OpenUrl("https://github.com/AvaloniaUI/Avalonia");
|
||||||
|
}
|
||||||
private static void OpenUrl(string url) {
|
|
||||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
public void ShowLibraryPropertyChangedSourceGenerator() {
|
||||||
}
|
SystemUtils.OpenUrl("https://github.com/canton7/PropertyChanged.SourceGenerator");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowLibrarySqlite() {
|
||||||
|
SystemUtils.OpenUrl("https://www.sqlite.org");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowLibrarySqliteAdoNet() {
|
||||||
|
SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowLibraryRxNet() {
|
||||||
|
SystemUtils.OpenUrl("https://github.com/dotnet/reactive");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,17 +0,0 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class AttachmentFilterPanel : UserControl {
|
|
||||||
public AttachmentFilterPanel() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@@ -1,125 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using DHT.Desktop.Common;
|
|
||||||
using DHT.Server.Data.Filters;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
|
||||||
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
|
||||||
public sealed record Unit(string Name, int Scale);
|
|
||||||
|
|
||||||
private static readonly Unit[] AllUnits = {
|
|
||||||
new ("B", 1),
|
|
||||||
new ("kB", 1024),
|
|
||||||
new ("MB", 1024 * 1024)
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly HashSet<string> FilterProperties = new () {
|
|
||||||
nameof(LimitSize),
|
|
||||||
nameof(MaximumSize),
|
|
||||||
nameof(MaximumSizeUnit)
|
|
||||||
};
|
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
|
||||||
|
|
||||||
private bool limitSize = false;
|
|
||||||
private int maximumSize = 0;
|
|
||||||
private Unit maximumSizeUnit = AllUnits[0];
|
|
||||||
|
|
||||||
public bool LimitSize {
|
|
||||||
get => limitSize;
|
|
||||||
set => Change(ref limitSize, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public int MaximumSize {
|
|
||||||
get => maximumSize;
|
|
||||||
set => Change(ref maximumSize, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Unit MaximumSizeUnit {
|
|
||||||
get => maximumSizeUnit;
|
|
||||||
set => Change(ref maximumSizeUnit, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<Unit> Units => AllUnits;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
private readonly string verb;
|
|
||||||
|
|
||||||
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
|
|
||||||
private long? matchingAttachmentCount;
|
|
||||||
private long? totalAttachmentCount;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") {
|
|
||||||
this.db = db;
|
|
||||||
this.verb = verb;
|
|
||||||
|
|
||||||
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
|
|
||||||
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
|
||||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
|
||||||
totalAttachmentCount = db.Statistics.TotalAttachments;
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
|
||||||
var filter = CreateFilter();
|
|
||||||
if (filter.IsEmpty) {
|
|
||||||
matchingAttachmentCountComputer.Cancel();
|
|
||||||
matchingAttachmentCount = totalAttachmentCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
matchingAttachmentCount = null;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetAttachmentCounts(long matchingAttachmentCount) {
|
|
||||||
this.matchingAttachmentCount = matchingAttachmentCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatisticsText() {
|
|
||||||
var matchingAttachmentCountStr = matchingAttachmentCount?.Format() ?? "(...)";
|
|
||||||
var totalAttachmentCountStr = totalAttachmentCount?.Format() ?? "(...)";
|
|
||||||
|
|
||||||
FilterStatisticsText = verb + " " + matchingAttachmentCountStr + " out of " + totalAttachmentCountStr + " attachment" + (totalAttachmentCount is null or 1 ? "." : "s.");
|
|
||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
|
||||||
}
|
|
||||||
|
|
||||||
public AttachmentFilter CreateFilter() {
|
|
||||||
AttachmentFilter filter = new();
|
|
||||||
|
|
||||||
if (LimitSize) {
|
|
||||||
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -4,10 +4,11 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel">
|
x:Class="DHT.Desktop.Main.Controls.DownloadItemFilterPanel"
|
||||||
|
x:DataType="controls:DownloadItemFilterPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:AttachmentFilterPanelModel />
|
<controls:DownloadItemFilterPanelModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
|
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
|
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
|
||||||
<ComboBox IsEnabled="{Binding LimitSize}" Items="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
|
<ComboBox IsEnabled="{Binding LimitSize}" ItemsSource="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock Text="{Binding Name}" />
|
<TextBlock Text="{Binding Name}" />
|
11
app/Desktop/Main/Controls/DownloadItemFilterPanel.axaml.cs
Normal file
11
app/Desktop/Main/Controls/DownloadItemFilterPanel.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
|
public sealed partial class DownloadItemFilterPanel : UserControl {
|
||||||
|
public DownloadItemFilterPanel() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
171
app/Desktop/Main/Controls/DownloadItemFilterPanelModel.cs
Normal file
171
app/Desktop/Main/Controls/DownloadItemFilterPanelModel.cs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Server;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Data.Settings;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using DHT.Utils.Tasks;
|
||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
|
sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable {
|
||||||
|
private static readonly Log Log = Log.ForType<DownloadItemFilterPanelModel>();
|
||||||
|
|
||||||
|
public sealed record Unit(string Name, uint Scale);
|
||||||
|
|
||||||
|
private static readonly Unit[] AllUnits = [
|
||||||
|
new Unit("B", Scale: 1),
|
||||||
|
new Unit("kB", Scale: 1024),
|
||||||
|
new Unit("MB", Scale: 1024 * 1024)
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> FilterProperties = [
|
||||||
|
nameof(LimitSize),
|
||||||
|
nameof(MaximumSize),
|
||||||
|
nameof(MaximumSizeUnit),
|
||||||
|
];
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private string filterStatisticsText = "";
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool limitSize = false;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private ulong maximumSize = 0UL;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private Unit maximumSizeUnit = AllUnits[0];
|
||||||
|
|
||||||
|
public IEnumerable<Unit> Units => AllUnits;
|
||||||
|
|
||||||
|
private readonly State state;
|
||||||
|
private readonly string verb;
|
||||||
|
|
||||||
|
private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask;
|
||||||
|
private bool isLoadingFilterSettings;
|
||||||
|
|
||||||
|
private readonly RestartableTask<long> downloadItemCountTask;
|
||||||
|
private long? matchingItemCount;
|
||||||
|
|
||||||
|
private readonly IDisposable downloadItemCountSubscription;
|
||||||
|
private long? totalItemCount;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public DownloadItemFilterPanelModel() : this(State.Dummy) {}
|
||||||
|
|
||||||
|
public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
|
||||||
|
this.state = state;
|
||||||
|
this.verb = verb;
|
||||||
|
|
||||||
|
this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings);
|
||||||
|
|
||||||
|
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
|
||||||
|
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
|
||||||
|
PropertyChanged += OnPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
isLoadingFilterSettings = true;
|
||||||
|
|
||||||
|
LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize);
|
||||||
|
MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize);
|
||||||
|
|
||||||
|
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, defaultValue: null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
|
||||||
|
MaximumSizeUnit = unitValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingFilterSettings = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() {
|
||||||
|
saveFilterSettingsTask.Dispose();
|
||||||
|
|
||||||
|
downloadItemCountTask.Cancel();
|
||||||
|
downloadItemCountSubscription.Dispose();
|
||||||
|
|
||||||
|
await SaveFilterSettings(new FilterSettings(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FilterSettings(bool LimitSize, ulong MaximumSize, Unit MaximumSizeUnit) {
|
||||||
|
public FilterSettings(DownloadItemFilterPanelModel model) : this(model.LimitSize, model.MaximumSize, model.MaximumSizeUnit) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveFilterSettings(FilterSettings settings) {
|
||||||
|
try {
|
||||||
|
await state.Db.Settings.Set(async setter => {
|
||||||
|
await setter.Set(SettingsKey.DownloadsLimitSize, settings.LimitSize);
|
||||||
|
await setter.Set(SettingsKey.DownloadsMaximumSize, settings.MaximumSize);
|
||||||
|
await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name);
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error("Could not save download filter settings");
|
||||||
|
Log.Error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
|
if (!isLoadingFilterSettings) {
|
||||||
|
saveFilterSettingsTask.Post(new FilterSettings(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDownloadItemCountChanged(long newItemCount) {
|
||||||
|
totalItemCount = newItemCount;
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void UpdateFilterStatistics() {
|
||||||
|
DownloadItemFilter filter = CreateFilter();
|
||||||
|
if (filter.IsEmpty) {
|
||||||
|
downloadItemCountTask.Cancel();
|
||||||
|
matchingItemCount = totalItemCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
matchingItemCount = null;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetMatchingCount(long matchingAttachmentCount) {
|
||||||
|
this.matchingItemCount = matchingAttachmentCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatisticsText() {
|
||||||
|
string matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
|
||||||
|
string totalItemCountStr = totalItemCount?.Format() ?? "(...)";
|
||||||
|
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadItemFilter CreateFilter() {
|
||||||
|
DownloadItemFilter filter = new ();
|
||||||
|
|
||||||
|
if (LimitSize) {
|
||||||
|
try {
|
||||||
|
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||||
|
} catch (ArithmeticException) {
|
||||||
|
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
}
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel">
|
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
|
||||||
|
x:DataType="controls:MessageFilterPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:MessageFilterPanelModel />
|
<controls:MessageFilterPanelModel />
|
||||||
|
@@ -1,34 +1,27 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class MessageFilterPanel : UserControl {
|
|
||||||
private CalendarDatePicker StartDatePicker => this.FindControl<CalendarDatePicker>("StartDatePicker");
|
|
||||||
private CalendarDatePicker EndDatePicker => this.FindControl<CalendarDatePicker>("EndDatePicker");
|
|
||||||
|
|
||||||
public MessageFilterPanel() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
InitializeComponent();
|
public sealed partial class MessageFilterPanel : UserControl {
|
||||||
|
public MessageFilterPanel() {
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
CultureInfo culture = Program.Culture;
|
||||||
|
foreach (CalendarDatePicker picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
||||||
|
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
|
||||||
|
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
|
||||||
|
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
|
||||||
|
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
||||||
|
if (DataContext is MessageFilterPanelModel model) {
|
||||||
var culture = Program.Culture;
|
model.StartDate = StartDatePicker.SelectedDate;
|
||||||
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
model.EndDate = EndDatePicker.SelectedDate;
|
||||||
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
|
|
||||||
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
|
|
||||||
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
|
|
||||||
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
|
||||||
if (DataContext is MessageFilterPanelModel model) {
|
|
||||||
model.StartDate = StartDatePicker.SelectedDate;
|
|
||||||
model.EndDate = EndDatePicker.SelectedDate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,195 +2,207 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.CheckBox;
|
using DHT.Desktop.Dialogs.CheckBox;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
|
using DHT.Server;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Utils.Tasks;
|
||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|
||||||
private static readonly HashSet<string> FilterProperties = new () {
|
|
||||||
nameof(FilterByDate),
|
|
||||||
nameof(StartDate),
|
|
||||||
nameof(EndDate),
|
|
||||||
nameof(FilterByChannel),
|
|
||||||
nameof(IncludedChannels),
|
|
||||||
nameof(FilterByUser),
|
|
||||||
nameof(IncludedUsers)
|
|
||||||
};
|
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
|
||||||
|
|
||||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
|
||||||
|
|
||||||
private bool filterByDate = false;
|
|
||||||
private DateTime? startDate = null;
|
|
||||||
private DateTime? endDate = null;
|
|
||||||
private bool filterByChannel = false;
|
|
||||||
private HashSet<ulong>? includedChannels = null;
|
|
||||||
private bool filterByUser = false;
|
|
||||||
private HashSet<ulong>? includedUsers = null;
|
|
||||||
|
|
||||||
public bool FilterByDate {
|
|
||||||
get => filterByDate;
|
|
||||||
set => Change(ref filterByDate, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DateTime? StartDate {
|
|
||||||
get => startDate;
|
|
||||||
set => Change(ref startDate, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DateTime? EndDate {
|
|
||||||
get => endDate;
|
|
||||||
set => Change(ref endDate, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool FilterByChannel {
|
|
||||||
get => filterByChannel;
|
|
||||||
set => Change(ref filterByChannel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public HashSet<ulong> IncludedChannels {
|
|
||||||
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
|
|
||||||
set => Change(ref includedChannels, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool FilterByUser {
|
|
||||||
get => filterByUser;
|
|
||||||
set => Change(ref filterByUser, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public HashSet<ulong> IncludedUsers {
|
|
||||||
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
|
|
||||||
set => Change(ref includedUsers, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string channelFilterLabel = "";
|
|
||||||
|
|
||||||
public string ChannelFilterLabel {
|
|
||||||
get => channelFilterLabel;
|
|
||||||
set => Change(ref channelFilterLabel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string userFilterLabel = "";
|
|
||||||
|
|
||||||
public string UserFilterLabel {
|
|
||||||
get => userFilterLabel;
|
|
||||||
set => Change(ref userFilterLabel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Window window;
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
private readonly string verb;
|
|
||||||
|
|
||||||
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
|
|
||||||
private long? exportedMessageCount;
|
|
||||||
private long? totalMessageCount;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
|
|
||||||
this.window = window;
|
|
||||||
this.db = db;
|
|
||||||
this.verb = verb;
|
|
||||||
|
|
||||||
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
|
|
||||||
|
|
||||||
|
sealed partial class MessageFilterPanelModel : IDisposable {
|
||||||
|
private static readonly HashSet<string> FilterProperties = [
|
||||||
|
nameof(FilterByDate),
|
||||||
|
nameof(StartDate),
|
||||||
|
nameof(EndDate),
|
||||||
|
nameof(FilterByChannel),
|
||||||
|
nameof(IncludedChannels),
|
||||||
|
nameof(FilterByUser),
|
||||||
|
nameof(IncludedUsers),
|
||||||
|
];
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
||||||
|
|
||||||
|
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private string filterStatisticsText = "";
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool filterByDate = false;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private DateTime? startDate = null;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private DateTime? endDate = null;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool filterByChannel = false;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private HashSet<ulong>? includedChannels = null;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool filterByUser = false;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private HashSet<ulong>? includedUsers = null;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private string channelFilterLabel = "";
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private string userFilterLabel = "";
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
private readonly State state;
|
||||||
|
private readonly string verb;
|
||||||
|
|
||||||
|
private readonly RestartableTask<long> exportedMessageCountTask;
|
||||||
|
private long? exportedMessageCount;
|
||||||
|
|
||||||
|
private readonly IDisposable messageCountSubscription;
|
||||||
|
private long? totalMessageCount;
|
||||||
|
|
||||||
|
private readonly IDisposable channelCountSubscription;
|
||||||
|
private long? totalChannelCount;
|
||||||
|
|
||||||
|
private readonly IDisposable userCountSubscription;
|
||||||
|
private long? totalUserCount;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
|
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
|
||||||
|
this.window = window;
|
||||||
|
this.state = state;
|
||||||
|
this.verb = verb;
|
||||||
|
|
||||||
|
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
|
||||||
|
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
|
||||||
|
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
|
||||||
|
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
|
||||||
|
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
UpdateChannelFilterLabel();
|
||||||
|
UpdateUserFilterLabel();
|
||||||
|
|
||||||
|
PropertyChanged += OnPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
exportedMessageCountTask.Cancel();
|
||||||
|
|
||||||
|
messageCountSubscription.Dispose();
|
||||||
|
channelCountSubscription.Dispose();
|
||||||
|
userCountSubscription.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
UpdateChannelFilterLabel();
|
FilterPropertyChanged?.Invoke(sender, e);
|
||||||
UpdateUserFilterLabel();
|
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
|
||||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
FilterPropertyChanged?.Invoke(sender, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
|
||||||
UpdateChannelFilterLabel();
|
|
||||||
}
|
|
||||||
else if (e.PropertyName is nameof(FilterByUser) or nameof(IncludedUsers)) {
|
|
||||||
UpdateUserFilterLabel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
|
||||||
totalMessageCount = db.Statistics.TotalMessages;
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
|
||||||
UpdateChannelFilterLabel();
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
|
||||||
UpdateUserFilterLabel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
|
||||||
var filter = CreateFilter();
|
|
||||||
if (filter.IsEmpty) {
|
|
||||||
exportedMessageCountComputer.Cancel();
|
|
||||||
exportedMessageCount = totalMessageCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
exportedMessageCount = null;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetExportedMessageCount(long exportedMessageCount) {
|
|
||||||
this.exportedMessageCount = exportedMessageCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateFilterStatisticsText() {
|
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
||||||
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
UpdateChannelFilterLabel();
|
||||||
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
|
||||||
|
|
||||||
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 0 ? "." : "s.");
|
|
||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
|
||||||
}
|
}
|
||||||
|
else if (e.PropertyName is nameof(FilterByUser) or nameof(IncludedUsers)) {
|
||||||
public async void OpenChannelFilterDialog() {
|
UpdateUserFilterLabel();
|
||||||
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMessageCountChanged(long newMessageCount) {
|
||||||
|
totalMessageCount = newMessageCount;
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChannelCountChanged(long newChannelCount) {
|
||||||
|
totalChannelCount = newChannelCount;
|
||||||
|
UpdateChannelFilterLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUserCountChanged(long newUserCount) {
|
||||||
|
totalUserCount = newUserCount;
|
||||||
|
UpdateUserFilterLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateChannelFilterLabel() {
|
||||||
|
if (totalChannelCount.HasValue) {
|
||||||
|
long total = totalChannelCount.Value;
|
||||||
|
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
|
||||||
|
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ChannelFilterLabel = "Loading...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateUserFilterLabel() {
|
||||||
|
if (totalUserCount.HasValue) {
|
||||||
|
long total = totalUserCount.Value;
|
||||||
|
long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total;
|
||||||
|
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
UserFilterLabel = "Loading...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatistics() {
|
||||||
|
MessageFilter filter = CreateFilter();
|
||||||
|
if (filter.IsEmpty) {
|
||||||
|
exportedMessageCountTask.Cancel();
|
||||||
|
exportedMessageCount = totalMessageCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
exportedMessageCount = null;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetExportedMessageCount(long exportedMessageCount) {
|
||||||
|
this.exportedMessageCount = exportedMessageCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatisticsText() {
|
||||||
|
string exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
||||||
|
string totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
||||||
|
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OpenChannelFilterDialog() {
|
||||||
|
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
|
||||||
var items = new List<CheckBoxItem<ulong>>();
|
var items = new List<CheckBoxItem<ulong>>();
|
||||||
var included = IncludedChannels;
|
Dictionary<ulong, DHT.Server.Data.Server> servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
||||||
|
|
||||||
foreach (var channel in db.GetAllChannels()) {
|
await foreach (Channel channel in state.Db.Channels.Get()) {
|
||||||
var channelId = channel.Id;
|
ulong channelId = channel.Id;
|
||||||
var channelName = channel.Name;
|
string channelName = channel.Name;
|
||||||
|
|
||||||
string title;
|
string title;
|
||||||
if (servers.TryGetValue(channel.Server, out var server)) {
|
if (servers.TryGetValue(channel.Server, out var server)) {
|
||||||
var titleBuilder = new StringBuilder();
|
var titleBuilder = new StringBuilder();
|
||||||
var serverType = server.Type;
|
ServerType? serverType = server.Type;
|
||||||
|
|
||||||
titleBuilder.Append('[')
|
titleBuilder.Append('[')
|
||||||
.Append(ServerTypes.ToString(serverType))
|
.Append(ServerTypes.ToString(serverType))
|
||||||
.Append("] ");
|
.Append("] ");
|
||||||
|
|
||||||
if (serverType == ServerType.DirectMessage) {
|
if (serverType == ServerType.DirectMessage) {
|
||||||
titleBuilder.Append(channelName);
|
titleBuilder.Append(channelName);
|
||||||
}
|
}
|
||||||
@@ -199,87 +211,97 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
.Append(" - ")
|
.Append(" - ")
|
||||||
.Append(channelName);
|
.Append(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
title = titleBuilder.ToString();
|
title = titleBuilder.ToString();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
title = channelName;
|
title = channelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||||
Title = title,
|
Title = title,
|
||||||
Checked = included.Contains(channelId)
|
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await OpenIdFilterDialog(window, "Included Channels", items);
|
return items;
|
||||||
if (result != null) {
|
|
||||||
IncludedChannels = result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenUserFilterDialog() {
|
const string Title = "Included Channels";
|
||||||
var items = new List<CheckBoxItem<ulong>>();
|
|
||||||
var included = IncludedUsers;
|
List<CheckBoxItem<ulong>> items;
|
||||||
|
try {
|
||||||
foreach (var user in db.GetAllUsers()) {
|
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
|
||||||
var name = user.Name;
|
} catch (Exception e) {
|
||||||
var discriminator = user.Discriminator;
|
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
|
||||||
|
return;
|
||||||
items.Add(new CheckBoxItem<ulong>(user.Id) {
|
|
||||||
Title = discriminator == null ? name : name + " #" + discriminator,
|
|
||||||
Checked = included.Contains(user.Id)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await OpenIdFilterDialog(window, "Included Users", items);
|
|
||||||
if (result != null) {
|
|
||||||
IncludedUsers = result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateChannelFilterLabel() {
|
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
|
||||||
long total = db.Statistics.TotalChannels;
|
if (result != null) {
|
||||||
long included = FilterByChannel ? IncludedChannels.Count : total;
|
IncludedChannels = result;
|
||||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateUserFilterLabel() {
|
|
||||||
long total = db.Statistics.TotalUsers;
|
|
||||||
long included = FilterByUser ? IncludedUsers.Count : total;
|
|
||||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
public MessageFilter CreateFilter() {
|
|
||||||
MessageFilter filter = new();
|
|
||||||
|
|
||||||
if (FilterByDate) {
|
|
||||||
filter.StartDate = StartDate;
|
|
||||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FilterByChannel) {
|
|
||||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FilterByUser) {
|
|
||||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
|
|
||||||
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
|
||||||
|
|
||||||
var model = new CheckBoxDialogModel<ulong>(items) {
|
|
||||||
Title = title
|
|
||||||
};
|
|
||||||
|
|
||||||
var dialog = new CheckBoxDialog { DataContext = model };
|
|
||||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
|
||||||
|
|
||||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task OpenUserFilterDialog() {
|
||||||
|
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
|
||||||
|
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
||||||
|
|
||||||
|
await foreach (User user in state.Db.Users.Get()) {
|
||||||
|
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||||
|
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
|
||||||
|
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkBoxItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string Title = "Included Users";
|
||||||
|
|
||||||
|
List<CheckBoxItem<ulong>> items;
|
||||||
|
try {
|
||||||
|
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
||||||
|
} catch (Exception e) {
|
||||||
|
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
|
||||||
|
if (result != null) {
|
||||||
|
IncludedUsers = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) {
|
||||||
|
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||||
|
|
||||||
|
var model = new CheckBoxDialogModel<ulong>(items) {
|
||||||
|
Title = title,
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = new CheckBoxDialog { DataContext = model };
|
||||||
|
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||||
|
|
||||||
|
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageFilter CreateFilter() {
|
||||||
|
MessageFilter filter = new ();
|
||||||
|
|
||||||
|
if (FilterByDate) {
|
||||||
|
filter.StartDate = StartDate;
|
||||||
|
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FilterByChannel && IncludedChannels != null) {
|
||||||
|
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FilterByUser && IncludedUsers != null) {
|
||||||
|
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel">
|
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
|
||||||
|
x:DataType="controls:ServerConfigurationPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:ServerConfigurationPanelModel />
|
<controls:ServerConfigurationPanelModel />
|
||||||
@@ -15,6 +16,10 @@
|
|||||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||||
<Setter Property="FontSize" Value="15" />
|
<Setter Property="FontSize" Value="15" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Label">
|
||||||
|
<Setter Property="Margin" Value="0 5" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
</Style>
|
||||||
<Style Selector="WrapPanel > StackPanel">
|
<Style Selector="WrapPanel > StackPanel">
|
||||||
<Setter Property="Orientation" Value="Vertical" />
|
<Setter Property="Orientation" Value="Vertical" />
|
||||||
<Setter Property="Margin" Value="0 0 10 10" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
@@ -24,7 +29,7 @@
|
|||||||
<StackPanel>
|
<StackPanel>
|
||||||
<Button Command="{Binding OnClickToggleServerButton}" Content="{Binding ToggleServerButtonText}" IsEnabled="{Binding IsToggleServerButtonEnabled}" />
|
<Button Command="{Binding OnClickToggleServerButton}" Content="{Binding ToggleServerButtonText}" IsEnabled="{Binding IsToggleServerButtonEnabled}" />
|
||||||
<TextBlock TextWrapping="Wrap" Margin="0 15">
|
<TextBlock TextWrapping="Wrap" Margin="0 15">
|
||||||
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script again.
|
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script or connection code again.
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<WrapPanel>
|
<WrapPanel>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class ServerConfigurationPanel : UserControl {
|
|
||||||
public ServerConfigurationPanel() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class ServerConfigurationPanel : UserControl {
|
||||||
}
|
public ServerConfigurationPanel() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,116 +1,118 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Server.Database;
|
using DHT.Server;
|
||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Logging;
|
||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
|
||||||
private string inputPort;
|
|
||||||
|
|
||||||
public string InputPort {
|
sealed partial class ServerConfigurationPanelModel : IDisposable {
|
||||||
get => inputPort;
|
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
|
||||||
set {
|
|
||||||
Change(ref inputPort, value);
|
[Notify]
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
private string inputPort;
|
||||||
}
|
|
||||||
|
[Notify]
|
||||||
|
private string inputToken;
|
||||||
|
|
||||||
|
[DependsOn(nameof(InputPort), nameof(InputToken))]
|
||||||
|
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private bool isToggleServerButtonEnabled = true;
|
||||||
|
|
||||||
|
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
private readonly ServerManager server;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
|
public ServerConfigurationPanelModel(Window window, State state) {
|
||||||
|
this.window = window;
|
||||||
|
this.server = state.Server;
|
||||||
|
this.inputPort = ServerConfiguration.Port.ToString();
|
||||||
|
this.inputToken = ServerConfiguration.Token;
|
||||||
|
|
||||||
|
server.StatusChanged += OnServerStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
server.StatusChanged -= OnServerStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||||
|
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateServerStatus() {
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(ToggleServerButtonText)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartServer() {
|
||||||
|
IsToggleServerButtonEnabled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string inputToken;
|
UpdateServerStatus();
|
||||||
|
IsToggleServerButtonEnabled = true;
|
||||||
public string InputToken {
|
}
|
||||||
get => inputToken;
|
|
||||||
set {
|
private async Task StopServer() {
|
||||||
Change(ref inputToken, value);
|
IsToggleServerButtonEnabled = false;
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
|
||||||
}
|
try {
|
||||||
|
await server.Stop();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
UpdateServerStatus();
|
||||||
|
IsToggleServerButtonEnabled = true;
|
||||||
private bool isToggleServerButtonEnabled = true;
|
}
|
||||||
|
|
||||||
public bool IsToggleServerButtonEnabled {
|
public async Task OnClickToggleServerButton() {
|
||||||
get => isToggleServerButtonEnabled;
|
if (server.IsRunning) {
|
||||||
set => Change(ref isToggleServerButtonEnabled, value);
|
await StopServer();
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
|
await StartServer();
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnClickRandomizeToken() {
|
||||||
|
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickApplyChanges() {
|
||||||
|
if (!ushort.TryParse(InputPort, out ushort port)) {
|
||||||
|
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerConfiguration.Port = port;
|
||||||
|
ServerConfiguration.Token = inputToken;
|
||||||
|
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(HasMadeChanges)));
|
||||||
|
|
||||||
|
await StartServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnClickCancelChanges() {
|
||||||
|
InputPort = ServerConfiguration.Port.ToString();
|
||||||
|
InputToken = ServerConfiguration.Token;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.StatusBar">
|
x:Class="DHT.Desktop.Main.Controls.StatusBar"
|
||||||
|
x:DataType="controls:StatusBarModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:StatusBarModel />
|
<controls:StatusBarModel />
|
||||||
@@ -39,22 +40,22 @@
|
|||||||
<StackPanel Orientation="Horizontal" Margin="6 3">
|
<StackPanel Orientation="Horizontal" Margin="6 3">
|
||||||
<StackPanel Orientation="Vertical" Width="65">
|
<StackPanel Orientation="Vertical" Width="65">
|
||||||
<TextBlock Classes="label">Status</TextBlock>
|
<TextBlock Classes="label">Status</TextBlock>
|
||||||
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
|
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Servers</TextBlock>
|
<TextBlock Classes="label">Servers</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding ServerCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Channels</TextBlock>
|
<TextBlock Classes="label">Channels</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding ChannelCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Messages</TextBlock>
|
<TextBlock Classes="label">Messages</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding MessageCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class StatusBar : UserControl {
|
|
||||||
public StatusBar() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class StatusBar : UserControl {
|
||||||
}
|
public StatusBar() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,45 +1,63 @@
|
|||||||
using System;
|
using System;
|
||||||
using DHT.Server.Database;
|
using System.Reactive.Linq;
|
||||||
using DHT.Utils.Models;
|
using Avalonia.ReactiveUI;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using DHT.Server;
|
||||||
|
using DHT.Server.Service;
|
||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class StatusBarModel : BaseModel {
|
|
||||||
public DatabaseStatistics DatabaseStatistics { get; }
|
|
||||||
|
|
||||||
private Status status = Status.Stopped;
|
sealed partial class StatusBarModel : IDisposable {
|
||||||
|
[Notify(Setter.Private)]
|
||||||
public Status CurrentStatus {
|
private long? serverCount;
|
||||||
get => status;
|
|
||||||
set {
|
[Notify(Setter.Private)]
|
||||||
status = value;
|
private long? channelCount;
|
||||||
OnPropertyChanged(nameof(StatusText));
|
|
||||||
}
|
[Notify(Setter.Private)]
|
||||||
}
|
private long? messageCount;
|
||||||
|
|
||||||
public string StatusText {
|
[Notify(Setter.Private)]
|
||||||
get {
|
private ServerManager.Status serverStatus;
|
||||||
return CurrentStatus switch {
|
|
||||||
Status.Starting => "STARTING",
|
[DependsOn(nameof(ServerStatus))]
|
||||||
Status.Ready => "READY",
|
public string ServerStatusText => ServerStatus switch {
|
||||||
Status.Stopping => "STOPPING",
|
ServerManager.Status.Starting => "STARTING",
|
||||||
Status.Stopped => "STOPPED",
|
ServerManager.Status.Started => "READY",
|
||||||
_ => ""
|
ServerManager.Status.Stopping => "STOPPING",
|
||||||
};
|
ServerManager.Status.Stopped => "STOPPED",
|
||||||
}
|
_ => "",
|
||||||
}
|
};
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly State state;
|
||||||
public StatusBarModel() : this(new DatabaseStatistics()) {}
|
private readonly IDisposable serverCountSubscription;
|
||||||
|
private readonly IDisposable channelCountSubscription;
|
||||||
public StatusBarModel(DatabaseStatistics databaseStatistics) {
|
private readonly IDisposable messageCountSubscription;
|
||||||
this.DatabaseStatistics = databaseStatistics;
|
|
||||||
}
|
[Obsolete("Designer")]
|
||||||
|
public StatusBarModel() : this(State.Dummy) {}
|
||||||
public enum Status {
|
|
||||||
Starting,
|
public StatusBarModel(State state) {
|
||||||
Ready,
|
this.state = state;
|
||||||
Stopping,
|
|
||||||
Stopped
|
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
|
||||||
}
|
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
|
||||||
|
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
|
||||||
|
|
||||||
|
state.Server.StatusChanged += OnStateServerStatusChanged;
|
||||||
|
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
serverCountSubscription.Dispose();
|
||||||
|
channelCountSubscription.Dispose();
|
||||||
|
messageCountSubscription.Dispose();
|
||||||
|
|
||||||
|
state.Server.StatusChanged -= OnStateServerStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStateServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||||
|
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
57
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml
Normal file
57
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:namespace="clr-namespace:DHT.Desktop.Main.Dialogs"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="450"
|
||||||
|
x:Class="DHT.Desktop.Main.Dialogs.NewDatabaseSettingsDialog"
|
||||||
|
x:DataType="namespace:NewDatabaseSettingsDialogModel"
|
||||||
|
Title="New Database Settings"
|
||||||
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
|
Closing="OnClosing"
|
||||||
|
Width="450" SizeToContent="Height" CanResize="False"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<Window.DataContext>
|
||||||
|
<namespace:NewDatabaseSettingsDialogModel />
|
||||||
|
</Window.DataContext>
|
||||||
|
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="Label > StackPanel">
|
||||||
|
<Setter Property="Margin" Value="0 4 0 0" />
|
||||||
|
<Setter Property="Orientation" Value="Vertical" />
|
||||||
|
<Setter Property="Spacing" Value="5" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.dim">
|
||||||
|
<Setter Property="Foreground" Value="DimGray" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Vertical" Margin="20">
|
||||||
|
<StackPanel Orientation="Vertical" Spacing="15">
|
||||||
|
<CheckBox IsChecked="{Binding SeparateFileForDownloads}">
|
||||||
|
<Label>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock>Use a Separate Database File for Downloads</TextBlock>
|
||||||
|
<TextBlock Classes="dim">Downloaded files will be stored separately from messages. This cannot be changed later.</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Label>
|
||||||
|
</CheckBox>
|
||||||
|
<CheckBox IsChecked="{Binding DownloadsAutoStart}">
|
||||||
|
<Label>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock>Download Files Automatically</TextBlock>
|
||||||
|
<TextBlock Classes="dim">Enables automatic downloads of files hosted on Discord. This can be changed later in the Downloads tab.</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Label>
|
||||||
|
</CheckBox>
|
||||||
|
</StackPanel>
|
||||||
|
<Panel Classes="buttons">
|
||||||
|
<WrapPanel Classes="right">
|
||||||
|
<Button Click="ClickOk">OK</Button>
|
||||||
|
</WrapPanel>
|
||||||
|
</Panel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Window>
|
22
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml.cs
Normal file
22
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Dialogs;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
|
public sealed partial class NewDatabaseSettingsDialog : Window {
|
||||||
|
public NewDatabaseSettingsDialog() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
|
if (!e.IsProgrammatic) {
|
||||||
|
e.Cancel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
11
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialogModel.cs
Normal file
11
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialogModel.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Dialogs;
|
||||||
|
|
||||||
|
sealed partial class NewDatabaseSettingsDialogModel {
|
||||||
|
[Notify]
|
||||||
|
private bool separateFileForDownloads = true;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool downloadsAutoStart = true;
|
||||||
|
}
|
@@ -5,12 +5,13 @@
|
|||||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.MainWindow"
|
x:Class="DHT.Desktop.Main.MainWindow"
|
||||||
|
x:DataType="main:MainWindowModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="800" Height="500"
|
Width="820" Height="520"
|
||||||
MinWidth="520" MinHeight="300"
|
MinWidth="520" MinHeight="300"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
Closed="OnClosed">
|
Closing="OnClosing">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<main:MainWindowModel />
|
<main:MainWindowModel />
|
||||||
|
@@ -1,44 +1,44 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Desktop.Main.Pages;
|
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class MainWindow : Window {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
[UsedImplicitly]
|
public sealed partial class MainWindow : Window {
|
||||||
public MainWindow() {
|
private static readonly Log Log = Log.ForType<MainWindow>();
|
||||||
InitializeComponent(Arguments.Empty);
|
|
||||||
|
[UsedImplicitly]
|
||||||
|
public MainWindow() {
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = new MainWindowModel(this, Arguments.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal MainWindow(Arguments args) {
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = new MainWindowModel(this, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
|
e.Cancel = true;
|
||||||
|
Closing -= OnClosing;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Dispose();
|
||||||
|
} finally {
|
||||||
|
Close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
internal MainWindow(Arguments args) {
|
|
||||||
InitializeComponent(args);
|
private async Task Dispose() {
|
||||||
}
|
if (DataContext is MainWindowModel model) {
|
||||||
|
try {
|
||||||
private void InitializeComponent(Arguments args) {
|
await model.DisposeAsync();
|
||||||
AvaloniaXamlLoader.Load(this);
|
} catch (Exception ex) {
|
||||||
DataContext = new MainWindowModel(this, args);
|
Log.Error("Caught exception while disposing window: " + ex);
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClosed(object? sender, EventArgs e) {
|
|
||||||
if (DataContext is IDisposable disposable) {
|
|
||||||
disposable.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
|
||||||
try {
|
|
||||||
File.Delete(temporaryFile);
|
|
||||||
} catch (Exception) {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,116 +1,137 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Main.Screens;
|
using DHT.Desktop.Main.Screens;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
|
using DHT.Server;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Logging;
|
||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
sealed class MainWindowModel : BaseModel, IDisposable {
|
|
||||||
private const string DefaultTitle = "Discord History Tracker";
|
|
||||||
|
|
||||||
public string Title { get; private set; } = DefaultTitle;
|
sealed partial class MainWindowModel : IAsyncDisposable {
|
||||||
|
private const string DefaultTitle = "Discord History Tracker";
|
||||||
public UserControl CurrentScreen { get; private set; }
|
|
||||||
|
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private string title = DefaultTitle;
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private UserControl currentScreen;
|
||||||
|
|
||||||
|
private readonly WelcomeScreen welcomeScreen;
|
||||||
|
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||||
|
|
||||||
|
private MainContentScreenModel? mainContentScreenModel;
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
private readonly int? concurrentDownloads;
|
||||||
|
|
||||||
|
private State? state;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||||
|
|
||||||
|
public MainWindowModel(Window window, Arguments args) {
|
||||||
|
this.window = window;
|
||||||
|
|
||||||
private readonly WelcomeScreen welcomeScreen;
|
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
|
|
||||||
private MainContentScreen? mainContentScreen;
|
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||||
private MainContentScreenModel? mainContentScreenModel;
|
currentScreen = welcomeScreen;
|
||||||
|
|
||||||
private readonly Window window;
|
string? dbFile = args.DatabaseFile;
|
||||||
|
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||||
private IDatabaseFile? db;
|
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
||||||
|
window.Opened -= OnWindowOpened;
|
||||||
[Obsolete("Designer")]
|
|
||||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
||||||
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
public MainWindowModel(Window window, Arguments args) {
|
await Task.Delay(500);
|
||||||
this.window = window;
|
|
||||||
|
|
||||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
|
||||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
|
||||||
CurrentScreen = welcomeScreen;
|
|
||||||
|
|
||||||
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
|
||||||
|
|
||||||
var dbFile = args.DatabaseFile;
|
|
||||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
|
||||||
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
|
||||||
window.Opened -= OnWindowOpened;
|
|
||||||
|
|
||||||
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
|
||||||
await Task.Delay(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(dbFile)) {
|
|
||||||
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Opened += OnWindowOpened;
|
if (File.Exists(dbFile)) {
|
||||||
}
|
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
||||||
|
|
||||||
if (args.ServerPort != null) {
|
|
||||||
ServerManager.Port = args.ServerPort.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.ServerToken != null) {
|
|
||||||
ServerManager.Token = args.ServerToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
|
|
||||||
if (mainContentScreenModel != null) {
|
|
||||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
|
||||||
mainContentScreenModel.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
db?.Dispose();
|
|
||||||
db = welcomeScreenModel.Db;
|
|
||||||
|
|
||||||
if (db == null) {
|
|
||||||
Title = DefaultTitle;
|
|
||||||
mainContentScreenModel = null;
|
|
||||||
mainContentScreen = null;
|
|
||||||
CurrentScreen = welcomeScreen;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
||||||
mainContentScreenModel = new MainContentScreenModel(window, db);
|
|
||||||
await mainContentScreenModel.Initialize();
|
|
||||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
|
||||||
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
|
||||||
CurrentScreen = mainContentScreen;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(CurrentScreen));
|
|
||||||
OnPropertyChanged(nameof(Title));
|
|
||||||
|
|
||||||
window.Focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.Opened += OnWindowOpened;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
if (args.ServerPort != null) {
|
||||||
welcomeScreenModel.CloseDatabase();
|
ServerConfiguration.Port = args.ServerPort.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
if (args.ServerToken != null) {
|
||||||
welcomeScreenModel.Dispose();
|
ServerConfiguration.Token = args.ServerToken;
|
||||||
mainContentScreenModel?.Dispose();
|
}
|
||||||
db?.Dispose();
|
|
||||||
db = null;
|
concurrentDownloads = args.ConcurrentDownloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
||||||
|
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
|
||||||
|
|
||||||
|
await DisposeState();
|
||||||
|
|
||||||
|
state = new State(db, concurrentDownloads);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.Error(ex);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainContentScreenModel = new MainContentScreenModel(window, state);
|
||||||
|
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||||
|
|
||||||
|
await mainContentScreenModel.Initialize();
|
||||||
|
|
||||||
|
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
||||||
|
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||||
|
|
||||||
|
window.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||||
|
await DisposeContent();
|
||||||
|
|
||||||
|
Title = DefaultTitle;
|
||||||
|
CurrentScreen = welcomeScreen;
|
||||||
|
|
||||||
|
await DisposeState();
|
||||||
|
|
||||||
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisposeContent() {
|
||||||
|
if (mainContentScreenModel != null) {
|
||||||
|
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||||
|
await mainContentScreenModel.DisposeAsync();
|
||||||
|
mainContentScreenModel = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task DisposeState() {
|
||||||
|
if (state != null) {
|
||||||
|
await DelayedProgressDialog.Await(() => state.DisposeAsync().AsTask(), TimeSpan.FromMilliseconds(200), window, "Close Database", "Please wait for the database to close...");
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() {
|
||||||
|
await DisposeContent();
|
||||||
|
await DisposeState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.AdvancedPage">
|
x:Class="DHT.Desktop.Main.Pages.AdvancedPage"
|
||||||
|
x:DataType="pages:AdvancedPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:AdvancedPageModel />
|
<pages:AdvancedPageModel />
|
||||||
@@ -15,11 +16,5 @@
|
|||||||
<Expander Header="Internal Server Configuration" IsExpanded="True">
|
<Expander Header="Internal Server Configuration" IsExpanded="True">
|
||||||
<controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" />
|
<controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" />
|
||||||
</Expander>
|
</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>
|
</StackPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class AdvancedPage : UserControl {
|
|
||||||
public AdvancedPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class AdvancedPage : UserControl {
|
||||||
}
|
public AdvancedPage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,39 +1,21 @@
|
|||||||
using System;
|
using System;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
|
||||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
|
||||||
|
|
||||||
private readonly Window window;
|
sealed class AdvancedPageModel : IDisposable {
|
||||||
private readonly IDatabaseFile db;
|
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
|
public AdvancedPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
|
public AdvancedPageModel(Window window, State state) {
|
||||||
this.window = window;
|
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
||||||
this.db = db;
|
}
|
||||||
|
|
||||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
|
public void Dispose() {
|
||||||
}
|
ServerConfigurationModel.Dispose();
|
||||||
|
|
||||||
public void Initialize() {
|
|
||||||
ServerConfigurationModel.Initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
ServerConfigurationModel.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void VacuumDatabase() {
|
|
||||||
db.Vacuum();
|
|
||||||
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,16 +0,0 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class AttachmentsPage : UserControl {
|
|
||||||
public AttachmentsPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,204 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using DHT.Desktop.Common;
|
|
||||||
using DHT.Desktop.Main.Controls;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Data.Aggregations;
|
|
||||||
using DHT.Server.Data.Filters;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Download;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
|
||||||
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
|
||||||
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
|
||||||
DownloadStatus.Enqueued
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private bool isThreadDownloadButtonEnabled = true;
|
|
||||||
|
|
||||||
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
|
|
||||||
|
|
||||||
public bool IsToggleDownloadButtonEnabled {
|
|
||||||
get => isThreadDownloadButtonEnabled;
|
|
||||||
set => Change(ref isThreadDownloadButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string DownloadMessage { get; set; } = "";
|
|
||||||
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
|
|
||||||
|
|
||||||
public AttachmentFilterPanelModel FilterModel { get; }
|
|
||||||
|
|
||||||
private readonly StatisticsRow statisticsEnqueued = new ("Enqueued");
|
|
||||||
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
|
||||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
|
||||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
|
||||||
|
|
||||||
public List<StatisticsRow> StatisticsRows {
|
|
||||||
get {
|
|
||||||
return new List<StatisticsRow> {
|
|
||||||
statisticsEnqueued,
|
|
||||||
statisticsDownloaded,
|
|
||||||
statisticsFailed,
|
|
||||||
statisticsSkipped
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsDownloading => downloadThread != null;
|
|
||||||
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
|
|
||||||
private BackgroundDownloadThread? downloadThread;
|
|
||||||
|
|
||||||
private int doneItemsCount;
|
|
||||||
private int? allItemsCount;
|
|
||||||
|
|
||||||
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public AttachmentsPageModel(IDatabaseFile db) {
|
|
||||||
this.db = db;
|
|
||||||
this.FilterModel = new AttachmentFilterPanelModel(db);
|
|
||||||
|
|
||||||
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics);
|
|
||||||
this.downloadStatisticsComputer.Recompute();
|
|
||||||
|
|
||||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
|
|
||||||
FilterModel.Dispose();
|
|
||||||
DisposeDownloadThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
|
||||||
if (IsDownloading) {
|
|
||||||
EnqueueDownloadItems();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnqueueDownloadItems() {
|
|
||||||
var filter = FilterModel.CreateFilter();
|
|
||||||
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
|
||||||
db.EnqueueDownloadItems(filter);
|
|
||||||
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
|
||||||
var hadFailedDownloads = HasFailedDownloads;
|
|
||||||
|
|
||||||
statisticsEnqueued.Items = statusStatistics.EnqueuedCount;
|
|
||||||
statisticsEnqueued.Size = statusStatistics.EnqueuedSize;
|
|
||||||
|
|
||||||
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
|
||||||
statisticsDownloaded.Size = statusStatistics.SuccessfulSize;
|
|
||||||
|
|
||||||
statisticsFailed.Items = statusStatistics.FailedCount;
|
|
||||||
statisticsFailed.Size = statusStatistics.FailedSize;
|
|
||||||
|
|
||||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
|
||||||
statisticsSkipped.Size = statusStatistics.SkippedSize;
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(StatisticsRows));
|
|
||||||
|
|
||||||
if (hadFailedDownloads != HasFailedDownloads) {
|
|
||||||
OnPropertyChanged(nameof(HasFailedDownloads));
|
|
||||||
}
|
|
||||||
|
|
||||||
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateDownloadMessage() {
|
|
||||||
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(DownloadMessage));
|
|
||||||
OnPropertyChanged(nameof(DownloadProgress));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
|
|
||||||
++doneItemsCount;
|
|
||||||
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DownloadThreadOnOnServerStopped(object? sender, EventArgs e) {
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
IsToggleDownloadButtonEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClickToggleDownload() {
|
|
||||||
if (downloadThread == null) {
|
|
||||||
EnqueueDownloadItems();
|
|
||||||
downloadThread = new BackgroundDownloadThread(db);
|
|
||||||
downloadThread.OnItemFinished += DownloadThreadOnOnItemFinished;
|
|
||||||
downloadThread.OnServerStopped += DownloadThreadOnOnServerStopped;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
IsToggleDownloadButtonEnabled = false;
|
|
||||||
DisposeDownloadThread();
|
|
||||||
|
|
||||||
db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
|
|
||||||
|
|
||||||
doneItemsCount = 0;
|
|
||||||
allItemsCount = null;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
|
||||||
OnPropertyChanged(nameof(IsDownloading));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClickRetryFailedDownloads() {
|
|
||||||
var allExceptFailedFilter = new DownloadItemFilter {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
|
||||||
DownloadStatus.Enqueued,
|
|
||||||
DownloadStatus.Success
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
|
|
||||||
if (IsDownloading) {
|
|
||||||
EnqueueDownloadItems();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DisposeDownloadThread() {
|
|
||||||
if (downloadThread != null) {
|
|
||||||
downloadThread.OnItemFinished -= DownloadThreadOnOnItemFinished;
|
|
||||||
downloadThread.StopThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadThread = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class StatisticsRow {
|
|
||||||
public string State { get; }
|
|
||||||
public int Items { get; set; }
|
|
||||||
public ulong? Size { get; set; }
|
|
||||||
|
|
||||||
public StatisticsRow(string state) {
|
|
||||||
State = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.DatabasePage">
|
x:Class="DHT.Desktop.Main.Pages.DatabasePage"
|
||||||
|
x:DataType="pages:DatabasePageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DatabasePageModel />
|
<pages:DatabasePageModel />
|
||||||
@@ -26,6 +27,12 @@
|
|||||||
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
|
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
|
||||||
<Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button>
|
<Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button>
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
|
<Expander Header="Advanced Tools" Margin="0 15 0 0">
|
||||||
|
<StackPanel Orientation="Vertical" Spacing="10">
|
||||||
|
<TextBlock TextWrapping="Wrap">Recreate the database to free up space after deleting data.</TextBlock>
|
||||||
|
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Expander>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class DatabasePage : UserControl {
|
|
||||||
public DatabasePage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class DatabasePage : UserControl {
|
||||||
}
|
public DatabasePage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,242 +7,306 @@ using System.Text;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Desktop.Dialogs.File;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Dialogs.TextBox;
|
using DHT.Desktop.Dialogs.TextBox;
|
||||||
|
using DHT.Server;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Import;
|
using DHT.Server.Database.Import;
|
||||||
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class DatabasePageModel : BaseModel {
|
|
||||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
|
||||||
|
|
||||||
public IDatabaseFile Db { get; }
|
sealed class DatabasePageModel {
|
||||||
|
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||||
public event EventHandler? DatabaseClosed;
|
|
||||||
|
public IDatabaseFile Db { get; }
|
||||||
private readonly Window window;
|
|
||||||
|
public event EventHandler? DatabaseClosed;
|
||||||
[Obsolete("Designer")]
|
|
||||||
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
private readonly Window window;
|
||||||
|
|
||||||
public DatabasePageModel(Window window, IDatabaseFile db) {
|
[Obsolete("Designer")]
|
||||||
this.window = window;
|
public DatabasePageModel() : this(null!, State.Dummy) {}
|
||||||
this.Db = db;
|
|
||||||
|
public DatabasePageModel(Window window, State state) {
|
||||||
|
this.window = window;
|
||||||
|
this.Db = state.Db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OpenDatabaseFolder() {
|
||||||
|
string file = Db.Path;
|
||||||
|
string? folder = Path.GetDirectoryName(file);
|
||||||
|
|
||||||
|
if (folder == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenDatabaseFolder() {
|
switch (Environment.OSVersion.Platform) {
|
||||||
string file = Db.Path;
|
case PlatformID.Win32NT:
|
||||||
string? folder = Path.GetDirectoryName(file);
|
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
||||||
|
break;
|
||||||
if (folder == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (Environment.OSVersion.Platform) {
|
|
||||||
case PlatformID.Win32NT:
|
|
||||||
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PlatformID.Unix:
|
|
||||||
Process.Start("xdg-open", new string[] { folder });
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PlatformID.MacOSX:
|
|
||||||
Process.Start("open", new string[] { folder });
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CloseDatabase() {
|
|
||||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void MergeWithDatabase() {
|
|
||||||
var fileDialog = DatabaseGui.NewOpenDatabaseFileDialog();
|
|
||||||
fileDialog.Directory = Path.GetDirectoryName(Db.Path);
|
|
||||||
fileDialog.AllowMultiple = true;
|
|
||||||
|
|
||||||
string[]? paths = await fileDialog.ShowAsync(window);
|
|
||||||
if (paths == null || paths.Length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog();
|
|
||||||
progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) {
|
|
||||||
Title = "Database Merge"
|
|
||||||
};
|
|
||||||
|
|
||||||
await progressDialog.ShowDialog(window);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
|
||||||
int total = paths.Length;
|
|
||||||
|
|
||||||
DialogResult.YesNo? upgradeResult = null;
|
|
||||||
|
|
||||||
async Task<bool> CheckCanUpgradeDatabase() {
|
|
||||||
upgradeResult ??= total > 1
|
|
||||||
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
|
||||||
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
|
|
||||||
|
|
||||||
return DialogResult.YesNo.Yes == upgradeResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
|
||||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
|
||||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
|
|
||||||
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
|
||||||
|
|
||||||
if (db == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
target.AddFrom(db);
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
db.Dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void ImportLegacyArchive() {
|
|
||||||
var fileDialog = new OpenFileDialog {
|
|
||||||
Title = "Open Legacy DHT Archive",
|
|
||||||
Directory = Path.GetDirectoryName(Db.Path),
|
|
||||||
AllowMultiple = true
|
|
||||||
};
|
|
||||||
|
|
||||||
string[]? paths = await fileDialog.ShowAsync(window);
|
|
||||||
if (paths == null || paths.Length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog();
|
|
||||||
progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) {
|
|
||||||
Title = "Legacy Archive Import"
|
|
||||||
};
|
|
||||||
|
|
||||||
await progressDialog.ShowDialog(window);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
|
||||||
var fakeSnowflake = new FakeSnowflake();
|
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
|
||||||
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
|
|
||||||
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
|
||||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
|
||||||
Dictionary<DHT.Server.Data.Server, ulong>? result = await Dispatcher.UIThread.InvokeAsync(() => AskForServerIds(dialog, servers));
|
|
||||||
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
|
||||||
static bool IsValidSnowflake(string value) {
|
|
||||||
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
case PlatformID.Unix:
|
||||||
|
Process.Start("xdg-open", [ folder ]);
|
||||||
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
break;
|
||||||
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
|
||||||
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
case PlatformID.MacOSX:
|
||||||
ValidityCheck = IsValidSnowflake
|
Process.Start("open", [ folder ]);
|
||||||
});
|
break;
|
||||||
}
|
|
||||||
|
default:
|
||||||
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
||||||
Title = "Imported Server IDs",
|
break;
|
||||||
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
|
|
||||||
};
|
|
||||||
|
|
||||||
var dialog = new TextBoxDialog { DataContext = model };
|
|
||||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
|
||||||
|
|
||||||
if (result != DialogResult.OkCancel.Ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return model.ValidItems
|
|
||||||
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
|
||||||
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
|
||||||
int total = paths.Length;
|
|
||||||
var oldStatistics = target.SnapshotStatistics();
|
|
||||||
|
|
||||||
int successful = 0;
|
|
||||||
int finished = 0;
|
|
||||||
|
|
||||||
foreach (string path in paths) {
|
|
||||||
await callback.Update(Path.GetFileName(path), finished, total);
|
|
||||||
++finished;
|
|
||||||
|
|
||||||
if (!File.Exists(path)) {
|
|
||||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (await performImport(path)) {
|
|
||||||
++successful;
|
|
||||||
}
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Log.Error(ex);
|
|
||||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await callback.Update("Done", finished, total);
|
|
||||||
|
|
||||||
if (successful == 0) {
|
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
|
|
||||||
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
|
||||||
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
|
||||||
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
|
|
||||||
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
|
|
||||||
|
|
||||||
StringBuilder message = new StringBuilder();
|
|
||||||
message.Append("Processed ");
|
|
||||||
|
|
||||||
if (successfulItems == totalItems) {
|
|
||||||
message.Append(successfulItems.Pluralize(itemName));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
|
||||||
}
|
|
||||||
|
|
||||||
message.Append(" and added:\n\n \u2022 ");
|
|
||||||
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
|
||||||
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
|
||||||
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
|
||||||
message.Append(newMessages.Pluralize("message"));
|
|
||||||
|
|
||||||
return message.ToString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CloseDatabase() {
|
||||||
|
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MergeWithDatabase() {
|
||||||
|
string[] paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||||
|
if (paths.Length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string Title = "Database Merge";
|
||||||
|
|
||||||
|
ImportResult? result;
|
||||||
|
try {
|
||||||
|
result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "database file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
|
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||||
|
|
||||||
|
return await PerformImport(target, paths, dialog, callback, "Database Merge", async path => {
|
||||||
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||||
|
|
||||||
|
if (db == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await target.AddFrom(db);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
await db.DisposeAsync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, int total) : ISchemaUpgradeCallbacks {
|
||||||
|
private bool? decision;
|
||||||
|
|
||||||
|
public Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() {
|
||||||
|
return Task.FromResult<InitialDatabaseSettings?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUpgrade() {
|
||||||
|
return decision ??= (total > 1
|
||||||
|
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||||
|
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
|
return doUpgrade(new NullReporter());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
|
public Task NextVersion() {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ImportLegacyArchive() {
|
||||||
|
string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
|
Title = "Open Legacy DHT Archive",
|
||||||
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
||||||
|
AllowMultiple = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paths.Length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string Title = "Legacy Archive Import";
|
||||||
|
|
||||||
|
ImportResult? result;
|
||||||
|
try {
|
||||||
|
result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, Title, "Could not import legacy archives: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "archive file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ImportResult?> ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
|
var fakeSnowflake = new FakeSnowflake();
|
||||||
|
|
||||||
|
return await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", async path => {
|
||||||
|
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
|
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||||
|
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||||
|
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||||
|
Dictionary<DHT.Server.Data.Server, ulong>? result = await Dispatcher.UIThread.InvokeAsync(() => AskForServerIds(dialog, servers));
|
||||||
|
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
||||||
|
static bool IsValidSnowflake(string value) {
|
||||||
|
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||||
|
|
||||||
|
foreach (DHT.Server.Data.Server server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||||
|
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
||||||
|
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
||||||
|
ValidityCheck = IsValidSnowflake,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
||||||
|
Title = "Imported Server IDs",
|
||||||
|
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID.",
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = new TextBoxDialog { DataContext = model };
|
||||||
|
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||||
|
|
||||||
|
if (result != DialogResult.OkCancel.Ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.ValidItems
|
||||||
|
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
||||||
|
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ImportResult?> PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string dialogTitle, Func<string, Task<bool>> performImport) {
|
||||||
|
int total = paths.Length;
|
||||||
|
DatabaseStatistics oldStatistics = await DatabaseStatistics.Take(target);
|
||||||
|
|
||||||
|
int successful = 0;
|
||||||
|
int finished = 0;
|
||||||
|
|
||||||
|
foreach (string path in paths) {
|
||||||
|
await callback.Update(Path.GetFileName(path), finished, total);
|
||||||
|
++finished;
|
||||||
|
|
||||||
|
if (!File.Exists(path)) {
|
||||||
|
await Dialog.ShowOk(dialog, dialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await performImport(path)) {
|
||||||
|
++successful;
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.Error(ex);
|
||||||
|
await Dialog.ShowOk(dialog, dialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.Update("Done", finished, total);
|
||||||
|
|
||||||
|
if (successful == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseStatistics newStatistics = await DatabaseStatistics.Take(target);
|
||||||
|
return new ImportResult(oldStatistics, newStatistics, successful, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
|
||||||
|
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
|
||||||
|
return new DatabaseStatistics(
|
||||||
|
await db.Servers.Count(),
|
||||||
|
await db.Channels.Count(),
|
||||||
|
await db.Users.Count(),
|
||||||
|
await db.Messages.Count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ImportResult(DatabaseStatistics OldStatistics, DatabaseStatistics NewStatistics, int SuccessfulItems, int TotalItems);
|
||||||
|
|
||||||
|
private static string GetImportDialogMessage(ImportResult? result, string itemName) {
|
||||||
|
if (result == null) {
|
||||||
|
return "Nothing was imported.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldStatistics = result.OldStatistics;
|
||||||
|
var newStatistics = result.NewStatistics;
|
||||||
|
|
||||||
|
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
|
||||||
|
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
|
||||||
|
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
|
||||||
|
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
|
||||||
|
|
||||||
|
var message = new StringBuilder();
|
||||||
|
message.Append("Processed ");
|
||||||
|
|
||||||
|
if (result.SuccessfulItems == result.TotalItems) {
|
||||||
|
message.Append(result.SuccessfulItems.Pluralize(itemName));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
message.Append(result.SuccessfulItems.Format()).Append(" out of ").Append(result.TotalItems.Pluralize(itemName));
|
||||||
|
}
|
||||||
|
|
||||||
|
message.Append(" and added:\n\n \u2022 ");
|
||||||
|
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
||||||
|
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
||||||
|
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
||||||
|
message.Append(newMessages.Pluralize("message"));
|
||||||
|
|
||||||
|
return message.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task VacuumDatabase() {
|
||||||
|
const string Title = "Vacuum Database";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => Db.Vacuum());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, Title, "Could not vacuum database: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Dialog.ShowOk(window, Title, "Done.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,8 +4,9 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.DebugPage">
|
x:Class="DHT.Desktop.Main.Pages.DebugPage"
|
||||||
|
x:DataType="pages:DebugPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DebugPageModel />
|
<pages:DebugPageModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
@@ -15,6 +16,10 @@
|
|||||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||||
<Setter Property="FontSize" Value="15" />
|
<Setter Property="FontSize" Value="15" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Label">
|
||||||
|
<Setter Property="Margin" Value="0 5" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
</Style>
|
||||||
<Style Selector="WrapPanel > StackPanel">
|
<Style Selector="WrapPanel > StackPanel">
|
||||||
<Setter Property="Orientation" Value="Vertical" />
|
<Setter Property="Orientation" Value="Vertical" />
|
||||||
<Setter Property="Margin" Value="0 0 10 10" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class DebugPage : UserControl {
|
|
||||||
public DebugPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class DebugPage : UserControl {
|
||||||
}
|
public DebugPage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,180 +6,169 @@ using System.Threading.Tasks;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
|
using DHT.Server;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
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;
|
sealed class DebugPageModel {
|
||||||
private readonly IDatabaseFile db;
|
public string GenerateChannels { get; set; } = "0";
|
||||||
|
public string GenerateUsers { get; set; } = "0";
|
||||||
[Obsolete("Designer")]
|
public string GenerateMessages { get; set; } = "0";
|
||||||
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
|
||||||
|
private readonly Window window;
|
||||||
public DebugPageModel(Window window, IDatabaseFile db) {
|
private readonly State state;
|
||||||
this.window = window;
|
|
||||||
this.db = db;
|
[Obsolete("Designer")]
|
||||||
}
|
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public async void OnClickAddRandomDataToDatabase() {
|
public DebugPageModel(Window window, State state) {
|
||||||
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
this.window = window;
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
this.state = state;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
public async void OnClickAddRandomDataToDatabase() {
|
||||||
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
||||||
return;
|
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;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
||||||
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
return;
|
||||||
|
|
||||||
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) {
|
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||||
ulong h = unchecked((ulong) rand.Next());
|
}
|
||||||
ulong l = unchecked((ulong) rand.Next());
|
|
||||||
return (h << 32) | l;
|
private const int BatchSize = 500;
|
||||||
}
|
|
||||||
|
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||||
private static string RandomName(string prefix) {
|
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||||
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
await callback.Update("Adding messages in batches of " + BatchSize, finishedItems: 0, totalItems: batchCount);
|
||||||
}
|
|
||||||
|
var rand = new Random();
|
||||||
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
var server = new DHT.Server.Data.Server {
|
||||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
Id = RandomId(rand),
|
||||||
}
|
Name = RandomName("s"),
|
||||||
|
Type = ServerType.Server,
|
||||||
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) {
|
Channel[] channels = Enumerable.Range(start: 0, channelCount).Select(i => new Channel {
|
||||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
Id = RandomId(rand),
|
||||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
Server = server.Id,
|
||||||
|
Name = RandomName("c"),
|
||||||
|
ParentId = null,
|
||||||
|
Position = i,
|
||||||
|
Topic = RandomText(rand, maxWords: 10),
|
||||||
|
Nsfw = rand.Next(4) == 0,
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
User[] users = Enumerable.Range(start: 0, userCount).Select(_ => new User {
|
||||||
|
Id = RandomId(rand),
|
||||||
|
Name = RandomName("u"),
|
||||||
|
DisplayName = RandomName("u"),
|
||||||
|
AvatarHash = null,
|
||||||
|
Discriminator = rand.Next(minValue: 0, maxValue: 9999).ToString(),
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
await state.Db.Users.Add(users);
|
||||||
|
await state.Db.Servers.Add([server]);
|
||||||
|
await state.Db.Channels.Add(channels);
|
||||||
|
|
||||||
|
DateTimeOffset now = DateTimeOffset.Now;
|
||||||
|
int batchIndex = 0;
|
||||||
|
|
||||||
|
while (messageCount > 0) {
|
||||||
|
int hourOffset = batchIndex;
|
||||||
|
|
||||||
|
Message[] messages = Enumerable.Range(start: 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(minValue: 1, maxValue: 60)) : null;
|
||||||
|
|
||||||
|
long timeMillis = time.ToUnixTimeMilliseconds();
|
||||||
|
long? editMillis = edit?.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
return new Message {
|
||||||
|
Id = (ulong) timeMillis,
|
||||||
|
Sender = RandomBiasedIndex(rand, users).Id,
|
||||||
|
Channel = RandomBiasedIndex(rand, channels).Id,
|
||||||
|
Text = RandomText(rand, maxWords: 100),
|
||||||
|
Timestamp = timeMillis,
|
||||||
|
EditTimestamp = editMillis,
|
||||||
|
RepliedToId = null,
|
||||||
|
Attachments = ImmutableList<Attachment>.Empty,
|
||||||
|
Embeds = ImmutableList<Embed>.Empty,
|
||||||
|
Reactions = ImmutableList<Reaction>.Empty,
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
await state.Db.Messages.Add(messages);
|
||||||
|
|
||||||
|
messageCount -= BatchSize;
|
||||||
|
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ulong RandomId(Random rand) {
|
||||||
|
ulong h = unchecked((ulong) rand.Next());
|
||||||
|
ulong l = unchecked((ulong) rand.Next());
|
||||||
|
return (h << 32) | l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RandomName(string prefix) {
|
||||||
|
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
||||||
|
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] RandomWords = [
|
||||||
|
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||||
|
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
||||||
|
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
|
||||||
|
"daikon", "date", "dewberry", "durian",
|
||||||
|
"edamame", "eggplant", "elderberry", "endive",
|
||||||
|
"fig",
|
||||||
|
"garlic", "ginger", "gooseberry", "grape", "grapefruit", "guava",
|
||||||
|
"honeysuckle", "horseradish", "huckleberry",
|
||||||
|
"jackfruit", "jicama",
|
||||||
|
"kale", "kiwi", "kohlrabi", "kumquat",
|
||||||
|
"leek", "lemon", "lentil", "lettuce", "lime",
|
||||||
|
"mandarin", "mango", "mushroom", "myrtle",
|
||||||
|
"nectarine", "nut",
|
||||||
|
"olive", "okra", "onion", "orange",
|
||||||
|
"papaya", "parsnip", "pawpaw", "peach", "pear", "pea", "pepper", "persimmon", "pineapple", "plum", "plantain", "pomegranate", "pomelo", "potato", "prune", "pumpkin",
|
||||||
|
"quandong", "quinoa",
|
||||||
|
"radicchio", "radish", "raisin", "raspberry", "redcurrant", "rhubarb", "rutabaga",
|
||||||
|
"spinach", "strawberry", "squash",
|
||||||
|
"tamarind", "tangerine", "tomatillo", "tomato", "turnip",
|
||||||
|
"vanilla",
|
||||||
|
"watercress", "watermelon",
|
||||||
|
"yam",
|
||||||
|
"zucchini",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static string RandomText(Random rand, int maxWords) {
|
||||||
|
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), y: 3));
|
||||||
|
return string.Join(separator: ' ', Enumerable.Range(start: 0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
using DHT.Utils.Models;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
sealed class DebugPageModel {
|
||||||
sealed class DebugPageModel : BaseModel {
|
public string GenerateChannels { get; set; } = "0";
|
||||||
public string GenerateChannels { get; set; } = "0";
|
public string GenerateUsers { get; set; } = "0";
|
||||||
public string GenerateUsers { get; set; } = "0";
|
public string GenerateMessages { get; set; } = "0";
|
||||||
public string GenerateMessages { get; set; } = "0";
|
|
||||||
|
|
||||||
public void OnClickAddRandomDataToDatabase() {}
|
public void OnClickAddRandomDataToDatabase() {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
@@ -5,15 +5,16 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage">
|
x:Class="DHT.Desktop.Main.Pages.DownloadsPage"
|
||||||
|
x:DataType="pages:DownloadsPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:AttachmentsPageModel />
|
<pages:DownloadsPageModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<Style Selector="Expander">
|
<Style Selector="WrapPanel > Button">
|
||||||
<Setter Property="Margin" Value="0 5 0 0" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="DataGridColumnHeader">
|
<Style Selector="DataGridColumnHeader">
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
@@ -29,26 +30,27 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<StackPanel Orientation="Vertical" Spacing="20">
|
<StackPanel Orientation="Vertical">
|
||||||
<DockPanel>
|
<WrapPanel Orientation="Horizontal">
|
||||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
|
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
|
||||||
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
|
<Button Command="{Binding OnClickRetryFailed}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed</Button>
|
||||||
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
|
<Button Command="{Binding OnClickDeleteOrphaned}">Delete Orphaned</Button>
|
||||||
</DockPanel>
|
<Button Command="{Binding OnClickExportAll}" IsEnabled="{Binding HasSuccessfulDownloads}">Export All...</Button>
|
||||||
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" />
|
</WrapPanel>
|
||||||
<StackPanel Orientation="Vertical" Spacing="12">
|
<StackPanel Orientation="Vertical" Spacing="20" Margin="0 10 0 0">
|
||||||
|
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
||||||
|
<TextBlock TextWrapping="Wrap">
|
||||||
|
Downloading state and filter settings are remembered per-database.
|
||||||
|
</TextBlock>
|
||||||
<Expander Header="Download Status" IsExpanded="True">
|
<Expander Header="Download Status" IsExpanded="True">
|
||||||
<DataGrid Items="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
<DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" />
|
||||||
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
<DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||||
<DataGridTextColumn Header="Size" Binding="{Binding Size, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
|
<DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</Expander>
|
</Expander>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
|
||||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding HasFailedDownloads}">Retry Failed Downloads</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</UserControl>
|
</UserControl>
|
11
app/Desktop/Main/Pages/DownloadsPage.axaml.cs
Normal file
11
app/Desktop/Main/Pages/DownloadsPage.axaml.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
|
public sealed partial class DownloadsPage : UserControl {
|
||||||
|
public DownloadsPage() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
311
app/Desktop/Main/Pages/DownloadsPageModel.cs
Normal file
311
app/Desktop/Main/Pages/DownloadsPageModel.cs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Desktop.Dialogs.File;
|
||||||
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
|
using DHT.Desktop.Main.Controls;
|
||||||
|
using DHT.Server;
|
||||||
|
using DHT.Server.Data;
|
||||||
|
using DHT.Server.Data.Aggregations;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Data.Settings;
|
||||||
|
using DHT.Server.Download;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using DHT.Utils.Tasks;
|
||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
|
sealed partial class DownloadsPageModel : IAsyncDisposable {
|
||||||
|
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private bool isToggleDownloadButtonEnabled = true;
|
||||||
|
|
||||||
|
[DependsOn(nameof(IsDownloading))]
|
||||||
|
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private bool isRetryingFailedDownloads = false;
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private bool hasSuccessfulDownloads;
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private bool hasFailedDownloads;
|
||||||
|
|
||||||
|
[DependsOn(nameof(IsRetryingFailedDownloads), nameof(HasFailedDownloads))]
|
||||||
|
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private string downloadMessage = "";
|
||||||
|
|
||||||
|
public DownloadItemFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
|
private readonly StatisticsRow statisticsPending = new ("Pending");
|
||||||
|
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
||||||
|
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||||
|
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||||
|
|
||||||
|
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
|
||||||
|
|
||||||
|
public bool IsDownloading => state.Downloader.IsDownloading;
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
private readonly State state;
|
||||||
|
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
||||||
|
private readonly IDisposable downloadItemCountSubscription;
|
||||||
|
|
||||||
|
private IDisposable? finishedItemsSubscription;
|
||||||
|
private DownloadItemFilter? currentDownloadFilter;
|
||||||
|
|
||||||
|
public DownloadsPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
|
public DownloadsPageModel(Window window, State state) {
|
||||||
|
this.window = window;
|
||||||
|
this.state = state;
|
||||||
|
|
||||||
|
FilterModel = new DownloadItemFilterPanelModel(state);
|
||||||
|
|
||||||
|
StatisticsRows = [
|
||||||
|
statisticsPending,
|
||||||
|
statisticsDownloaded,
|
||||||
|
statisticsFailed,
|
||||||
|
statisticsSkipped,
|
||||||
|
];
|
||||||
|
|
||||||
|
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
|
||||||
|
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
await FilterModel.Initialize();
|
||||||
|
|
||||||
|
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, defaultValue: false)) {
|
||||||
|
await StartDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() {
|
||||||
|
finishedItemsSubscription?.Dispose();
|
||||||
|
|
||||||
|
downloadItemCountSubscription.Dispose();
|
||||||
|
downloadStatisticsTask.Dispose();
|
||||||
|
|
||||||
|
await FilterModel.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDownloadCountChanged(long newDownloadCount) {
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickToggleDownload() {
|
||||||
|
IsToggleDownloadButtonEnabled = false;
|
||||||
|
|
||||||
|
if (IsDownloading) {
|
||||||
|
await StopDownload();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await StartDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
|
||||||
|
IsToggleDownloadButtonEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartDownload() {
|
||||||
|
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||||
|
|
||||||
|
IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
||||||
|
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
|
||||||
|
|
||||||
|
OnDownloadStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopDownload() {
|
||||||
|
await state.Downloader.Stop();
|
||||||
|
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||||
|
|
||||||
|
finishedItemsSubscription?.Dispose();
|
||||||
|
finishedItemsSubscription = null;
|
||||||
|
|
||||||
|
currentDownloadFilter = null;
|
||||||
|
OnDownloadStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDownloadStateChanged() {
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsDownloading)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnItemFinished(DownloadItem item) {
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickRetryFailed() {
|
||||||
|
IsRetryingFailedDownloads = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await state.Db.Downloads.RetryFailed();
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
} finally {
|
||||||
|
IsRetryingFailedDownloads = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeDownloadStatistics() {
|
||||||
|
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickDeleteOrphaned() {
|
||||||
|
const string Title = "Delete Orphaned Downloads";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ProgressDialog.Show(window, Title, async (_, callback) => {
|
||||||
|
await callback.UpdateIndeterminate("Searching for orphaned downloads...");
|
||||||
|
|
||||||
|
HashSet<string> reachableNormalizedUrls = [];
|
||||||
|
HashSet<string> orphanedNormalizedUrls = [];
|
||||||
|
|
||||||
|
await foreach (FileUrl fileUrl in state.Db.Downloads.FindReachableFiles()) {
|
||||||
|
reachableNormalizedUrls.Add(fileUrl.NormalizedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await foreach (Download download in state.Db.Downloads.Get()) {
|
||||||
|
string normalizedUrl = download.NormalizedUrl;
|
||||||
|
if (!reachableNormalizedUrls.Contains(normalizedUrl)) {
|
||||||
|
orphanedNormalizedUrls.Add(normalizedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanedNormalizedUrls.Count == 0) {
|
||||||
|
await Dialog.ShowOk(window, Title, "No orphaned downloads found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, Title, orphanedNormalizedUrls.Count + " orphaned download(s) will be removed from this database. This action cannot be undone. Proceed?") != DialogResult.YesNo.Yes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Deleting orphaned downloads...");
|
||||||
|
await state.Db.Downloads.Remove(orphanedNormalizedUrls);
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, Title, "Orphaned downloads deleted. Vacuum database now to reclaim space?") != DialogResult.YesNo.Yes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Vacuuming database...");
|
||||||
|
await state.Db.Vacuum();
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, Title, "Could not delete orphaned downloads: " + e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickExportAll() {
|
||||||
|
const string Title = "Export Downloaded Files";
|
||||||
|
|
||||||
|
string[] folders = await window.StorageProvider.OpenFolders(new FolderPickerOpenOptions {
|
||||||
|
Title = Title,
|
||||||
|
AllowMultiple = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (folders.Length != 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string folderPath = folders[0];
|
||||||
|
|
||||||
|
DownloadExporter exporter = new DownloadExporter(state.Db, folderPath);
|
||||||
|
DownloadExporter.Result result;
|
||||||
|
try {
|
||||||
|
result = await ProgressDialog.Show(window, Title, async (_, callback) => {
|
||||||
|
await callback.UpdateIndeterminate("Exporting downloaded files...");
|
||||||
|
return await exporter.Export(new ExportProgressReporter(callback));
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, Title, "Could not export downloaded files: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string messageStart = "Exported " + result.SuccessfulCount.Pluralize("file");
|
||||||
|
|
||||||
|
if (result.FailedCount > 0L) {
|
||||||
|
await Dialog.ShowOk(window, Title, messageStart + " (" + result.FailedCount.Format() + " failed).");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await Dialog.ShowOk(window, Title, messageStart + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ExportProgressReporter(IProgressCallback callback) : DownloadExporter.IProgressReporter {
|
||||||
|
public Task ReportProgress(long processedCount, long totalCount) {
|
||||||
|
return callback.Update("Exporting downloaded files...", processedCount, totalCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||||
|
statisticsPending.Items = statusStatistics.PendingCount;
|
||||||
|
statisticsPending.Size = statusStatistics.PendingTotalSize;
|
||||||
|
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
|
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
||||||
|
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
|
||||||
|
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
|
statisticsFailed.Items = statusStatistics.FailedCount;
|
||||||
|
statisticsFailed.Size = statusStatistics.FailedTotalSize;
|
||||||
|
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
|
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||||
|
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
|
||||||
|
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
|
HasSuccessfulDownloads = statusStatistics.SuccessfulCount > 0;
|
||||||
|
HasFailedDownloads = statusStatistics.FailedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class StatisticsRow(string state) {
|
||||||
|
public string State { get; } = state;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private int items;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private ulong? size;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool hasFilesWithUnknownSize;
|
||||||
|
|
||||||
|
[DependsOn(nameof(Size), nameof(HasFilesWithUnknownSize))]
|
||||||
|
public string SizeText {
|
||||||
|
get {
|
||||||
|
if (size == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
else if (hasFilesWithUnknownSize) {
|
||||||
|
return "\u2265 " + BytesValueConverter.Convert(size.Value);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return BytesValueConverter.Convert(size.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -4,23 +4,54 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.TrackingPage">
|
x:Class="DHT.Desktop.Main.Pages.TrackingPage"
|
||||||
|
x:DataType="pages:TrackingPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:TrackingPageModel />
|
<pages:TrackingPageModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<StackPanel Spacing="10">
|
<UserControl.Styles>
|
||||||
<TextBlock TextWrapping="Wrap">
|
<Style Selector="TextBlock">
|
||||||
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.
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
</TextBlock>
|
</Style>
|
||||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
<Style Selector="WrapPanel > Button">
|
||||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
</StackPanel>
|
</Style>
|
||||||
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
</UserControl.Styles>
|
||||||
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>
|
<StackPanel Spacing="20">
|
||||||
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
<Expander Header="Method 1: Manual" IsExpanded="True">
|
||||||
|
<StackPanel Orientation="Vertical" Spacing="10">
|
||||||
|
<TextBlock>
|
||||||
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="Use {0} to open Dev Tools in your browser or the Discord app, copy the tracking script, and paste it into the console.">
|
||||||
|
<Binding Path="OpenDevToolsShortcutText" />
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
|
</TextBlock>
|
||||||
|
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
||||||
|
<TextBlock Margin="0 5 0 0">
|
||||||
|
By default, the Discord app blocks the Dev Tools shortcut. The button below changes a hidden setting to unblock the shortcut.
|
||||||
|
</TextBlock>
|
||||||
|
<Button Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Expander>
|
||||||
|
<Expander Header="Method 2: Userscript" IsExpanded="True" Padding="12 12 12 2.5">
|
||||||
|
<StackPanel Orientation="Vertical" Spacing="10">
|
||||||
|
<TextBlock>
|
||||||
|
Requires a userscript manager in your browser. The userscript adds a DHT icon next to the Help icon on Discord.
|
||||||
|
If the icon does not appear, update this app and reinstall the userscript.
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock>
|
||||||
|
Copy the Connection Code, click the DHT icon, and paste the code into the prompt.
|
||||||
|
</TextBlock>
|
||||||
|
<WrapPanel>
|
||||||
|
<Button Command="{Binding OnClickInstallOrUpdateUserscript}">Install or Update Userscript</Button>
|
||||||
|
<Button x:Name="CopyConnectionCode" Click="CopyConnectionScriptButton_OnClick">Copy Connection Code</Button>
|
||||||
|
</WrapPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Expander>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
@@ -1,36 +1,43 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class TrackingPage : UserControl {
|
|
||||||
private bool isCopyingScript;
|
|
||||||
|
|
||||||
public TrackingPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
|
||||||
if (DataContext is TrackingPageModel model) {
|
|
||||||
var button = this.FindControl<Button>("CopyTrackingScript");
|
|
||||||
var originalText = button.Content;
|
|
||||||
button.MinWidth = button.Bounds.Width;
|
|
||||||
|
|
||||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
|
||||||
isCopyingScript = true;
|
|
||||||
button.Content = "Script Copied!";
|
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
|
public sealed partial class TrackingPage : UserControl {
|
||||||
|
private readonly HashSet<Button> copyingButtons = new (ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
public TrackingPage() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||||
|
await HandleCopyButton(CopyTrackingScript, "Script Copied!", static model => model.OnClickCopyTrackingScript());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void CopyConnectionScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||||
|
await HandleCopyButton(CopyConnectionCode, "Code Copied!", static model => model.OnClickCopyConnectionCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCopyButton(Button button, string copiedText, Func<TrackingPageModel, Task<bool>> onClick) {
|
||||||
|
if (DataContext is TrackingPageModel model) {
|
||||||
|
object? originalText = button.Content;
|
||||||
|
button.MinWidth = button.Bounds.Width;
|
||||||
|
|
||||||
|
if (await onClick(model) && copyingButtons.Add(button)) {
|
||||||
|
button.IsEnabled = false;
|
||||||
|
button.Content = copiedText;
|
||||||
|
|
||||||
|
try {
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
|
} finally {
|
||||||
|
copyingButtons.Remove(button);
|
||||||
|
button.IsEnabled = true;
|
||||||
button.Content = originalText;
|
button.Content = originalText;
|
||||||
isCopyingScript = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,117 +1,154 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Discord;
|
using DHT.Desktop.Discord;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Logging;
|
||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
using static DHT.Desktop.Program;
|
using static DHT.Desktop.Program;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class TrackingPageModel : BaseModel {
|
|
||||||
private bool areDevToolsEnabled;
|
|
||||||
|
|
||||||
private bool AreDevToolsEnabled {
|
sealed partial class TrackingPageModel {
|
||||||
get => areDevToolsEnabled;
|
private static readonly Log Log = Log.ForType<TrackingPageModel>();
|
||||||
set {
|
|
||||||
Change(ref areDevToolsEnabled, value);
|
[Notify(Setter.Private)]
|
||||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
private bool? areDevToolsEnabled = null;
|
||||||
|
|
||||||
|
[Notify(Setter.Private)]
|
||||||
|
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||||
|
|
||||||
|
public string OpenDevToolsShortcutText { get; } = OperatingSystem.IsMacOS() ? "Cmd+Shift+I" : "Ctrl+Shift+I";
|
||||||
|
|
||||||
|
[DependsOn(nameof(AreDevToolsEnabled), nameof(IsToggleAppDevToolsButtonEnabled))]
|
||||||
|
public string ToggleAppDevToolsButtonText {
|
||||||
|
get {
|
||||||
|
if (!AreDevToolsEnabled.HasValue) {
|
||||||
|
return "Loading...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||||
|
return "Unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (AreDevToolsEnabled.Value ? "Disable" : "Enable") + " " + OpenDevToolsShortcutText;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
|
||||||
|
private readonly Window window;
|
||||||
public string ToggleAppDevToolsButtonText {
|
|
||||||
get {
|
[Obsolete("Designer")]
|
||||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
public TrackingPageModel() : this(null!) {}
|
||||||
return "Unavailable";
|
|
||||||
}
|
public TrackingPageModel(Window window) {
|
||||||
|
this.window = window;
|
||||||
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
|
||||||
}
|
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> OnClickCopyTrackingScript() {
|
||||||
|
string url = ServerConfiguration.HttpHost + $"/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
|
||||||
|
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
||||||
|
return await TryCopy(script, "Copy Tracking Script");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeDevToolsToggle() {
|
||||||
|
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
|
||||||
|
|
||||||
|
if (devToolsEnabled.HasValue) {
|
||||||
|
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||||
|
IsToggleAppDevToolsButtonEnabled = true;
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
private readonly Window window;
|
IsToggleAppDevToolsButtonEnabled = false;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public TrackingPageModel() : this(null!) {}
|
|
||||||
|
|
||||||
public TrackingPageModel(Window window) {
|
|
||||||
this.window = window;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public async Task Initialize() {
|
|
||||||
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
public async Task OnClickToggleAppDevTools() {
|
||||||
if (devToolsEnabled.HasValue) {
|
const string DialogTitle = "Discord App Settings File";
|
||||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
|
||||||
}
|
if (!AreDevToolsEnabled.HasValue) {
|
||||||
else {
|
return;
|
||||||
IsToggleAppDevToolsButtonEnabled = false;
|
|
||||||
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> OnClickCopyTrackingScript() {
|
bool oldState = AreDevToolsEnabled.Value;
|
||||||
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
|
bool newState = !oldState;
|
||||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
|
|
||||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
|
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||||
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
case SettingsJsonResult.Success:
|
||||||
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
|
AreDevToolsEnabled = newState;
|
||||||
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
|
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
||||||
|
break;
|
||||||
var clipboard = Application.Current?.Clipboard;
|
|
||||||
if (clipboard == null) {
|
case SettingsJsonResult.AlreadySet:
|
||||||
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " is already " + (newState ? "enabled." : "disabled."));
|
||||||
return false;
|
AreDevToolsEnabled = newState;
|
||||||
}
|
break;
|
||||||
|
|
||||||
try {
|
case SettingsJsonResult.FileNotFound:
|
||||||
await clipboard.SetTextAsync(script);
|
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
return true;
|
break;
|
||||||
} catch {
|
|
||||||
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
|
case SettingsJsonResult.ReadError:
|
||||||
return false;
|
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 async void OnClickToggleAppDevTools() {
|
|
||||||
const string DialogTitle = "Discord App Settings File";
|
public async Task OnClickInstallOrUpdateUserscript() {
|
||||||
|
try {
|
||||||
bool oldState = AreDevToolsEnabled;
|
SystemUtils.OpenUrl(ServerConfiguration.HttpHost + "/get-userscript/dht.user.js");
|
||||||
bool newState = !oldState;
|
} catch (Exception e) {
|
||||||
|
await Dialog.ShowOk(window, "Install or Update Userscript", "Could not open the browser: " + e.Message);
|
||||||
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.");
|
[GeneratedRegex("^[a-zA-Z0-9]{1,100}$")]
|
||||||
break;
|
private static partial Regex ConnectionCodeTokenRegex();
|
||||||
|
|
||||||
case SettingsJsonResult.AlreadySet:
|
public async Task<bool> OnClickCopyConnectionCode() {
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
|
const string Title = "Copy Connection Code";
|
||||||
AreDevToolsEnabled = newState;
|
|
||||||
break;
|
if (ConnectionCodeTokenRegex().IsMatch(ServerConfiguration.Token)) {
|
||||||
|
return await TryCopy(ServerConfiguration.Port + ":" + ServerConfiguration.Token, Title);
|
||||||
case SettingsJsonResult.FileNotFound:
|
}
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
else {
|
||||||
break;
|
await Dialog.ShowOk(window, Title, "The internal server token cannot be used to create a connection code.\n\nCheck the 'Advanced' tab and ensure the token is 1-100 characters long, and only contains plain letters and numbers.");
|
||||||
|
return false;
|
||||||
case SettingsJsonResult.ReadError:
|
}
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
}
|
||||||
break;
|
|
||||||
|
private async Task<bool> TryCopy(string script, string errorDialogTitle) {
|
||||||
case SettingsJsonResult.InvalidJson:
|
IClipboard? clipboard = window.Clipboard;
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
if (clipboard == null) {
|
||||||
break;
|
await Dialog.ShowOk(window, errorDialogTitle, "Clipboard is not available on this system.");
|
||||||
|
return false;
|
||||||
case SettingsJsonResult.WriteError:
|
}
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
|
||||||
break;
|
try {
|
||||||
|
await clipboard.SetTextAsync(script);
|
||||||
default:
|
return true;
|
||||||
throw new ArgumentOutOfRangeException();
|
} catch (Exception e) {
|
||||||
}
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, errorDialogTitle, "An error occurred while copying to clipboard.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.ViewerPage">
|
x:Class="DHT.Desktop.Main.Pages.ViewerPage"
|
||||||
|
x:DataType="pages:ViewerPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:ViewerPageModel />
|
<pages:ViewerPageModel />
|
||||||
@@ -20,7 +21,6 @@
|
|||||||
<StackPanel Orientation="Vertical" Spacing="20">
|
<StackPanel Orientation="Vertical" Spacing="20">
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
||||||
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
|
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
|
||||||
<Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<controls:MessageFilterPanel DataContext="{Binding FilterModel}" />
|
<controls:MessageFilterPanel DataContext="{Binding FilterModel}" />
|
||||||
<Expander Header="Database Tools">
|
<Expander Header="Database Tools">
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class ViewerPage : UserControl {
|
|
||||||
public ViewerPage() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class ViewerPage : UserControl {
|
||||||
}
|
public ViewerPage() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,148 +1,101 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
|
using DHT.Desktop.Server;
|
||||||
|
using DHT.Server;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Service.Viewer;
|
||||||
using DHT.Server.Database.Export;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Models;
|
using PropertyChanged.SourceGenerator;
|
||||||
using static DHT.Desktop.Program;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
|
||||||
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
|
sealed partial class ViewerPageModel : IDisposable {
|
||||||
|
private static readonly Log Log = Log.ForType<ViewerPageModel>();
|
||||||
|
|
||||||
|
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||||
|
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||||
|
|
||||||
|
[Notify]
|
||||||
|
private bool hasFilters = false;
|
||||||
|
|
||||||
|
public MessageFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
private readonly State state;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public ViewerPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
|
public ViewerPageModel(Window window, State state) {
|
||||||
|
this.window = window;
|
||||||
|
this.state = state;
|
||||||
|
|
||||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
|
||||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||||
|
}
|
||||||
private bool hasFilters = false;
|
|
||||||
|
public void Dispose() {
|
||||||
public bool HasFilters {
|
FilterModel.Dispose();
|
||||||
get => hasFilters;
|
}
|
||||||
set => Change(ref hasFilters, value);
|
|
||||||
|
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
HasFilters = FilterModel.HasAnyFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void OnClickOpenViewer() {
|
||||||
|
try {
|
||||||
|
string serverToken = ServerConfiguration.Token;
|
||||||
|
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
|
||||||
|
SystemUtils.OpenUrl(ServerConfiguration.HttpHost + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
|
||||||
|
} catch (Exception e) {
|
||||||
|
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private MessageFilterPanelModel FilterModel { get; }
|
|
||||||
|
public async Task OnClickApplyFiltersToDatabase() {
|
||||||
private readonly Window window;
|
try {
|
||||||
private readonly IDatabaseFile db;
|
MessageFilter filter = FilterModel.CreateFilter();
|
||||||
|
long messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
||||||
[Obsolete("Designer")]
|
|
||||||
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public ViewerPageModel(Window window, IDatabaseFile db) {
|
|
||||||
this.window = window;
|
|
||||||
this.db = db;
|
|
||||||
|
|
||||||
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
|
|
||||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
FilterModel.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
HasFilters = FilterModel.HasAnyFilters;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task WriteViewerFile(string path) {
|
|
||||||
const string ArchiveTag = "/*[ARCHIVE]*/";
|
|
||||||
|
|
||||||
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
|
||||||
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
|
||||||
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
|
|
||||||
|
|
||||||
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
|
|
||||||
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
|
|
||||||
|
|
||||||
string jsonTempFile = path + ".tmp";
|
|
||||||
|
|
||||||
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
|
||||||
await ViewerJsonExport.Generate(jsonStream, db, FilterModel.CreateFilter());
|
|
||||||
|
|
||||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
|
||||||
jsonStream.Position = 0;
|
|
||||||
|
|
||||||
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
|
|
||||||
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
|
|
||||||
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
|
|
||||||
|
|
||||||
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
|
|
||||||
int readBytes;
|
|
||||||
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
|
|
||||||
string jsonChunk = new string(jsonBuffer, 0, readBytes);
|
|
||||||
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File.Delete(jsonTempFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickOpenViewer() {
|
|
||||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
|
||||||
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
|
||||||
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
|
||||||
int counter = 0;
|
|
||||||
|
|
||||||
while (File.Exists(fullPath)) {
|
|
||||||
++counter;
|
|
||||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
|
||||||
}
|
|
||||||
|
|
||||||
TemporaryFiles.Add(fullPath);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(rootPath);
|
|
||||||
await WriteViewerFile(fullPath);
|
|
||||||
|
|
||||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickSaveViewer() {
|
|
||||||
var dialog = new SaveFileDialog {
|
|
||||||
Title = "Save Viewer",
|
|
||||||
InitialFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
|
|
||||||
Directory = Path.GetDirectoryName(db.Path),
|
|
||||||
Filters = new List<FileDialogFilter> {
|
|
||||||
new() {
|
|
||||||
Name = "Discord History Viewer",
|
|
||||||
Extensions = { "html" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.ShowAsync(window);
|
|
||||||
|
|
||||||
string? path = await dialog;
|
|
||||||
if (!string.IsNullOrEmpty(path)) {
|
|
||||||
await WriteViewerFile(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickApplyFiltersToDatabase() {
|
|
||||||
var filter = FilterModel.CreateFilter();
|
|
||||||
|
|
||||||
if (DatabaseToolFilterModeKeep) {
|
if (DatabaseToolFilterModeKeep) {
|
||||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||||
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
|
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (DatabaseToolFilterModeRemove) {
|
else if (DatabaseToolFilterModeRemove) {
|
||||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||||
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
|
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, "Apply Filters", "Could not apply filters: " + e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
|
||||||
|
await ProgressDialog.Show(window, "Apply Filters", async (_, callback) => {
|
||||||
|
await callback.UpdateIndeterminate("Removing messages...");
|
||||||
|
Log.Info("Removed messages: " + await state.Db.Messages.Remove(filter, removalMode));
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up attachments...");
|
||||||
|
Log.Info("Removed orphaned attachments: " + await state.Db.Messages.RemoveUnreachableAttachments());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up users...");
|
||||||
|
Log.Info("Removed orphaned users: " + await state.Db.Users.RemoveUnreachable());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up channels...");
|
||||||
|
Log.Info("Removed orphaned channels: " + await state.Db.Channels.RemoveUnreachable());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up servers...");
|
||||||
|
Log.Info("Removed orphaned servers: " + await state.Db.Servers.RemoveUnreachable());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Screens.MainContentScreen">
|
x:Class="DHT.Desktop.Main.Screens.MainContentScreen"
|
||||||
|
x:DataType="screens:MainContentScreenModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<screens:MainContentScreenModel />
|
<screens:MainContentScreenModel />
|
||||||
@@ -39,13 +40,13 @@
|
|||||||
<Style Selector="TabItem:pointerover /template/ Border">
|
<Style Selector="TabItem:pointerover /template/ Border">
|
||||||
<Setter Property="Background" Value="#455785" />
|
<Setter Property="Background" Value="#455785" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:pointerover > TextBlock">
|
<Style Selector="TabItem:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#E9E9E9" />
|
<Setter Property="Foreground" Value="#E9E9E9" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected:pointerover /template/ Border">
|
<Style Selector="TabItem:selected:pointerover /template/ Border">
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected:pointerover > TextBlock">
|
<Style Selector="TabItem:selected:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#1A2234" />
|
<Setter Property="Foreground" Value="#1A2234" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected">
|
<Style Selector="TabItem:selected">
|
||||||
@@ -55,9 +56,11 @@
|
|||||||
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
|
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
|
||||||
<Setter Property="IsVisible" Value="False" />
|
<Setter Property="IsVisible" Value="False" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:disabled > TextBlock">
|
<Style Selector="TabItem:disabled /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#B2B2B2" />
|
<Setter Property="Foreground" Value="#B2B2B2" />
|
||||||
<Setter Property="TextDecorations" Value="Strikethrough" />
|
</Style>
|
||||||
|
<Style Selector="TabItem:disabled /template/ ContentPresenter">
|
||||||
|
<Setter Property="AccessText.TextDecorations" Value="Strikethrough" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem.first">
|
<Style Selector="TabItem.first">
|
||||||
<Setter Property="Margin" Value="0 13 0 0" />
|
<Setter Property="Margin" Value="0 13 0 0" />
|
||||||
@@ -73,7 +76,7 @@
|
|||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Border Classes="statusBar" DockPanel.Dock="Bottom">
|
<Border Classes="statusBar" DockPanel.Dock="Bottom">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Attachments</TextBlock>
|
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Downloads</TextBlock>
|
||||||
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
|
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -93,9 +96,9 @@
|
|||||||
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem x:Name="TabAttachments" Header="Attachments" Grid.Row="2">
|
<TabItem x:Name="TabDownloads" Header="Downloads" Grid.Row="2">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<ContentPresenter Content="{Binding AttachmentsPage}" Classes="page" />
|
<ContentPresenter Content="{Binding DownloadsPage}" Classes="page" />
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
|
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class MainContentScreen : UserControl {
|
|
||||||
public MainContentScreen() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class MainContentScreen : UserControl {
|
||||||
}
|
public MainContentScreen() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,119 +1,81 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Desktop.Main.Pages;
|
using DHT.Desktop.Main.Pages;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Service;
|
|
||||||
using DHT.Utils.Logging;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
sealed class MainContentScreenModel : IDisposable {
|
|
||||||
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
|
|
||||||
|
|
||||||
public DatabasePage DatabasePage { get; }
|
|
||||||
private DatabasePageModel DatabasePageModel { get; }
|
|
||||||
|
|
||||||
public TrackingPage TrackingPage { get; }
|
|
||||||
private TrackingPageModel TrackingPageModel { get; }
|
|
||||||
|
|
||||||
public AttachmentsPage AttachmentsPage { get; }
|
|
||||||
private AttachmentsPageModel AttachmentsPageModel { get; }
|
|
||||||
|
|
||||||
public ViewerPage ViewerPage { get; }
|
|
||||||
private ViewerPageModel ViewerPageModel { get; }
|
|
||||||
|
|
||||||
public AdvancedPage AdvancedPage { get; }
|
|
||||||
private AdvancedPageModel AdvancedPageModel { get; }
|
|
||||||
|
|
||||||
public DebugPage? DebugPage { get; }
|
|
||||||
|
|
||||||
|
sealed class MainContentScreenModel : IAsyncDisposable {
|
||||||
|
public DatabasePage DatabasePage { get; }
|
||||||
|
private DatabasePageModel DatabasePageModel { get; }
|
||||||
|
|
||||||
|
public TrackingPage TrackingPage { get; }
|
||||||
|
private TrackingPageModel TrackingPageModel { get; }
|
||||||
|
|
||||||
|
public DownloadsPage DownloadsPage { get; }
|
||||||
|
private DownloadsPageModel DownloadsPageModel { get; }
|
||||||
|
|
||||||
|
public ViewerPage ViewerPage { get; }
|
||||||
|
private ViewerPageModel ViewerPageModel { get; }
|
||||||
|
|
||||||
|
public AdvancedPage AdvancedPage { get; }
|
||||||
|
private AdvancedPageModel AdvancedPageModel { get; }
|
||||||
|
|
||||||
|
public DebugPage? DebugPage { get; }
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
public bool HasDebugPage => true;
|
||||||
|
private DebugPageModel DebugPageModel { get; }
|
||||||
|
#else
|
||||||
|
public bool HasDebugPage => false;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public StatusBarModel StatusBarModel { get; }
|
||||||
|
|
||||||
|
public event EventHandler? DatabaseClosed {
|
||||||
|
add => DatabasePageModel.DatabaseClosed += value;
|
||||||
|
remove => DatabasePageModel.DatabaseClosed -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
|
public MainContentScreenModel(Window window, State state) {
|
||||||
|
DatabasePageModel = new DatabasePageModel(window, state);
|
||||||
|
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||||
|
|
||||||
|
TrackingPageModel = new TrackingPageModel(window);
|
||||||
|
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||||
|
|
||||||
|
DownloadsPageModel = new DownloadsPageModel(window, state);
|
||||||
|
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
|
||||||
|
|
||||||
|
ViewerPageModel = new ViewerPageModel(window, state);
|
||||||
|
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||||
|
|
||||||
|
AdvancedPageModel = new AdvancedPageModel(window, state);
|
||||||
|
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
public bool HasDebugPage => true;
|
DebugPageModel = new DebugPageModel(window, state);
|
||||||
private DebugPageModel DebugPageModel { get; }
|
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
||||||
#else
|
#else
|
||||||
public bool HasDebugPage => false;
|
DebugPage = null;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public StatusBarModel StatusBarModel { get; }
|
StatusBarModel = new StatusBarModel(state);
|
||||||
|
}
|
||||||
public event EventHandler? DatabaseClosed {
|
|
||||||
add {
|
public async Task Initialize() {
|
||||||
DatabasePageModel.DatabaseClosed += value;
|
await DownloadsPageModel.Initialize();
|
||||||
}
|
}
|
||||||
remove {
|
|
||||||
DatabasePageModel.DatabaseClosed -= value;
|
public async ValueTask DisposeAsync() {
|
||||||
}
|
await DownloadsPageModel.DisposeAsync();
|
||||||
}
|
ViewerPageModel.Dispose();
|
||||||
|
AdvancedPageModel.Dispose();
|
||||||
private readonly Window window;
|
StatusBarModel.Dispose();
|
||||||
private readonly ServerManager serverManager;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
|
||||||
this.window = window;
|
|
||||||
this.serverManager = new ServerManager(db);
|
|
||||||
|
|
||||||
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
|
||||||
|
|
||||||
DatabasePageModel = new DatabasePageModel(window, db);
|
|
||||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
|
||||||
|
|
||||||
TrackingPageModel = new TrackingPageModel(window);
|
|
||||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
|
||||||
|
|
||||||
AttachmentsPageModel = new AttachmentsPageModel(db);
|
|
||||||
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
|
||||||
|
|
||||||
ViewerPageModel = new ViewerPageModel(window, db);
|
|
||||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
|
||||||
|
|
||||||
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
|
|
||||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
DebugPageModel = new DebugPageModel(window, db);
|
|
||||||
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
|
||||||
#else
|
|
||||||
DebugPage = null;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
StatusBarModel = new StatusBarModel(db.Statistics);
|
|
||||||
|
|
||||||
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
|
|
||||||
DatabaseClosed += OnDatabaseClosed;
|
|
||||||
|
|
||||||
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Initialize() {
|
|
||||||
await TrackingPageModel.Initialize();
|
|
||||||
AdvancedPageModel.Initialize();
|
|
||||||
serverManager.Launch();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
|
|
||||||
AttachmentsPageModel.Dispose();
|
|
||||||
ViewerPageModel.Dispose();
|
|
||||||
serverManager.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
|
|
||||||
StatusBarModel.CurrentStatus = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDatabaseClosed(object? sender, EventArgs e) {
|
|
||||||
serverManager.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
|
|
||||||
Log.Error(ex);
|
|
||||||
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen">
|
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
|
||||||
|
x:DataType="screens:WelcomeScreenModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<screens:WelcomeScreenModel />
|
<screens:WelcomeScreenModel />
|
||||||
@@ -21,20 +22,21 @@
|
|||||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button">
|
<Style Selector="Grid#ButtonPanel > Button">
|
||||||
<Setter Property="Margin" Value="5 0" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Panel Name="RootPanel">
|
<Panel Name="RootPanel">
|
||||||
<StackPanel Margin="42">
|
<StackPanel Margin="42 30">
|
||||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
|
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
<Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
|
||||||
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>
|
<Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>
|
||||||
<Button Command="{Binding Exit}">Exit</Button>
|
<Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button>
|
||||||
</StackPanel>
|
<Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button>
|
||||||
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
|
||||||
public sealed class WelcomeScreen : UserControl {
|
|
||||||
public WelcomeScreen() {
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
AvaloniaXamlLoader.Load(this);
|
public sealed partial class WelcomeScreen : UserControl {
|
||||||
}
|
public WelcomeScreen() {
|
||||||
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,73 +1,162 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
|
using DHT.Desktop.Main.Dialogs;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using PropertyChanged.SourceGenerator;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
|
||||||
public string Version => Program.Version;
|
|
||||||
|
|
||||||
public IDatabaseFile? Db { get; private set; }
|
sealed partial class WelcomeScreenModel {
|
||||||
public bool HasDatabase => Db != null;
|
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
|
||||||
|
|
||||||
private readonly Window window;
|
public string Version => Program.Version;
|
||||||
|
|
||||||
private string? dbFilePath;
|
[Notify(Setter.Private)]
|
||||||
|
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||||
[Obsolete("Designer")]
|
|
||||||
public WelcomeScreenModel() : this(null!) {}
|
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||||
|
|
||||||
public WelcomeScreenModel(Window window) {
|
private readonly Window window;
|
||||||
this.window = window;
|
|
||||||
}
|
private string? dbFilePath;
|
||||||
|
|
||||||
public async void OpenOrCreateDatabase() {
|
[Obsolete("Designer")]
|
||||||
var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog();
|
public WelcomeScreenModel() : this(null!) {}
|
||||||
dialog.Directory = Path.GetDirectoryName(dbFilePath);
|
|
||||||
|
public WelcomeScreenModel(Window window) {
|
||||||
string? path = await dialog.ShowAsync(window);
|
this.window = window;
|
||||||
if (!string.IsNullOrWhiteSpace(path)) {
|
}
|
||||||
|
|
||||||
|
public async Task OpenOrCreateDatabase() {
|
||||||
|
IsOpenOrCreateDatabaseButtonEnabled = false;
|
||||||
|
try {
|
||||||
|
string? path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||||
|
if (path != null) {
|
||||||
await OpenOrCreateDatabaseFromPath(path);
|
await OpenOrCreateDatabaseFromPath(path);
|
||||||
}
|
}
|
||||||
}
|
} finally {
|
||||||
|
IsOpenOrCreateDatabaseButtonEnabled = true;
|
||||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
|
||||||
if (Db != null) {
|
|
||||||
Db = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
dbFilePath = path;
|
|
||||||
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Db));
|
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> CheckCanUpgradeDatabase() {
|
|
||||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CloseDatabase() {
|
|
||||||
Dispose();
|
|
||||||
OnPropertyChanged(nameof(Db));
|
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void ShowAboutDialog() {
|
|
||||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Exit() {
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
Db?.Dispose();
|
|
||||||
Db = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||||
|
dbFilePath = path;
|
||||||
|
|
||||||
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||||
|
if (db != null) {
|
||||||
|
DatabaseSelected?.Invoke(this, db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
|
||||||
|
public async Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() {
|
||||||
|
var model = new NewDatabaseSettingsDialogModel();
|
||||||
|
|
||||||
|
var dialog = new NewDatabaseSettingsDialog { DataContext = model };
|
||||||
|
await dialog.ShowDialog(window);
|
||||||
|
|
||||||
|
return new InitialDatabaseSettings(model.SeparateFileForDownloads, model.DownloadsAutoStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUpgrade() {
|
||||||
|
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
|
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
|
var reporter = new ProgressReporter(versionSteps, callbacks);
|
||||||
|
await reporter.NextVersion();
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(800));
|
||||||
|
await doUpgrade(reporter);
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||||
|
}
|
||||||
|
|
||||||
|
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
|
private int versionProgress = 0;
|
||||||
|
|
||||||
|
public async Task NextVersion() {
|
||||||
|
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||||
|
await HideChildren(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
|
await callbacks[1].Update(message, finishedItems, totalItems);
|
||||||
|
await HideChildren(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
|
await callbacks[2].Update(message, finishedItems, totalItems);
|
||||||
|
await HideChildren(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HideChildren(int parentIndex) {
|
||||||
|
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
||||||
|
await callbacks[i].Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CheckUpdates() {
|
||||||
|
string response;
|
||||||
|
try {
|
||||||
|
response = await ProgressDialog.ShowIndeterminate<string>(window, "Check Updates", "Checking for updates...", static async _ => {
|
||||||
|
var client = new HttpClient(new SocketsHttpHandler {
|
||||||
|
AutomaticDecompression = DecompressionMethods.None,
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
UseCookies = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.MaxResponseContentBufferSize = 1024;
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
|
||||||
|
|
||||||
|
return await client.GetStringAsync(Program.Website + "/version");
|
||||||
|
});
|
||||||
|
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
|
||||||
|
return;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.Version.TryParse(response, out Version? latestVersion)) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Program.AssemblyVersion >= latestVersion) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "You are using the latest version.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, "Check Updates", "A newer version is available: v" + Program.VersionToString(latestVersion) + "\nVisit the official website and close the app?") == DialogResult.YesNo.Yes) {
|
||||||
|
SystemUtils.OpenUrl(Program.Website);
|
||||||
|
Exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShowAboutDialog() {
|
||||||
|
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Exit() {
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,39 +1,65 @@
|
|||||||
using System.Globalization;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Resources;
|
using DHT.Utils.Resources;
|
||||||
|
|
||||||
namespace DHT.Desktop {
|
namespace DHT.Desktop;
|
||||||
static class Program {
|
|
||||||
public static string Version { get; }
|
|
||||||
public static CultureInfo Culture { get; }
|
|
||||||
public static ResourceLoader Resources { get; }
|
|
||||||
|
|
||||||
static Program() {
|
static class Program {
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
public static string Version { get; }
|
||||||
|
public static Version AssemblyVersion { get; }
|
||||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
public static CultureInfo Culture { get; }
|
||||||
while (Version.EndsWith(".0")) {
|
public static ResourceLoader Resources { get; }
|
||||||
Version = Version[..^2];
|
public static Arguments Arguments { get; }
|
||||||
}
|
|
||||||
|
public const string Website = "https://dht.chylex.com";
|
||||||
Culture = CultureInfo.CurrentCulture;
|
|
||||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
static Program() {
|
||||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
|
||||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
AssemblyVersion = assembly.GetName().Version ?? new Version(major: 0, minor: 0, build: 0, revision: 0);
|
||||||
|
Version = VersionToString(AssemblyVersion);
|
||||||
Resources = new ResourceLoader(assembly);
|
|
||||||
|
Culture = CultureInfo.CurrentCulture;
|
||||||
|
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||||
|
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
|
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
||||||
|
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
|
Resources = new ResourceLoader(assembly);
|
||||||
|
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string VersionToString(Version version) {
|
||||||
|
string versionStr = version.ToString();
|
||||||
|
|
||||||
|
while (versionStr.EndsWith(".0")) {
|
||||||
|
versionStr = versionStr[..^2];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Main(string[] args) {
|
return versionStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Main(string[] args) {
|
||||||
|
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||||
|
WindowsConsole.AllocConsole();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
}
|
} finally {
|
||||||
|
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||||
private static AppBuilder BuildAvaloniaApp() {
|
WindowsConsole.FreeConsole();
|
||||||
return AppBuilder.Configure<App>()
|
}
|
||||||
.UsePlatformDetect()
|
|
||||||
.LogToTrace();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AppBuilder BuildAvaloniaApp() {
|
||||||
|
return AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
1
app/Desktop/Resources/tracker-loader.js
Normal file
1
app/Desktop/Resources/tracker-loader.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fetch("{url}").then(r => r.ok ? (r.headers.get("X-DHT") === "1" ? r.text() : Promise.reject("Invalid response")) : Promise.reject(r.status + " " + r.statusText)).then(s => eval(s)).catch(e => alert("Could not load tracking script:\n" + e));
|
10
app/Desktop/Server/ServerConfiguration.cs
Normal file
10
app/Desktop/Server/ServerConfiguration.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using DHT.Server.Service;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Server;
|
||||||
|
|
||||||
|
static class ServerConfiguration {
|
||||||
|
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(min: 50000, max: 60000);
|
||||||
|
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||||
|
|
||||||
|
public static string HttpHost => "http://127.0.0.1:" + Port;
|
||||||
|
}
|
@@ -1,50 +0,0 @@
|
|||||||
using System;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Service;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Server {
|
|
||||||
sealed class ServerManager : IDisposable {
|
|
||||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
|
||||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
|
||||||
|
|
||||||
private static ServerManager? instance;
|
|
||||||
|
|
||||||
public bool IsRunning => ServerLauncher.IsRunning;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
|
|
||||||
public ServerManager(IDatabaseFile db) {
|
|
||||||
if (db != DummyDatabaseFile.Instance) {
|
|
||||||
if (instance != null) {
|
|
||||||
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
|
|
||||||
}
|
|
||||||
|
|
||||||
instance = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Launch() {
|
|
||||||
ServerLauncher.Relaunch(Port, Token, db);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Relaunch(ushort port, string token) {
|
|
||||||
Port = port;
|
|
||||||
Token = token;
|
|
||||||
Launch();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Stop() {
|
|
||||||
ServerLauncher.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
Stop();
|
|
||||||
|
|
||||||
if (instance == this) {
|
|
||||||
instance = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
51
app/Directory.Build.props
Normal file
51
app/Directory.Build.props
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<LangVersion>13</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Authors>chylex</Authors>
|
||||||
|
<Company>DiscordHistoryTracker</Company>
|
||||||
|
<Product>DiscordHistoryTracker</Product>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
|
||||||
|
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
|
||||||
|
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
|
<TrimMode>full</TrimMode>
|
||||||
|
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
|
||||||
|
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
|
||||||
|
<EventSourceSupport>false</EventSourceSupport>
|
||||||
|
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
<XmlResolverIsNetworkingEnabledByDefault>false</XmlResolverIsNetworkingEnabledByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||||
|
<DebugSymbols>false</DebugSymbols>
|
||||||
|
<DebugType>none</DebugType>
|
||||||
|
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
|
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
6
app/NuGet.Config
Normal file
6
app/NuGet.Config
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<add key="chylex's repository" value="https://nuget.chylex.com/feed/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
@@ -9,6 +9,8 @@ items:
|
|||||||
pattern: "^[0-9]+$"
|
pattern: "^[0-9]+$"
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
avatar:
|
avatar:
|
||||||
type: string
|
type: string
|
||||||
discriminator:
|
discriminator:
|
||||||
|
54
app/Resources/Tracker/bootstrap.js
vendored
54
app/Resources/Tracker/bootstrap.js
vendored
@@ -10,11 +10,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*[IMPORTS]*/
|
||||||
|
|
||||||
|
if (!DISCORD.isCompatible()) {
|
||||||
|
alert("Discord History Tracker is not compatible with this version of Discord.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.DHT_LOADED = true;
|
window.DHT_LOADED = true;
|
||||||
window.DHT_ON_UNLOAD = [];
|
window.DHT_ON_UNLOAD = [];
|
||||||
|
|
||||||
/*[IMPORTS]*/
|
|
||||||
|
|
||||||
const port = 0; /*[PORT]*/
|
const port = 0; /*[PORT]*/
|
||||||
const token = "/*[TOKEN]*/";
|
const token = "/*[TOKEN]*/";
|
||||||
STATE.setup(port, token);
|
STATE.setup(port, token);
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTrackingContinued = function(anyNewMessages) {
|
const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
|
||||||
if (!STATE.isTracking()) {
|
if (!STATE.isTracking()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,10 +68,12 @@
|
|||||||
if (SETTINGS.autoscroll) {
|
if (SETTINGS.autoscroll) {
|
||||||
let action = null;
|
let action = null;
|
||||||
|
|
||||||
if (!DISCORD.hasMoreMessages()) {
|
if (!hasMoreBefore) {
|
||||||
|
console.debug("[DHT] Reached first message.");
|
||||||
action = SETTINGS.afterFirstMsg;
|
action = SETTINGS.afterFirstMsg;
|
||||||
}
|
}
|
||||||
if (isNoAction(action) && !anyNewMessages) {
|
if (isNoAction(action) && !anyNewMessages) {
|
||||||
|
console.debug("[DHT] No new messages.");
|
||||||
action = SETTINGS.afterSavedMsg;
|
action = SETTINGS.afterSavedMsg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +89,7 @@
|
|||||||
|
|
||||||
let waitUntilSendingFinishedTimer = null;
|
let waitUntilSendingFinishedTimer = null;
|
||||||
|
|
||||||
const onMessagesUpdated = async messages => {
|
const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
|
||||||
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -92,24 +99,16 @@
|
|||||||
|
|
||||||
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
||||||
waitUntilSendingFinishedTimer = null;
|
waitUntilSendingFinishedTimer = null;
|
||||||
onMessagesUpdated(messages);
|
onMessagesUpdated(server, channel, messages, hasMoreBefore);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = DISCORD.getSelectedChannel();
|
|
||||||
|
|
||||||
if (!info) {
|
|
||||||
GUI.setStatus("Error (Unknown Channel)");
|
|
||||||
stopTrackingDelayed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSending = true;
|
isSending = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await STATE.addDiscordChannel(info.server, info.channel);
|
await STATE.addDiscordChannel(server, channel);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
return;
|
return;
|
||||||
@@ -118,35 +117,33 @@
|
|||||||
try {
|
try {
|
||||||
if (!messages.length) {
|
if (!messages.length) {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
onTrackingContinued(false);
|
onTrackingContinued(false, hasMoreBefore);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);
|
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||||
onTrackingContinued(anyNewMessages);
|
onTrackingContinued(anyNewMessages, hasMoreBefore);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
DISCORD.setupMessageCallback(onMessagesUpdated);
|
const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||||
|
|
||||||
STATE.onTrackingStateChanged(enabled => {
|
STATE.onTrackingStateChanged(enabled => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
const messages = DISCORD.getMessages();
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GUI.setStatus("Starting");
|
GUI.setStatus("Starting");
|
||||||
|
GUI.createTrackingStyles();
|
||||||
hasJustStarted = true;
|
hasJustStarted = true;
|
||||||
// noinspection JSIgnoredPromiseFromCall
|
|
||||||
onMessagesUpdated(messages);
|
if (!starter()) {
|
||||||
|
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
||||||
|
hasJustStarted = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
|
GUI.deleteTrackingStyles();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,3 +153,4 @@
|
|||||||
GUI.showSettings();
|
GUI.showSettings();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
/*[DEBUGGER]*/
|
||||||
|
273
app/Resources/Tracker/loader/dht.user.js
Normal file
273
app/Resources/Tracker/loader/dht.user.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name Discord History Tracker
|
||||||
|
// @license MIT
|
||||||
|
// @namespace https://chylex.com
|
||||||
|
// @homepageURL https://dht.chylex.com/
|
||||||
|
// @supportURL https://github.com/chylex/Discord-History-Tracker/issues
|
||||||
|
// @include https://discord.com/*
|
||||||
|
// @run-at document-idle
|
||||||
|
// @grant none
|
||||||
|
// @noframes
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
function startMutationObserver(callback) {
|
||||||
|
let hasDebounceStarted = false;
|
||||||
|
|
||||||
|
function handleMutations(mutations, observer) {
|
||||||
|
if (hasDebounceStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDebounceStarted = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
hasDebounceStarted = false;
|
||||||
|
callback(observer);
|
||||||
|
}, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
new MutationObserver(handleMutations).observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
startMutationObserver(observer => {
|
||||||
|
const triggerButton = installConnectDialogButton();
|
||||||
|
if (triggerButton === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.disconnect();
|
||||||
|
|
||||||
|
startMutationObserver(() => {
|
||||||
|
if (!triggerButton.isConnected) {
|
||||||
|
getHelpIcon()?.parentElement.parentElement.insertAdjacentElement("afterend", triggerButton);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function installConnectDialogButton() {
|
||||||
|
const helpIcon = getHelpIcon();
|
||||||
|
if (!helpIcon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpIconWrapper = helpIcon.parentElement;
|
||||||
|
const helpIconLink = helpIconWrapper.parentElement;
|
||||||
|
|
||||||
|
helpIconLink.insertAdjacentHTML("afterend", `
|
||||||
|
<div id="dht-connector-show-dialog" role="button" class="${helpIconWrapper.getAttribute("class")}">
|
||||||
|
<svg viewBox="0 0 24 20" fill="currentColor" class="${helpIcon.getAttribute("class")}" style="width: auto;">
|
||||||
|
<path d="M7.446,9.924c-0,0.846 -0.105,1.593 -0.315,2.234c-0.21,0.644 -0.497,1.181 -0.86,1.615c-0.365,0.431 -0.795,0.761 -1.292,0.982c-0.494,0.22 -1.029,0.332 -1.605,0.332l-3.374,-0l0,-10.174l3.021,0c0.645,0 1.239,0.1 1.781,0.296c0.542,0.198 1.011,0.501 1.4,0.911c0.391,0.408 0.695,0.928 0.914,1.56c0.22,0.629 0.33,1.378 0.33,2.244Zm-1.765,-0c0,-0.592 -0.067,-1.1 -0.198,-1.524c-0.134,-0.422 -0.318,-0.77 -0.554,-1.042c-0.236,-0.272 -0.518,-0.473 -0.848,-0.604c-0.332,-0.128 -0.697,-0.195 -1.096,-0.195l-1.237,-0l0,6.882l1.483,0c0.351,0 0.676,-0.076 0.977,-0.224c0.298,-0.15 0.556,-0.372 0.776,-0.668c0.22,-0.295 0.389,-0.663 0.513,-1.101c0.122,-0.439 0.184,-0.947 0.184,-1.524Z"/>
|
||||||
|
<path d="M14.147,15.087l-0,-4.362l-3.637,-0l-0,4.362l-1.748,-0l-0,-10.174l1.748,0l-0,4.052l3.637,-0l-0,-4.052l1.75,0l0,10.174l-1.75,-0Z"/>
|
||||||
|
<path d="M21.297,6.559l-0,8.528l-1.748,-0l-0,-8.528l-2.699,-0l0,-1.646l7.15,0l0,1.646l-2.703,-0Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const button = document.getElementById("dht-connector-show-dialog");
|
||||||
|
button.addEventListener("click", showConnectDialog);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHelpIcon() {
|
||||||
|
return document.querySelector("div[class*='bar_'] a[href$='://support.discord.com'] > div[class*='clickable'] > svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConnectDialog() {
|
||||||
|
if (window.DHT_LOADED) {
|
||||||
|
alert("Discord History Tracker is already loaded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogElement = document.createElement("dialog");
|
||||||
|
dialogElement.id = "dht-connector-dialog";
|
||||||
|
dialogElement.innerHTML = `
|
||||||
|
<form id="dht-connector-dialog-form">
|
||||||
|
<label for="dht-connector-dialog-input-code">Connection Code</label>
|
||||||
|
<input id="dht-connector-dialog-input-code" type="text">
|
||||||
|
<p id="dht-connector-dialog-input-error"></p>
|
||||||
|
|
||||||
|
<div id="dht-connector-dialog-buttons">
|
||||||
|
<button type="submit" id="dht-connector-dialog-button-connect">Connect</button>
|
||||||
|
<button type="button" id="dht-connector-dialog-button-close">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<style>
|
||||||
|
#dht-connector-dialog {
|
||||||
|
width: 300px;
|
||||||
|
padding: 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #313338;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-connector-dialog::backdrop {
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-connector-dialog label {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
color: #fbfbfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-connector-dialog-input-error {
|
||||||
|
margin: 11px 1px;
|
||||||
|
color: #ff484c;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-connector-dialog input[type="text"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 10px;
|
||||||
|
border: 1px solid rgba(151, 151, 159, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #efeff0;
|
||||||
|
background-color: #1d1d21;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-connector-dialog-buttons {
|
||||||
|
margin-top: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-connector-dialog button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #5865f2;
|
||||||
|
transition: background-color 200ms ease, color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-connector-dialog button:hover {
|
||||||
|
background-color: #4752c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dht-connector-dialog button[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentElement("beforeend", dialogElement);
|
||||||
|
|
||||||
|
const formElement = document.getElementById("dht-connector-dialog-form");
|
||||||
|
const codeErrorElement = document.getElementById("dht-connector-dialog-input-error");
|
||||||
|
const codeInputElement = document.getElementById("dht-connector-dialog-input-code");
|
||||||
|
const connectButtonElement = document.getElementById("dht-connector-dialog-button-connect");
|
||||||
|
const closeButtonElement = document.getElementById("dht-connector-dialog-button-close");
|
||||||
|
|
||||||
|
dialogElement.addEventListener("close", function() {
|
||||||
|
dialogElement.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
closeButtonElement.addEventListener("click", function() {
|
||||||
|
dialogElement.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
codeInputElement.addEventListener("paste", function() {
|
||||||
|
setTimeout(async function() {
|
||||||
|
const code = parseConnectionCode(codeInputElement.value);
|
||||||
|
if (code !== null) {
|
||||||
|
await onSubmit(code);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
formElement.addEventListener("submit", async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const code = parseConnectionCode(codeInputElement.value);
|
||||||
|
if (code === null) {
|
||||||
|
codeErrorElement.innerText = "Code is not valid.";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await onSubmit(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let isSubmitting = false;
|
||||||
|
|
||||||
|
async function onSubmit(code) {
|
||||||
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
connectButtonElement.setAttribute("disabled", "");
|
||||||
|
try {
|
||||||
|
await loadTrackingScript(code);
|
||||||
|
} catch (e) {
|
||||||
|
onError("Could not load tracking script.", e);
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
connectButtonElement.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrackingScript(code) {
|
||||||
|
const trackingScript = await getTrackingScript(code.port, code.token);
|
||||||
|
|
||||||
|
if (dialogElement.isConnected) {
|
||||||
|
// noinspection DynamicallyGeneratedCodeJS
|
||||||
|
eval(trackingScript);
|
||||||
|
dialogElement.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(message, e) {
|
||||||
|
console.error(message, e);
|
||||||
|
codeErrorElement.innerText = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogElement.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
* @return {?{port: number, token: string}}
|
||||||
|
*/
|
||||||
|
function parseConnectionCode(code) {
|
||||||
|
if (code.length > 5 + 1 + 100) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = code.match(/^(\d{1,5}):([a-zA-Z0-9]{1,100})$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(match[1]);
|
||||||
|
if (port < 0 || port > 65535) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { port, token: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTrackingScript(port, token) {
|
||||||
|
const url = "http://127.0.0.1:" + port + "/get-tracking-script?token=" + encodeURIComponent(token);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: "omit",
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw response.status + " " + response.statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.headers.get("X-DHT") !== "1") {
|
||||||
|
throw "Invalid response";
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
}
|
@@ -1,5 +1,106 @@
|
|||||||
// noinspection JSUnresolvedVariable
|
// noinspection JSUnresolvedVariable
|
||||||
|
// noinspection LocalVariableNamingConventionJS
|
||||||
class DISCORD {
|
class DISCORD {
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
|
static CHANNEL_TYPE = {
|
||||||
|
GUILD_TEXT: 0,
|
||||||
|
DM: 1,
|
||||||
|
GROUP_DM: 3,
|
||||||
|
GUILD_ANNOUNCEMENT: 5,
|
||||||
|
ANNOUNCEMENT_THREAD: 10,
|
||||||
|
PUBLIC_THREAD: 11,
|
||||||
|
PRIVATE_THREAD: 12,
|
||||||
|
|
||||||
|
isPrivate(type) {
|
||||||
|
return type === this.DM
|
||||||
|
|| type === this.GROUP_DM;
|
||||||
|
},
|
||||||
|
|
||||||
|
isNavigableGuildChannel(type) {
|
||||||
|
return type === this.GUILD_TEXT
|
||||||
|
|| type === this.GUILD_ANNOUNCEMENT
|
||||||
|
|| type === this.ANNOUNCEMENT_THREAD
|
||||||
|
|| type === this.PUBLIC_THREAD
|
||||||
|
|| type === this.PRIVATE_THREAD;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
|
static MESSAGE_TYPE = {
|
||||||
|
DEFAULT: 0,
|
||||||
|
REPLY: 19,
|
||||||
|
THREAD_STARTER: 21
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
|
||||||
|
static PERMISSION = {
|
||||||
|
VIEW_CHANNEL: 1n << 10n
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): ?DiscordGuild} getGuild
|
||||||
|
*/
|
||||||
|
static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): Boolean} isOptInEnabled
|
||||||
|
* @property {function(String): Set<String>} getOptedInChannels
|
||||||
|
*/
|
||||||
|
static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): ?DiscordChannel} getChannel
|
||||||
|
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
|
||||||
|
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
|
||||||
|
*/
|
||||||
|
static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(BigInt, Object): Boolean}
|
||||||
|
*/
|
||||||
|
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): MessageData}
|
||||||
|
*/
|
||||||
|
static #getMessages = WEBPACK.findFunction("getMessages");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): void}
|
||||||
|
*/
|
||||||
|
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(): String}
|
||||||
|
*/
|
||||||
|
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): void}
|
||||||
|
*/
|
||||||
|
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String, Object, String=null): void}
|
||||||
|
*/
|
||||||
|
static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync");
|
||||||
|
|
||||||
|
static isCompatible() {
|
||||||
|
return !!this.#guildStore
|
||||||
|
&& !!this.#guildSettings
|
||||||
|
&& !!this.#channelStore
|
||||||
|
&& !!this.#hasPermission
|
||||||
|
&& !!this.#getMessages
|
||||||
|
&& !!this.#jumpToMessage
|
||||||
|
&& !!this.#getCurrentlySelectedChannelId
|
||||||
|
&& !!this.#selectPrivateChannel
|
||||||
|
&& !!this.#transitionToGuildSync;
|
||||||
|
}
|
||||||
|
|
||||||
static getMessageOuterElement() {
|
static getMessageOuterElement() {
|
||||||
return DOM.queryReactClass("messagesWrapper");
|
return DOM.queryReactClass("messagesWrapper");
|
||||||
}
|
}
|
||||||
@@ -8,14 +109,6 @@ class DISCORD {
|
|||||||
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
||||||
}
|
}
|
||||||
|
|
||||||
static getMessageElements() {
|
|
||||||
return this.getMessageOuterElement().querySelectorAll("[class*='message-']");
|
|
||||||
}
|
|
||||||
|
|
||||||
static hasMoreMessages() {
|
|
||||||
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static loadOlderMessages() {
|
static loadOlderMessages() {
|
||||||
const view = this.getMessageScrollerElement();
|
const view = this.getMessageScrollerElement();
|
||||||
|
|
||||||
@@ -24,15 +117,63 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getMessagesFromSelectedChannel() {
|
||||||
|
const channelId = this.#getCurrentlySelectedChannelId();
|
||||||
|
return channelId ? this.#getMessages(channelId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||||
|
* @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)}
|
||||||
*/
|
*/
|
||||||
static setupMessageCallback(callback) {
|
static setupMessageCallback(callback) {
|
||||||
let skipsLeft = 0;
|
|
||||||
let waitForCleanup = false;
|
|
||||||
const previousMessages = new Set();
|
const previousMessages = new Set();
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const onMessageElementsChanged = force => {
|
||||||
|
const messages = this.getMessagesFromSelectedChannel();
|
||||||
|
if (!messages || !messages.ready || messages.loadingMore) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = this.#channelStore.getChannel(messages.channelId);
|
||||||
|
if (!channel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
|
||||||
|
if (!hasChanged) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMessages.clear();
|
||||||
|
for (const message of messages._array) {
|
||||||
|
previousMessages.add(message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = this.#guildStore.getGuild(channel.guild_id);
|
||||||
|
|
||||||
|
callback(server, channel, messages._array, messages.hasMoreBefore);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let debounceTimer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do not trigger the callback too often due to autoscrolling.
|
||||||
|
*/
|
||||||
|
const onMessageElementsChangedLater = function() {
|
||||||
|
window.clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver(function() {
|
||||||
|
onMessageElementsChangedLater();
|
||||||
|
});
|
||||||
|
|
||||||
|
let skipsLeft = 0;
|
||||||
|
let observedElement = null;
|
||||||
|
|
||||||
|
const observerTimer = window.setInterval(() => {
|
||||||
if (skipsLeft > 0) {
|
if (skipsLeft > 0) {
|
||||||
--skipsLeft;
|
--skipsLeft;
|
||||||
return;
|
return;
|
||||||
@@ -41,244 +182,76 @@ class DISCORD {
|
|||||||
const view = this.getMessageOuterElement();
|
const view = this.getMessageOuterElement();
|
||||||
|
|
||||||
if (!view) {
|
if (!view) {
|
||||||
skipsLeft = 2;
|
skipsLeft = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anyMessage = DOM.queryReactClass("message", this.getMessageOuterElement());
|
if (observedElement !== null && observedElement.isConnected) {
|
||||||
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
|
|
||||||
|
|
||||||
if (messageCount > 300) {
|
|
||||||
if (waitForCleanup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
skipsLeft = 3;
|
|
||||||
waitForCleanup = true;
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
const view = this.getMessageScrollerElement();
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
view.scrollTop = view.scrollHeight / 2;
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
waitForCleanup = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = this.getMessages();
|
|
||||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
|
|
||||||
|
|
||||||
if (!hasChanged) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
previousMessages.clear();
|
observedElement = view.querySelector("[data-list-id='chat-messages']");
|
||||||
for (const message of messages) {
|
|
||||||
previousMessages.add(message.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(messages);
|
if (observedElement) {
|
||||||
}, 200);
|
console.debug("[DHT] Observed message container.");
|
||||||
|
observer.observe(observedElement, { childList: true });
|
||||||
|
onMessageElementsChangedLater();
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer));
|
window.DHT_ON_UNLOAD.push(() => {
|
||||||
}
|
observer.disconnect();
|
||||||
|
observedElement = null;
|
||||||
/**
|
window.clearInterval(observerTimer);
|
||||||
* Returns the property object of a message element.
|
});
|
||||||
* @returns { null | { message: DiscordMessage, channel: Object } }
|
|
||||||
*/
|
|
||||||
static getMessageElementProps(ele) {
|
|
||||||
const props = DOM.getReactProps(ele);
|
|
||||||
|
|
||||||
if (props.children && props.children.length >= 4) {
|
return () => onMessageElementsChanged(true);
|
||||||
const childProps = props.children[3].props;
|
|
||||||
|
|
||||||
if ("message" in childProps && "channel" in childProps) {
|
|
||||||
return childProps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an array containing currently loaded messages.
|
|
||||||
*/
|
|
||||||
static getMessages() {
|
|
||||||
try {
|
|
||||||
const messages = [];
|
|
||||||
|
|
||||||
for (const ele of this.getMessageElements()) {
|
|
||||||
const props = this.getMessageElementProps(ele);
|
|
||||||
|
|
||||||
if (props != null) {
|
|
||||||
messages.push(props.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an object containing the selected server and channel information.
|
|
||||||
* For types DM and GROUP, the server and channel ids and names are identical.
|
|
||||||
* @returns { {} | null }
|
|
||||||
*/
|
|
||||||
static getSelectedChannel() {
|
|
||||||
try {
|
|
||||||
let obj;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
|
||||||
if (child && child.props && child.props.channel) {
|
|
||||||
obj = child.props.channel;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
|
||||||
|
|
||||||
for (const ele of this.getMessageElements()) {
|
|
||||||
const props = this.getMessageElementProps(ele);
|
|
||||||
|
|
||||||
if (props != null) {
|
|
||||||
obj = props.channel;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!obj || typeof obj.id !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dms = DOM.queryReactClass("privateChannels");
|
|
||||||
|
|
||||||
if (dms) {
|
|
||||||
let 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) {
|
|
||||||
name = node.nodeValue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let type;
|
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
|
||||||
switch (obj.type) {
|
|
||||||
case 1: type = "DM"; break;
|
|
||||||
case 3: type = "GROUP"; break;
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = obj.id;
|
|
||||||
const server = { id, name, type };
|
|
||||||
const channel = { id, name };
|
|
||||||
|
|
||||||
return { server, channel };
|
|
||||||
}
|
|
||||||
else if (obj.guild_id) {
|
|
||||||
const server = {
|
|
||||||
"id": obj.guild_id,
|
|
||||||
"name": document.querySelector("nav header h1[class*='name-']").innerText,
|
|
||||||
"type": "SERVER"
|
|
||||||
};
|
|
||||||
|
|
||||||
const channel = {
|
|
||||||
"id": obj.id,
|
|
||||||
"name": obj.name,
|
|
||||||
"extra": {
|
|
||||||
"nsfw": obj.nsfw
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (obj.parent_id) {
|
|
||||||
channel["extra"]["parent"] = obj.parent_id;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
channel["extra"]["position"] = obj.position;
|
|
||||||
channel["extra"]["topic"] = obj.topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { server, channel };
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error retrieving selected channel.", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
||||||
*/
|
*/
|
||||||
static selectNextTextChannel() {
|
static selectNextTextChannel() {
|
||||||
const dms = DOM.queryReactClass("privateChannels");
|
const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
|
||||||
|
if (!currentChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (dms) {
|
if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
|
||||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
const privateChannel = this.#channelStore.getSortedPrivateChannels();
|
||||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel-']");
|
const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
|
||||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
|
||||||
|
|
||||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-")) {
|
if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
|
||||||
if (!nextChannelLink) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextChannelLink.click();
|
|
||||||
nextChannelLink.scrollIntoView(true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const channelListEle = document.getElementById("channels");
|
const guildId = currentChannel.guild_id;
|
||||||
if (!channelListEle) {
|
|
||||||
|
let isChannelOptedIn;
|
||||||
|
if (this.#guildSettings.isOptInEnabled(guildId)) {
|
||||||
|
const optedInChannels = this.#guildSettings.getOptedInChannels(guildId);
|
||||||
|
isChannelOptedIn = channel => optedInChannels.has(channel.id);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
isChannelOptedIn = _ => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId);
|
||||||
|
const guildChannels = Object.values(guildChannelMap)
|
||||||
|
.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel))
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || currentIndex === guildChannels.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLinkElement(channel) {
|
this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id);
|
||||||
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 < allTextChannels.length - 1; index++) {
|
|
||||||
if (allTextChannels[index].className.includes("selected-")) {
|
|
||||||
nextChannel = allTextChannels[index + 1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextChannel === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextChannelLink = getLinkElement(nextChannel);
|
|
||||||
if (!nextChannelLink) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextChannelLink.click();
|
|
||||||
nextChannel.scrollIntoView(true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user