mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-18 13:31:42 +02:00
Compare commits
137 Commits
Author | SHA1 | Date | |
---|---|---|---|
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
|
|||
c9bb46c8c7
|
|||
73f4c70325
|
|||
de5a8b690b
|
|||
daa2feb445
|
|||
4e94e788bc
|
|||
133ec532d2
|
20
README.md
20
README.md
@@ -16,10 +16,9 @@ Fork the repository and clone it to your computer (if you've never used git, you
|
|||||||
|
|
||||||
Folder organization:
|
Folder organization:
|
||||||
* `app/` contains a Visual Studio solution for the desktop app
|
* `app/` contains a Visual Studio solution for the desktop app
|
||||||
* `lib/` contains utilities required to build the project
|
|
||||||
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
||||||
|
|
||||||
To start editing source code for the desktop app, install the [.NET 5 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/5.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
@@ -29,27 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
|
|||||||
|
|
||||||
#### Release – Windows (64-bit)
|
#### Release – Windows (64-bit)
|
||||||
|
|
||||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH`
|
1. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
|
||||||
2. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
|
|
||||||
|
|
||||||
The `lib/` folder contains an installation of [Node](https://nodejs.org/en) and [uglify-js](https://www.npmjs.com/package/uglify-js), which are used to minify the tracking script. This installation will only work on 64-bit Windows; building on 32-bit Windows is not supported, but you can try.
|
|
||||||
|
|
||||||
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
||||||
|
|
||||||
#### Release – Other Operating Systems
|
#### Release – Other Operating Systems
|
||||||
|
|
||||||
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3
|
1. Install the `zip` package from your repository
|
||||||
- On Debian and derivatives, you can install `python-is-python3`
|
|
||||||
- On other distributions, you can create a link manually, for ex. `ln -s /usr/bin/python3 /usr/bin/python`
|
|
||||||
- If you don't want `python` to mean Python 3, then edit `Desktop.csproj` and change `python` to `python3`
|
|
||||||
2. Install [Node + npm](https://nodejs.org/en)
|
|
||||||
3. Install [uglify-js](https://www.npmjs.com/package/uglify-js) globally (`npm install -g uglify-js`)
|
|
||||||
4. Install the `zip` package from your repository
|
|
||||||
|
|
||||||
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
|
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
|
||||||
|
|
||||||
#### Distribution
|
#### Distribution
|
||||||
|
|
||||||
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 5 to be installed.
|
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 8 to be installed.
|
||||||
|
|
||||||
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking `DiscordHistoryTracker`. I tried using Python to re-create the archives with correct file permissions, but found that Linux `zip` tools could not see them. The only working solution is building the Windows + portable version on Windows, and Linux + Mac version on Linux.
|
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking the executable. Since .NET 8 fixed several issues with publishing Windows executables on Linux, I recommend using Linux to build the app for all operating systems.
|
||||||
|
@@ -7,16 +7,17 @@
|
|||||||
<entry key="Desktop/Dialogs/CheckBox/CheckBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Dialogs/CheckBox/CheckBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Dialogs/Message/MessageDialog.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Dialogs/Message/MessageDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<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/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/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>
|
||||||
|
@@ -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,72 +34,24 @@
|
|||||||
<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) -->
|
<Style Selector="TextBox:error DataValidationErrors">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextControlForegroundDisabled}" />
|
<Style.Resources>
|
||||||
|
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
|
||||||
|
<ContentPresenter Name="PART_ContentPresenter"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="{TemplateBinding CornerRadius}"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
||||||
|
</ControlTemplate>
|
||||||
|
</Style.Resources>
|
||||||
|
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
|
<Style Selector="Expander">
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
<Setter Property="MinHeight" Value="40" />
|
||||||
<Setter Property="Template">
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<ControlTemplate>
|
|
||||||
<Border x:Name="ToggleButtonBackground">
|
|
||||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="35">
|
|
||||||
<Border x:Name="ExpandCollapseChevronBorder"
|
|
||||||
Grid.Column="0"
|
|
||||||
Width="35"
|
|
||||||
Height="35"
|
|
||||||
Margin="2,0"
|
|
||||||
RenderTransformOrigin="50%,50%">
|
|
||||||
<Path x:Name="ExpandCollapseChevron"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
RenderTransformOrigin="50%,50%"
|
|
||||||
Stretch="None"
|
|
||||||
Stroke="{DynamicResource ExpanderChevronForeground}"
|
|
||||||
StrokeThickness="1" />
|
|
||||||
<Border.RenderTransform>
|
|
||||||
<RotateTransform />
|
|
||||||
</Border.RenderTransform>
|
|
||||||
</Border>
|
|
||||||
<ContentPresenter x:Name="PART_ContentPresenter"
|
|
||||||
Grid.Column="1"
|
|
||||||
Margin="0"
|
|
||||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
|
||||||
VerticalContentAlignment="Center"
|
|
||||||
Background="Transparent"
|
|
||||||
BorderBrush="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
Content="{TemplateBinding Content}"
|
|
||||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
|
||||||
TextBlock.Foreground="{DynamicResource ExpanderForeground}" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
|
|
||||||
<Style.Animations>
|
|
||||||
<Animation FillMode="Both" Duration="0:0:0.0625">
|
|
||||||
<KeyFrame Cue="0%">
|
|
||||||
<Setter Property="RotateTransform.Angle" Value="180" />
|
|
||||||
</KeyFrame>
|
|
||||||
<KeyFrame Cue="100%">
|
|
||||||
<Setter Property="RotateTransform.Angle" Value="180" />
|
|
||||||
</KeyFrame>
|
|
||||||
</Animation>
|
|
||||||
</Style.Animations>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
|
|
||||||
<Style.Animations>
|
|
||||||
<Animation FillMode="Both" Duration="0:0:0.0625">
|
|
||||||
<KeyFrame Cue="0%">
|
|
||||||
<Setter Property="RotateTransform.Angle" Value="0" />
|
|
||||||
</KeyFrame>
|
|
||||||
<KeyFrame Cue="100%">
|
|
||||||
<Setter Property="RotateTransform.Angle" Value="0" />
|
|
||||||
</KeyFrame>
|
|
||||||
</Animation>
|
|
||||||
</Style.Animations>
|
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
@@ -157,12 +110,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,7 +3,8 @@ 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 {
|
sealed class App : Application {
|
||||||
public override void Initialize() {
|
public override void Initialize() {
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
@@ -11,10 +12,9 @@ namespace DHT.Desktop {
|
|||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted() {
|
public override void OnFrameworkInitializationCompleted() {
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||||
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args));
|
desktop.MainWindow = new MainWindow(Program.Arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -1,33 +1,43 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop {
|
namespace DHT.Desktop;
|
||||||
|
|
||||||
sealed class Arguments {
|
sealed class Arguments {
|
||||||
private static readonly Log Log = Log.ForType<Arguments>();
|
private static readonly Log Log = Log.ForType<Arguments>();
|
||||||
|
|
||||||
|
private const int FirstArgument = 1;
|
||||||
|
|
||||||
public static Arguments Empty => new (Array.Empty<string>());
|
public static Arguments Empty => new (Array.Empty<string>());
|
||||||
|
|
||||||
|
public bool Console { get; }
|
||||||
public string? DatabaseFile { get; }
|
public string? DatabaseFile { get; }
|
||||||
public ushort? ServerPort { get; }
|
public ushort? ServerPort { get; }
|
||||||
public string? ServerToken { get; }
|
public string? ServerToken { get; }
|
||||||
|
public byte? ConcurrentDownloads { get; }
|
||||||
|
|
||||||
public Arguments(string[] args) {
|
public Arguments(IReadOnlyList<string> args) {
|
||||||
for (int i = 0; i < args.Length; i++) {
|
for (int i = FirstArgument; i < args.Count; i++) {
|
||||||
string key = args[i];
|
string key = args[i];
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "-debug":
|
case "-debug":
|
||||||
Log.IsDebugEnabled = true;
|
Log.IsDebugEnabled = true;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
case "-console":
|
||||||
|
Console = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string value;
|
string value;
|
||||||
|
|
||||||
if (i == 0 && !key.StartsWith('-')) {
|
if (i == FirstArgument && !key.StartsWith('-')) {
|
||||||
value = key;
|
value = key;
|
||||||
key = "-db";
|
key = "-db";
|
||||||
}
|
}
|
||||||
else if (i >= args.Length - 1) {
|
else if (i >= args.Count - 1) {
|
||||||
Log.Warn("Missing value for command line argument: " + key);
|
Log.Warn("Missing value for command line argument: " + key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -41,11 +51,11 @@ namespace DHT.Desktop {
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
case "-port": {
|
case "-port": {
|
||||||
if (ushort.TryParse(value, out var port)) {
|
if (!ushort.TryParse(value, out var port)) {
|
||||||
ServerPort = port;
|
Log.Warn("Invalid port number: " + value);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Log.Warn("Invalid port number: " + value);
|
ServerPort = port;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -55,6 +65,20 @@ namespace DHT.Desktop {
|
|||||||
ServerToken = value;
|
ServerToken = value;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
case "-concurrentdownloads":
|
||||||
|
if (!ulong.TryParse(value, out var concurrentDownloads) || concurrentDownloads == 0) {
|
||||||
|
Log.Warn("Invalid concurrent downloads count: " + value);
|
||||||
|
}
|
||||||
|
else if (concurrentDownloads > 10) {
|
||||||
|
Log.Warn("Limiting concurrent downloads to 10");
|
||||||
|
ConcurrentDownloads = 10;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ConcurrentDownloads = (byte) concurrentDownloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
Log.Warn("Unknown command line argument: " + key);
|
Log.Warn("Unknown command line argument: " + key);
|
||||||
break;
|
break;
|
||||||
@@ -62,4 +86,3 @@ namespace DHT.Desktop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -2,28 +2,37 @@ 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 {
|
sealed class BytesValueConverter : IValueConverter {
|
||||||
private static readonly string[] Units = {
|
private sealed class Unit {
|
||||||
"B",
|
private readonly string label;
|
||||||
"kB",
|
private readonly string numberFormat;
|
||||||
"MB",
|
|
||||||
"GB",
|
public Unit(string label, int decimalPlaces) {
|
||||||
"TB"
|
this.label = label;
|
||||||
};
|
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Format(double size) {
|
||||||
|
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Unit[] Units = [
|
||||||
|
new Unit("B", decimalPlaces: 0),
|
||||||
|
new Unit("kB", decimalPlaces: 0),
|
||||||
|
new Unit("MB", decimalPlaces: 1),
|
||||||
|
new Unit("GB", decimalPlaces: 1),
|
||||||
|
new Unit("TB", decimalPlaces: 1)
|
||||||
|
];
|
||||||
|
|
||||||
private const int Scale = 1000;
|
private const int Scale = 1000;
|
||||||
|
|
||||||
private static string Convert(ulong size) {
|
public static string Convert(ulong size) {
|
||||||
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
||||||
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
||||||
if (unit == 0) {
|
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||||
return string.Format(Program.Culture, "{0:n0}", size) + " " + Units[unit];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
double humanReadableSize = size / Math.Pow(Scale, unit);
|
|
||||||
return string.Format(Program.Culture, "{0:n0}", humanReadableSize) + " " + Units[unit];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
@@ -42,4 +51,3 @@ namespace DHT.Desktop.Common {
|
|||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -3,46 +3,50 @@ 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 {
|
static class DatabaseGui {
|
||||||
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
||||||
|
|
||||||
private const string DatabaseFileInitialName = "archive.dht";
|
private const string DatabaseFileInitialName = "archive.dht";
|
||||||
|
|
||||||
private static readonly List<FileDialogFilter> DatabaseFileDialogFilter = new() {
|
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
|
||||||
new FileDialogFilter {
|
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"])
|
||||||
Name = "Discord History Tracker Database",
|
];
|
||||||
Extensions = { "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() {
|
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
||||||
return new SaveFileDialog {
|
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||||
Title = "Open or Create Database File",
|
Title = "Open or Create Database File",
|
||||||
InitialFileName = DatabaseFileInitialName,
|
FileTypeChoices = DatabaseFileDialogFilter,
|
||||||
Filters = DatabaseFileDialogFilter
|
SuggestedFileName = DatabaseFileInitialName,
|
||||||
};
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
|
ShowOverwritePrompt = false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
|
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||||
IDatabaseFile? file = null;
|
IDatabaseFile? file = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
|
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
|
||||||
} catch (InvalidDatabaseVersionException ex) {
|
} catch (InvalidDatabaseVersionException ex) {
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
||||||
} catch (DatabaseTooNewException ex) {
|
} catch (DatabaseTooNewException ex) {
|
||||||
@@ -63,4 +67,3 @@ namespace DHT.Desktop.Common {
|
|||||||
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 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,7 +2,8 @@ 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 {
|
sealed class NumberValueConverter : IValueConverter {
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
||||||
@@ -12,4 +13,3 @@ namespace DHT.Desktop.Common {
|
|||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
namespace DHT.Desktop.Common {
|
namespace DHT.Desktop.Common;
|
||||||
|
|
||||||
static class TextFormat {
|
static class TextFormat {
|
||||||
public static string Format(this int number) {
|
public static string Format(this int number) {
|
||||||
return number.ToString("N0", Program.Culture);
|
return number.ToString("N0", Program.Culture);
|
||||||
@@ -16,4 +17,3 @@ namespace DHT.Desktop.Common {
|
|||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -1,73 +1,44 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>DHT.Desktop</RootNamespace>
|
||||||
|
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
||||||
|
<PackageId>DiscordHistoryTracker</PackageId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<AssemblyName>DiscordHistoryTracker</AssemblyName>
|
|
||||||
<RootNamespace>DHT.Desktop</RootNamespace>
|
|
||||||
<PackageId>DiscordHistoryTracker</PackageId>
|
|
||||||
<Authors>chylex</Authors>
|
|
||||||
<Company>DiscordHistoryTracker</Company>
|
|
||||||
<Product>DiscordHistoryTracker</Product>
|
|
||||||
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
||||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
|
|
||||||
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
|
|
||||||
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
|
||||||
<DebugSymbols>true</DebugSymbols>
|
|
||||||
<DebugType>none</DebugType>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="0.10.14" />
|
<PackageReference Include="Avalonia" Version="11.0.11" />
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.14" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.11" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.14" />
|
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.11" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.14" Condition=" '$(Configuration)' == 'Debug' " />
|
<PackageReference Include="Avalonia.Desktop" Version="11.0.11" />
|
||||||
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.11" Condition=" '$(Configuration)' == 'Debug' " />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.11" />
|
||||||
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.11" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.11" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
|
||||||
</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" />
|
||||||
</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"
|
||||||
@@ -34,10 +35,10 @@
|
|||||||
|
|
||||||
<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,22 +1,14 @@
|
|||||||
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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class CheckBoxDialog : Window {
|
public sealed partial class CheckBoxDialog : Window {
|
||||||
public CheckBoxDialog() {
|
public CheckBoxDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
@@ -27,5 +19,3 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
|||||||
Close(DialogResult.OkCancel.Cancel);
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
@@ -2,10 +2,11 @@ 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 CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
class CheckBoxDialogModel : BaseModel {
|
|
||||||
|
class CheckBoxDialogModel : ObservableObject {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
||||||
@@ -28,8 +29,8 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
|||||||
|
|
||||||
private bool pauseCheckEvents = false;
|
private bool pauseCheckEvents = false;
|
||||||
|
|
||||||
public bool AreAllSelected => Items.All(static item => item.Checked);
|
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
||||||
public bool AreNoneSelected => Items.All(static item => !item.Checked);
|
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
||||||
|
|
||||||
public void SelectAll() => SetAllChecked(true);
|
public void SelectAll() => SetAllChecked(true);
|
||||||
public void SelectNone() => SetAllChecked(false);
|
public void SelectNone() => SetAllChecked(false);
|
||||||
@@ -38,7 +39,7 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
|||||||
pauseCheckEvents = true;
|
pauseCheckEvents = true;
|
||||||
|
|
||||||
foreach (var item in Items) {
|
foreach (var item in Items) {
|
||||||
item.Checked = isChecked;
|
item.IsChecked = isChecked;
|
||||||
}
|
}
|
||||||
|
|
||||||
pauseCheckEvents = false;
|
pauseCheckEvents = false;
|
||||||
@@ -51,20 +52,19 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
|
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
||||||
UpdateBulkButtons();
|
UpdateBulkButtons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||||
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
|
||||||
|
|
||||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||||
this.Items = new List<CheckBoxItem<T>>(items);
|
this.Items = new List<CheckBoxItem<T>>(items);
|
||||||
base.Items = this.Items;
|
base.Items = this.Items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -1,16 +1,13 @@
|
|||||||
using DHT.Utils.Models;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox {
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
class CheckBoxItem : BaseModel {
|
|
||||||
|
partial class CheckBoxItem : ObservableObject {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public object? Item { get; init; } = null;
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool isChecked = false;
|
private bool isChecked = false;
|
||||||
|
|
||||||
public bool Checked {
|
|
||||||
get => isChecked;
|
|
||||||
set => Change(ref isChecked, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||||
@@ -21,4 +18,3 @@ namespace DHT.Desktop.Dialogs.CheckBox {
|
|||||||
base.Item = item;
|
base.Item = item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
36
app/Desktop/Dialogs/File/FileDialogs.cs
Normal file
36
app/Desktop/Dialogs/File/FileDialogs.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.File;
|
||||||
|
|
||||||
|
static class FileDialogs {
|
||||||
|
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
||||||
|
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
||||||
|
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
||||||
|
return new FilePickerFileType(name) {
|
||||||
|
Patterns = extensions.Select(static ext => "*." + ext).ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
||||||
|
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToLocalPath(this IStorageFile file) {
|
||||||
|
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
|
||||||
|
return files.Select(ToLocalPath).ToArray();
|
||||||
|
}
|
||||||
|
}
|
@@ -2,7 +2,8 @@ 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 {
|
static class Dialog {
|
||||||
public static async Task ShowOk(Window owner, string title, string message) {
|
public static async Task ShowOk(Window owner, string title, string message) {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
@@ -71,4 +72,3 @@ namespace DHT.Desktop.Dialogs.Message {
|
|||||||
return result.ToYesNoCancel();
|
return result.ToYesNoCancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
static class DialogResult {
|
static class DialogResult {
|
||||||
public enum All {
|
public enum All {
|
||||||
Ok,
|
Ok,
|
||||||
@@ -56,4 +57,3 @@ namespace DHT.Desktop.Dialogs.Message {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
|
||||||
mc:Ignorable="d" d:DesignWidth="500"
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
|
||||||
|
x:DataType="namespace:MessageDialogModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="500" SizeToContent="Height" CanResize="False"
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
|
@@ -1,21 +1,13 @@
|
|||||||
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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class MessageDialog : Window {
|
public sealed partial class MessageDialog : Window {
|
||||||
public MessageDialog() {
|
public MessageDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
@@ -34,4 +26,3 @@ namespace DHT.Desktop.Dialogs.Message {
|
|||||||
Close(DialogResult.All.Cancel);
|
Close(DialogResult.All.Cancel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
namespace DHT.Desktop.Dialogs.Message {
|
namespace DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
sealed class MessageDialogModel {
|
sealed class MessageDialogModel {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public string Message { get; init; } = "";
|
public string Message { get; init; } = "";
|
||||||
@@ -8,4 +9,3 @@ namespace DHT.Desktop.Dialogs.Message {
|
|||||||
public bool IsNoVisible { get; init; } = false;
|
public bool IsNoVisible { get; init; } = false;
|
||||||
public bool IsCancelVisible { get; init; } = false;
|
public bool IsCancelVisible { get; init; } = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress {
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
interface IProgressCallback {
|
interface IProgressCallback {
|
||||||
Task Update(string message, int finishedItems, int totalItems);
|
Task Update(string message, int finishedItems, int totalItems);
|
||||||
}
|
Task UpdateIndeterminate(string message);
|
||||||
|
Task Hide();
|
||||||
}
|
}
|
||||||
|
@@ -5,9 +5,10 @@
|
|||||||
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
|
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">
|
||||||
|
<ItemsRepeater.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
||||||
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
<ProgressBar Value="{Binding Progress}" />
|
<ProgressBar IsIndeterminate="{Binding IsIndeterminate}" Value="{Binding Progress}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsRepeater.ItemTemplate>
|
||||||
|
</ItemsRepeater>
|
||||||
|
|
||||||
</Window>
|
</Window>
|
||||||
|
@@ -1,40 +1,89 @@
|
|||||||
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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class ProgressDialog : Window {
|
public sealed partial class ProgressDialog : Window {
|
||||||
|
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
|
||||||
|
var taskCompletionSource = new TaskCompletionSource();
|
||||||
|
var dialog = new ProgressDialog();
|
||||||
|
|
||||||
|
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||||
|
try {
|
||||||
|
await action(dialog, callbacks[0]);
|
||||||
|
taskCompletionSource.SetResult();
|
||||||
|
} catch (Exception e) {
|
||||||
|
taskCompletionSource.SetException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await dialog.ShowProgressDialog(owner);
|
||||||
|
await taskCompletionSource.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
|
||||||
|
var taskCompletionSource = new TaskCompletionSource();
|
||||||
|
var dialog = new ProgressDialog();
|
||||||
|
|
||||||
|
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||||
|
await callbacks[0].UpdateIndeterminate(message);
|
||||||
|
try {
|
||||||
|
await action(dialog);
|
||||||
|
taskCompletionSource.SetResult();
|
||||||
|
} catch (Exception e) {
|
||||||
|
taskCompletionSource.SetException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await dialog.ShowProgressDialog(owner);
|
||||||
|
await taskCompletionSource.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
||||||
|
var taskCompletionSource = new TaskCompletionSource<T>();
|
||||||
|
var dialog = new ProgressDialog();
|
||||||
|
|
||||||
|
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||||
|
await callbacks[0].UpdateIndeterminate(message);
|
||||||
|
try {
|
||||||
|
taskCompletionSource.SetResult(await action(dialog));
|
||||||
|
} catch (Exception e) {
|
||||||
|
taskCompletionSource.SetException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await dialog.ShowProgressDialog(owner);
|
||||||
|
return await taskCompletionSource.Task;
|
||||||
|
}
|
||||||
|
|
||||||
private bool isFinished = false;
|
private bool isFinished = false;
|
||||||
|
private Task progressTask = Task.CompletedTask;
|
||||||
|
|
||||||
public ProgressDialog() {
|
public ProgressDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
public void OnOpened(object? sender, EventArgs e) {
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClosing(object? sender, CancelEventArgs e) {
|
|
||||||
e.Cancel = !isFinished;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Loaded(object? sender, EventArgs e) {
|
|
||||||
if (DataContext is ProgressDialogModel model) {
|
if (DataContext is ProgressDialogModel model) {
|
||||||
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
progressTask = Task.Run(model.StartTask);
|
||||||
|
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
|
e.Cancel = !isFinished;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnFinished(Task task) {
|
private void OnFinished(Task task) {
|
||||||
isFinished = true;
|
isFinished = true;
|
||||||
Close();
|
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 {
|
|
||||||
|
sealed class ProgressDialogModel {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
private string message = "";
|
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
||||||
|
|
||||||
public string Message {
|
|
||||||
get => message;
|
|
||||||
private set => Change(ref message, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string items = "";
|
|
||||||
|
|
||||||
public string Items {
|
|
||||||
get => items;
|
|
||||||
private set => Change(ref items, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int progress = 0;
|
|
||||||
|
|
||||||
public int Progress {
|
|
||||||
get => progress;
|
|
||||||
private set => Change(ref progress, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly TaskRunner? task;
|
private readonly TaskRunner? task;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ProgressDialogModel() {}
|
public ProgressDialogModel() {}
|
||||||
|
|
||||||
public ProgressDialogModel(TaskRunner task) {
|
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
|
||||||
|
this.Title = title;
|
||||||
this.task = task;
|
this.task = task;
|
||||||
|
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task StartTask() {
|
internal async Task StartTask() {
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
await task(new Callback(this));
|
await task(Items.Select(static item => new Callback(item)).ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public delegate Task TaskRunner(IProgressCallback callback);
|
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
||||||
|
|
||||||
private sealed class Callback : IProgressCallback {
|
private sealed class Callback : IProgressCallback {
|
||||||
private readonly ProgressDialogModel model;
|
private readonly ProgressItem item;
|
||||||
|
|
||||||
public Callback(ProgressDialogModel model) {
|
public Callback(ProgressItem item) {
|
||||||
this.model = model;
|
this.item = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
|
public async Task Update(string message, int finishedItems, int totalItems) {
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||||
model.Message = message;
|
item.Message = message;
|
||||||
model.Items = finishedItems.Format() + " / " + totalItems.Format();
|
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||||
model.Progress = 100 * finishedItems / totalItems;
|
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
||||||
|
item.IsIndeterminate = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdateIndeterminate(string message) {
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||||
|
item.Message = message;
|
||||||
|
item.Items = string.Empty;
|
||||||
|
item.Progress = 0;
|
||||||
|
item.IsIndeterminate = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Hide() {
|
||||||
|
return Update(string.Empty, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
30
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
30
app/Desktop/Dialogs/Progress/ProgressItem.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
|
sealed partial class ProgressItem : ObservableObject {
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Opacity))]
|
||||||
|
private bool isVisible = false;
|
||||||
|
|
||||||
|
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||||
|
|
||||||
|
private string message = "";
|
||||||
|
|
||||||
|
public string Message {
|
||||||
|
get => message;
|
||||||
|
set {
|
||||||
|
SetProperty(ref message, value);
|
||||||
|
IsVisible = !string.IsNullOrEmpty(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string items = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int progress = 0;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isIndeterminate;
|
||||||
|
}
|
57
app/Desktop/Dialogs/TextBox/TextBoxDialog.axaml
Normal file
57
app/Desktop/Dialogs/TextBox/TextBoxDialog.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.Dialogs.TextBox"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="500"
|
||||||
|
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
|
||||||
|
x:DataType="namespace:TextBoxDialogModel"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
|
Width="500" SizeToContent="Height" CanResize="False"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<Window.DataContext>
|
||||||
|
<namespace:TextBoxDialogModel />
|
||||||
|
</Window.DataContext>
|
||||||
|
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="Panel.buttons">
|
||||||
|
<Setter Property="Margin" Value="0 20 0 0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Panel.buttons > WrapPanel.right">
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Panel.buttons Button">
|
||||||
|
<Setter Property="MinWidth" Value="80" />
|
||||||
|
<Setter Property="Margin" Value="8 0 0 0" />
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
|
<StackPanel Margin="20">
|
||||||
|
<ScrollViewer MaxHeight="400">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
|
||||||
|
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||||
|
<ItemsRepeater.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<DockPanel Margin="0 5 25 0">
|
||||||
|
<TextBox Name="Input" Text="{Binding Value}" Width="180" VerticalAlignment="Top" DockPanel.Dock="Right" />
|
||||||
|
<Label Target="Input" VerticalAlignment="Center" DockPanel.Dock="Left">
|
||||||
|
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
||||||
|
</Label>
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsRepeater.ItemTemplate>
|
||||||
|
</ItemsRepeater>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
<Panel Classes="buttons">
|
||||||
|
<WrapPanel Classes="right">
|
||||||
|
<Button Click="ClickOk" IsEnabled="{Binding !HasErrors}">OK</Button>
|
||||||
|
<Button Click="ClickCancel">Cancel</Button>
|
||||||
|
</WrapPanel>
|
||||||
|
</Panel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Window>
|
21
app/Desktop/Dialogs/TextBox/TextBoxDialog.axaml.cs
Normal file
21
app/Desktop/Dialogs/TextBox/TextBoxDialog.axaml.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
|
public sealed partial class TextBoxDialog : Window {
|
||||||
|
public TextBoxDialog() {
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.OkCancel.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
|
}
|
||||||
|
}
|
47
app/Desktop/Dialogs/TextBox/TextBoxDialogModel.cs
Normal file
47
app/Desktop/Dialogs/TextBox/TextBoxDialogModel.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
|
class TextBoxDialogModel : ObservableObject {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
|
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
|
||||||
|
|
||||||
|
public IReadOnlyList<TextBoxItem> Items {
|
||||||
|
get => items;
|
||||||
|
|
||||||
|
protected set {
|
||||||
|
foreach (var item in items) {
|
||||||
|
item.ErrorsChanged -= OnItemErrorsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
items = value;
|
||||||
|
|
||||||
|
foreach (var item in items) {
|
||||||
|
item.ErrorsChanged += OnItemErrorsChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
||||||
|
|
||||||
|
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||||
|
OnPropertyChanged(nameof(HasErrors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||||
|
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
|
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||||
|
|
||||||
|
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
||||||
|
this.Items = new List<TextBoxItem<T>>(items);
|
||||||
|
base.Items = this.Items;
|
||||||
|
}
|
||||||
|
}
|
42
app/Desktop/Dialogs/TextBox/TextBoxItem.cs
Normal file
42
app/Desktop/Dialogs/TextBox/TextBoxItem.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
|
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
|
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
||||||
|
public bool IsValid => ValidityCheck(Value);
|
||||||
|
|
||||||
|
private string value = string.Empty;
|
||||||
|
|
||||||
|
public string Value {
|
||||||
|
get => this.value;
|
||||||
|
set {
|
||||||
|
SetProperty(ref this.value, value);
|
||||||
|
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable GetErrors(string? propertyName) {
|
||||||
|
if (propertyName == nameof(Value) && !IsValid) {
|
||||||
|
yield return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasErrors => !IsValid;
|
||||||
|
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class TextBoxItem<T> : TextBoxItem {
|
||||||
|
public new T Item { get; }
|
||||||
|
|
||||||
|
public TextBoxItem(T item) {
|
||||||
|
this.Item = item;
|
||||||
|
base.Item = item;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,15 +1,16 @@
|
|||||||
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 {
|
static class DiscordAppSettings {
|
||||||
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
||||||
|
|
||||||
@@ -38,18 +39,21 @@ namespace DHT.Desktop.Discord {
|
|||||||
|
|
||||||
public static async Task<bool?> AreDevToolsEnabled() {
|
public static async Task<bool?> AreDevToolsEnabled() {
|
||||||
try {
|
try {
|
||||||
return AreDevToolsEnabled(await ReadSettingsJson());
|
var settingsJson = await ReadSettingsJson();
|
||||||
} catch (Exception) {
|
return AreDevToolsEnabled(settingsJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error("Cannot read settings file.");
|
||||||
|
Log.Error(e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) {
|
private static bool AreDevToolsEnabled(JsonObject json) {
|
||||||
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True };
|
return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
||||||
Dictionary<string, object?> json;
|
JsonObject json;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json = await ReadSettingsJson();
|
json = await ReadSettingsJson();
|
||||||
@@ -106,14 +110,13 @@ namespace DHT.Desktop.Discord {
|
|||||||
return SettingsJsonResult.Success;
|
return SettingsJsonResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
|
private static async Task<JsonObject> ReadSettingsJson() {
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
|
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
|
private static async Task WriteSettingsJson(JsonObject json) {
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
||||||
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
|
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
8
app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
Normal file
8
app/Desktop/Discord/DiscordAppSettingsJsonContext.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Discord;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
||||||
|
[JsonSerializable(typeof(JsonObject))]
|
||||||
|
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext;
|
@@ -1,4 +1,5 @@
|
|||||||
namespace DHT.Desktop.Discord {
|
namespace DHT.Desktop.Discord;
|
||||||
|
|
||||||
enum SettingsJsonResult {
|
enum SettingsJsonResult {
|
||||||
Success,
|
Success,
|
||||||
AlreadySet,
|
AlreadySet,
|
||||||
@@ -7,4 +8,3 @@ namespace DHT.Desktop.Discord {
|
|||||||
InvalidJson,
|
InvalidJson,
|
||||||
WriteError
|
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="480" d:DesignHeight="360"
|
||||||
x:Class="DHT.Desktop.Main.AboutWindow"
|
x:Class="DHT.Desktop.Main.AboutWindow"
|
||||||
|
x:DataType="main:AboutWindowModel"
|
||||||
Title="About Discord History Tracker"
|
Title="About Discord History Tracker"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="480" Height="295" CanResize="False"
|
Width="480" Height="360" CanResize="False"
|
||||||
WindowStartupLocation="CenterOwner">
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
@@ -38,15 +39,16 @@
|
|||||||
|
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<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="175,125,*" 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 8</TextBlock>
|
||||||
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
|
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
|
||||||
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
||||||
|
|
||||||
@@ -54,13 +56,21 @@
|
|||||||
<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}">NuGet</Button>
|
||||||
|
|
||||||
<TextBlock Grid.Row="4" Grid.Column="0">SQLite</TextBlock>
|
<TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</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 ShowLibraryCommunityToolkit}">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}">Official 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">Rx.NET</TextBlock>
|
||||||
|
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
|
||||||
|
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class AboutWindow : Window {
|
public sealed partial class AboutWindow : Window {
|
||||||
public AboutWindow() {
|
public AboutWindow() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,19 +1,32 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
|
|
||||||
sealed class AboutWindowModel {
|
sealed class AboutWindowModel {
|
||||||
public void ShowOfficialWebsite() {
|
public void ShowOfficialWebsite() {
|
||||||
OpenUrl("https://dht.chylex.com");
|
OpenUrl("https://dht.chylex.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ShowIssueTracker() {
|
||||||
|
OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
||||||
|
}
|
||||||
|
|
||||||
public void ShowSourceCode() {
|
public void ShowSourceCode() {
|
||||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ShowLibraryNetCore() {
|
||||||
|
OpenUrl("https://github.com/dotnet/core");
|
||||||
|
}
|
||||||
|
|
||||||
public void ShowLibraryAvalonia() {
|
public void ShowLibraryAvalonia() {
|
||||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ShowLibraryCommunityToolkit() {
|
||||||
|
OpenUrl("https://github.com/CommunityToolkit/dotnet");
|
||||||
|
}
|
||||||
|
|
||||||
public void ShowLibrarySqlite() {
|
public void ShowLibrarySqlite() {
|
||||||
OpenUrl("https://www.sqlite.org");
|
OpenUrl("https://www.sqlite.org");
|
||||||
}
|
}
|
||||||
@@ -22,12 +35,11 @@ namespace DHT.Desktop.Main {
|
|||||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibraryNetCore() {
|
public void ShowLibraryRxNet() {
|
||||||
OpenUrl("https://github.com/dotnet/core");
|
OpenUrl("https://github.com/dotnet/reactive");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OpenUrl(string url) {
|
private static void OpenUrl(string url) {
|
||||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
172
app/Desktop/Main/Controls/DownloadItemFilterPanelModel.cs
Normal file
172
app/Desktop/Main/Controls/DownloadItemFilterPanelModel.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Server;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Data.Settings;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
|
sealed partial class DownloadItemFilterPanelModel : ObservableObject, 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", 1),
|
||||||
|
new Unit("kB", 1024),
|
||||||
|
new Unit("MB", 1024 * 1024)
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> FilterProperties = [
|
||||||
|
nameof(LimitSize),
|
||||||
|
nameof(MaximumSize),
|
||||||
|
nameof(MaximumSizeUnit)
|
||||||
|
];
|
||||||
|
|
||||||
|
public string FilterStatisticsText { get; private set; } = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool limitSize = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private ulong maximumSize = 0UL;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
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, 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() {
|
||||||
|
var filter = CreateFilter();
|
||||||
|
if (filter.IsEmpty) {
|
||||||
|
downloadItemCountTask.Cancel();
|
||||||
|
matchingItemCount = totalItemCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
matchingItemCount = null;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetMatchingCount(long matchingAttachmentCount) {
|
||||||
|
this.matchingItemCount = matchingAttachmentCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatisticsText() {
|
||||||
|
var matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
|
||||||
|
var totalItemCountStr = totalItemCount?.Format() ?? "(...)";
|
||||||
|
|
||||||
|
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
|
||||||
|
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadItemFilter CreateFilter() {
|
||||||
|
DownloadItemFilter filter = new ();
|
||||||
|
|
||||||
|
if (LimitSize) {
|
||||||
|
try {
|
||||||
|
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||||
|
} catch (ArithmeticException) {
|
||||||
|
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
}
|
@@ -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,19 +1,12 @@
|
|||||||
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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class MessageFilterPanel : UserControl {
|
public sealed partial class MessageFilterPanel : UserControl {
|
||||||
private CalendarDatePicker StartDatePicker => this.FindControl<CalendarDatePicker>("StartDatePicker");
|
|
||||||
private CalendarDatePicker EndDatePicker => this.FindControl<CalendarDatePicker>("EndDatePicker");
|
|
||||||
|
|
||||||
public MessageFilterPanel() {
|
public MessageFilterPanel() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
|
|
||||||
var culture = Program.Culture;
|
var culture = Program.Culture;
|
||||||
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
||||||
@@ -31,4 +24,3 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -2,21 +2,25 @@ 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 CommunityToolkit.Mvvm.ComponentModel;
|
||||||
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;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|
||||||
private static readonly HashSet<string> FilterProperties = new () {
|
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||||
|
private static readonly HashSet<string> FilterProperties = [
|
||||||
nameof(FilterByDate),
|
nameof(FilterByDate),
|
||||||
nameof(StartDate),
|
nameof(StartDate),
|
||||||
nameof(EndDate),
|
nameof(EndDate),
|
||||||
@@ -24,7 +28,7 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
nameof(IncludedChannels),
|
nameof(IncludedChannels),
|
||||||
nameof(FilterByUser),
|
nameof(FilterByUser),
|
||||||
nameof(IncludedUsers)
|
nameof(IncludedUsers)
|
||||||
};
|
];
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
public string FilterStatisticsText { get; private set; } = "";
|
||||||
|
|
||||||
@@ -32,91 +36,76 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
|
|
||||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool filterByDate = false;
|
private bool filterByDate = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private DateTime? startDate = null;
|
private DateTime? startDate = null;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private DateTime? endDate = null;
|
private DateTime? endDate = null;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool filterByChannel = false;
|
private bool filterByChannel = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private HashSet<ulong>? includedChannels = null;
|
private HashSet<ulong>? includedChannels = null;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool filterByUser = false;
|
private bool filterByUser = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private HashSet<ulong>? includedUsers = null;
|
private HashSet<ulong>? includedUsers = null;
|
||||||
|
|
||||||
public bool FilterByDate {
|
[ObservableProperty]
|
||||||
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 = "";
|
private string channelFilterLabel = "";
|
||||||
|
|
||||||
public string ChannelFilterLabel {
|
[ObservableProperty]
|
||||||
get => channelFilterLabel;
|
|
||||||
set => Change(ref channelFilterLabel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string userFilterLabel = "";
|
private string userFilterLabel = "";
|
||||||
|
|
||||||
public string UserFilterLabel {
|
|
||||||
get => userFilterLabel;
|
|
||||||
set => Change(ref userFilterLabel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly IDatabaseFile db;
|
private readonly State state;
|
||||||
private readonly string verb;
|
private readonly string verb;
|
||||||
|
|
||||||
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
|
private readonly RestartableTask<long> exportedMessageCountTask;
|
||||||
private long? exportedMessageCount;
|
private long? exportedMessageCount;
|
||||||
|
|
||||||
|
private readonly IDisposable messageCountSubscription;
|
||||||
private long? totalMessageCount;
|
private long? totalMessageCount;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly IDisposable channelCountSubscription;
|
||||||
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
private long? totalChannelCount;
|
||||||
|
|
||||||
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
|
private readonly IDisposable userCountSubscription;
|
||||||
|
private long? totalUserCount;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
|
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.db = db;
|
this.state = state;
|
||||||
this.verb = verb;
|
this.verb = verb;
|
||||||
|
|
||||||
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
|
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
|
||||||
|
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
|
||||||
|
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
|
||||||
|
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
|
||||||
|
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
PropertyChanged += OnPropertyChanged;
|
||||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
exportedMessageCountTask.Cancel();
|
||||||
|
|
||||||
|
messageCountSubscription.Dispose();
|
||||||
|
channelCountSubscription.Dispose();
|
||||||
|
userCountSubscription.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
@@ -133,30 +122,54 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnMessageCountChanged(long newMessageCount) {
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
totalMessageCount = newMessageCount;
|
||||||
totalMessageCount = db.Statistics.TotalMessages;
|
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
}
|
}
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
|
||||||
|
private void OnChannelCountChanged(long newChannelCount) {
|
||||||
|
totalChannelCount = newChannelCount;
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
}
|
}
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
|
||||||
|
private void OnUserCountChanged(long newUserCount) {
|
||||||
|
totalUserCount = newUserCount;
|
||||||
UpdateUserFilterLabel();
|
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() {
|
private void UpdateFilterStatistics() {
|
||||||
var filter = CreateFilter();
|
var filter = CreateFilter();
|
||||||
if (filter.IsEmpty) {
|
if (filter.IsEmpty) {
|
||||||
exportedMessageCountComputer.Cancel();
|
exportedMessageCountTask.Cancel();
|
||||||
exportedMessageCount = totalMessageCount;
|
exportedMessageCount = totalMessageCount;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
exportedMessageCount = null;
|
exportedMessageCount = null;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
|
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,16 +182,16 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
||||||
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
||||||
|
|
||||||
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 0 ? "." : "s.");
|
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
|
||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenChannelFilterDialog() {
|
public async Task OpenChannelFilterDialog() {
|
||||||
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
|
||||||
var items = new List<CheckBoxItem<ulong>>();
|
var items = new List<CheckBoxItem<ulong>>();
|
||||||
var included = IncludedChannels;
|
var servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
||||||
|
|
||||||
foreach (var channel in db.GetAllChannels()) {
|
await foreach (var channel in state.Db.Channels.Get()) {
|
||||||
var channelId = channel.Id;
|
var channelId = channel.Id;
|
||||||
var channelName = channel.Name;
|
var channelName = channel.Name;
|
||||||
|
|
||||||
@@ -208,68 +221,60 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string Title = "Included Channels";
|
||||||
|
|
||||||
|
List<CheckBoxItem<ulong>> items;
|
||||||
|
try {
|
||||||
|
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
|
||||||
|
} catch (Exception e) {
|
||||||
|
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await OpenIdFilterDialog(Title, items);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
IncludedChannels = result;
|
IncludedChannels = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenUserFilterDialog() {
|
public async Task OpenUserFilterDialog() {
|
||||||
var items = new List<CheckBoxItem<ulong>>();
|
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
|
||||||
var included = IncludedUsers;
|
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
||||||
|
|
||||||
foreach (var user in db.GetAllUsers()) {
|
await foreach (var user in state.Db.Users.Get()) {
|
||||||
var name = user.Name;
|
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||||
var discriminator = user.Discriminator;
|
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
|
||||||
|
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
||||||
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);
|
return checkBoxItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string Title = "Included Users";
|
||||||
|
|
||||||
|
List<CheckBoxItem<ulong>> items;
|
||||||
|
try {
|
||||||
|
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
||||||
|
} catch (Exception e) {
|
||||||
|
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await OpenIdFilterDialog(Title, items);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
IncludedUsers = result;
|
IncludedUsers = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateChannelFilterLabel() {
|
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) {
|
||||||
long total = db.Statistics.TotalChannels;
|
|
||||||
long included = FilterByChannel ? IncludedChannels.Count : total;
|
|
||||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateUserFilterLabel() {
|
|
||||||
long total = db.Statistics.TotalUsers;
|
|
||||||
long included = FilterByUser ? IncludedUsers.Count : total;
|
|
||||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
public MessageFilter CreateFilter() {
|
|
||||||
MessageFilter filter = new();
|
|
||||||
|
|
||||||
if (FilterByDate) {
|
|
||||||
filter.StartDate = StartDate;
|
|
||||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FilterByChannel) {
|
|
||||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FilterByUser) {
|
|
||||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
|
|
||||||
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||||
|
|
||||||
var model = new CheckBoxDialogModel<ulong>(items) {
|
var model = new CheckBoxDialogModel<ulong>(items) {
|
||||||
@@ -281,5 +286,23 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
|
|
||||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
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 />
|
||||||
|
@@ -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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class ServerConfigurationPanel : UserControl {
|
public sealed partial class ServerConfigurationPanel : UserControl {
|
||||||
public ServerConfigurationPanel() {
|
public ServerConfigurationPanel() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,95 +1,95 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
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;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls {
|
namespace DHT.Desktop.Main.Controls;
|
||||||
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
|
||||||
|
sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable {
|
||||||
|
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||||
private string inputPort;
|
private string inputPort;
|
||||||
|
|
||||||
public string InputPort {
|
[ObservableProperty]
|
||||||
get => inputPort;
|
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||||
set {
|
|
||||||
Change(ref inputPort, value);
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string inputToken;
|
private string inputToken;
|
||||||
|
|
||||||
public string InputToken {
|
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
||||||
get => inputToken;
|
|
||||||
set {
|
|
||||||
Change(ref inputToken, value);
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private bool isToggleServerButtonEnabled = true;
|
private bool isToggleServerButtonEnabled = true;
|
||||||
|
|
||||||
public bool IsToggleServerButtonEnabled {
|
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
||||||
get => isToggleServerButtonEnabled;
|
|
||||||
set => Change(ref isToggleServerButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
|
|
||||||
|
|
||||||
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
|
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly ServerManager serverManager;
|
private readonly ServerManager server;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
|
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
|
public ServerConfigurationPanelModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.serverManager = serverManager;
|
this.server = state.Server;
|
||||||
this.inputPort = ServerManager.Port.ToString();
|
this.inputPort = ServerConfiguration.Port.ToString();
|
||||||
this.inputToken = ServerManager.Token;
|
this.inputToken = ServerConfiguration.Token;
|
||||||
}
|
|
||||||
|
|
||||||
public void Initialize() {
|
server.StatusChanged += OnServerStatusChanged;
|
||||||
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
|
server.StatusChanged -= OnServerStatusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||||
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateServerStatus() {
|
||||||
OnPropertyChanged(nameof(ToggleServerButtonText));
|
OnPropertyChanged(nameof(ToggleServerButtonText));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartServer() {
|
||||||
|
IsToggleServerButtonEnabled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateServerStatus();
|
||||||
IsToggleServerButtonEnabled = true;
|
IsToggleServerButtonEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BeforeServerStart() {
|
private async Task StopServer() {
|
||||||
IsToggleServerButtonEnabled = false;
|
IsToggleServerButtonEnabled = false;
|
||||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
|
||||||
|
try {
|
||||||
|
await server.Stop();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartServer() {
|
UpdateServerStatus();
|
||||||
BeforeServerStart();
|
IsToggleServerButtonEnabled = true;
|
||||||
serverManager.Launch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StopServer() {
|
public async Task OnClickToggleServerButton() {
|
||||||
IsToggleServerButtonEnabled = false;
|
if (server.IsRunning) {
|
||||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
await StopServer();
|
||||||
serverManager.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClickToggleServerButton() {
|
|
||||||
if (serverManager.IsRunning) {
|
|
||||||
StopServer();
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
StartServer();
|
await StartServer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,20 +97,22 @@ namespace DHT.Desktop.Main.Controls {
|
|||||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickApplyChanges() {
|
public async Task OnClickApplyChanges() {
|
||||||
if (!ushort.TryParse(InputPort, out ushort port)) {
|
if (!ushort.TryParse(InputPort, out ushort port)) {
|
||||||
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeServerStart();
|
ServerConfiguration.Port = port;
|
||||||
serverManager.Relaunch(port, InputToken);
|
ServerConfiguration.Token = inputToken;
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
OnPropertyChanged(nameof(HasMadeChanges));
|
||||||
|
|
||||||
|
await StartServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClickCancelChanges() {
|
public void OnClickCancelChanges() {
|
||||||
InputPort = ServerManager.Port.ToString();
|
InputPort = ServerConfiguration.Port.ToString();
|
||||||
InputToken = ServerManager.Token;
|
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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class StatusBar : UserControl {
|
public sealed partial class StatusBar : UserControl {
|
||||||
public StatusBar() {
|
public StatusBar() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -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 CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DHT.Server;
|
||||||
|
using DHT.Server.Service;
|
||||||
|
|
||||||
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 : ObservableObject, IDisposable {
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private long? serverCount;
|
||||||
|
|
||||||
public Status CurrentStatus {
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
get => status;
|
private long? channelCount;
|
||||||
set {
|
|
||||||
status = value;
|
|
||||||
OnPropertyChanged(nameof(StatusText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string StatusText {
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
get {
|
private long? messageCount;
|
||||||
return CurrentStatus switch {
|
|
||||||
Status.Starting => "STARTING",
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
Status.Ready => "READY",
|
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
||||||
Status.Stopping => "STOPPING",
|
private ServerManager.Status serverStatus;
|
||||||
Status.Stopped => "STOPPED",
|
|
||||||
|
public string ServerStatusText => serverStatus switch {
|
||||||
|
ServerManager.Status.Starting => "STARTING",
|
||||||
|
ServerManager.Status.Started => "READY",
|
||||||
|
ServerManager.Status.Stopping => "STOPPING",
|
||||||
|
ServerManager.Status.Stopped => "STOPPED",
|
||||||
_ => ""
|
_ => ""
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
private readonly State state;
|
||||||
|
private readonly IDisposable serverCountSubscription;
|
||||||
|
private readonly IDisposable channelCountSubscription;
|
||||||
|
private readonly IDisposable messageCountSubscription;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public StatusBarModel() : this(new DatabaseStatistics()) {}
|
public StatusBarModel() : this(State.Dummy) {}
|
||||||
|
|
||||||
public StatusBarModel(DatabaseStatistics databaseStatistics) {
|
public StatusBarModel(State state) {
|
||||||
this.DatabaseStatistics = databaseStatistics;
|
this.state = state;
|
||||||
|
|
||||||
|
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
|
||||||
|
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
|
||||||
|
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
|
||||||
|
|
||||||
|
state.Server.StatusChanged += OnServerStatusChanged;
|
||||||
|
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Status {
|
public void Dispose() {
|
||||||
Starting,
|
serverCountSubscription.Dispose();
|
||||||
Ready,
|
channelCountSubscription.Dispose();
|
||||||
Stopping,
|
messageCountSubscription.Dispose();
|
||||||
Stopped
|
|
||||||
}
|
state.Server.StatusChanged -= OnServerStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||||
|
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,12 +5,13 @@
|
|||||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class MainWindow : Window {
|
public sealed partial class MainWindow : Window {
|
||||||
|
private static readonly Log Log = Log.ForType<MainWindow>();
|
||||||
|
|
||||||
[UsedImplicitly]
|
[UsedImplicitly]
|
||||||
public MainWindow() {
|
public MainWindow() {
|
||||||
InitializeComponent(Arguments.Empty);
|
InitializeComponent();
|
||||||
|
DataContext = new MainWindowModel(this, Arguments.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal MainWindow(Arguments args) {
|
internal MainWindow(Arguments args) {
|
||||||
InitializeComponent(args);
|
InitializeComponent();
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent(Arguments args) {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
DataContext = new MainWindowModel(this, args);
|
DataContext = new MainWindowModel(this, args);
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
this.AttachDevTools();
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClosed(object? sender, EventArgs e) {
|
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
if (DataContext is IDisposable disposable) {
|
e.Cancel = true;
|
||||||
disposable.Dispose();
|
Closing -= OnClosing;
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
|
||||||
try {
|
try {
|
||||||
File.Delete(temporaryFile);
|
await Dispose();
|
||||||
} catch (Exception) {
|
} finally {
|
||||||
// ignored
|
Close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Dispose() {
|
||||||
|
if (DataContext is MainWindowModel model) {
|
||||||
|
try {
|
||||||
|
await model.DisposeAsync();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.Error("Caught exception while disposing window: " + ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,32 +1,38 @@
|
|||||||
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 CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
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;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main {
|
namespace DHT.Desktop.Main;
|
||||||
sealed class MainWindowModel : BaseModel, IDisposable {
|
|
||||||
|
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||||
private const string DefaultTitle = "Discord History Tracker";
|
private const string DefaultTitle = "Discord History Tracker";
|
||||||
|
|
||||||
public string Title { get; private set; } = DefaultTitle;
|
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
||||||
|
|
||||||
public UserControl CurrentScreen { get; private set; }
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private string title = DefaultTitle;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private UserControl currentScreen;
|
||||||
|
|
||||||
private readonly WelcomeScreen welcomeScreen;
|
private readonly WelcomeScreen welcomeScreen;
|
||||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||||
|
|
||||||
private MainContentScreen? mainContentScreen;
|
|
||||||
private MainContentScreenModel? mainContentScreenModel;
|
private MainContentScreenModel? mainContentScreenModel;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
private readonly int? concurrentDownloads;
|
||||||
|
|
||||||
private IDatabaseFile? db;
|
private State? state;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||||
@@ -35,10 +41,10 @@ namespace DHT.Desktop.Main {
|
|||||||
this.window = window;
|
this.window = window;
|
||||||
|
|
||||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
CurrentScreen = welcomeScreen;
|
|
||||||
|
|
||||||
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||||
|
currentScreen = welcomeScreen;
|
||||||
|
|
||||||
var dbFile = args.DatabaseFile;
|
var dbFile = args.DatabaseFile;
|
||||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||||
@@ -62,55 +68,68 @@ namespace DHT.Desktop.Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.ServerPort != null) {
|
if (args.ServerPort != null) {
|
||||||
ServerManager.Port = args.ServerPort.Value;
|
ServerConfiguration.Port = args.ServerPort.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.ServerToken != null) {
|
if (args.ServerToken != null) {
|
||||||
ServerManager.Token = args.ServerToken;
|
ServerConfiguration.Token = args.ServerToken;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
concurrentDownloads = args.ConcurrentDownloads;
|
||||||
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
|
|
||||||
if (mainContentScreenModel != null) {
|
|
||||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
|
||||||
mainContentScreenModel.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db?.Dispose();
|
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
||||||
db = welcomeScreenModel.Db;
|
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
|
||||||
|
|
||||||
if (db == null) {
|
await DisposeState();
|
||||||
Title = DefaultTitle;
|
|
||||||
mainContentScreenModel = null;
|
state = new State(db, concurrentDownloads);
|
||||||
mainContentScreen = null;
|
|
||||||
CurrentScreen = welcomeScreen;
|
try {
|
||||||
|
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.Error(ex);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
mainContentScreenModel = new MainContentScreenModel(window, state);
|
||||||
mainContentScreenModel = new MainContentScreenModel(window, db);
|
|
||||||
await mainContentScreenModel.Initialize();
|
|
||||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||||
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
|
||||||
CurrentScreen = mainContentScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(CurrentScreen));
|
await mainContentScreenModel.Initialize();
|
||||||
OnPropertyChanged(nameof(Title));
|
|
||||||
|
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
||||||
|
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||||
|
|
||||||
window.Focus();
|
window.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||||
|
if (mainContentScreenModel != null) {
|
||||||
|
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||||
|
await mainContentScreenModel.DisposeAsync();
|
||||||
|
mainContentScreenModel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
await DisposeState();
|
||||||
welcomeScreenModel.CloseDatabase();
|
|
||||||
|
Title = DefaultTitle;
|
||||||
|
CurrentScreen = welcomeScreen;
|
||||||
|
|
||||||
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
private async Task DisposeState() {
|
||||||
welcomeScreenModel.Dispose();
|
if (state != null) {
|
||||||
mainContentScreenModel?.Dispose();
|
await state.DisposeAsync();
|
||||||
db?.Dispose();
|
state = null;
|
||||||
db = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() {
|
||||||
|
if (mainContentScreenModel != null) {
|
||||||
|
await mainContentScreenModel.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 />
|
||||||
|
@@ -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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class AdvancedPage : UserControl {
|
public sealed partial class AdvancedPage : UserControl {
|
||||||
public AdvancedPage() {
|
public AdvancedPage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,39 +1,36 @@
|
|||||||
using System;
|
using System;
|
||||||
|
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.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 {
|
|
||||||
|
sealed class AdvancedPageModel : IDisposable {
|
||||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly IDatabaseFile db;
|
private readonly State state;
|
||||||
|
|
||||||
[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;
|
this.window = window;
|
||||||
this.db = db;
|
this.state = state;
|
||||||
|
|
||||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
|
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
||||||
}
|
|
||||||
|
|
||||||
public void Initialize() {
|
|
||||||
ServerConfigurationModel.Initialize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
ServerConfigurationModel.Dispose();
|
ServerConfigurationModel.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void VacuumDatabase() {
|
public async Task VacuumDatabase() {
|
||||||
db.Vacuum();
|
const string Title = "Vacuum Database";
|
||||||
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
|
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
|
||||||
}
|
await Dialog.ShowOk(window, Title, "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,201 +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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 />
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
<WrapPanel>
|
<WrapPanel>
|
||||||
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
|
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
|
||||||
<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>
|
||||||
</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.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class DatabasePage : UserControl {
|
public sealed partial class DatabasePage : UserControl {
|
||||||
public DatabasePage() {
|
public DatabasePage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,18 +1,29 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.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.Server;
|
||||||
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
|
using DHT.Server.Database.Import;
|
||||||
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
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 {
|
|
||||||
|
sealed class DatabasePageModel {
|
||||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||||
|
|
||||||
public IDatabaseFile Db { get; }
|
public IDatabaseFile Db { get; }
|
||||||
@@ -22,14 +33,14 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
public DatabasePageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public DatabasePageModel(Window window, IDatabaseFile db) {
|
public DatabasePageModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.Db = db;
|
this.Db = state.Db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenDatabaseFolder() {
|
public async Task OpenDatabaseFolder() {
|
||||||
string file = Db.Path;
|
string file = Db.Path;
|
||||||
string? folder = Path.GetDirectoryName(file);
|
string? folder = Path.GetDirectoryName(file);
|
||||||
|
|
||||||
@@ -56,43 +67,133 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CloseDatabase() {
|
public void CloseDatabase() {
|
||||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
public async Task MergeWithDatabase() {
|
||||||
int total = paths.Length;
|
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||||
|
if (paths.Length > 0) {
|
||||||
DialogResult.YesNo? upgradeResult = null;
|
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
||||||
|
}
|
||||||
async Task<bool> CheckCanUpgradeDatabase() {
|
|
||||||
upgradeResult ??= total > 1
|
|
||||||
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
|
||||||
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
|
|
||||||
|
|
||||||
return DialogResult.YesNo.Yes == upgradeResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var oldStatistics = target.Statistics.Clone();
|
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
var oldMessageCount = target.CountMessages();
|
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||||
|
|
||||||
|
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
||||||
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||||
|
|
||||||
|
if (db == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await target.AddFrom(db);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
await db.DisposeAsync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
|
private readonly ProgressDialog dialog;
|
||||||
|
private readonly int total;
|
||||||
|
private bool? decision;
|
||||||
|
|
||||||
|
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||||
|
this.total = total;
|
||||||
|
this.dialog = dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUpgrade() {
|
||||||
|
return decision ??= (total > 1
|
||||||
|
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||||
|
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
|
return doUpgrade(new NullReporter());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
|
public Task NextVersion() {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ImportLegacyArchive() {
|
||||||
|
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
|
Title = "Open Legacy DHT Archive",
|
||||||
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
||||||
|
AllowMultiple = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paths.Length > 0) {
|
||||||
|
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
|
var fakeSnowflake = new FakeSnowflake();
|
||||||
|
|
||||||
|
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
||||||
|
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
|
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||||
|
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||||
|
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||||
|
Dictionary<DHT.Server.Data.Server, ulong>? result = await Dispatcher.UIThread.InvokeAsync(() => AskForServerIds(dialog, servers));
|
||||||
|
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
||||||
|
static bool IsValidSnowflake(string value) {
|
||||||
|
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||||
|
|
||||||
|
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||||
|
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
||||||
|
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
||||||
|
ValidityCheck = IsValidSnowflake
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
||||||
|
Title = "Imported Server IDs",
|
||||||
|
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = new TextBoxDialog { DataContext = model };
|
||||||
|
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||||
|
|
||||||
|
if (result != DialogResult.OkCancel.Ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.ValidItems
|
||||||
|
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
||||||
|
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
||||||
|
int total = paths.Length;
|
||||||
|
var oldStatistics = await DatabaseStatistics.Take(target);
|
||||||
|
|
||||||
int successful = 0;
|
int successful = 0;
|
||||||
int finished = 0;
|
int finished = 0;
|
||||||
@@ -102,56 +203,64 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
++finished;
|
++finished;
|
||||||
|
|
||||||
if (!File.Exists(path)) {
|
if (!File.Exists(path)) {
|
||||||
await Dialog.ShowOk(dialog, "Database Error", "Database '" + Path.GetFileName(path) + "' no longer exists.");
|
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
|
|
||||||
if (db == null) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
target.AddFrom(db);
|
if (await performImport(path)) {
|
||||||
|
++successful;
|
||||||
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Log.Error(ex);
|
Log.Error(ex);
|
||||||
await Dialog.ShowOk(dialog, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be merged: " + ex.Message);
|
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
||||||
continue;
|
|
||||||
} finally {
|
|
||||||
db.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
++successful;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await callback.Update("Done", finished, total);
|
await callback.Update("Done", finished, total);
|
||||||
|
|
||||||
if (successful == 0) {
|
if (successful == 0) {
|
||||||
await Dialog.ShowOk(dialog, "Database Merge", "Nothing was merged.");
|
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newStatistics = target.Statistics;
|
var newStatistics = await DatabaseStatistics.Take(target);
|
||||||
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
||||||
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
}
|
||||||
long newMessages = target.CountMessages() - oldMessageCount;
|
|
||||||
|
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
|
||||||
|
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
|
||||||
|
return new DatabaseStatistics(
|
||||||
|
await db.Servers.Count(),
|
||||||
|
await db.Channels.Count(),
|
||||||
|
await db.Users.Count(),
|
||||||
|
await db.Messages.Count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
|
||||||
|
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
|
||||||
|
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
|
||||||
|
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
|
||||||
|
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
|
||||||
|
|
||||||
StringBuilder message = new StringBuilder();
|
StringBuilder message = new StringBuilder();
|
||||||
message.Append("Processed ");
|
message.Append("Processed ");
|
||||||
|
|
||||||
if (successful == total) {
|
if (successfulItems == totalItems) {
|
||||||
message.Append(successful.Pluralize("database file"));
|
message.Append(successfulItems.Pluralize(itemName));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
message.Append(successful.Format()).Append(" out of ").Append(total.Pluralize("database file"));
|
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
||||||
}
|
}
|
||||||
|
|
||||||
message.Append(" and added:\n\n \u2022 ");
|
message.Append(" and added:\n\n \u2022 ");
|
||||||
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
||||||
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
||||||
|
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
||||||
message.Append(newMessages.Pluralize("message"));
|
message.Append(newMessages.Pluralize("message"));
|
||||||
|
|
||||||
await Dialog.ShowOk(dialog, "Database Merge", message.ToString());
|
return message.ToString();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.DebugPage">
|
x:Class="DHT.Desktop.Main.Pages.DebugPage"
|
||||||
|
x:DataType="pages:DebugPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DebugPageModel />
|
<pages:DebugPageModel />
|
||||||
|
@@ -1,16 +1,11 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class DebugPage : UserControl {
|
public sealed partial class DebugPage : UserControl {
|
||||||
public DebugPage() {
|
public DebugPage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -6,26 +6,26 @@ 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 {
|
|
||||||
|
sealed class DebugPageModel {
|
||||||
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";
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly IDatabaseFile db;
|
private readonly State state;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public DebugPageModel(Window window, IDatabaseFile db) {
|
public DebugPageModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.db = db;
|
this.state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickAddRandomDataToDatabase() {
|
public async void OnClickAddRandomDataToDatabase() {
|
||||||
@@ -44,13 +44,7 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog {
|
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||||
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
|
|
||||||
Title = "Generating Random Data"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await progressDialog.ShowDialog(window);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int BatchSize = 500;
|
private const int BatchSize = 500;
|
||||||
@@ -63,7 +57,7 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
var server = new DHT.Server.Data.Server {
|
var server = new DHT.Server.Data.Server {
|
||||||
Id = RandomId(rand),
|
Id = RandomId(rand),
|
||||||
Name = RandomName("s"),
|
Name = RandomName("s"),
|
||||||
Type = ServerType.Server
|
Type = ServerType.Server,
|
||||||
};
|
};
|
||||||
|
|
||||||
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
||||||
@@ -73,22 +67,20 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
ParentId = null,
|
ParentId = null,
|
||||||
Position = i,
|
Position = i,
|
||||||
Topic = RandomText(rand, 10),
|
Topic = RandomText(rand, 10),
|
||||||
Nsfw = rand.Next(4) == 0
|
Nsfw = rand.Next(4) == 0,
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
||||||
Id = RandomId(rand),
|
Id = RandomId(rand),
|
||||||
Name = RandomName("u"),
|
Name = RandomName("u"),
|
||||||
|
DisplayName = RandomName("u"),
|
||||||
AvatarUrl = null,
|
AvatarUrl = null,
|
||||||
Discriminator = rand.Next(0, 9999).ToString()
|
Discriminator = rand.Next(0, 9999).ToString(),
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
db.AddServer(server);
|
await state.Db.Users.Add(users);
|
||||||
db.AddUsers(users);
|
await state.Db.Servers.Add([server]);
|
||||||
|
await state.Db.Channels.Add(channels);
|
||||||
foreach (var channel in channels) {
|
|
||||||
db.AddChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.Now;
|
var now = DateTimeOffset.Now;
|
||||||
int batchIndex = 0;
|
int batchIndex = 0;
|
||||||
@@ -97,7 +89,7 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
int hourOffset = batchIndex;
|
int hourOffset = batchIndex;
|
||||||
|
|
||||||
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
|
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
|
||||||
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes((i * 60.0) / BatchSize);
|
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
|
||||||
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
||||||
|
|
||||||
var timeMillis = time.ToUnixTimeMilliseconds();
|
var timeMillis = time.ToUnixTimeMilliseconds();
|
||||||
@@ -111,13 +103,13 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
Timestamp = timeMillis,
|
Timestamp = timeMillis,
|
||||||
EditTimestamp = editMillis,
|
EditTimestamp = editMillis,
|
||||||
RepliedToId = null,
|
RepliedToId = null,
|
||||||
Attachments = ImmutableArray<Attachment>.Empty,
|
Attachments = ImmutableList<Attachment>.Empty,
|
||||||
Embeds = ImmutableArray<Embed>.Empty,
|
Embeds = ImmutableList<Embed>.Empty,
|
||||||
Reactions = ImmutableArray<Reaction>.Empty
|
Reactions = ImmutableList<Reaction>.Empty,
|
||||||
};
|
};
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
db.AddMessages(messages);
|
await state.Db.Messages.Add(messages);
|
||||||
|
|
||||||
messageCount -= BatchSize;
|
messageCount -= BatchSize;
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||||
@@ -138,7 +130,7 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly string[] RandomWords = {
|
private static readonly string[] RandomWords = [
|
||||||
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||||
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
"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",
|
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
|
||||||
@@ -162,24 +154,21 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
"watercress", "watermelon",
|
"watercress", "watermelon",
|
||||||
"yam",
|
"yam",
|
||||||
"zucchini"
|
"zucchini"
|
||||||
};
|
];
|
||||||
|
|
||||||
private static string RandomText(Random rand, int maxWords) {
|
private static string RandomText(Random rand, int maxWords) {
|
||||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
||||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#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,10 +5,11 @@
|
|||||||
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>
|
||||||
@@ -30,25 +31,24 @@
|
|||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<StackPanel Orientation="Vertical" Spacing="20">
|
<StackPanel Orientation="Vertical" Spacing="20">
|
||||||
<DockPanel>
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<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 OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
||||||
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
|
</StackPanel>
|
||||||
</DockPanel>
|
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
||||||
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" />
|
<TextBlock TextWrapping="Wrap">
|
||||||
|
Downloading state and filter settings are remembered per-database.
|
||||||
|
</TextBlock>
|
||||||
<StackPanel Orientation="Vertical" Spacing="12">
|
<StackPanel Orientation="Vertical" Spacing="12">
|
||||||
<Expander Header="Download Status" IsExpanded="True">
|
<Expander Header="Download Status" IsExpanded="True">
|
||||||
<DataGrid Items="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
<DataGridTextColumn Header="State" Binding="{Binding State, 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();
|
||||||
|
}
|
||||||
|
}
|
210
app/Desktop/Main/Pages/DownloadsPageModel.cs
Normal file
210
app/Desktop/Main/Pages/DownloadsPageModel.cs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Desktop.Main.Controls;
|
||||||
|
using DHT.Server;
|
||||||
|
using DHT.Server.Data.Aggregations;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Data.Settings;
|
||||||
|
using DHT.Server.Download;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
|
sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
||||||
|
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private bool isToggleDownloadButtonEnabled = true;
|
||||||
|
|
||||||
|
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||||
|
private bool isRetryingFailedDownloads = false;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||||
|
private bool hasFailedDownloads;
|
||||||
|
|
||||||
|
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private string downloadMessage = "";
|
||||||
|
|
||||||
|
public DownloadItemFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
|
private readonly StatisticsRow statisticsPending = new ("Pending");
|
||||||
|
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
||||||
|
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||||
|
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||||
|
|
||||||
|
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
|
||||||
|
|
||||||
|
public bool IsDownloading => state.Downloader.IsDownloading;
|
||||||
|
|
||||||
|
private readonly State state;
|
||||||
|
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
||||||
|
private readonly IDisposable downloadItemCountSubscription;
|
||||||
|
|
||||||
|
private IDisposable? finishedItemsSubscription;
|
||||||
|
private DownloadItemFilter? currentDownloadFilter;
|
||||||
|
|
||||||
|
public DownloadsPageModel() : this(State.Dummy) {}
|
||||||
|
|
||||||
|
public DownloadsPageModel(State state) {
|
||||||
|
this.state = state;
|
||||||
|
|
||||||
|
FilterModel = new DownloadItemFilterPanelModel(state);
|
||||||
|
|
||||||
|
StatisticsRows = [
|
||||||
|
statisticsPending,
|
||||||
|
statisticsDownloaded,
|
||||||
|
statisticsFailed,
|
||||||
|
statisticsSkipped
|
||||||
|
];
|
||||||
|
|
||||||
|
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
|
||||||
|
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
await FilterModel.Initialize();
|
||||||
|
|
||||||
|
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, 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();
|
||||||
|
|
||||||
|
var 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(nameof(ToggleDownloadButtonText));
|
||||||
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnItemFinished(DownloadItem item) {
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickRetryFailedDownloads() {
|
||||||
|
IsRetryingFailedDownloads = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await state.Db.Downloads.RetryFailed();
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
} finally {
|
||||||
|
IsRetryingFailedDownloads = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeDownloadStatistics() {
|
||||||
|
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||||
|
statisticsPending.Items = statusStatistics.PendingCount;
|
||||||
|
statisticsPending.Size = statusStatistics.PendingTotalSize;
|
||||||
|
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
|
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
||||||
|
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
|
||||||
|
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
|
statisticsFailed.Items = statusStatistics.FailedCount;
|
||||||
|
statisticsFailed.Size = statusStatistics.FailedTotalSize;
|
||||||
|
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
|
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||||
|
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
|
||||||
|
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
|
HasFailedDownloads = statusStatistics.FailedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableObject]
|
||||||
|
public sealed partial class StatisticsRow(string state) {
|
||||||
|
public string State { get; } = state;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int items;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||||
|
private ulong? size;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||||
|
private bool hasFilesWithUnknownSize;
|
||||||
|
|
||||||
|
public string SizeText {
|
||||||
|
get {
|
||||||
|
if (size == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
else if (hasFilesWithUnknownSize) {
|
||||||
|
return "\u2265 " + BytesValueConverter.Convert(size.Value);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return BytesValueConverter.Convert(size.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -4,7 +4,8 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.TrackingPage">
|
x:Class="DHT.Desktop.Main.Pages.TrackingPage"
|
||||||
|
x:DataType="pages:TrackingPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:TrackingPageModel />
|
<pages:TrackingPageModel />
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
|
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
||||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
||||||
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
|
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
|
||||||
|
@@ -3,36 +3,30 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using System.Threading.Tasks;
|
using 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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class TrackingPage : UserControl {
|
public sealed partial class TrackingPage : UserControl {
|
||||||
private bool isCopyingScript;
|
private bool isCopyingScript;
|
||||||
|
|
||||||
public TrackingPage() {
|
public TrackingPage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||||
if (DataContext is TrackingPageModel model) {
|
if (DataContext is TrackingPageModel model) {
|
||||||
var button = this.FindControl<Button>("CopyTrackingScript");
|
var originalText = CopyTrackingScript.Content;
|
||||||
var originalText = button.Content;
|
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
||||||
button.MinWidth = button.Bounds.Width;
|
|
||||||
|
|
||||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||||
isCopyingScript = true;
|
isCopyingScript = true;
|
||||||
button.Content = "Script Copied!";
|
CopyTrackingScript.Content = "Script Copied!";
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
button.Content = originalText;
|
CopyTrackingScript.Content = originalText;
|
||||||
isCopyingScript = false;
|
isCopyingScript = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -1,35 +1,39 @@
|
|||||||
using System;
|
using System;
|
||||||
|
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 CommunityToolkit.Mvvm.ComponentModel;
|
||||||
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 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 : ObservableObject {
|
||||||
get => areDevToolsEnabled;
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
set {
|
private bool isCopyTrackingScriptButtonEnabled = true;
|
||||||
Change(ref areDevToolsEnabled, value);
|
|
||||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||||
|
private bool? areDevToolsEnabled = null;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||||
|
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||||
|
|
||||||
public string ToggleAppDevToolsButtonText {
|
public string ToggleAppDevToolsButtonText {
|
||||||
get {
|
get {
|
||||||
|
if (!AreDevToolsEnabled.HasValue) {
|
||||||
|
return "Loading...";
|
||||||
|
}
|
||||||
|
|
||||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||||
return "Unavailable";
|
return "Unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,28 +44,25 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
|
|
||||||
public TrackingPageModel(Window window) {
|
public TrackingPageModel(Window window) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Initialize() {
|
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
|
||||||
if (devToolsEnabled.HasValue) {
|
|
||||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
IsToggleAppDevToolsButtonEnabled = false;
|
|
||||||
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> OnClickCopyTrackingScript() {
|
public async Task<bool> OnClickCopyTrackingScript() {
|
||||||
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
|
IsCopyTrackingScriptButtonEnabled = false;
|
||||||
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
|
|
||||||
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
|
|
||||||
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
|
|
||||||
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
|
|
||||||
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
|
|
||||||
|
|
||||||
var clipboard = Application.Current?.Clipboard;
|
try {
|
||||||
|
return await CopyTrackingScript();
|
||||||
|
} finally {
|
||||||
|
IsCopyTrackingScriptButtonEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CopyTrackingScript() {
|
||||||
|
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
|
||||||
|
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
||||||
|
|
||||||
|
var clipboard = window.Clipboard;
|
||||||
if (clipboard == null) {
|
if (clipboard == null) {
|
||||||
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
||||||
return false;
|
return false;
|
||||||
@@ -76,10 +77,26 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickToggleAppDevTools() {
|
private async Task InitializeDevToolsToggle() {
|
||||||
|
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
|
||||||
|
|
||||||
|
if (devToolsEnabled.HasValue) {
|
||||||
|
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||||
|
IsToggleAppDevToolsButtonEnabled = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
IsToggleAppDevToolsButtonEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickToggleAppDevTools() {
|
||||||
const string DialogTitle = "Discord App Settings File";
|
const string DialogTitle = "Discord App Settings File";
|
||||||
|
|
||||||
bool oldState = AreDevToolsEnabled;
|
if (!AreDevToolsEnabled.HasValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool oldState = AreDevToolsEnabled.Value;
|
||||||
bool newState = !oldState;
|
bool newState = !oldState;
|
||||||
|
|
||||||
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||||
@@ -114,4 +131,3 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class ViewerPage : UserControl {
|
public sealed partial class ViewerPage : UserControl {
|
||||||
public ViewerPage() {
|
public ViewerPage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,49 +1,41 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
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.Models;
|
|
||||||
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 : ObservableObject, IDisposable {
|
||||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool hasFilters = false;
|
private bool hasFilters = false;
|
||||||
|
|
||||||
public bool HasFilters {
|
public MessageFilterPanelModel FilterModel { get; }
|
||||||
get => hasFilters;
|
|
||||||
set => Change(ref hasFilters, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private MessageFilterPanelModel FilterModel { get; }
|
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly IDatabaseFile db;
|
private readonly State state;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
public ViewerPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public ViewerPageModel(Window window, IDatabaseFile db) {
|
public ViewerPageModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.db = db;
|
this.state = state;
|
||||||
|
|
||||||
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
|
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
|
||||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,94 +47,37 @@ namespace DHT.Desktop.Main.Pages {
|
|||||||
HasFilters = FilterModel.HasAnyFilters;
|
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() {
|
public async void OnClickOpenViewer() {
|
||||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
try {
|
||||||
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
|
||||||
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
string serverToken = ServerConfiguration.Token;
|
||||||
int counter = 0;
|
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
|
||||||
|
|
||||||
while (File.Exists(fullPath)) {
|
Process.Start(new ProcessStartInfo(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId)) {
|
||||||
++counter;
|
UseShellExecute = true
|
||||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
});
|
||||||
}
|
} catch (Exception e) {
|
||||||
|
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
|
||||||
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() {
|
public async Task OnClickApplyFiltersToDatabase() {
|
||||||
var filter = FilterModel.CreateFilter();
|
var filter = FilterModel.CreateFilter();
|
||||||
|
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
|
||||||
|
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,8 @@
|
|||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns: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 />
|
||||||
@@ -73,7 +74,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 +94,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")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class MainContentScreen : UserControl {
|
public sealed partial class MainContentScreen : UserControl {
|
||||||
public MainContentScreen() {
|
public MainContentScreen() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,26 +1,21 @@
|
|||||||
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>();
|
|
||||||
|
|
||||||
|
sealed class MainContentScreenModel : IAsyncDisposable {
|
||||||
public DatabasePage DatabasePage { get; }
|
public DatabasePage DatabasePage { get; }
|
||||||
private DatabasePageModel DatabasePageModel { get; }
|
private DatabasePageModel DatabasePageModel { get; }
|
||||||
|
|
||||||
public TrackingPage TrackingPage { get; }
|
public TrackingPage TrackingPage { get; }
|
||||||
private TrackingPageModel TrackingPageModel { get; }
|
private TrackingPageModel TrackingPageModel { get; }
|
||||||
|
|
||||||
public AttachmentsPage AttachmentsPage { get; }
|
public DownloadsPage DownloadsPage { get; }
|
||||||
private AttachmentsPageModel AttachmentsPageModel { get; }
|
private DownloadsPageModel DownloadsPageModel { get; }
|
||||||
|
|
||||||
public ViewerPage ViewerPage { get; }
|
public ViewerPage ViewerPage { get; }
|
||||||
private ViewerPageModel ViewerPageModel { get; }
|
private ViewerPageModel ViewerPageModel { get; }
|
||||||
@@ -48,72 +43,43 @@ namespace DHT.Desktop.Main.Screens {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Window window;
|
|
||||||
private readonly ServerManager serverManager;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
public MainContentScreenModel(Window window, State state) {
|
||||||
this.window = window;
|
DatabasePageModel = new DatabasePageModel(window, state);
|
||||||
this.serverManager = new ServerManager(db);
|
|
||||||
|
|
||||||
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
|
||||||
|
|
||||||
DatabasePageModel = new DatabasePageModel(window, db);
|
|
||||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||||
|
|
||||||
TrackingPageModel = new TrackingPageModel(window);
|
TrackingPageModel = new TrackingPageModel(window);
|
||||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||||
|
|
||||||
AttachmentsPageModel = new AttachmentsPageModel(db);
|
DownloadsPageModel = new DownloadsPageModel(state);
|
||||||
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
|
||||||
|
|
||||||
ViewerPageModel = new ViewerPageModel(window, db);
|
ViewerPageModel = new ViewerPageModel(window, state);
|
||||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||||
|
|
||||||
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
|
AdvancedPageModel = new AdvancedPageModel(window, state);
|
||||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
DebugPageModel = new DebugPageModel(window, db);
|
DebugPageModel = new DebugPageModel(window, state);
|
||||||
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
||||||
#else
|
#else
|
||||||
DebugPage = null;
|
DebugPage = null;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
StatusBarModel = new StatusBarModel(db.Statistics);
|
StatusBarModel = new StatusBarModel(state);
|
||||||
|
|
||||||
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
|
|
||||||
DatabaseClosed += OnDatabaseClosed;
|
|
||||||
|
|
||||||
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Initialize() {
|
public async Task Initialize() {
|
||||||
await TrackingPageModel.Initialize();
|
await DownloadsPageModel.Initialize();
|
||||||
AdvancedPageModel.Initialize();
|
|
||||||
serverManager.Launch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public async ValueTask DisposeAsync() {
|
||||||
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
|
await DownloadsPageModel.DisposeAsync();
|
||||||
AttachmentsPageModel.Dispose();
|
|
||||||
ViewerPageModel.Dispose();
|
ViewerPageModel.Dispose();
|
||||||
serverManager.Dispose();
|
AdvancedPageModel.Dispose();
|
||||||
}
|
StatusBarModel.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 />
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
<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 30" HorizontalAlignment="Center" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||||
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>
|
<Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
||||||
<Button Command="{Binding Exit}">Exit</Button>
|
<Button Command="{Binding Exit}">Exit</Button>
|
||||||
</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.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed class WelcomeScreen : UserControl {
|
public sealed partial class WelcomeScreen : UserControl {
|
||||||
public WelcomeScreen() {
|
public WelcomeScreen() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,18 +1,25 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
|
using DHT.Server.Data.Settings;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Models;
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens {
|
namespace DHT.Desktop.Main.Screens;
|
||||||
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
|
||||||
|
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||||
public string Version => Program.Version;
|
public string Version => Program.Version;
|
||||||
|
|
||||||
public IDatabaseFile? Db { get; private set; }
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
public bool HasDatabase => Db != null;
|
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||||
|
|
||||||
|
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
@@ -25,49 +32,97 @@ namespace DHT.Desktop.Main.Screens {
|
|||||||
this.window = window;
|
this.window = window;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenOrCreateDatabase() {
|
public async Task OpenOrCreateDatabase() {
|
||||||
var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog();
|
IsOpenOrCreateDatabaseButtonEnabled = false;
|
||||||
dialog.Directory = Path.GetDirectoryName(dbFilePath);
|
try {
|
||||||
|
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||||
string? path = await dialog.ShowAsync(window);
|
if (path != null) {
|
||||||
if (!string.IsNullOrWhiteSpace(path)) {
|
|
||||||
await OpenOrCreateDatabaseFromPath(path);
|
await OpenOrCreateDatabaseFromPath(path);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
IsOpenOrCreateDatabaseButtonEnabled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||||
if (Db != null) {
|
|
||||||
Db = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
dbFilePath = path;
|
dbFilePath = path;
|
||||||
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Db));
|
bool isNew = !File.Exists(path);
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
|
||||||
|
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||||
|
if (db == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CheckCanUpgradeDatabase() {
|
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
|
||||||
|
await db.Settings.Set(SettingsKey.DownloadsAutoStart, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseSelected?.Invoke(this, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
|
private readonly Window window;
|
||||||
|
|
||||||
|
public SchemaUpgradeCallbacks(Window window) {
|
||||||
|
this.window = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanUpgrade() {
|
||||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CloseDatabase() {
|
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
Dispose();
|
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
OnPropertyChanged(nameof(Db));
|
var reporter = new ProgressReporter(versionSteps, callbacks);
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
await reporter.NextVersion();
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(800));
|
||||||
|
await doUpgrade(reporter);
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void ShowAboutDialog() {
|
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
||||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
}
|
||||||
|
|
||||||
|
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
|
private readonly IReadOnlyList<IProgressCallback> callbacks;
|
||||||
|
|
||||||
|
private readonly int versionSteps;
|
||||||
|
private int versionProgress = 0;
|
||||||
|
|
||||||
|
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.versionSteps = versionSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NextVersion() {
|
||||||
|
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||||
|
await HideChildren(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
|
await callbacks[1].Update(message, finishedItems, totalItems);
|
||||||
|
await HideChildren(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
|
await callbacks[2].Update(message, finishedItems, totalItems);
|
||||||
|
await HideChildren(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HideChildren(int parentIndex) {
|
||||||
|
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
||||||
|
await callbacks[i].Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShowAboutDialog() {
|
||||||
|
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Exit() {
|
public void Exit() {
|
||||||
window.Close();
|
window.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
Db?.Dispose();
|
|
||||||
Db = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,13 +1,17 @@
|
|||||||
using System.Globalization;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using 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 {
|
static class Program {
|
||||||
public static string Version { get; }
|
public static string Version { get; }
|
||||||
public static CultureInfo Culture { get; }
|
public static CultureInfo Culture { get; }
|
||||||
public static ResourceLoader Resources { get; }
|
public static ResourceLoader Resources { get; }
|
||||||
|
public static Arguments Arguments { get; }
|
||||||
|
|
||||||
static Program() {
|
static Program() {
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
@@ -24,16 +28,27 @@ namespace DHT.Desktop {
|
|||||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
Resources = new ResourceLoader(assembly);
|
Resources = new ResourceLoader(assembly);
|
||||||
|
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Main(string[] args) {
|
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()) {
|
||||||
|
WindowsConsole.FreeConsole();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppBuilder BuildAvaloniaApp() {
|
private static AppBuilder BuildAvaloniaApp() {
|
||||||
return AppBuilder.Configure<App>()
|
return AppBuilder.Configure<App>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
1
app/Desktop/Resources/tracker-loader.js
Normal file
1
app/Desktop/Resources/tracker-loader.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fetch("{url}").then(r => r.ok ? (r.headers.get("X-DHT") === "1" ? r.text() : Promise.reject("Invalid response")) : Promise.reject(r.status + " " + r.statusText)).then(s => eval(s)).catch(e => alert("Could not load tracking script:\n" + e));
|
8
app/Desktop/Server/ServerConfiguration.cs
Normal file
8
app/Desktop/Server/ServerConfiguration.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using DHT.Server.Service;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Server;
|
||||||
|
|
||||||
|
static class ServerConfiguration {
|
||||||
|
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
||||||
|
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||||
|
}
|
@@ -1,50 +0,0 @@
|
|||||||
using System;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Service;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Server {
|
|
||||||
sealed class ServerManager : IDisposable {
|
|
||||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
|
||||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
|
||||||
|
|
||||||
private static ServerManager? instance;
|
|
||||||
|
|
||||||
public bool IsRunning => ServerLauncher.IsRunning;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
|
|
||||||
public ServerManager(IDatabaseFile db) {
|
|
||||||
if (db != DummyDatabaseFile.Instance) {
|
|
||||||
if (instance != null) {
|
|
||||||
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
|
|
||||||
}
|
|
||||||
|
|
||||||
instance = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Launch() {
|
|
||||||
ServerLauncher.Relaunch(Port, Token, db);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Relaunch(ushort port, string token) {
|
|
||||||
Port = port;
|
|
||||||
Token = token;
|
|
||||||
Launch();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Stop() {
|
|
||||||
ServerLauncher.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
Stop();
|
|
||||||
|
|
||||||
if (instance == this) {
|
|
||||||
instance = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
49
app/Directory.Build.props
Normal file
49
app/Directory.Build.props
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Authors>chylex</Authors>
|
||||||
|
<Company>DiscordHistoryTracker</Company>
|
||||||
|
<Product>DiscordHistoryTracker</Product>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
|
||||||
|
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
|
||||||
|
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
|
<TrimMode>partial</TrimMode>
|
||||||
|
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
|
||||||
|
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
|
||||||
|
<EventSourceSupport>false</EventSourceSupport>
|
||||||
|
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||||
|
<DebugSymbols>false</DebugSymbols>
|
||||||
|
<DebugType>none</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
|
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
6
app/NuGet.Config
Normal file
6
app/NuGet.Config
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<add key="chylex's repository" value="https://nuget.chylex.com/feed/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
@@ -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:
|
||||||
|
5
app/Resources/Tracker/bootstrap.js
vendored
5
app/Resources/Tracker/bootstrap.js
vendored
@@ -64,9 +64,11 @@
|
|||||||
let action = null;
|
let action = null;
|
||||||
|
|
||||||
if (!DISCORD.hasMoreMessages()) {
|
if (!DISCORD.hasMoreMessages()) {
|
||||||
|
console.debug("[DHT] Reached first message.");
|
||||||
action = SETTINGS.afterFirstMsg;
|
action = SETTINGS.afterFirstMsg;
|
||||||
}
|
}
|
||||||
if (isNoAction(action) && !anyNewMessages) {
|
if (isNoAction(action) && !anyNewMessages) {
|
||||||
|
console.debug("[DHT] No new messages.");
|
||||||
action = SETTINGS.afterSavedMsg;
|
action = SETTINGS.afterSavedMsg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +123,7 @@
|
|||||||
onTrackingContinued(false);
|
onTrackingContinued(false);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);
|
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||||
onTrackingContinued(anyNewMessages);
|
onTrackingContinued(anyNewMessages);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -156,3 +158,4 @@
|
|||||||
GUI.showSettings();
|
GUI.showSettings();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
/*[DEBUGGER]*/
|
||||||
|
@@ -1,5 +1,23 @@
|
|||||||
// noinspection JSUnresolvedVariable
|
// noinspection JSUnresolvedVariable
|
||||||
|
// noinspection LocalVariableNamingConventionJS
|
||||||
class DISCORD {
|
class DISCORD {
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
|
static CHANNEL_TYPE = {
|
||||||
|
DM: 1,
|
||||||
|
GROUP_DM: 3,
|
||||||
|
ANNOUNCEMENT_THREAD: 10,
|
||||||
|
PUBLIC_THREAD: 11,
|
||||||
|
PRIVATE_THREAD: 12
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
|
static MESSAGE_TYPE = {
|
||||||
|
DEFAULT: 0,
|
||||||
|
REPLY: 19,
|
||||||
|
THREAD_STARTER: 21
|
||||||
|
};
|
||||||
|
|
||||||
static getMessageOuterElement() {
|
static getMessageOuterElement() {
|
||||||
return DOM.queryReactClass("messagesWrapper");
|
return DOM.queryReactClass("messagesWrapper");
|
||||||
}
|
}
|
||||||
@@ -9,7 +27,7 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getMessageElements() {
|
static getMessageElements() {
|
||||||
return this.getMessageOuterElement().querySelectorAll("[class*='message-']");
|
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
|
||||||
}
|
}
|
||||||
|
|
||||||
static hasMoreMessages() {
|
static hasMoreMessages() {
|
||||||
@@ -28,46 +46,11 @@ class DISCORD {
|
|||||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||||
*/
|
*/
|
||||||
static setupMessageCallback(callback) {
|
static setupMessageCallback(callback) {
|
||||||
let skipsLeft = 0;
|
|
||||||
let waitForCleanup = false;
|
|
||||||
const previousMessages = new Set();
|
const previousMessages = new Set();
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const onMessageElementsChanged = function() {
|
||||||
if (skipsLeft > 0) {
|
const messages = DISCORD.getMessages();
|
||||||
--skipsLeft;
|
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = this.getMessageOuterElement();
|
|
||||||
|
|
||||||
if (!view) {
|
|
||||||
skipsLeft = 2;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anyMessage = DOM.queryReactClass("message", this.getMessageOuterElement());
|
|
||||||
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
|
|
||||||
|
|
||||||
if (messageCount > 300) {
|
|
||||||
if (waitForCleanup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
skipsLeft = 3;
|
|
||||||
waitForCleanup = true;
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
const view = this.getMessageScrollerElement();
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
view.scrollTop = view.scrollHeight / 2;
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
waitForCleanup = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = this.getMessages();
|
|
||||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
|
|
||||||
|
|
||||||
if (!hasChanged) {
|
if (!hasChanged) {
|
||||||
return;
|
return;
|
||||||
@@ -79,23 +62,75 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
callback(messages);
|
callback(messages);
|
||||||
}, 200);
|
};
|
||||||
|
|
||||||
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer));
|
let debounceTimer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do not trigger the callback too often due to autoscrolling.
|
||||||
|
*/
|
||||||
|
const onMessageElementsChangedLater = function() {
|
||||||
|
window.clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver(function () {
|
||||||
|
onMessageElementsChangedLater();
|
||||||
|
});
|
||||||
|
|
||||||
|
let skipsLeft = 0;
|
||||||
|
let observedElement = null;
|
||||||
|
|
||||||
|
const observerTimer = window.setInterval(() => {
|
||||||
|
if (skipsLeft > 0) {
|
||||||
|
--skipsLeft;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = this.getMessageOuterElement();
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
skipsLeft = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observedElement !== null && observedElement.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observedElement = view.querySelector("[data-list-id='chat-messages']");
|
||||||
|
|
||||||
|
if (observedElement) {
|
||||||
|
console.debug("[DHT] Observed message container.");
|
||||||
|
observer.observe(observedElement, { childList: true });
|
||||||
|
onMessageElementsChangedLater();
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
window.DHT_ON_UNLOAD.push(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
observedElement = null;
|
||||||
|
window.clearInterval(observerTimer);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the property object of a message element.
|
* Returns the message from a message element.
|
||||||
* @returns { null | { message: DiscordMessage, channel: Object } }
|
* @returns { null | DiscordMessage } }
|
||||||
*/
|
*/
|
||||||
static getMessageElementProps(ele) {
|
static getMessageFromElement(ele) {
|
||||||
const props = DOM.getReactProps(ele);
|
const props = DOM.getReactProps(ele);
|
||||||
|
|
||||||
if (props.children && props.children.length >= 4) {
|
if (props && Array.isArray(props.children)) {
|
||||||
const childProps = props.children[3].props;
|
for (const child of props.children) {
|
||||||
|
if (!(child instanceof Object)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ("message" in childProps && "channel" in childProps) {
|
const childProps = child.props;
|
||||||
return childProps;
|
if (childProps instanceof Object && "message" in childProps) {
|
||||||
|
return childProps.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,16 +145,20 @@ class DISCORD {
|
|||||||
const messages = [];
|
const messages = [];
|
||||||
|
|
||||||
for (const ele of this.getMessageElements()) {
|
for (const ele of this.getMessageElements()) {
|
||||||
const props = this.getMessageElementProps(ele);
|
try {
|
||||||
|
const message = this.getMessageFromElement(ele);
|
||||||
|
|
||||||
if (props != null) {
|
if (message != null) {
|
||||||
messages.push(props.message);
|
messages.push(message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error("[DHT] Error retrieving messages.", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +170,7 @@ class DISCORD {
|
|||||||
*/
|
*/
|
||||||
static getSelectedChannel() {
|
static getSelectedChannel() {
|
||||||
try {
|
try {
|
||||||
let obj;
|
let obj = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
||||||
@@ -142,15 +181,6 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
||||||
|
|
||||||
for (const ele of this.getMessageElements()) {
|
|
||||||
const props = this.getMessageElementProps(ele);
|
|
||||||
|
|
||||||
if (props != null) {
|
|
||||||
obj = props.channel;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!obj || typeof obj.id !== "string") {
|
if (!obj || typeof obj.id !== "string") {
|
||||||
@@ -162,7 +192,7 @@ class DISCORD {
|
|||||||
if (dms) {
|
if (dms) {
|
||||||
let name;
|
let name;
|
||||||
|
|
||||||
for (const ele of dms.querySelectorAll("[class*='channel-'] [class*='selected-'] [class^='name-'] *, [class*='channel-'][class*='selected-'] [class^='name-'] *")) {
|
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
|
||||||
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
|
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
|
||||||
|
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -179,8 +209,8 @@ class DISCORD {
|
|||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
switch (obj.type) {
|
switch (obj.type) {
|
||||||
case 1: type = "DM"; break;
|
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
|
||||||
case 3: type = "GROUP"; break;
|
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,9 +221,22 @@ class DISCORD {
|
|||||||
return { server, channel };
|
return { server, channel };
|
||||||
}
|
}
|
||||||
else if (obj.guild_id) {
|
else if (obj.guild_id) {
|
||||||
|
let guild;
|
||||||
|
|
||||||
|
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
|
||||||
|
if (child && child.props && child.props.guild) {
|
||||||
|
guild = child.props.guild;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const server = {
|
const server = {
|
||||||
"id": obj.guild_id,
|
"id": guild.id,
|
||||||
"name": document.querySelector("nav header h1[class*='name-']").innerText,
|
"name": guild.name,
|
||||||
"type": "SERVER"
|
"type": "SERVER"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,7 +248,7 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (obj.parent_id) {
|
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||||
channel["extra"]["parent"] = obj.parent_id;
|
channel["extra"]["parent"] = obj.parent_id;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -232,10 +275,10 @@ class DISCORD {
|
|||||||
|
|
||||||
if (dms) {
|
if (dms) {
|
||||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
const currentChannel = DOM.queryReactClass("selected", dms);
|
||||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel-']");
|
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
|
||||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
||||||
|
|
||||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-")) {
|
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +305,7 @@ class DISCORD {
|
|||||||
let nextChannel = null;
|
let nextChannel = null;
|
||||||
|
|
||||||
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
||||||
if (allTextChannels[index].className.includes("selected-")) {
|
if (allTextChannels[index].className.includes("selected_")) {
|
||||||
nextChannel = allTextChannels[index + 1];
|
nextChannel = allTextChannels[index + 1];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,7 @@ class DOM {
|
|||||||
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
||||||
*/
|
*/
|
||||||
static queryReactClass(cls, parent) {
|
static queryReactClass(cls, parent) {
|
||||||
return (parent || document).querySelector(`[class*="${cls}-"]`);
|
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,4 +71,15 @@ class DOM {
|
|||||||
key = keys.find(key => key.startsWith("__reactProps$"));
|
key = keys.find(key => key.startsWith("__reactProps$"));
|
||||||
return key ? ele[key] : null;
|
return key ? ele[key] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns internal React state object of an element, or null if the retrieval throws.
|
||||||
|
*/
|
||||||
|
static tryGetReactProps(ele) {
|
||||||
|
try {
|
||||||
|
return this.getReactProps(ele);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -86,12 +86,12 @@ const GUI = (function() {
|
|||||||
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
||||||
<br>
|
<br>
|
||||||
<label>After reaching the first message in channel...</label><br>
|
<label>After reaching the first message in channel...</label><br>
|
||||||
${radio("afm", "nothing", "Do Nothing")}
|
${radio("afm", "nothing", "Continue Tracking")}
|
||||||
${radio("afm", "pause", "Pause Tracking")}
|
${radio("afm", "pause", "Pause Tracking")}
|
||||||
${radio("afm", "switch", "Switch to Next Channel")}
|
${radio("afm", "switch", "Switch to Next Channel")}
|
||||||
<br>
|
<br>
|
||||||
<label>After reaching a previously saved message...</label><br>
|
<label>After reaching a previously saved message...</label><br>
|
||||||
${radio("asm", "nothing", "Do Nothing")}
|
${radio("asm", "nothing", "Continue Tracking")}
|
||||||
${radio("asm", "pause", "Pause Tracking")}
|
${radio("asm", "pause", "Pause Tracking")}
|
||||||
${radio("asm", "switch", "Switch to Next Channel")}
|
${radio("asm", "switch", "Switch to Next Channel")}
|
||||||
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;
|
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;
|
||||||
|
@@ -48,6 +48,16 @@ const STATE = (function() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDate = function(date) {
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// noinspection JSUnresolvedReference
|
||||||
|
return date.toDate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const trackingStateChangedListeners = [];
|
const trackingStateChangedListeners = [];
|
||||||
let isTracking = false;
|
let isTracking = false;
|
||||||
|
|
||||||
@@ -59,6 +69,7 @@ const STATE = (function() {
|
|||||||
* @property {String} id
|
* @property {String} id
|
||||||
* @property {String} username
|
* @property {String} username
|
||||||
* @property {String} discriminator
|
* @property {String} discriminator
|
||||||
|
* @property {String} [globalName]
|
||||||
* @property {String} [avatar]
|
* @property {String} [avatar]
|
||||||
* @property {Boolean} [bot]
|
* @property {Boolean} [bot]
|
||||||
*/
|
*/
|
||||||
@@ -69,8 +80,8 @@ const STATE = (function() {
|
|||||||
* @property {String} channel_id
|
* @property {String} channel_id
|
||||||
* @property {DiscordUser} author
|
* @property {DiscordUser} author
|
||||||
* @property {String} content
|
* @property {String} content
|
||||||
* @property {Timestamp} timestamp
|
* @property {Date} timestamp
|
||||||
* @property {Timestamp|null} editedTimestamp
|
* @property {Date|null} editedTimestamp
|
||||||
* @property {DiscordAttachment[]} attachments
|
* @property {DiscordAttachment[]} attachments
|
||||||
* @property {Object[]} embeds
|
* @property {Object[]} embeds
|
||||||
* @property {DiscordMessageReaction[]} [reactions]
|
* @property {DiscordMessageReaction[]} [reactions]
|
||||||
@@ -106,11 +117,6 @@ const STATE = (function() {
|
|||||||
* @property {Boolean} animated
|
* @property {Boolean} animated
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* @name Timestamp
|
|
||||||
* @property {Function} toDate
|
|
||||||
*/
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setup(port, token) {
|
setup(port, token) {
|
||||||
serverPort = port;
|
serverPort = port;
|
||||||
@@ -174,12 +180,10 @@ const STATE = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {String} channelId
|
|
||||||
* @param {DiscordMessage[]} discordMessageArray
|
* @param {DiscordMessage[]} discordMessageArray
|
||||||
*/
|
*/
|
||||||
async addDiscordMessages(channelId, discordMessageArray) {
|
async addDiscordMessages(discordMessageArray) {
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
discordMessageArray = discordMessageArray.filter(msg => (msg.type === DISCORD.MESSAGE_TYPE.DEFAULT || msg.type === DISCORD.MESSAGE_TYPE.REPLY || msg.type === DISCORD.MESSAGE_TYPE.THREAD_STARTER) && msg.state === "SENT");
|
||||||
discordMessageArray = discordMessageArray.filter(msg => (msg.type === 0 || msg.type === 19 || msg.type === 21) && msg.state === "SENT");
|
|
||||||
|
|
||||||
if (discordMessageArray.length === 0) {
|
if (discordMessageArray.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -197,6 +201,10 @@ const STATE = (function() {
|
|||||||
name: user.username
|
name: user.username
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (user.globalName) {
|
||||||
|
obj.displayName = user.globalName;
|
||||||
|
}
|
||||||
|
|
||||||
if (user.avatar) {
|
if (user.avatar) {
|
||||||
obj.avatar = user.avatar;
|
obj.avatar = user.avatar;
|
||||||
}
|
}
|
||||||
@@ -225,12 +233,12 @@ const STATE = (function() {
|
|||||||
sender: msg.author.id,
|
sender: msg.author.id,
|
||||||
channel: msg.channel_id,
|
channel: msg.channel_id,
|
||||||
text: msg.content,
|
text: msg.content,
|
||||||
timestamp: msg.timestamp.toDate().getTime()
|
timestamp: getDate(msg.timestamp).getTime()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (msg.editedTimestamp !== null) {
|
if (msg.editedTimestamp !== null) {
|
||||||
// noinspection JSUnusedGlobalSymbols
|
// noinspection JSUnusedGlobalSymbols
|
||||||
obj.editTimestamp = msg.editedTimestamp.toDate().getTime();
|
obj.editTimestamp = getDate(msg.editedTimestamp).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.messageReference !== null) {
|
if (msg.messageReference !== null) {
|
||||||
@@ -251,6 +259,11 @@ const STATE = (function() {
|
|||||||
mapped.type = attachment.content_type;
|
mapped.type = attachment.content_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachment.width && attachment.height) {
|
||||||
|
mapped.width = attachment.width;
|
||||||
|
mapped.height = attachment.height;
|
||||||
|
}
|
||||||
|
|
||||||
return mapped;
|
return mapped;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,5 @@
|
|||||||
#app-mount div[class*="app-"] {
|
#app-mount {
|
||||||
margin-bottom: 48px !important;
|
height: calc(100% - 48px) !important;
|
||||||
}
|
|
||||||
|
|
||||||
#app-mount div[class*="app-"] > div[class*="app-"] {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#dht-ctrl {
|
#dht-ctrl {
|
||||||
|
@@ -2,20 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
|
||||||
<title>Discord Offline History</title>
|
<title>Discord Offline History</title>
|
||||||
|
|
||||||
|
<link rel="icon" href="favicon.ico">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="styles/main.css">
|
||||||
|
<link rel="stylesheet" href="styles/menu.css">
|
||||||
|
<link rel="stylesheet" href="styles/channels.css">
|
||||||
|
<link rel="stylesheet" href="styles/messages.css">
|
||||||
|
<link rel="stylesheet" href="styles/modal.css">
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.DHT_EMBEDDED = "/*[ARCHIVE]*/";
|
const query = new URLSearchParams(location.search);
|
||||||
/*[JS]*/
|
window.DHT_SERVER_TOKEN = query.get("token");
|
||||||
|
window.DHT_SERVER_SESSION = query.get("session");
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<script type="module" src="scripts/bootstrap.mjs"></script>
|
||||||
/*[CSS]*/
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="menu">
|
<div id="menu">
|
||||||
<button id="btn-settings">Settings</button>
|
<button id="btn-settings">Settings</button>
|
||||||
|
|
||||||
|
<div class="splitter"></div>
|
||||||
|
|
||||||
<div> <!-- needed to stop the select from messing up -->
|
<div> <!-- needed to stop the select from messing up -->
|
||||||
<select id="opt-messages-per-page">
|
<select id="opt-messages-per-page">
|
||||||
<option value="50">50 messages per page </option>
|
<option value="50">50 messages per page </option>
|
||||||
@@ -64,7 +75,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="channels"></div>
|
<div id="channels">
|
||||||
|
<div class="loading"></div>
|
||||||
|
</div>
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
39
app/Resources/Viewer/scripts/bootstrap.js
vendored
39
app/Resources/Viewer/scripts/bootstrap.js
vendored
@@ -1,39 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
DISCORD.setup();
|
|
||||||
GUI.setup();
|
|
||||||
|
|
||||||
GUI.onOptionMessagesPerPageChanged(() => {
|
|
||||||
STATE.setMessagesPerPage(GUI.getOptionMessagesPerPage());
|
|
||||||
});
|
|
||||||
|
|
||||||
STATE.setMessagesPerPage(GUI.getOptionMessagesPerPage());
|
|
||||||
|
|
||||||
GUI.onOptMessageFilterChanged(filter => {
|
|
||||||
STATE.setActiveFilter(filter);
|
|
||||||
});
|
|
||||||
|
|
||||||
GUI.onNavigationButtonClicked(action => {
|
|
||||||
STATE.updateCurrentPage(action);
|
|
||||||
});
|
|
||||||
|
|
||||||
STATE.onUsersRefreshed(users => {
|
|
||||||
GUI.updateUserList(users);
|
|
||||||
});
|
|
||||||
|
|
||||||
STATE.onChannelsRefreshed((channels, selected) => {
|
|
||||||
GUI.updateChannelList(channels, selected, STATE.selectChannel);
|
|
||||||
});
|
|
||||||
|
|
||||||
STATE.onMessagesRefreshed(messages => {
|
|
||||||
GUI.updateNavigation(STATE.getCurrentPage(), STATE.getPageCount());
|
|
||||||
GUI.updateMessageList(messages);
|
|
||||||
GUI.scrollMessagesToTop();
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
STATE.uploadFile(JSON.parse(window.DHT_EMBEDDED));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
alert("Could not parse embedded file, see console for details.");
|
|
||||||
}
|
|
||||||
});
|
|
111
app/Resources/Viewer/scripts/bootstrap.mjs
Normal file
111
app/Resources/Viewer/scripts/bootstrap.mjs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import discord from "./discord.mjs";
|
||||||
|
import gui from "./gui.mjs";
|
||||||
|
import state from "./state.mjs";
|
||||||
|
import "./polyfills.mjs";
|
||||||
|
|
||||||
|
window.DISCORD = discord;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
discord.setup();
|
||||||
|
gui.setup();
|
||||||
|
|
||||||
|
gui.onOptionMessagesPerPageChanged(() => {
|
||||||
|
state.setMessagesPerPage(gui.getOptionMessagesPerPage());
|
||||||
|
});
|
||||||
|
|
||||||
|
state.setMessagesPerPage(gui.getOptionMessagesPerPage());
|
||||||
|
|
||||||
|
gui.onOptMessageFilterChanged(filter => {
|
||||||
|
state.setActiveFilter(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
gui.onNavigationButtonClicked(action => {
|
||||||
|
state.updateCurrentPage(action);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.onUsersRefreshed(users => {
|
||||||
|
gui.updateUserList(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.onChannelsRefreshed((channels, selected) => {
|
||||||
|
gui.updateChannelList(channels, selected, state.selectChannel);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.onMessagesRefreshed(messages => {
|
||||||
|
gui.updateNavigation(state.getCurrentPage(), state.getPageCount());
|
||||||
|
gui.updateMessageList(messages);
|
||||||
|
gui.scrollMessagesToTop();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchUrl(path, contentType) {
|
||||||
|
const response = await fetch("/" + path + "?token=" + encodeURIComponent(window.DHT_SERVER_TOKEN) + "&session=" + encodeURIComponent(window.DHT_SERVER_SESSION), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
},
|
||||||
|
credentials: "omit",
|
||||||
|
redirect: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw "Unexpected response status: " + response.statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processLines(response, callback) {
|
||||||
|
let body = "";
|
||||||
|
|
||||||
|
for await (const chunk of response.body.pipeThrough(new TextDecoderStream("utf-8"))) {
|
||||||
|
body += chunk;
|
||||||
|
|
||||||
|
let startIndex = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const endIndex = body.indexOf("\n", startIndex);
|
||||||
|
if (endIndex === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(body.substring(startIndex, endIndex));
|
||||||
|
startIndex = endIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = body.substring(startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body !== "") {
|
||||||
|
callback(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const metadataResponse = await fetchUrl("get-viewer-metadata", "application/json");
|
||||||
|
const metadataJson = await metadataResponse.json();
|
||||||
|
|
||||||
|
const messagesResponse = await fetchUrl("get-viewer-messages", "application/x-ndjson");
|
||||||
|
const messages = {};
|
||||||
|
|
||||||
|
await processLines(messagesResponse, line => {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
const channel = message.c;
|
||||||
|
|
||||||
|
const channelMessages = messages[channel] || (messages[channel] = {});
|
||||||
|
channelMessages[message.id] = message;
|
||||||
|
|
||||||
|
delete message.id;
|
||||||
|
delete message.c;
|
||||||
|
});
|
||||||
|
|
||||||
|
state.uploadFile(metadataJson, messages);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("Could not load data, see console for details.");
|
||||||
|
document.querySelector("#channels > div.loading").remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
});
|
@@ -1,7 +1,14 @@
|
|||||||
const DISCORD = (function() {
|
import discord from "./discord.mjs";
|
||||||
|
import dom from "./dom.mjs";
|
||||||
|
import template from "./template.mjs";
|
||||||
|
import settings from "./settings.mjs";
|
||||||
|
import state from "./state.mjs";
|
||||||
|
|
||||||
|
export default (function() {
|
||||||
const regex = {
|
const regex = {
|
||||||
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
||||||
formatItalic: /(.)?\*([\s\S]+?)\*(?!\*)/g,
|
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
||||||
|
formatItalic2: /_([\s\S]+?)_(?!_)\b/g,
|
||||||
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
formatUnderline: /__([\s\S]+?)__(?!_)/g,
|
||||||
formatStrike: /~~([\s\S]+?)~~(?!~)/g,
|
formatStrike: /~~([\s\S]+?)~~(?!~)/g,
|
||||||
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
|
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
|
||||||
@@ -9,7 +16,7 @@ const DISCORD = (function() {
|
|||||||
formatUrl: /(\b(?:https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig,
|
formatUrl: /(\b(?:https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig,
|
||||||
formatUrlNoEmbed: /<(\b(?:https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])>/ig,
|
formatUrlNoEmbed: /<(\b(?:https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])>/ig,
|
||||||
specialEscapedBacktick: /\\`/g,
|
specialEscapedBacktick: /\\`/g,
|
||||||
specialEscapedSingle: /\\([*\\])/g,
|
specialEscapedSingle: /\\([*_\\])/g,
|
||||||
specialEscapedDouble: /\\__|_\\_|\\_\\_|\\~~|~\\~|\\~\\~/g,
|
specialEscapedDouble: /\\__|_\\_|\\_\\_|\\~~|~\\~|\\~\\~/g,
|
||||||
specialUnescaped: /([*_~\\])/g,
|
specialUnescaped: /([*_~\\])/g,
|
||||||
mentionRole: /<@&(\d+?)>/g,
|
mentionRole: /<@&(\d+?)>/g,
|
||||||
@@ -26,6 +33,7 @@ const DISCORD = (function() {
|
|||||||
let templateUserAvatar;
|
let templateUserAvatar;
|
||||||
let templateAttachmentDownload;
|
let templateAttachmentDownload;
|
||||||
let templateEmbedImage;
|
let templateEmbedImage;
|
||||||
|
let templateEmbedImageWithSize;
|
||||||
let templateEmbedRich;
|
let templateEmbedRich;
|
||||||
let templateEmbedRichNoDescription;
|
let templateEmbedRichNoDescription;
|
||||||
let templateEmbedUrl;
|
let templateEmbedUrl;
|
||||||
@@ -33,10 +41,24 @@ const DISCORD = (function() {
|
|||||||
let templateReaction;
|
let templateReaction;
|
||||||
let templateReactionCustom;
|
let templateReactionCustom;
|
||||||
|
|
||||||
const processMessageContents = function(contents) {
|
const fileUrlProcessor = function(serverToken) {
|
||||||
let processed = DOM.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1"));
|
if (typeof serverToken === "string") {
|
||||||
|
return url => "/get-downloaded-file/" + encodeURIComponent(url) + "?token=" + encodeURIComponent(serverToken);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return url => url;
|
||||||
|
}
|
||||||
|
}(window.DHT_SERVER_TOKEN);
|
||||||
|
|
||||||
if (SETTINGS.enableFormatting) {
|
const getEmoji = function(name, id, extension) {
|
||||||
|
const tag = ":" + name + ":";
|
||||||
|
return "<img src='" + fileUrlProcessor("https://cdn.discordapp.com/emojis/" + id + "." + extension) + "' alt='" + tag + "' title='" + tag + "' class='emoji'>";
|
||||||
|
};
|
||||||
|
|
||||||
|
const processMessageContents = function(contents) {
|
||||||
|
let processed = dom.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1"));
|
||||||
|
|
||||||
|
if (settings.enableFormatting) {
|
||||||
const escapeHtmlMatch = (full, match) => "&#" + match.charCodeAt(0) + ";";
|
const escapeHtmlMatch = (full, match) => "&#" + match.charCodeAt(0) + ";";
|
||||||
|
|
||||||
processed = processed
|
processed = processed
|
||||||
@@ -46,56 +68,80 @@ const DISCORD = (function() {
|
|||||||
.replace(regex.specialEscapedSingle, escapeHtmlMatch)
|
.replace(regex.specialEscapedSingle, escapeHtmlMatch)
|
||||||
.replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
|
.replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
|
||||||
.replace(regex.formatBold, "<b>$1</b>")
|
.replace(regex.formatBold, "<b>$1</b>")
|
||||||
.replace(regex.formatItalic, (full, pre, match) => pre === "\\" ? full : (pre || "") + "<i>" + match + "</i>")
|
|
||||||
.replace(regex.formatUnderline, "<u>$1</u>")
|
.replace(regex.formatUnderline, "<u>$1</u>")
|
||||||
|
.replace(regex.formatItalic1, "<i>$1</i>")
|
||||||
|
.replace(regex.formatItalic2, "<i>$1</i>")
|
||||||
.replace(regex.formatStrike, "<s>$1</s>");
|
.replace(regex.formatStrike, "<s>$1</s>");
|
||||||
}
|
}
|
||||||
|
|
||||||
const animatedEmojiExtension = SETTINGS.enableAnimatedEmoji ? "gif" : "png";
|
const animatedEmojiExtension = settings.enableAnimatedEmoji ? "gif" : "webp";
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
processed = processed
|
processed = processed
|
||||||
.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
|
.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
|
||||||
.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + STATE.getChannelName(match) + "</span>")
|
.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + state.getChannelName(match) + "</span>")
|
||||||
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='#" + (STATE.getUserTag(match) || "????") + "'>@" + STATE.getUserName(match) + "</span>")
|
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='" + state.getUserName(match) + "'>@" + state.getUserDisplayName(match) + "</span>")
|
||||||
.replace(regex.customEmojiStatic, "<img src='https://cdn.discordapp.com/emojis/$2.png' alt=':$1:' title=':$1:' class='emoji'>")
|
.replace(regex.customEmojiStatic, (full, m1, m2) => getEmoji(m1, m2, "webp"))
|
||||||
.replace(regex.customEmojiAnimated, "<img src='https://cdn.discordapp.com/emojis/$2." + animatedEmojiExtension + "' alt=':$1:' title=':$1:' class='emoji'>");
|
.replace(regex.customEmojiAnimated, (full, m1, m2) => getEmoji(m1, m2, animatedEmojiExtension));
|
||||||
|
|
||||||
return "<p>" + processed + "</p>";
|
return "<p>" + processed + "</p>";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAvatarUrlObject = function(avatar) {
|
||||||
|
return { url: fileUrlProcessor("https://cdn.discordapp.com/avatars/" + avatar.id + "/" + avatar.path + ".webp") };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageEmbed = function(url, image) {
|
||||||
|
if (!settings.enableImagePreviews) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.width && image.height) {
|
||||||
|
return templateEmbedImageWithSize.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url), width: image.width, height: image.height });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return templateEmbedImage.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImageUrl = function(url) {
|
||||||
|
const dot = url.pathname.lastIndexOf(".");
|
||||||
|
const ext = dot === -1 ? "" : url.pathname.substring(dot).toLowerCase();
|
||||||
|
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setup() {
|
setup() {
|
||||||
templateChannelServer = new TEMPLATE([
|
templateChannelServer = new template([
|
||||||
"<div data-channel='{id}'>",
|
"<div class='channel' data-channel='{id}'>",
|
||||||
"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>",
|
"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>",
|
||||||
"<span class='server'>{server.name} ({server.type})</span>",
|
"<span class='server'>{server.name} ({server.type})</span>",
|
||||||
"</div>"
|
"</div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateChannelPrivate = new TEMPLATE([
|
templateChannelPrivate = new template([
|
||||||
"<div data-channel='{id}'>",
|
"<div class='channel' data-channel='{id}'>",
|
||||||
"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>",
|
"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>",
|
||||||
"<span class='server'>({server.type})</span>",
|
"<span class='server'>({server.type})</span>",
|
||||||
"</div>"
|
"</div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateMessageNoAvatar = new TEMPLATE([
|
templateMessageNoAvatar = new template([
|
||||||
"<div>",
|
"<div>",
|
||||||
"<div class='reply-message'>{reply}</div>",
|
"<div class='reply-message'>{reply}</div>",
|
||||||
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||||
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
||||||
"{reactions}",
|
"{reactions}",
|
||||||
"</div>"
|
"</div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateMessageWithAvatar = new TEMPLATE([
|
templateMessageWithAvatar = new template([
|
||||||
"<div>",
|
"<div>",
|
||||||
"<div class='reply-message reply-message-with-avatar'>{reply}</div>",
|
"<div class='reply-message reply-message-with-avatar'>{reply}</div>",
|
||||||
"<div class='avatar-wrapper'>",
|
"<div class='avatar-wrapper'>",
|
||||||
"<div class='avatar'>{avatar}</div>",
|
"<div class='avatar'>{avatar}</div>",
|
||||||
"<div>",
|
"<div>",
|
||||||
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||||
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
||||||
"{reactions}",
|
"{reactions}",
|
||||||
"</div>",
|
"</div>",
|
||||||
@@ -103,52 +149,69 @@ const DISCORD = (function() {
|
|||||||
"</div>"
|
"</div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateUserAvatar = new TEMPLATE([
|
// noinspection HtmlUnknownTarget
|
||||||
"<img src='https://cdn.discordapp.com/avatars/{id}/{path}.webp?size=128' alt=''>"
|
templateUserAvatar = new template([
|
||||||
|
"<img src='{url}' alt=''>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateAttachmentDownload = new TEMPLATE([
|
templateAttachmentDownload = new template([
|
||||||
"<a href='{url}' class='embed download'>Download {filename}</a>"
|
"<a href='{url}' class='embed download'>Download {name}</a>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedImage = new TEMPLATE([
|
templateEmbedImage = new template([
|
||||||
"<a href='{url}' class='embed thumbnail'><img src='{src}' alt='(image attachment not found)'></a><br>"
|
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedRich = new TEMPLATE([
|
templateEmbedImageWithSize = new template([
|
||||||
|
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>"
|
||||||
|
].join(""));
|
||||||
|
|
||||||
|
// noinspection HtmlUnknownTarget
|
||||||
|
templateEmbedRich = new template([
|
||||||
"<div class='embed download'><a href='{url}' class='title'>{title}</a><p class='desc'>{description}</p></div>"
|
"<div class='embed download'><a href='{url}' class='title'>{title}</a><p class='desc'>{description}</p></div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedRichNoDescription = new TEMPLATE([
|
templateEmbedRichNoDescription = new template([
|
||||||
"<div class='embed download'><a href='{url}' class='title'>{title}</a></div>"
|
"<div class='embed download'><a href='{url}' class='title'>{title}</a></div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedUrl = new TEMPLATE([
|
templateEmbedUrl = new template([
|
||||||
"<a href='{url}' class='embed download'>{url}</a>"
|
"<a href='{url}' class='embed download'>{url}</a>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateEmbedUnsupported = new TEMPLATE([
|
templateEmbedUnsupported = new template([
|
||||||
"<div class='embed download'><p>(Unsupported embed)</p></div>"
|
"<div class='embed download'><p>(Unsupported embed)</p></div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateReaction = new TEMPLATE([
|
templateReaction = new template([
|
||||||
"<span class='reaction-wrapper'><span class='reaction-emoji'>{n}</span><span class='count'>{c}</span></span>"
|
"<span class='reaction-wrapper'><span class='reaction-emoji'>{n}</span><span class='count'>{c}</span></span>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateReactionCustom = new TEMPLATE([
|
// noinspection HtmlUnknownTarget
|
||||||
"<span class='reaction-wrapper'><img src='https://cdn.discordapp.com/emojis/{id}.{ext}' alt=':{n}:' title=':{n}:' class='reaction-emoji-custom'><span class='count'>{c}</span></span>"
|
templateReactionCustom = new template([
|
||||||
|
"<span class='reaction-wrapper'><img src='{url}' alt=':{n}:' title=':{n}:' class='reaction-emoji-custom'><span class='count'>{c}</span></span>"
|
||||||
].join(""));
|
].join(""));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleImageLoad(ele) {
|
||||||
|
ele.parentElement.classList.remove("loading");
|
||||||
|
},
|
||||||
|
|
||||||
|
handleImageLoadError(ele) {
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
ele.onerror = null;
|
||||||
|
ele.parentElement.classList.remove("loading");
|
||||||
|
ele.setAttribute("alt", "(image attachment not found)");
|
||||||
|
},
|
||||||
|
|
||||||
isImageAttachment(attachment) {
|
isImageAttachment(attachment) {
|
||||||
const dot = attachment.url.lastIndexOf(".");
|
const url = dom.tryParseUrl(attachment.url);
|
||||||
const ext = dot === -1 ? "" : attachment.url.substring(dot).toLowerCase();
|
return url != null && isImageUrl(url);
|
||||||
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getChannelHTML(channel) { // noinspection FunctionWithInconsistentReturnsJS
|
getChannelHTML(channel) { // noinspection FunctionWithInconsistentReturnsJS
|
||||||
@@ -160,15 +223,15 @@ const DISCORD = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getMessageHTML(message) { // noinspection FunctionWithInconsistentReturnsJS
|
getMessageHTML(message) { // noinspection FunctionWithInconsistentReturnsJS
|
||||||
return (SETTINGS.enableUserAvatars ? templateMessageWithAvatar : templateMessageNoAvatar).apply(message, (property, value) => {
|
return (settings.enableUserAvatars ? templateMessageWithAvatar : templateMessageNoAvatar).apply(message, (property, value) => {
|
||||||
if (property === "avatar") {
|
if (property === "avatar") {
|
||||||
return value ? templateUserAvatar.apply(value) : "";
|
return value ? templateUserAvatar.apply(getAvatarUrlObject(value)) : "";
|
||||||
}
|
}
|
||||||
else if (property === "user.tag") {
|
else if (property === "user.displayName") {
|
||||||
return value ? value : "????";
|
return value ? value : message.user.name;
|
||||||
}
|
}
|
||||||
else if (property === "timestamp") {
|
else if (property === "timestamp") {
|
||||||
return DOM.getHumanReadableTime(value);
|
return dom.getHumanReadableTime(value);
|
||||||
}
|
}
|
||||||
else if (property === "contents") {
|
else if (property === "contents") {
|
||||||
return value && value.length > 0 ? processMessageContents(value) : "";
|
return value && value.length > 0 ? processMessageContents(value) : "";
|
||||||
@@ -183,10 +246,10 @@ const DISCORD = (function() {
|
|||||||
return templateEmbedUnsupported.apply(embed);
|
return templateEmbedUnsupported.apply(embed);
|
||||||
}
|
}
|
||||||
else if ("image" in embed && embed.image.url) {
|
else if ("image" in embed && embed.image.url) {
|
||||||
return SETTINGS.enableImagePreviews ? templateEmbedImage.apply({ url: embed.url, src: embed.image.url }) : "";
|
return getImageEmbed(embed.url, embed.image);
|
||||||
}
|
}
|
||||||
else if ("thumbnail" in embed && embed.thumbnail.url) {
|
else if ("thumbnail" in embed && embed.thumbnail.url) {
|
||||||
return SETTINGS.enableImagePreviews ? templateEmbedImage.apply({ url: embed.url, src: embed.thumbnail.url }) : "";
|
return getImageEmbed(embed.url, embed.thumbnail);
|
||||||
}
|
}
|
||||||
else if ("title" in embed && "description" in embed) {
|
else if ("title" in embed && "description" in embed) {
|
||||||
return templateEmbedRich.apply(embed);
|
return templateEmbedRich.apply(embed);
|
||||||
@@ -205,32 +268,32 @@ const DISCORD = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return value.map(attachment => {
|
return value.map(attachment => {
|
||||||
if (this.isImageAttachment(attachment) && SETTINGS.enableImagePreviews) {
|
const url = fileUrlProcessor(attachment.url);
|
||||||
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
|
|
||||||
|
if (!discord.isImageAttachment(attachment) || !settings.enableImagePreviews) {
|
||||||
|
return templateAttachmentDownload.apply({ url, name: attachment.name });
|
||||||
|
}
|
||||||
|
else if ("width" in attachment && "height" in attachment) {
|
||||||
|
return templateEmbedImageWithSize.apply({ url, src: url, width: attachment.width, height: attachment.height });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const sliced = attachment.url.split("/");
|
return templateEmbedImage.apply({ url, src: url });
|
||||||
|
|
||||||
return templateAttachmentDownload.apply({
|
|
||||||
"url": attachment.url,
|
|
||||||
"filename": sliced[sliced.length - 1]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
else if (property === "edit") {
|
else if (property === "edit") {
|
||||||
return value ? "<span class='info edited'>Edited " + DOM.getHumanReadableTime(value) + "</span>" : "";
|
return value ? "<span class='info edited'>Edited " + dom.getHumanReadableTime(value) + "</span>" : "";
|
||||||
}
|
}
|
||||||
else if (property === "jump") {
|
else if (property === "jump") {
|
||||||
return STATE.hasActiveFilter ? "<span class='info jump' data-jump='" + value + "'>Jump to message</span>" : "";
|
return state.hasActiveFilter ? "<span class='info jump' data-jump='" + value + "'>Jump to message</span>" : "";
|
||||||
}
|
}
|
||||||
else if (property === "reply") {
|
else if (property === "reply") {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : "";
|
return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>";
|
const user = "<span class='reply-username' title='" + value.user.name + "'>" + (value.user.displayName ?? value.user.name) + "</span>";
|
||||||
const avatar = SETTINGS.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(value.avatar) + "</span>" : "";
|
const avatar = settings.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(getAvatarUrlObject(value.avatar)) + "</span>" : "";
|
||||||
const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
|
const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
|
||||||
|
|
||||||
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
|
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
|
||||||
@@ -242,9 +305,10 @@ const DISCORD = (function() {
|
|||||||
|
|
||||||
return "<div class='reactions'>" + value.map(reaction => {
|
return "<div class='reactions'>" + value.map(reaction => {
|
||||||
if ("id" in reaction){
|
if ("id" in reaction){
|
||||||
// noinspection JSUnusedGlobalSymbols, JSUnresolvedVariable
|
const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp";
|
||||||
reaction.ext = reaction.a && SETTINGS.enableAnimatedEmoji ? "gif" : "png";
|
const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
|
||||||
return templateReactionCustom.apply(reaction);
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
return templateReactionCustom.apply({ url, n: reaction.n, c: reaction.c });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return templateReaction.apply(reaction);
|
return templateReaction.apply(reaction);
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user