mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 10:31:41 +02:00
Compare commits
82 Commits
wip-viewer
...
v45.0
Author | SHA1 | Date | |
---|---|---|---|
50d5bdba97
|
|||
295c32c7b4
|
|||
5e4bb5b44d
|
|||
96c19afa66
|
|||
a45f60b528
|
|||
62b97807b8
|
|||
0fff3e8eaf
|
|||
02b773db8f
|
|||
261a583b50
|
|||
2ecb658e42
|
|||
7a6dd23912
|
|||
60761d80ab
|
|||
e11db62015
|
|||
bfac9b91d9
|
|||
4215dc76f4
|
|||
86adda610b
|
|||
f1c4cd040e
|
|||
737ab43ea4
|
|||
0229c00259
|
|||
95361d2e01
|
|||
5b7312109b
|
|||
9f1db9a662
|
|||
d3e1bb901c
|
|||
9bd105f57e
|
|||
e1eae393c3
|
|||
fbed74529d
|
|||
8924aa7c06
|
|||
9738880ba7
|
|||
1700f99bf7
|
|||
84acf5f5d5
|
|||
ae56433836
|
|||
c3d4fa5532
|
|||
a6225b9721
|
|||
943163473a
|
|||
fa00df10d8
|
|||
f54465e5fe
|
|||
2d55ca4013
|
|||
0072e025f4
|
|||
383ee5c90e
|
|||
2b1c178f49
|
|||
16d7e5a6f8
|
|||
052d24d1e7
|
|||
b058f1fb77
|
|||
d4da64a5ed
|
|||
8de309a6c4
|
|||
de8d6a1e11
|
|||
d79e6f53b4
|
|||
70c04fc986
|
|||
c8d8d95daa
|
|||
daafdbbfaf
|
|||
07615de87a
|
|||
7fdc19880e
|
|||
67b9c12843
|
|||
9030a2f010
|
|||
a6dad6b4c7
|
|||
72b8fb7c14
|
|||
7173dc6cfc
|
|||
2c1e5a7603
|
|||
4929a19397
|
|||
c5f77872fe
|
|||
c9e50e1a80
|
|||
ef3e34066a
|
|||
37374eeb18
|
|||
23ddb45a0d
|
|||
9904a711f7
|
|||
d5720c8758
|
|||
89161e14b1
|
|||
9d208b026c
|
|||
119649ef9b
|
|||
0bc6232da7
|
|||
de266473c5
|
|||
e0f359c15b
|
|||
935f11d736
|
|||
f64141e768
|
|||
edea3470df
|
|||
031d521402
|
|||
0131f8cb50
|
|||
3bf5acfa65
|
|||
f603c861c5
|
|||
d2934f4d6a
|
|||
567253d147
|
|||
aa6555990c
|
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,3 +1 @@
|
|||||||
github: chylex
|
|
||||||
patreon: chylex
|
|
||||||
ko_fi: chylex
|
ko_fi: chylex
|
||||||
|
18
README.md
18
README.md
@@ -18,7 +18,7 @@ Folder organization:
|
|||||||
* `app/` contains a Visual Studio solution for the desktop app
|
* `app/` contains a Visual Studio solution for the desktop app
|
||||||
* `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 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/).
|
To start editing source code for the desktop app, install the [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
@@ -28,18 +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 [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)
|
1. Install Debian in WSL and open a terminal in the project folder.
|
||||||
|
2. Run the `app/build.wsl.sh` script.
|
||||||
|
3. Read the [Distribution](#distribution) section below.
|
||||||
|
|
||||||
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
Note: The build script expects `dotnet.exe` to be installed in `C:\Program Files\dotnet`.
|
||||||
|
|
||||||
#### Release – Other Operating Systems
|
#### Release – Other Operating Systems
|
||||||
|
|
||||||
1. Install the `zip` package from your repository
|
1. Install the `zip` package from your repository.
|
||||||
|
2. Run the `app/build.sh` script.
|
||||||
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
|
3. 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 8 to be installed.
|
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires the ASP.NET Core Runtime to be installed.
|
||||||
|
|
||||||
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking 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.
|
|
||||||
|
@@ -9,15 +9,15 @@
|
|||||||
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/AttachmentFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Controls/DownloadItemFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/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" />
|
||||||
@@ -25,4 +25,4 @@
|
|||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
@@ -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,10 +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>
|
||||||
<HSQLCodeStyleSettings version="6">
|
<HSQLCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</HSQLCodeStyleSettings>
|
</HSQLCodeStyleSettings>
|
||||||
<HTMLCodeStyleSettings>
|
<HTMLCodeStyleSettings>
|
||||||
@@ -85,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>
|
||||||
@@ -98,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>
|
||||||
@@ -116,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>
|
||||||
@@ -143,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" />
|
||||||
@@ -173,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">
|
||||||
@@ -203,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" />
|
||||||
@@ -282,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" />
|
||||||
@@ -342,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" />
|
||||||
@@ -357,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>
|
||||||
@@ -367,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" />
|
||||||
@@ -427,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>
|
||||||
@@ -442,4 +454,4 @@
|
|||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
@@ -91,7 +91,6 @@
|
|||||||
<inspection_tool class="ComparatorNotSerializable" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ComparatorNotSerializable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="CompareToUsesNonFinalVariable" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="CompareToUsesNonFinalVariable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="ComparisonOfShortAndChar" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ComparisonOfShortAndChar" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="ComposeMissingKeys" enabled="true" level="ERROR" enabled_by_default="true" />
|
|
||||||
<inspection_tool class="ConditionSignal" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ConditionSignal" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="ConditionalExpression" enabled="true" level="INFORMATION" enabled_by_default="true">
|
<inspection_tool class="ConditionalExpression" enabled="true" level="INFORMATION" enabled_by_default="true">
|
||||||
<option name="ignoreSimpleAssignmentsAndReturns" value="true" />
|
<option name="ignoreSimpleAssignmentsAndReturns" value="true" />
|
||||||
@@ -362,7 +361,7 @@
|
|||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="LocalVariableDeclarationSideOnly" enabled="true" level="ERROR" enabled_by_default="true" />
|
<inspection_tool class="LocalVariableDeclarationSideOnly" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||||
<inspection_tool class="LocalVariableNamingConventionJS" enabled="true" level="WARNING" enabled_by_default="true">
|
<inspection_tool class="LocalVariableNamingConventionJS" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
<option name="m_regex" value="[a-z][A-Za-z]*" />
|
<option name="m_regex" value="[a-z][A-Za-z0-9]*" />
|
||||||
<option name="m_minLength" value="0" />
|
<option name="m_minLength" value="0" />
|
||||||
<option name="m_maxLength" value="99" />
|
<option name="m_maxLength" value="99" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
|
@@ -1,22 +0,0 @@
|
|||||||
<component name="libraryTable">
|
|
||||||
<library name="Generated files" type="javaScript">
|
|
||||||
<properties>
|
|
||||||
<sourceFilesUrls>
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/discord.js" />
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/dom.js" />
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/gui.js" />
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/settings.js" />
|
|
||||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/state.js" />
|
|
||||||
</sourceFilesUrls>
|
|
||||||
</properties>
|
|
||||||
<CLASSES>
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/discord.js" />
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/dom.js" />
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/gui.js" />
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/settings.js" />
|
|
||||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/state.js" />
|
|
||||||
</CLASSES>
|
|
||||||
<JAVADOC />
|
|
||||||
<SOURCES />
|
|
||||||
</library>
|
|
||||||
</component>
|
|
@@ -53,7 +53,7 @@
|
|||||||
<Setter Property="MinHeight" Value="40" />
|
<Setter Property="MinHeight" Value="40" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
|
||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
|
||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
|
||||||
|
|
||||||
<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="ExpanderHeaderBorderBrush" Color="#697DAB" />
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />
|
||||||
|
@@ -9,12 +9,12 @@ sealed class App : Application {
|
|||||||
public override void Initialize() {
|
public override void Initialize() {
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted() {
|
public override void OnFrameworkInitializationCompleted() {
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||||
desktop.MainWindow = new MainWindow(Program.Arguments);
|
desktop.MainWindow = new MainWindow(Program.Arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop;
|
namespace DHT.Desktop;
|
||||||
@@ -7,18 +7,19 @@ sealed class Arguments {
|
|||||||
private static readonly Log Log = Log.ForType<Arguments>();
|
private static readonly Log Log = Log.ForType<Arguments>();
|
||||||
|
|
||||||
private const int FirstArgument = 1;
|
private const int FirstArgument = 1;
|
||||||
|
|
||||||
public static Arguments Empty => new(Array.Empty<string>());
|
public static Arguments Empty => new ([]);
|
||||||
|
|
||||||
public bool Console { get; }
|
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) {
|
|
||||||
for (int i = FirstArgument; i < args.Length; i++) {
|
public Arguments(IReadOnlyList<string> args) {
|
||||||
|
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;
|
||||||
@@ -28,41 +29,55 @@ sealed class Arguments {
|
|||||||
Console = true;
|
Console = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string value;
|
string value;
|
||||||
|
|
||||||
if (i == FirstArgument && !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;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
value = args[++i];
|
value = args[++i];
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "-db":
|
case "-db":
|
||||||
DatabaseFile = value;
|
DatabaseFile = value;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
case "-port": {
|
case "-port": {
|
||||||
if (ushort.TryParse(value, out var port)) {
|
if (!ushort.TryParse(value, out ushort port)) {
|
||||||
ServerPort = port;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Log.Warn("Invalid port number: " + value);
|
Log.Warn("Invalid port number: " + value);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
ServerPort = port;
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "-token":
|
case "-token":
|
||||||
ServerToken = value;
|
ServerToken = value;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
case "-concurrentdownloads":
|
||||||
|
if (!ulong.TryParse(value, out ulong 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;
|
||||||
|
@@ -8,33 +8,33 @@ sealed class BytesValueConverter : IValueConverter {
|
|||||||
private sealed class Unit {
|
private sealed class Unit {
|
||||||
private readonly string label;
|
private readonly string label;
|
||||||
private readonly string numberFormat;
|
private readonly string numberFormat;
|
||||||
|
|
||||||
public Unit(string label, int decimalPlaces) {
|
public Unit(string label, int decimalPlaces) {
|
||||||
this.label = label;
|
this.label = label;
|
||||||
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Format(double size) {
|
public string Format(double size) {
|
||||||
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly Unit[] Units = {
|
private static readonly Unit[] Units = [
|
||||||
new ("B", decimalPlaces: 0),
|
new Unit("B", decimalPlaces: 0),
|
||||||
new ("kB", decimalPlaces: 0),
|
new Unit("kB", decimalPlaces: 0),
|
||||||
new ("MB", decimalPlaces: 1),
|
new Unit("MB", decimalPlaces: 1),
|
||||||
new ("GB", decimalPlaces: 1),
|
new Unit("GB", decimalPlaces: 1),
|
||||||
new ("TB", 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;
|
||||||
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||||
}
|
}
|
||||||
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
if (value is long size and >= 0L) {
|
if (value is long size and >= 0L) {
|
||||||
return Convert((ulong) size);
|
return Convert((ulong) size);
|
||||||
@@ -46,7 +46,7 @@ sealed class BytesValueConverter : IValueConverter {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
@@ -1,58 +1,52 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Threading;
|
|
||||||
using DHT.Desktop.Dialogs.File;
|
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 IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = new List<FilePickerFileType> {
|
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
|
||||||
FileDialogs.CreateFilter("Discord History Tracker Database", new [] { "dht" })
|
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"]),
|
||||||
};
|
];
|
||||||
|
|
||||||
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
||||||
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
Title = "Open Database File",
|
Title = "Open Database File",
|
||||||
FileTypeFilter = DatabaseFileDialogFilter,
|
FileTypeFilter = DatabaseFileDialogFilter,
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
AllowMultiple = true
|
AllowMultiple = true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
||||||
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||||
Title = "Open or Create Database File",
|
Title = "Open or Create Database File",
|
||||||
FileTypeChoices = DatabaseFileDialogFilter,
|
FileTypeChoices = DatabaseFileDialogFilter,
|
||||||
SuggestedFileName = DatabaseFileInitialName,
|
SuggestedFileName = DatabaseFileInitialName,
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
ShowOverwritePrompt = false
|
ShowOverwritePrompt = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||||
var prevSynchronizationContext = SynchronizationContext.Current;
|
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
|
||||||
var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
|
||||||
SynchronizationContext.SetSynchronizationContext(prevSynchronizationContext);
|
|
||||||
|
|
||||||
IDatabaseFile? file = null;
|
IDatabaseFile? file = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler);
|
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) {
|
||||||
@@ -61,14 +55,14 @@ static class DatabaseGui {
|
|||||||
Log.Error(ex);
|
Log.Error(ex);
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
|
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
||||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
||||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
|
return 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?");
|
||||||
}
|
}
|
||||||
|
@@ -8,7 +8,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
9
app/Desktop/Common/SystemUtils.cs
Normal file
9
app/Desktop/Common/SystemUtils.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Common;
|
||||||
|
|
||||||
|
static class SystemUtils {
|
||||||
|
public static void OpenUrl(string url) {
|
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
}
|
@@ -4,15 +4,15 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Format(this long number) {
|
public static string Format(this long number) {
|
||||||
return number.ToString("N0", Program.Culture);
|
return number.ToString("N0", Program.Culture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Pluralize(this int number, string singular) {
|
public static string Pluralize(this int number, string singular) {
|
||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Pluralize(this long number, string singular) {
|
public static string Pluralize(this long number, string singular) {
|
||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||||
}
|
}
|
||||||
|
@@ -15,13 +15,15 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.0.6" />
|
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.3" Condition=" '$(Configuration)' == 'Debug' " />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -30,10 +32,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||||
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
|
|
||||||
<DependentUpon>CheckBoxDialog.axaml</DependentUpon>
|
|
||||||
<SubType>Code</SubType>
|
|
||||||
</Compile>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -41,11 +39,6 @@
|
|||||||
<EmbeddedResource Include="Resources/tracker-loader.js">
|
<EmbeddedResource Include="Resources/tracker-loader.js">
|
||||||
<LogicalName>tracker-loader.js</LogicalName>
|
<LogicalName>tracker-loader.js</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
<EmbeddedResource Include="../Resources/Viewer/**">
|
|
||||||
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
|
||||||
<Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@@ -38,7 +38,7 @@
|
|||||||
<ItemsRepeater ItemsSource="{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>
|
||||||
|
@@ -10,11 +10,11 @@ public sealed partial class CheckBoxDialog : Window {
|
|||||||
public CheckBoxDialog() {
|
public CheckBoxDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Ok);
|
Close(DialogResult.OkCancel.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Cancel);
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
}
|
||||||
|
@@ -1,70 +1,69 @@
|
|||||||
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 = [];
|
||||||
|
|
||||||
public IReadOnlyList<CheckBoxItem> Items {
|
public IReadOnlyList<CheckBoxItem> Items {
|
||||||
get => items;
|
get => items;
|
||||||
|
|
||||||
protected set {
|
protected set {
|
||||||
foreach (var item in items) {
|
foreach (CheckBoxItem item in items) {
|
||||||
item.PropertyChanged -= OnItemPropertyChanged;
|
item.PropertyChanged -= OnItemPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
items = value;
|
items = value;
|
||||||
|
|
||||||
foreach (var item in items) {
|
foreach (CheckBoxItem item in items) {
|
||||||
item.PropertyChanged += OnItemPropertyChanged;
|
item.PropertyChanged += OnItemPropertyChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
private void SetAllChecked(bool isChecked) {
|
private void SetAllChecked(bool isChecked) {
|
||||||
pauseCheckEvents = true;
|
pauseCheckEvents = true;
|
||||||
|
|
||||||
foreach (var item in Items) {
|
foreach (CheckBoxItem item in Items) {
|
||||||
item.Checked = isChecked;
|
item.IsChecked = isChecked;
|
||||||
}
|
}
|
||||||
|
|
||||||
pauseCheckEvents = false;
|
pauseCheckEvents = false;
|
||||||
UpdateBulkButtons();
|
UpdateBulkButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateBulkButtons() {
|
private void UpdateBulkButtons() {
|
||||||
OnPropertyChanged(nameof(AreAllSelected));
|
OnPropertyChanged(nameof(AreAllSelected));
|
||||||
OnPropertyChanged(nameof(AreNoneSelected));
|
OnPropertyChanged(nameof(AreNoneSelected));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = Items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,22 +1,18 @@
|
|||||||
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 {
|
||||||
public new T Item { get; }
|
public new T Item { get; }
|
||||||
|
|
||||||
public CheckBoxItem(T item) {
|
public CheckBoxItem(T item) {
|
||||||
this.Item = item;
|
this.Item = item;
|
||||||
base.Item = item;
|
base.Item = item;
|
||||||
|
@@ -11,25 +11,25 @@ static class FileDialogs {
|
|||||||
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
||||||
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
||||||
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
||||||
return new FilePickerFileType(name) {
|
return new FilePickerFileType(name) {
|
||||||
Patterns = extensions.Select(static ext => "*." + ext).ToArray()
|
Patterns = extensions.Select(static ext => "*." + ext).ToArray(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
||||||
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ToLocalPath(this IStorageFile file) {
|
private static string ToLocalPath(this IStorageFile file) {
|
||||||
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
|
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
|
||||||
return files.Select(ToLocalPath).ToArray();
|
return files.Select(ToLocalPath).ToArray();
|
||||||
}
|
}
|
||||||
|
@@ -10,65 +10,65 @@ static class Dialog {
|
|||||||
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await new MessageDialog {
|
await new MessageDialog {
|
||||||
DataContext = new MessageDialogModel {
|
DataContext = new MessageDialogModel {
|
||||||
Title = title,
|
Title = title,
|
||||||
Message = message,
|
Message = message,
|
||||||
IsOkVisible = true
|
IsOkVisible = true,
|
||||||
}
|
},
|
||||||
}.ShowDialog<DialogResult.All>(owner);
|
}.ShowDialog<DialogResult.All>(owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
var result = await new MessageDialog {
|
||||||
DataContext = new MessageDialogModel {
|
DataContext = new MessageDialogModel {
|
||||||
Title = title,
|
Title = title,
|
||||||
Message = message,
|
Message = message,
|
||||||
IsOkVisible = true,
|
IsOkVisible = true,
|
||||||
IsCancelVisible = true
|
IsCancelVisible = true,
|
||||||
}
|
},
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
return result.ToOkCancel();
|
return result.ToOkCancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
|
||||||
DataContext = new MessageDialogModel {
|
|
||||||
Title = title,
|
|
||||||
Message = message,
|
|
||||||
IsYesVisible = true,
|
|
||||||
IsNoVisible = true
|
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
|
||||||
|
|
||||||
return result.ToYesNo();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
var result = await new MessageDialog {
|
||||||
DataContext = new MessageDialogModel {
|
DataContext = new MessageDialogModel {
|
||||||
Title = title,
|
Title = title,
|
||||||
Message = message,
|
Message = message,
|
||||||
IsYesVisible = true,
|
IsYesVisible = true,
|
||||||
IsNoVisible = true,
|
IsNoVisible = true,
|
||||||
IsCancelVisible = true
|
},
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
|
return result.ToYesNo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
||||||
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await new MessageDialog {
|
||||||
|
DataContext = new MessageDialogModel {
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
IsYesVisible = true,
|
||||||
|
IsNoVisible = true,
|
||||||
|
IsCancelVisible = true,
|
||||||
|
},
|
||||||
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
return result.ToYesNoCancel();
|
return result.ToYesNoCancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,53 +7,53 @@ static class DialogResult {
|
|||||||
Ok,
|
Ok,
|
||||||
Yes,
|
Yes,
|
||||||
No,
|
No,
|
||||||
Cancel
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum OkCancel {
|
public enum OkCancel {
|
||||||
Closed,
|
Closed,
|
||||||
Ok,
|
Ok,
|
||||||
Cancel
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum YesNo {
|
public enum YesNo {
|
||||||
Closed,
|
Closed,
|
||||||
Yes,
|
Yes,
|
||||||
No
|
No,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum YesNoCancel {
|
public enum YesNoCancel {
|
||||||
Closed,
|
Closed,
|
||||||
Yes,
|
Yes,
|
||||||
No,
|
No,
|
||||||
Cancel
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static OkCancel ToOkCancel(this All? result) {
|
public static OkCancel ToOkCancel(this All? result) {
|
||||||
return result switch {
|
return result switch {
|
||||||
null => OkCancel.Closed,
|
null => OkCancel.Closed,
|
||||||
All.Ok => OkCancel.Ok,
|
All.Ok => OkCancel.Ok,
|
||||||
All.Cancel => OkCancel.Cancel,
|
All.Cancel => OkCancel.Cancel,
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static YesNo ToYesNo(this All? result) {
|
public static YesNo ToYesNo(this All? result) {
|
||||||
return result switch {
|
return result switch {
|
||||||
null => YesNo.Closed,
|
null => YesNo.Closed,
|
||||||
All.Yes => YesNo.Yes,
|
All.Yes => YesNo.Yes,
|
||||||
All.No => YesNo.No,
|
All.No => YesNo.No,
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static YesNoCancel ToYesNoCancel(this All? result) {
|
public static YesNoCancel ToYesNoCancel(this All? result) {
|
||||||
return result switch {
|
return result switch {
|
||||||
null => YesNoCancel.Closed,
|
null => YesNoCancel.Closed,
|
||||||
All.Yes => YesNoCancel.Yes,
|
All.Yes => YesNoCancel.Yes,
|
||||||
All.No => YesNoCancel.No,
|
All.No => YesNoCancel.No,
|
||||||
All.Cancel => YesNoCancel.Cancel,
|
All.Cancel => YesNoCancel.Cancel,
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,19 +9,19 @@ public sealed partial class MessageDialog : Window {
|
|||||||
public MessageDialog() {
|
public MessageDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Ok);
|
Close(DialogResult.All.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickYes(object? sender, RoutedEventArgs e) {
|
public void ClickYes(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Yes);
|
Close(DialogResult.All.Yes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickNo(object? sender, RoutedEventArgs e) {
|
public void ClickNo(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.No);
|
Close(DialogResult.All.No);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Cancel);
|
Close(DialogResult.All.Cancel);
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,7 @@ 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; } = "";
|
||||||
|
|
||||||
public bool IsOkVisible { get; init; } = false;
|
public bool IsOkVisible { get; init; } = false;
|
||||||
public bool IsYesVisible { get; init; } = false;
|
public bool IsYesVisible { get; init; } = false;
|
||||||
public bool IsNoVisible { get; init; } = false;
|
public bool IsNoVisible { get; init; } = false;
|
||||||
|
@@ -4,5 +4,6 @@ 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();
|
Task Hide();
|
||||||
}
|
}
|
||||||
|
@@ -40,7 +40,7 @@
|
|||||||
<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>
|
</DataTemplate>
|
||||||
</ItemsRepeater.ItemTemplate>
|
</ItemsRepeater.ItemTemplate>
|
||||||
|
@@ -2,36 +2,97 @@ using System;
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress;
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class ProgressDialog : Window {
|
public sealed partial class ProgressDialog : Window {
|
||||||
|
private static readonly Log Log = Log.ForType<ProgressDialog>();
|
||||||
|
|
||||||
|
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;
|
private Task progressTask = Task.CompletedTask;
|
||||||
|
|
||||||
public ProgressDialog() {
|
public ProgressDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnOpened(object? sender, EventArgs e) {
|
public void OnOpened(object? sender, EventArgs e) {
|
||||||
if (DataContext is ProgressDialogModel model) {
|
if (DataContext is ProgressDialogModel model) {
|
||||||
progressTask = Task.Run(model.StartTask);
|
progressTask = Task.Run(model.StartTask);
|
||||||
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClosing(object? sender, WindowClosingEventArgs e) {
|
public void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
e.Cancel = !isFinished;
|
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) {
|
public async Task ShowProgressDialog(Window owner) {
|
||||||
await ShowDialog(owner);
|
await ShowDialog(owner);
|
||||||
await progressTask;
|
try {
|
||||||
|
await progressTask;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(owner, "Unexpected Error", e.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,50 +4,60 @@ 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; } = "";
|
||||||
|
|
||||||
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
public IReadOnlyList<ProgressItem> Items { get; } = [];
|
||||||
|
|
||||||
private readonly TaskRunner? task;
|
private readonly TaskRunner? task;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ProgressDialogModel() {}
|
public ProgressDialogModel() {}
|
||||||
|
|
||||||
public ProgressDialogModel(TaskRunner task, int progressItems = 1) {
|
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
|
||||||
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
this.Title = title;
|
||||||
this.task = task;
|
this.task = task;
|
||||||
|
this.Items = Enumerable.Range(start: 0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task StartTask() {
|
internal async Task StartTask() {
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
await task(Items.Select(static item => new Callback(item)).ToArray());
|
await task(Items.Select(static item => new Callback(item)).ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
||||||
|
|
||||||
private sealed class Callback : IProgressCallback {
|
private sealed class Callback : IProgressCallback {
|
||||||
private readonly ProgressItem item;
|
private readonly ProgressItem item;
|
||||||
|
|
||||||
public Callback(ProgressItem item) {
|
public Callback(ProgressItem item) {
|
||||||
this.item = item;
|
this.item = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task 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(() => {
|
||||||
item.Message = message;
|
item.Message = message;
|
||||||
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||||
item.Progress = totalItems == 0 ? 0 : 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() {
|
public Task Hide() {
|
||||||
return Update(string.Empty, 0, 0);
|
return Update(string.Empty, finishedItems: 0, totalItems: 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,41 +1,30 @@
|
|||||||
using DHT.Utils.Models;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress;
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
sealed class ProgressItem : BaseModel {
|
sealed partial class ProgressItem : ObservableObject {
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Opacity))]
|
||||||
private bool isVisible = false;
|
private bool isVisible = false;
|
||||||
|
|
||||||
public bool IsVisible {
|
|
||||||
get => isVisible;
|
|
||||||
private set {
|
|
||||||
Change(ref isVisible, value);
|
|
||||||
OnPropertyChanged(nameof(Opacity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public double Opacity => IsVisible ? 1.0 : 0.0;
|
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||||
|
|
||||||
private string message = "";
|
private string message = "";
|
||||||
|
|
||||||
public string Message {
|
public string Message {
|
||||||
get => message;
|
get => message;
|
||||||
set {
|
set {
|
||||||
Change(ref message, value);
|
SetProperty(ref message, value);
|
||||||
IsVisible = !string.IsNullOrEmpty(value);
|
IsVisible = !string.IsNullOrEmpty(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private string items = "";
|
private string items = "";
|
||||||
|
|
||||||
public string Items {
|
[ObservableProperty]
|
||||||
get => items;
|
|
||||||
set => Change(ref items, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int progress = 0;
|
private int progress = 0;
|
||||||
|
|
||||||
public int Progress {
|
[ObservableProperty]
|
||||||
get => progress;
|
private bool isIndeterminate;
|
||||||
set => Change(ref progress, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -3,18 +3,18 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox;
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class TextBoxDialog : Window {
|
public sealed partial class TextBoxDialog : Window {
|
||||||
public TextBoxDialog() {
|
public TextBoxDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Ok);
|
Close(DialogResult.OkCancel.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Cancel);
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
}
|
||||||
|
@@ -1,45 +1,44 @@
|
|||||||
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.TextBox;
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
class TextBoxDialogModel : BaseModel {
|
class TextBoxDialogModel : ObservableObject {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public string Description { get; init; } = "";
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
|
private IReadOnlyList<TextBoxItem> items = [];
|
||||||
|
|
||||||
public IReadOnlyList<TextBoxItem> Items {
|
public IReadOnlyList<TextBoxItem> Items {
|
||||||
get => items;
|
get => items;
|
||||||
|
|
||||||
protected set {
|
protected set {
|
||||||
foreach (var item in items) {
|
foreach (TextBoxItem item in items) {
|
||||||
item.ErrorsChanged -= OnItemErrorsChanged;
|
item.ErrorsChanged -= OnItemErrorsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
items = value;
|
items = value;
|
||||||
|
|
||||||
foreach (var item in items) {
|
foreach (TextBoxItem item in items) {
|
||||||
item.ErrorsChanged += OnItemErrorsChanged;
|
item.ErrorsChanged += OnItemErrorsChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
||||||
|
|
||||||
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||||
OnPropertyChanged(nameof(HasErrors));
|
OnPropertyChanged(nameof(HasErrors));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||||
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||||
|
|
||||||
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
||||||
this.Items = new List<TextBoxItem<T>>(items);
|
this.Items = new List<TextBoxItem<T>>(items);
|
||||||
base.Items = this.Items;
|
base.Items = this.Items;
|
||||||
|
@@ -1,40 +1,40 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using DHT.Utils.Models;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox;
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public object? Item { get; init; } = null;
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
||||||
public bool IsValid => ValidityCheck(Value);
|
public bool IsValid => ValidityCheck(Value);
|
||||||
|
|
||||||
private string value = string.Empty;
|
private string value = string.Empty;
|
||||||
|
|
||||||
public string Value {
|
public string Value {
|
||||||
get => this.value;
|
get => value;
|
||||||
set {
|
set {
|
||||||
Change(ref this.value, value);
|
SetProperty(ref this.value, value);
|
||||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable GetErrors(string? propertyName) {
|
public IEnumerable GetErrors(string? propertyName) {
|
||||||
if (propertyName == nameof(Value) && !IsValid) {
|
if (propertyName == nameof(Value) && !IsValid) {
|
||||||
yield return string.Empty;
|
yield return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasErrors => !IsValid;
|
public bool HasErrors => !IsValid;
|
||||||
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class TextBoxItem<T> : TextBoxItem {
|
sealed class TextBoxItem<T> : TextBoxItem {
|
||||||
public new T Item { get; }
|
public new T Item { get; }
|
||||||
|
|
||||||
public TextBoxItem(T item) {
|
public TextBoxItem(T item) {
|
||||||
this.Item = item;
|
this.Item = item;
|
||||||
base.Item = item;
|
base.Item = item;
|
||||||
|
@@ -13,16 +13,16 @@ 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));
|
||||||
|
|
||||||
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
||||||
|
|
||||||
public static string JsonFilePath { get; }
|
public static string JsonFilePath { get; }
|
||||||
private static string JsonBackupFilePath { get; }
|
private static string JsonBackupFilePath { get; }
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
||||||
static DiscordAppSettings() {
|
static DiscordAppSettings() {
|
||||||
string rootFolder;
|
string rootFolder;
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
||||||
}
|
}
|
||||||
@@ -32,28 +32,29 @@ static class DiscordAppSettings {
|
|||||||
else {
|
else {
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
||||||
JsonBackupFilePath = JsonFilePath + ".bak";
|
JsonBackupFilePath = JsonFilePath + ".bak";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<bool?> AreDevToolsEnabled() {
|
public static async Task<bool?> AreDevToolsEnabled() {
|
||||||
try {
|
try {
|
||||||
return AreDevToolsEnabled(await ReadSettingsJson());
|
JsonObject settingsJson = await ReadSettingsJson();
|
||||||
|
return AreDevToolsEnabled(settingsJson);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.Error("Cannot read settings file.");
|
Log.Error("Cannot read settings file.");
|
||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool AreDevToolsEnabled(JsonObject json) {
|
private static bool AreDevToolsEnabled(JsonObject json) {
|
||||||
return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
|
return json.TryGetPropertyValue(JsonKeyDevTools, out JsonNode? node) && node?.GetValueKind() == JsonValueKind.True;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
||||||
JsonObject json;
|
JsonObject json;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json = await ReadSettingsJson();
|
json = await ReadSettingsJson();
|
||||||
} catch (FileNotFoundException) {
|
} catch (FileNotFoundException) {
|
||||||
@@ -64,56 +65,56 @@ static class DiscordAppSettings {
|
|||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
return SettingsJsonResult.ReadError;
|
return SettingsJsonResult.ReadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enable == AreDevToolsEnabled(json)) {
|
if (enable == AreDevToolsEnabled(json)) {
|
||||||
return SettingsJsonResult.AlreadySet;
|
return SettingsJsonResult.AlreadySet;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enable) {
|
if (enable) {
|
||||||
json[JsonKeyDevTools] = true;
|
json[JsonKeyDevTools] = true;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
json.Remove(JsonKeyDevTools);
|
json.Remove(JsonKeyDevTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!File.Exists(JsonBackupFilePath)) {
|
if (!File.Exists(JsonBackupFilePath)) {
|
||||||
File.Copy(JsonFilePath, JsonBackupFilePath);
|
File.Copy(JsonFilePath, JsonBackupFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
await WriteSettingsJson(json);
|
await WriteSettingsJson(json);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.Error("An error occurred when writing settings file.");
|
Log.Error("An error occurred when writing settings file.");
|
||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
|
|
||||||
if (File.Exists(JsonBackupFilePath)) {
|
if (File.Exists(JsonBackupFilePath)) {
|
||||||
try {
|
try {
|
||||||
File.Move(JsonBackupFilePath, JsonFilePath, true);
|
File.Move(JsonBackupFilePath, JsonFilePath, overwrite: true);
|
||||||
Log.Info("Restored settings file from backup.");
|
Log.Info("Restored settings file from backup.");
|
||||||
} catch (Exception e2) {
|
} catch (Exception e2) {
|
||||||
Log.Error("Cannot restore settings file from backup.");
|
Log.Error("Cannot restore settings file from backup.");
|
||||||
Log.Error(e2);
|
Log.Error(e2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return SettingsJsonResult.WriteError;
|
return SettingsJsonResult.WriteError;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
File.Delete(JsonBackupFilePath);
|
File.Delete(JsonBackupFilePath);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.Error("Cannot delete backup file.");
|
Log.Error("Cannot delete backup file.");
|
||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SettingsJsonResult.Success;
|
return SettingsJsonResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<JsonObject> 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(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WriteSettingsJson(JsonObject 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, DiscordAppSettingsJsonContext.Default.JsonObject);
|
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
|
||||||
|
@@ -5,4 +5,4 @@ namespace DHT.Desktop.Discord;
|
|||||||
|
|
||||||
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
||||||
[JsonSerializable(typeof(JsonObject))]
|
[JsonSerializable(typeof(JsonObject))]
|
||||||
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {}
|
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext;
|
||||||
|
@@ -6,5 +6,5 @@ enum SettingsJsonResult {
|
|||||||
FileNotFound,
|
FileNotFound,
|
||||||
ReadError,
|
ReadError,
|
||||||
InvalidJson,
|
InvalidJson,
|
||||||
WriteError
|
WriteError,
|
||||||
}
|
}
|
||||||
|
@@ -3,12 +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"
|
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>
|
||||||
@@ -39,10 +39,11 @@
|
|||||||
|
|
||||||
<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>
|
||||||
@@ -55,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,33 +1,41 @@
|
|||||||
using System.Diagnostics;
|
using DHT.Desktop.Common;
|
||||||
|
|
||||||
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");
|
SystemUtils.OpenUrl(Program.Website);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ShowIssueTracker() {
|
||||||
|
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
||||||
|
}
|
||||||
|
|
||||||
public void ShowSourceCode() {
|
public void ShowSourceCode() {
|
||||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibraryAvalonia() {
|
|
||||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowLibrarySqlite() {
|
|
||||||
OpenUrl("https://www.sqlite.org");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowLibrarySqliteAdoNet() {
|
|
||||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowLibraryNetCore() {
|
public void ShowLibraryNetCore() {
|
||||||
OpenUrl("https://github.com/dotnet/core");
|
SystemUtils.OpenUrl("https://github.com/dotnet/core");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OpenUrl(string url) {
|
public void ShowLibraryAvalonia() {
|
||||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
SystemUtils.OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowLibraryCommunityToolkit() {
|
||||||
|
SystemUtils.OpenUrl("https://github.com/CommunityToolkit/dotnet");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowLibrarySqlite() {
|
||||||
|
SystemUtils.OpenUrl("https://www.sqlite.org");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowLibrarySqliteAdoNet() {
|
||||||
|
SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowLibraryRxNet() {
|
||||||
|
SystemUtils.OpenUrl("https://github.com/dotnet/reactive");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,129 +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, uint 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 ulong maximumSize = 0L;
|
|
||||||
private Unit maximumSizeUnit = AllUnits[0];
|
|
||||||
|
|
||||||
public bool LimitSize {
|
|
||||||
get => limitSize;
|
|
||||||
set => Change(ref limitSize, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ulong MaximumSize {
|
|
||||||
get => maximumSize;
|
|
||||||
set => Change(ref maximumSize, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Unit MaximumSizeUnit {
|
|
||||||
get => maximumSizeUnit;
|
|
||||||
set => Change(ref maximumSizeUnit, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<Unit> Units => AllUnits;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
private readonly 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) {
|
|
||||||
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,11 +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:AttachmentFilterPanelModel">
|
x:DataType="controls:DownloadItemFilterPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:AttachmentFilterPanelModel />
|
<controls:DownloadItemFilterPanelModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
@@ -4,8 +4,8 @@ using Avalonia.Controls;
|
|||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class AttachmentFilterPanel : UserControl {
|
public sealed partial class DownloadItemFilterPanel : UserControl {
|
||||||
public AttachmentFilterPanel() {
|
public DownloadItemFilterPanel() {
|
||||||
InitializeComponent();
|
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", Scale: 1),
|
||||||
|
new Unit("kB", Scale: 1024),
|
||||||
|
new Unit("MB", Scale: 1024 * 1024)
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> FilterProperties = [
|
||||||
|
nameof(LimitSize),
|
||||||
|
nameof(MaximumSize),
|
||||||
|
nameof(MaximumSizeUnit),
|
||||||
|
];
|
||||||
|
|
||||||
|
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, defaultValue: null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
|
||||||
|
MaximumSizeUnit = unitValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingFilterSettings = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() {
|
||||||
|
saveFilterSettingsTask.Dispose();
|
||||||
|
|
||||||
|
downloadItemCountTask.Cancel();
|
||||||
|
downloadItemCountSubscription.Dispose();
|
||||||
|
|
||||||
|
await SaveFilterSettings(new FilterSettings(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FilterSettings(bool LimitSize, ulong MaximumSize, Unit MaximumSizeUnit) {
|
||||||
|
public FilterSettings(DownloadItemFilterPanelModel model) : this(model.LimitSize, model.MaximumSize, model.MaximumSizeUnit) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveFilterSettings(FilterSettings settings) {
|
||||||
|
try {
|
||||||
|
await state.Db.Settings.Set(async setter => {
|
||||||
|
await setter.Set(SettingsKey.DownloadsLimitSize, settings.LimitSize);
|
||||||
|
await setter.Set(SettingsKey.DownloadsMaximumSize, settings.MaximumSize);
|
||||||
|
await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name);
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error("Could not save download filter settings");
|
||||||
|
Log.Error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
|
if (!isLoadingFilterSettings) {
|
||||||
|
saveFilterSettingsTask.Post(new FilterSettings(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDownloadItemCountChanged(long newItemCount) {
|
||||||
|
totalItemCount = newItemCount;
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void UpdateFilterStatistics() {
|
||||||
|
DownloadItemFilter filter = CreateFilter();
|
||||||
|
if (filter.IsEmpty) {
|
||||||
|
downloadItemCountTask.Cancel();
|
||||||
|
matchingItemCount = totalItemCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
matchingItemCount = null;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetMatchingCount(long matchingAttachmentCount) {
|
||||||
|
this.matchingItemCount = matchingAttachmentCount;
|
||||||
|
UpdateFilterStatisticsText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFilterStatisticsText() {
|
||||||
|
string matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
|
||||||
|
string totalItemCountStr = totalItemCount?.Format() ?? "(...)";
|
||||||
|
|
||||||
|
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
@@ -7,16 +8,16 @@ namespace DHT.Desktop.Main.Controls;
|
|||||||
public sealed partial class MessageFilterPanel : UserControl {
|
public sealed partial class MessageFilterPanel : UserControl {
|
||||||
public MessageFilterPanel() {
|
public MessageFilterPanel() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
var culture = Program.Culture;
|
CultureInfo culture = Program.Culture;
|
||||||
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
foreach (CalendarDatePicker picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
||||||
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
|
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
|
||||||
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
|
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
|
||||||
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
|
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
|
||||||
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
|
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
||||||
if (DataContext is MessageFilterPanelModel model) {
|
if (DataContext is MessageFilterPanelModel model) {
|
||||||
model.StartDate = StartDatePicker.SelectedDate;
|
model.StartDate = StartDatePicker.SelectedDate;
|
||||||
|
@@ -2,130 +2,118 @@ 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 {
|
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||||
private static readonly HashSet<string> FilterProperties = new () {
|
private static readonly HashSet<string> FilterProperties = [
|
||||||
nameof(FilterByDate),
|
nameof(FilterByDate),
|
||||||
nameof(StartDate),
|
nameof(StartDate),
|
||||||
nameof(EndDate),
|
nameof(EndDate),
|
||||||
nameof(FilterByChannel),
|
nameof(FilterByChannel),
|
||||||
nameof(IncludedChannels),
|
nameof(IncludedChannels),
|
||||||
nameof(FilterByUser),
|
nameof(FilterByUser),
|
||||||
nameof(IncludedUsers)
|
nameof(IncludedUsers),
|
||||||
};
|
];
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
public string FilterStatisticsText { get; private set; } = "";
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
private readonly IDisposable channelCountSubscription;
|
||||||
|
private long? totalChannelCount;
|
||||||
|
|
||||||
|
private readonly IDisposable userCountSubscription;
|
||||||
|
private long? totalUserCount;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
|
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) {
|
||||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
FilterPropertyChanged?.Invoke(sender, e);
|
FilterPropertyChanged?.Invoke(sender, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
}
|
}
|
||||||
@@ -133,153 +121,188 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
}
|
||||||
|
|
||||||
|
private void OnChannelCountChanged(long newChannelCount) {
|
||||||
|
totalChannelCount = newChannelCount;
|
||||||
|
UpdateChannelFilterLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUserCountChanged(long newUserCount) {
|
||||||
|
totalUserCount = newUserCount;
|
||||||
|
UpdateUserFilterLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateChannelFilterLabel() {
|
||||||
|
if (totalChannelCount.HasValue) {
|
||||||
|
long total = totalChannelCount.Value;
|
||||||
|
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
|
||||||
|
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
||||||
}
|
}
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
else {
|
||||||
UpdateChannelFilterLabel();
|
ChannelFilterLabel = "Loading...";
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
|
||||||
UpdateUserFilterLabel();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
MessageFilter 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetExportedMessageCount(long exportedMessageCount) {
|
private void SetExportedMessageCount(long exportedMessageCount) {
|
||||||
this.exportedMessageCount = exportedMessageCount;
|
this.exportedMessageCount = exportedMessageCount;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateFilterStatisticsText() {
|
private void UpdateFilterStatisticsText() {
|
||||||
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
string exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
||||||
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
string totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
||||||
|
|
||||||
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "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;
|
Dictionary<ulong, DHT.Server.Data.Server> servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
||||||
|
|
||||||
foreach (var channel in db.GetAllChannels()) {
|
await foreach (Channel channel in state.Db.Channels.Get()) {
|
||||||
var channelId = channel.Id;
|
ulong channelId = channel.Id;
|
||||||
var channelName = channel.Name;
|
string channelName = channel.Name;
|
||||||
|
|
||||||
string title;
|
string title;
|
||||||
if (servers.TryGetValue(channel.Server, out var server)) {
|
if (servers.TryGetValue(channel.Server, out var server)) {
|
||||||
var titleBuilder = new StringBuilder();
|
var titleBuilder = new StringBuilder();
|
||||||
var serverType = server.Type;
|
ServerType? serverType = server.Type;
|
||||||
|
|
||||||
titleBuilder.Append('[')
|
titleBuilder.Append('[')
|
||||||
.Append(ServerTypes.ToString(serverType))
|
.Append(ServerTypes.ToString(serverType))
|
||||||
.Append("] ");
|
.Append("] ");
|
||||||
|
|
||||||
if (serverType == ServerType.DirectMessage) {
|
if (serverType == ServerType.DirectMessage) {
|
||||||
titleBuilder.Append(channelName);
|
titleBuilder.Append(channelName);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
titleBuilder.Append(server.Name)
|
||||||
|
.Append(" - ")
|
||||||
|
.Append(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
title = titleBuilder.ToString();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
titleBuilder.Append(server.Name)
|
title = channelName;
|
||||||
.Append(" - ")
|
|
||||||
.Append(channelName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
title = titleBuilder.ToString();
|
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||||
|
Title = title,
|
||||||
|
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
title = channelName;
|
return items;
|
||||||
}
|
|
||||||
|
|
||||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
|
||||||
Title = title,
|
|
||||||
Checked = included.Contains(channelId)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await OpenIdFilterDialog(window, "Included Channels", 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<ulong>? 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 (User 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)
|
|
||||||
});
|
return checkBoxItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await OpenIdFilterDialog(window, "Included Users", items);
|
const string Title = "Included Users";
|
||||||
|
|
||||||
|
List<CheckBoxItem<ulong>> items;
|
||||||
|
try {
|
||||||
|
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
||||||
|
} catch (Exception e) {
|
||||||
|
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
|
||||||
if (result != null) {
|
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;
|
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||||
long included = FilterByChannel ? IncludedChannels.Count : total;
|
|
||||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
var model = new CheckBoxDialogModel<ulong>(items) {
|
||||||
|
Title = title,
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = new CheckBoxDialog { DataContext = model };
|
||||||
|
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||||
|
|
||||||
|
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateUserFilterLabel() {
|
|
||||||
long total = db.Statistics.TotalUsers;
|
|
||||||
long included = FilterByUser ? IncludedUsers.Count : total;
|
|
||||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
public MessageFilter CreateFilter() {
|
public MessageFilter CreateFilter() {
|
||||||
MessageFilter filter = new();
|
MessageFilter filter = new ();
|
||||||
|
|
||||||
if (FilterByDate) {
|
if (FilterByDate) {
|
||||||
filter.StartDate = StartDate;
|
filter.StartDate = StartDate;
|
||||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FilterByChannel) {
|
if (FilterByChannel && IncludedChannels != null) {
|
||||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FilterByUser) {
|
if (FilterByUser && IncludedUsers != null) {
|
||||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
|
|
||||||
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
|
||||||
|
|
||||||
var model = new CheckBoxDialogModel<ulong>(items) {
|
|
||||||
Title = title
|
|
||||||
};
|
|
||||||
|
|
||||||
var dialog = new CheckBoxDialog { DataContext = model };
|
|
||||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
|
||||||
|
|
||||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -16,6 +16,10 @@
|
|||||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||||
<Setter Property="FontSize" Value="15" />
|
<Setter Property="FontSize" Value="15" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Label">
|
||||||
|
<Setter Property="Margin" Value="0 5" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
</Style>
|
||||||
<Style Selector="WrapPanel > StackPanel">
|
<Style Selector="WrapPanel > StackPanel">
|
||||||
<Setter Property="Orientation" Value="Vertical" />
|
<Setter Property="Orientation" Value="Vertical" />
|
||||||
<Setter Property="Margin" Value="0 0 10 10" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
|
@@ -1,116 +1,118 @@
|
|||||||
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 {
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
Change(ref inputToken, value);
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
server.StatusChanged += OnServerStatusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize() {
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateServerStatus();
|
||||||
|
IsToggleServerButtonEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartServer() {
|
public async Task OnClickToggleServerButton() {
|
||||||
BeforeServerStart();
|
if (server.IsRunning) {
|
||||||
serverManager.Launch();
|
await StopServer();
|
||||||
}
|
|
||||||
|
|
||||||
private void StopServer() {
|
|
||||||
IsToggleServerButtonEnabled = false;
|
|
||||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
|
||||||
serverManager.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnClickToggleServerButton() {
|
|
||||||
if (serverManager.IsRunning) {
|
|
||||||
StopServer();
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
StartServer();
|
await StartServer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClickRandomizeToken() {
|
public void OnClickRandomizeToken() {
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -40,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,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 {
|
sealed partial class StatusBarModel : ObservableObject, IDisposable {
|
||||||
public DatabaseStatistics DatabaseStatistics { get; }
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private long? serverCount;
|
||||||
private Status status = Status.Stopped;
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
public Status CurrentStatus {
|
private long? channelCount;
|
||||||
get => status;
|
|
||||||
set {
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
status = value;
|
private long? messageCount;
|
||||||
OnPropertyChanged(nameof(StatusText));
|
|
||||||
}
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
}
|
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
||||||
|
private ServerManager.Status serverStatus;
|
||||||
public string StatusText {
|
|
||||||
get {
|
public string ServerStatusText => serverStatus switch {
|
||||||
return CurrentStatus switch {
|
ServerManager.Status.Starting => "STARTING",
|
||||||
Status.Starting => "STARTING",
|
ServerManager.Status.Started => "READY",
|
||||||
Status.Ready => "READY",
|
ServerManager.Status.Stopping => "STOPPING",
|
||||||
Status.Stopping => "STOPPING",
|
ServerManager.Status.Stopped => "STOPPED",
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,10 +8,10 @@
|
|||||||
x:DataType="main:MainWindowModel"
|
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,35 +1,44 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Main.Pages;
|
using DHT.Utils.Logging;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main;
|
namespace DHT.Desktop.Main;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class MainWindow : Window {
|
public sealed partial class MainWindow : Window {
|
||||||
|
private static readonly Log Log = Log.ForType<MainWindow>();
|
||||||
|
|
||||||
[UsedImplicitly]
|
[UsedImplicitly]
|
||||||
public MainWindow() {
|
public MainWindow() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = new MainWindowModel(this, Arguments.Empty);
|
DataContext = new MainWindowModel(this, Arguments.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal MainWindow(Arguments args) {
|
internal MainWindow(Arguments args) {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = new MainWindowModel(this, args);
|
DataContext = new MainWindowModel(this, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Dispose();
|
||||||
|
} finally {
|
||||||
|
Close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
|
||||||
|
private async Task Dispose() {
|
||||||
|
if (DataContext is MainWindowModel model) {
|
||||||
try {
|
try {
|
||||||
File.Delete(temporaryFile);
|
await model.DisposeAsync();
|
||||||
} catch (Exception) {
|
} catch (Exception ex) {
|
||||||
// ignored
|
Log.Error("Caught exception while disposing window: " + ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,56 +1,61 @@
|
|||||||
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) {}
|
||||||
|
|
||||||
public MainWindowModel(Window window, Arguments args) {
|
public MainWindowModel(Window window, Arguments args) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
|
|
||||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||||
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
|
|
||||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||||
CurrentScreen = welcomeScreen;
|
currentScreen = welcomeScreen;
|
||||||
|
|
||||||
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
string? dbFile = args.DatabaseFile;
|
||||||
|
|
||||||
var dbFile = args.DatabaseFile;
|
|
||||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||||
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
||||||
window.Opened -= OnWindowOpened;
|
window.Opened -= OnWindowOpened;
|
||||||
|
|
||||||
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File.Exists(dbFile)) {
|
if (File.Exists(dbFile)) {
|
||||||
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
||||||
}
|
}
|
||||||
@@ -58,59 +63,73 @@ sealed class MainWindowModel : BaseModel, IDisposable {
|
|||||||
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Opened += OnWindowOpened;
|
window.Opened += OnWindowOpened;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
concurrentDownloads = args.ConcurrentDownloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
||||||
|
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
|
||||||
|
|
||||||
|
await DisposeState();
|
||||||
|
|
||||||
|
state = new State(db, concurrentDownloads);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.Error(ex);
|
||||||
|
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainContentScreenModel = new MainContentScreenModel(window, state);
|
||||||
|
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||||
|
|
||||||
|
await mainContentScreenModel.Initialize();
|
||||||
|
|
||||||
|
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
||||||
|
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||||
|
|
||||||
|
window.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||||
|
if (mainContentScreenModel != null) {
|
||||||
|
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||||
|
await mainContentScreenModel.DisposeAsync();
|
||||||
|
mainContentScreenModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DisposeState();
|
||||||
|
|
||||||
|
Title = DefaultTitle;
|
||||||
|
CurrentScreen = welcomeScreen;
|
||||||
|
|
||||||
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisposeState() {
|
||||||
|
if (state != null) {
|
||||||
|
await state.DisposeAsync();
|
||||||
|
state = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
public async ValueTask DisposeAsync() {
|
||||||
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
|
if (mainContentScreenModel != null) {
|
||||||
if (mainContentScreenModel != null) {
|
await mainContentScreenModel.DisposeAsync();
|
||||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
|
||||||
mainContentScreenModel.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
db?.Dispose();
|
|
||||||
db = welcomeScreenModel.Db;
|
|
||||||
|
|
||||||
if (db == null) {
|
|
||||||
Title = DefaultTitle;
|
|
||||||
mainContentScreenModel = null;
|
|
||||||
mainContentScreen = null;
|
|
||||||
CurrentScreen = welcomeScreen;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
|
||||||
mainContentScreenModel = new MainContentScreenModel(window, db);
|
|
||||||
await mainContentScreenModel.Initialize();
|
|
||||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
|
||||||
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
|
||||||
CurrentScreen = mainContentScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(CurrentScreen));
|
|
||||||
OnPropertyChanged(nameof(Title));
|
|
||||||
|
|
||||||
window.Focus();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
await DisposeState();
|
||||||
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
|
||||||
welcomeScreenModel.CloseDatabase();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
welcomeScreenModel.Dispose();
|
|
||||||
mainContentScreenModel?.Dispose();
|
|
||||||
db?.Dispose();
|
|
||||||
db = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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,205 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Threading;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using DHT.Desktop.Common;
|
|
||||||
using DHT.Desktop.Main.Controls;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Data.Aggregations;
|
|
||||||
using DHT.Server.Data.Filters;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Download;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
|
||||||
|
|
||||||
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
|
||||||
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
|
||||||
DownloadStatus.Enqueued
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private bool isThreadDownloadButtonEnabled = true;
|
|
||||||
|
|
||||||
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
|
|
||||||
|
|
||||||
public bool IsToggleDownloadButtonEnabled {
|
|
||||||
get => isThreadDownloadButtonEnabled;
|
|
||||||
set => Change(ref isThreadDownloadButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string DownloadMessage { get; set; } = "";
|
|
||||||
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
|
|
||||||
|
|
||||||
public AttachmentFilterPanelModel FilterModel { get; }
|
|
||||||
|
|
||||||
private readonly StatisticsRow statisticsEnqueued = new ("Enqueued");
|
|
||||||
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
|
||||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
|
||||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
|
||||||
|
|
||||||
public List<StatisticsRow> StatisticsRows {
|
|
||||||
get {
|
|
||||||
return new List<StatisticsRow> {
|
|
||||||
statisticsEnqueued,
|
|
||||||
statisticsDownloaded,
|
|
||||||
statisticsFailed,
|
|
||||||
statisticsSkipped
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsDownloading => downloadThread != null;
|
|
||||||
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
|
||||||
|
|
||||||
private readonly IDatabaseFile db;
|
|
||||||
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
|
|
||||||
private BackgroundDownloadThread? downloadThread;
|
|
||||||
|
|
||||||
private int doneItemsCount;
|
|
||||||
private int? allItemsCount;
|
|
||||||
|
|
||||||
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {}
|
|
||||||
|
|
||||||
public AttachmentsPageModel(IDatabaseFile db) {
|
|
||||||
this.db = db;
|
|
||||||
this.FilterModel = new AttachmentFilterPanelModel(db);
|
|
||||||
|
|
||||||
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics);
|
|
||||||
this.downloadStatisticsComputer.Recompute();
|
|
||||||
|
|
||||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
|
|
||||||
FilterModel.Dispose();
|
|
||||||
DisposeDownloadThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
|
||||||
if (IsDownloading) {
|
|
||||||
EnqueueDownloadItems();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnqueueDownloadItems() {
|
|
||||||
var filter = FilterModel.CreateFilter();
|
|
||||||
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
|
||||||
db.EnqueueDownloadItems(filter);
|
|
||||||
|
|
||||||
downloadStatisticsComputer.Recompute();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
|
||||||
var hadFailedDownloads = HasFailedDownloads;
|
|
||||||
|
|
||||||
statisticsEnqueued.Items = statusStatistics.EnqueuedCount;
|
|
||||||
statisticsEnqueued.Size = statusStatistics.EnqueuedSize;
|
|
||||||
|
|
||||||
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
|
||||||
statisticsDownloaded.Size = statusStatistics.SuccessfulSize;
|
|
||||||
|
|
||||||
statisticsFailed.Items = statusStatistics.FailedCount;
|
|
||||||
statisticsFailed.Size = statusStatistics.FailedSize;
|
|
||||||
|
|
||||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
|
||||||
statisticsSkipped.Size = statusStatistics.SkippedSize;
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(StatisticsRows));
|
|
||||||
|
|
||||||
if (hadFailedDownloads != HasFailedDownloads) {
|
|
||||||
OnPropertyChanged(nameof(HasFailedDownloads));
|
|
||||||
}
|
|
||||||
|
|
||||||
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateDownloadMessage() {
|
|
||||||
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(DownloadMessage));
|
|
||||||
OnPropertyChanged(nameof(DownloadProgress));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
|
|
||||||
Interlocked.Increment(ref doneItemsCount);
|
|
||||||
|
|
||||||
Dispatcher.UIThread.Invoke(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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -14,96 +14,89 @@ using DHT.Desktop.Dialogs.File;
|
|||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Dialogs.TextBox;
|
using DHT.Desktop.Dialogs.TextBox;
|
||||||
|
using DHT.Server;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Import;
|
using DHT.Server.Database.Import;
|
||||||
using DHT.Server.Database.Sqlite;
|
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; }
|
||||||
|
|
||||||
public event EventHandler? DatabaseClosed;
|
public event EventHandler? DatabaseClosed;
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
if (folder == null) {
|
if (folder == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (Environment.OSVersion.Platform) {
|
switch (Environment.OSVersion.Platform) {
|
||||||
case PlatformID.Win32NT:
|
case PlatformID.Win32NT:
|
||||||
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlatformID.Unix:
|
case PlatformID.Unix:
|
||||||
Process.Start("xdg-open", new string[] { folder });
|
Process.Start("xdg-open", new string[] { folder });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlatformID.MacOSX:
|
case PlatformID.MacOSX:
|
||||||
Process.Start("open", new string[] { folder });
|
Process.Start("open", new string[] { folder });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CloseDatabase() {
|
public void CloseDatabase() {
|
||||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void MergeWithDatabase() {
|
public async Task MergeWithDatabase() {
|
||||||
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
string[] paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||||
if (paths.Length == 0) {
|
if (paths.Length > 0) {
|
||||||
return;
|
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog();
|
|
||||||
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
|
||||||
Title = "Database Merge"
|
|
||||||
};
|
|
||||||
|
|
||||||
await progressDialog.ShowProgressDialog(window);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
||||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||||
|
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
target.AddFrom(db);
|
await target.AddFrom(db);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
db.Dispose();
|
await db.DisposeAsync();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
private readonly ProgressDialog dialog;
|
private readonly ProgressDialog dialog;
|
||||||
private readonly int total;
|
private readonly int total;
|
||||||
@@ -113,57 +106,50 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
this.total = total;
|
this.total = total;
|
||||||
this.dialog = dialog;
|
this.dialog = dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> CanUpgrade() {
|
public async Task<bool> CanUpgrade() {
|
||||||
return decision ??= (total > 1
|
return decision ??= (total > 1
|
||||||
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||||
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
return doUpgrade(new NullReporter());
|
return doUpgrade(new NullReporter());
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
public Task NextVersion() {
|
public Task NextVersion() {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task MainWork(string message, int finishedItems, int totalItems) {
|
public Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SubWork(string message, int finishedItems, int totalItems) {
|
public Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void ImportLegacyArchive() {
|
public async Task ImportLegacyArchive() {
|
||||||
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
Title = "Open Legacy DHT Archive",
|
Title = "Open Legacy DHT Archive",
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
||||||
AllowMultiple = true
|
AllowMultiple = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (paths.Length == 0) {
|
if (paths.Length > 0) {
|
||||||
return;
|
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog();
|
|
||||||
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
|
||||||
Title = "Legacy Archive Import"
|
|
||||||
};
|
|
||||||
|
|
||||||
await progressDialog.ShowProgressDialog(window);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
var fakeSnowflake = new FakeSnowflake();
|
var fakeSnowflake = new FakeSnowflake();
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
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);
|
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||||
@@ -173,54 +159,54 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
||||||
static bool IsValidSnowflake(string value) {
|
static bool IsValidSnowflake(string value) {
|
||||||
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||||
|
|
||||||
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
foreach (DHT.Server.Data.Server server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||||
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
||||||
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
||||||
ValidityCheck = IsValidSnowflake
|
ValidityCheck = IsValidSnowflake,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
||||||
Title = "Imported Server IDs",
|
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."
|
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 dialog = new TextBoxDialog { DataContext = model };
|
||||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||||
|
|
||||||
if (result != DialogResult.OkCancel.Ok) {
|
if (result != DialogResult.OkCancel.Ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return model.ValidItems
|
return model.ValidItems
|
||||||
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
||||||
.ToDictionary(static item => item.Item, static item => ulong.Parse(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) {
|
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;
|
int total = paths.Length;
|
||||||
var oldStatistics = target.SnapshotStatistics();
|
DatabaseStatistics oldStatistics = await DatabaseStatistics.Take(target);
|
||||||
|
|
||||||
int successful = 0;
|
int successful = 0;
|
||||||
int finished = 0;
|
int finished = 0;
|
||||||
|
|
||||||
foreach (string path in paths) {
|
foreach (string path in paths) {
|
||||||
await callback.Update(Path.GetFileName(path), finished, total);
|
await callback.Update(Path.GetFileName(path), finished, total);
|
||||||
++finished;
|
++finished;
|
||||||
|
|
||||||
if (!File.Exists(path)) {
|
if (!File.Exists(path)) {
|
||||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (await performImport(path)) {
|
if (await performImport(path)) {
|
||||||
++successful;
|
++successful;
|
||||||
@@ -230,39 +216,51 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await callback.Update("Done", finished, total);
|
await callback.Update("Done", finished, total);
|
||||||
|
|
||||||
if (successful == 0) {
|
if (successful == 0) {
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName));
|
DatabaseStatistics newStatistics = await DatabaseStatistics.Take(target);
|
||||||
|
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
|
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
|
||||||
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
|
||||||
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
return new DatabaseStatistics(
|
||||||
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
|
await db.Servers.Count(),
|
||||||
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
|
await db.Channels.Count(),
|
||||||
|
await db.Users.Count(),
|
||||||
StringBuilder message = new StringBuilder();
|
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;
|
||||||
|
|
||||||
|
var message = new StringBuilder();
|
||||||
message.Append("Processed ");
|
message.Append("Processed ");
|
||||||
|
|
||||||
if (successfulItems == totalItems) {
|
if (successfulItems == totalItems) {
|
||||||
message.Append(successfulItems.Pluralize(itemName));
|
message.Append(successfulItems.Pluralize(itemName));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
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(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
||||||
message.Append(newMessages.Pluralize("message"));
|
message.Append(newMessages.Pluralize("message"));
|
||||||
|
|
||||||
return message.ToString();
|
return message.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
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">
|
x:DataType="pages:DebugPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DebugPageModel />
|
<pages:DebugPageModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||||
<Setter Property="FontSize" Value="15" />
|
<Setter Property="FontSize" Value="15" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Label">
|
||||||
|
<Setter Property="Margin" Value="0 5" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
</Style>
|
||||||
<Style Selector="WrapPanel > StackPanel">
|
<Style Selector="WrapPanel > StackPanel">
|
||||||
<Setter Property="Orientation" Value="Vertical" />
|
<Setter Property="Orientation" Value="Vertical" />
|
||||||
<Setter Property="Margin" Value="0 0 10 10" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
|
@@ -6,180 +6,169 @@ using System.Threading.Tasks;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
|
using DHT.Server;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
namespace DHT.Desktop.Main.Pages;
|
||||||
sealed class DebugPageModel : BaseModel {
|
|
||||||
public string GenerateChannels { get; set; } = "0";
|
|
||||||
public string GenerateUsers { get; set; } = "0";
|
|
||||||
public string GenerateMessages { get; set; } = "0";
|
|
||||||
|
|
||||||
private readonly Window window;
|
sealed class DebugPageModel {
|
||||||
private readonly IDatabaseFile db;
|
public string GenerateChannels { get; set; } = "0";
|
||||||
|
public string GenerateUsers { get; set; } = "0";
|
||||||
[Obsolete("Designer")]
|
public string GenerateMessages { get; set; } = "0";
|
||||||
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
|
||||||
|
private readonly Window window;
|
||||||
public DebugPageModel(Window window, IDatabaseFile db) {
|
private readonly State state;
|
||||||
this.window = window;
|
|
||||||
this.db = db;
|
[Obsolete("Designer")]
|
||||||
|
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
|
public DebugPageModel(Window window, State state) {
|
||||||
|
this.window = window;
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void OnClickAddRandomDataToDatabase() {
|
||||||
|
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
||||||
|
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickAddRandomDataToDatabase() {
|
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
||||||
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressDialog progressDialog = new ProgressDialog {
|
|
||||||
DataContext = new ProgressDialogModel(async callbacks => await GenerateRandomData(channels, users, messages, callbacks[0])) {
|
|
||||||
Title = "Generating Random Data"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await progressDialog.ShowProgressDialog(window);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int BatchSize = 500;
|
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
||||||
|
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
||||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
return;
|
||||||
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
|
||||||
|
|
||||||
var rand = new Random();
|
|
||||||
var server = new DHT.Server.Data.Server {
|
|
||||||
Id = RandomId(rand),
|
|
||||||
Name = RandomName("s"),
|
|
||||||
Type = ServerType.Server,
|
|
||||||
};
|
|
||||||
|
|
||||||
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
|
||||||
Id = RandomId(rand),
|
|
||||||
Server = server.Id,
|
|
||||||
Name = RandomName("c"),
|
|
||||||
ParentId = null,
|
|
||||||
Position = i,
|
|
||||||
Topic = RandomText(rand, 10),
|
|
||||||
Nsfw = rand.Next(4) == 0,
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
|
||||||
Id = RandomId(rand),
|
|
||||||
Name = RandomName("u"),
|
|
||||||
AvatarUrl = null,
|
|
||||||
Discriminator = rand.Next(0, 9999).ToString(),
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
db.AddServer(server);
|
|
||||||
db.AddUsers(users);
|
|
||||||
|
|
||||||
foreach (var channel in channels) {
|
|
||||||
db.AddChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.Now;
|
|
||||||
int batchIndex = 0;
|
|
||||||
|
|
||||||
while (messageCount > 0) {
|
|
||||||
int hourOffset = batchIndex;
|
|
||||||
|
|
||||||
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
|
|
||||||
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
|
|
||||||
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
|
||||||
|
|
||||||
var timeMillis = time.ToUnixTimeMilliseconds();
|
|
||||||
var editMillis = edit?.ToUnixTimeMilliseconds();
|
|
||||||
|
|
||||||
return new Message {
|
|
||||||
Id = (ulong) timeMillis,
|
|
||||||
Sender = RandomBiasedIndex(rand, users).Id,
|
|
||||||
Channel = RandomBiasedIndex(rand, channels).Id,
|
|
||||||
Text = RandomText(rand, 100),
|
|
||||||
Timestamp = timeMillis,
|
|
||||||
EditTimestamp = editMillis,
|
|
||||||
RepliedToId = null,
|
|
||||||
Attachments = ImmutableArray<Attachment>.Empty,
|
|
||||||
Embeds = ImmutableArray<Embed>.Empty,
|
|
||||||
Reactions = ImmutableArray<Reaction>.Empty,
|
|
||||||
};
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
db.AddMessages(messages);
|
|
||||||
|
|
||||||
messageCount -= BatchSize;
|
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ulong RandomId(Random rand) {
|
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||||
ulong h = unchecked((ulong) rand.Next());
|
}
|
||||||
ulong l = unchecked((ulong) rand.Next());
|
|
||||||
return (h << 32) | l;
|
private const int BatchSize = 500;
|
||||||
}
|
|
||||||
|
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||||
private static string RandomName(string prefix) {
|
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||||
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
await callback.Update("Adding messages in batches of " + BatchSize, finishedItems: 0, batchCount);
|
||||||
}
|
|
||||||
|
var rand = new Random();
|
||||||
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
var server = new DHT.Server.Data.Server {
|
||||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
Id = RandomId(rand),
|
||||||
}
|
Name = RandomName("s"),
|
||||||
|
Type = ServerType.Server,
|
||||||
private static readonly string[] RandomWords = {
|
|
||||||
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
|
||||||
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
|
||||||
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
|
|
||||||
"daikon", "date", "dewberry", "durian",
|
|
||||||
"edamame", "eggplant", "elderberry", "endive",
|
|
||||||
"fig",
|
|
||||||
"garlic", "ginger", "gooseberry", "grape", "grapefruit", "guava",
|
|
||||||
"honeysuckle", "horseradish", "huckleberry",
|
|
||||||
"jackfruit", "jicama",
|
|
||||||
"kale", "kiwi", "kohlrabi", "kumquat",
|
|
||||||
"leek", "lemon", "lentil", "lettuce", "lime",
|
|
||||||
"mandarin", "mango", "mushroom", "myrtle",
|
|
||||||
"nectarine", "nut",
|
|
||||||
"olive", "okra", "onion", "orange",
|
|
||||||
"papaya", "parsnip", "pawpaw", "peach", "pear", "pea", "pepper", "persimmon", "pineapple", "plum", "plantain", "pomegranate", "pomelo", "potato", "prune", "pumpkin",
|
|
||||||
"quandong", "quinoa",
|
|
||||||
"radicchio", "radish", "raisin", "raspberry", "redcurrant", "rhubarb", "rutabaga",
|
|
||||||
"spinach", "strawberry", "squash",
|
|
||||||
"tamarind", "tangerine", "tomatillo", "tomato", "turnip",
|
|
||||||
"vanilla",
|
|
||||||
"watercress", "watermelon",
|
|
||||||
"yam",
|
|
||||||
"zucchini",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string RandomText(Random rand, int maxWords) {
|
Channel[] channels = Enumerable.Range(start: 0, channelCount).Select(i => new Channel {
|
||||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
Id = RandomId(rand),
|
||||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
Server = server.Id,
|
||||||
|
Name = RandomName("c"),
|
||||||
|
ParentId = null,
|
||||||
|
Position = i,
|
||||||
|
Topic = RandomText(rand, maxWords: 10),
|
||||||
|
Nsfw = rand.Next(4) == 0,
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
User[] users = Enumerable.Range(start: 0, userCount).Select(_ => new User {
|
||||||
|
Id = RandomId(rand),
|
||||||
|
Name = RandomName("u"),
|
||||||
|
DisplayName = RandomName("u"),
|
||||||
|
AvatarUrl = null,
|
||||||
|
Discriminator = rand.Next(minValue: 0, maxValue: 9999).ToString(),
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
await state.Db.Users.Add(users);
|
||||||
|
await state.Db.Servers.Add([server]);
|
||||||
|
await state.Db.Channels.Add(channels);
|
||||||
|
|
||||||
|
DateTimeOffset now = DateTimeOffset.Now;
|
||||||
|
int batchIndex = 0;
|
||||||
|
|
||||||
|
while (messageCount > 0) {
|
||||||
|
int hourOffset = batchIndex;
|
||||||
|
|
||||||
|
Message[] messages = Enumerable.Range(start: 0, Math.Min(messageCount, BatchSize)).Select(i => {
|
||||||
|
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
|
||||||
|
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(minValue: 1, maxValue: 60)) : null;
|
||||||
|
|
||||||
|
long timeMillis = time.ToUnixTimeMilliseconds();
|
||||||
|
long? editMillis = edit?.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
return new Message {
|
||||||
|
Id = (ulong) timeMillis,
|
||||||
|
Sender = RandomBiasedIndex(rand, users).Id,
|
||||||
|
Channel = RandomBiasedIndex(rand, channels).Id,
|
||||||
|
Text = RandomText(rand, maxWords: 100),
|
||||||
|
Timestamp = timeMillis,
|
||||||
|
EditTimestamp = editMillis,
|
||||||
|
RepliedToId = null,
|
||||||
|
Attachments = ImmutableList<Attachment>.Empty,
|
||||||
|
Embeds = ImmutableList<Embed>.Empty,
|
||||||
|
Reactions = ImmutableList<Reaction>.Empty,
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
await state.Db.Messages.Add(messages);
|
||||||
|
|
||||||
|
messageCount -= BatchSize;
|
||||||
|
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ulong RandomId(Random rand) {
|
||||||
|
ulong h = unchecked((ulong) rand.Next());
|
||||||
|
ulong l = unchecked((ulong) rand.Next());
|
||||||
|
return (h << 32) | l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RandomName(string prefix) {
|
||||||
|
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
||||||
|
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] RandomWords = [
|
||||||
|
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||||
|
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
||||||
|
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
|
||||||
|
"daikon", "date", "dewberry", "durian",
|
||||||
|
"edamame", "eggplant", "elderberry", "endive",
|
||||||
|
"fig",
|
||||||
|
"garlic", "ginger", "gooseberry", "grape", "grapefruit", "guava",
|
||||||
|
"honeysuckle", "horseradish", "huckleberry",
|
||||||
|
"jackfruit", "jicama",
|
||||||
|
"kale", "kiwi", "kohlrabi", "kumquat",
|
||||||
|
"leek", "lemon", "lentil", "lettuce", "lime",
|
||||||
|
"mandarin", "mango", "mushroom", "myrtle",
|
||||||
|
"nectarine", "nut",
|
||||||
|
"olive", "okra", "onion", "orange",
|
||||||
|
"papaya", "parsnip", "pawpaw", "peach", "pear", "pea", "pepper", "persimmon", "pineapple", "plum", "plantain", "pomegranate", "pomelo", "potato", "prune", "pumpkin",
|
||||||
|
"quandong", "quinoa",
|
||||||
|
"radicchio", "radish", "raisin", "raspberry", "redcurrant", "rhubarb", "rutabaga",
|
||||||
|
"spinach", "strawberry", "squash",
|
||||||
|
"tamarind", "tangerine", "tomatillo", "tomato", "turnip",
|
||||||
|
"vanilla",
|
||||||
|
"watercress", "watermelon",
|
||||||
|
"yam",
|
||||||
|
"zucchini",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static string RandomText(Random rand, int maxWords) {
|
||||||
|
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), y: 3));
|
||||||
|
return string.Join(separator: ' ', Enumerable.Range(start: 0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
using DHT.Utils.Models;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
sealed class DebugPageModel {
|
||||||
sealed class DebugPageModel : BaseModel {
|
public string GenerateChannels { get; set; } = "0";
|
||||||
public string GenerateChannels { get; set; } = "0";
|
public string GenerateUsers { get; set; } = "0";
|
||||||
public string GenerateUsers { get; set; } = "0";
|
public string GenerateMessages { get; set; } = "0";
|
||||||
public string GenerateMessages { get; set; } = "0";
|
|
||||||
|
|
||||||
public void OnClickAddRandomDataToDatabase() {}
|
public void OnClickAddRandomDataToDatabase() {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
@@ -5,16 +5,16 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
|
x:Class="DHT.Desktop.Main.Pages.DownloadsPage"
|
||||||
x:DataType="pages:AttachmentsPageModel">
|
x:DataType="pages:DownloadsPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:AttachmentsPageModel />
|
<pages:DownloadsPageModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<Style Selector="Expander">
|
<Style Selector="WrapPanel > Button">
|
||||||
<Setter Property="Margin" Value="0 5 0 0" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="DataGridColumnHeader">
|
<Style Selector="DataGridColumnHeader">
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
@@ -30,26 +30,26 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<StackPanel Orientation="Vertical" Spacing="20">
|
<StackPanel Orientation="Vertical">
|
||||||
<DockPanel>
|
<WrapPanel Orientation="Horizontal">
|
||||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
|
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
|
||||||
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
|
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
||||||
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
|
<Button Command="{Binding OnClickDeleteOrphanedDownloads}">Delete Orphaned Downloads</Button>
|
||||||
</DockPanel>
|
</WrapPanel>
|
||||||
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
|
<StackPanel Orientation="Vertical" Spacing="20" Margin="0 10 0 0">
|
||||||
<StackPanel Orientation="Vertical" Spacing="12">
|
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
||||||
|
<TextBlock TextWrapping="Wrap">
|
||||||
|
Downloading state and filter settings are remembered per-database.
|
||||||
|
</TextBlock>
|
||||||
<Expander Header="Download Status" IsExpanded="True">
|
<Expander Header="Download Status" IsExpanded="True">
|
||||||
<DataGrid ItemsSource="{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>
|
@@ -4,8 +4,8 @@ using Avalonia.Controls;
|
|||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class AttachmentsPage : UserControl {
|
public sealed partial class DownloadsPage : UserControl {
|
||||||
public AttachmentsPage() {
|
public DownloadsPage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
261
app/Desktop/Main/Pages/DownloadsPageModel.cs
Normal file
261
app/Desktop/Main/Pages/DownloadsPageModel.cs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
|
using DHT.Desktop.Main.Controls;
|
||||||
|
using DHT.Server;
|
||||||
|
using DHT.Server.Data;
|
||||||
|
using DHT.Server.Data.Aggregations;
|
||||||
|
using DHT.Server.Data.Filters;
|
||||||
|
using DHT.Server.Data.Settings;
|
||||||
|
using DHT.Server.Download;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
|
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 Window window;
|
||||||
|
private readonly State state;
|
||||||
|
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
||||||
|
private readonly IDisposable downloadItemCountSubscription;
|
||||||
|
|
||||||
|
private IDisposable? finishedItemsSubscription;
|
||||||
|
private DownloadItemFilter? currentDownloadFilter;
|
||||||
|
|
||||||
|
public DownloadsPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
|
public DownloadsPageModel(Window window, State state) {
|
||||||
|
this.window = window;
|
||||||
|
this.state = state;
|
||||||
|
|
||||||
|
FilterModel = new DownloadItemFilterPanelModel(state);
|
||||||
|
|
||||||
|
StatisticsRows = [
|
||||||
|
statisticsPending,
|
||||||
|
statisticsDownloaded,
|
||||||
|
statisticsFailed,
|
||||||
|
statisticsSkipped,
|
||||||
|
];
|
||||||
|
|
||||||
|
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
|
||||||
|
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
await FilterModel.Initialize();
|
||||||
|
|
||||||
|
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, defaultValue: false)) {
|
||||||
|
await StartDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() {
|
||||||
|
finishedItemsSubscription?.Dispose();
|
||||||
|
|
||||||
|
downloadItemCountSubscription.Dispose();
|
||||||
|
downloadStatisticsTask.Dispose();
|
||||||
|
|
||||||
|
await FilterModel.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDownloadCountChanged(long newDownloadCount) {
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickToggleDownload() {
|
||||||
|
IsToggleDownloadButtonEnabled = false;
|
||||||
|
|
||||||
|
if (IsDownloading) {
|
||||||
|
await StopDownload();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await StartDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
|
||||||
|
IsToggleDownloadButtonEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartDownload() {
|
||||||
|
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||||
|
|
||||||
|
IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
||||||
|
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
|
||||||
|
|
||||||
|
OnDownloadStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopDownload() {
|
||||||
|
await state.Downloader.Stop();
|
||||||
|
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||||
|
|
||||||
|
finishedItemsSubscription?.Dispose();
|
||||||
|
finishedItemsSubscription = null;
|
||||||
|
|
||||||
|
currentDownloadFilter = null;
|
||||||
|
OnDownloadStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDownloadStateChanged() {
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
|
||||||
|
OnPropertyChanged(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 const string DeleteOrphanedDownloadsTitle = "Delete Orphaned Downloads";
|
||||||
|
|
||||||
|
public async Task OnClickDeleteOrphanedDownloads() {
|
||||||
|
await ProgressDialog.Show(window, DeleteOrphanedDownloadsTitle, DeleteOrphanedDownloads);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteOrphanedDownloads(ProgressDialog dialog, IProgressCallback callback) {
|
||||||
|
await callback.UpdateIndeterminate("Searching for orphaned downloads...");
|
||||||
|
|
||||||
|
HashSet<string> reachableNormalizedUrls = [];
|
||||||
|
HashSet<string> orphanedNormalizedUrls = [];
|
||||||
|
|
||||||
|
await foreach (Download download in state.Db.Downloads.FindAllDownloadableUrls()) {
|
||||||
|
reachableNormalizedUrls.Add(download.NormalizedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await foreach (Download download in state.Db.Downloads.Get()) {
|
||||||
|
string normalizedUrl = download.NormalizedUrl;
|
||||||
|
if (!reachableNormalizedUrls.Contains(normalizedUrl)) {
|
||||||
|
orphanedNormalizedUrls.Add(normalizedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanedNormalizedUrls.Count == 0) {
|
||||||
|
await Dialog.ShowOk(window, DeleteOrphanedDownloadsTitle, "No orphaned downloads found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, DeleteOrphanedDownloadsTitle, orphanedNormalizedUrls.Count + " orphaned download(s) will be removed from this database. This action cannot be undone. Proceed?") != DialogResult.YesNo.Yes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Deleting orphaned downloads...");
|
||||||
|
await state.Db.Downloads.Remove(orphanedNormalizedUrls);
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, DeleteOrphanedDownloadsTitle, "Orphaned downloads deleted. Vacuum database now to reclaim space?") != DialogResult.YesNo.Yes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Vacuuming database...");
|
||||||
|
await state.Db.Vacuum();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -13,13 +13,21 @@
|
|||||||
|
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
<TextBlock TextWrapping="Wrap">
|
<TextBlock TextWrapping="Wrap">
|
||||||
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="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 {0}.">
|
||||||
|
<Binding Path="OpenDevToolsShortcutText" />
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
</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.
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="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 {0} shortcut is enabled.">
|
||||||
|
<Binding Path="OpenDevToolsShortcutText" />
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
@@ -9,20 +9,20 @@ namespace DHT.Desktop.Main.Pages;
|
|||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class TrackingPage : UserControl {
|
public sealed partial class TrackingPage : UserControl {
|
||||||
private bool isCopyingScript;
|
private bool isCopyingScript;
|
||||||
|
|
||||||
public TrackingPage() {
|
public TrackingPage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
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 originalText = CopyTrackingScript.Content;
|
object? originalText = CopyTrackingScript.Content;
|
||||||
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
||||||
|
|
||||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||||
isCopyingScript = true;
|
isCopyingScript = true;
|
||||||
CopyTrackingScript.Content = "Script Copied!";
|
CopyTrackingScript.Content = "Script Copied!";
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
CopyTrackingScript.Content = originalText;
|
CopyTrackingScript.Content = originalText;
|
||||||
isCopyingScript = false;
|
isCopyingScript = false;
|
||||||
|
@@ -1,68 +1,76 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
|
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 {
|
sealed partial class TrackingPageModel : ObservableObject {
|
||||||
private bool areDevToolsEnabled;
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private bool isCopyTrackingScriptButtonEnabled = true;
|
||||||
private bool AreDevToolsEnabled {
|
|
||||||
get => areDevToolsEnabled;
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
set {
|
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||||
Change(ref areDevToolsEnabled, value);
|
private bool? areDevToolsEnabled = null;
|
||||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
|
||||||
}
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
}
|
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||||
|
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
|
||||||
|
public string OpenDevToolsShortcutText { get; } = OperatingSystem.IsMacOS() ? "Cmd+Shift+I" : "Ctrl+Shift+I";
|
||||||
|
|
||||||
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" : "Enable") + " " + OpenDevToolsShortcutText;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Window window;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public TrackingPageModel() : this(null!) {}
|
|
||||||
|
|
||||||
public TrackingPageModel(Window window) {
|
|
||||||
this.window = window;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Initialize() {
|
|
||||||
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
|
||||||
if (devToolsEnabled.HasValue) {
|
|
||||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
IsToggleAppDevToolsButtonEnabled = false;
|
|
||||||
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
|
|
||||||
|
[Obsolete("Designer")]
|
||||||
|
public TrackingPageModel() : this(null!) {}
|
||||||
|
|
||||||
|
public TrackingPageModel(Window window) {
|
||||||
|
this.window = window;
|
||||||
|
|
||||||
|
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> OnClickCopyTrackingScript() {
|
public async Task<bool> OnClickCopyTrackingScript() {
|
||||||
string url = $"http://127.0.0.1:{ServerManager.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerManager.Token)}";
|
IsCopyTrackingScriptButtonEnabled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await CopyTrackingScript();
|
||||||
|
} finally {
|
||||||
|
IsCopyTrackingScriptButtonEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CopyTrackingScript() {
|
||||||
|
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
|
||||||
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
||||||
|
|
||||||
var clipboard = window.Clipboard;
|
IClipboard? 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await clipboard.SetTextAsync(script);
|
await clipboard.SetTextAsync(script);
|
||||||
return true;
|
return true;
|
||||||
@@ -71,40 +79,56 @@ sealed class TrackingPageModel : BaseModel {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
case SettingsJsonResult.Success:
|
case SettingsJsonResult.Success:
|
||||||
AreDevToolsEnabled = newState;
|
AreDevToolsEnabled = newState;
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.AlreadySet:
|
case SettingsJsonResult.AlreadySet:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
|
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " is already " + (newState ? "enabled." : "disabled."));
|
||||||
AreDevToolsEnabled = newState;
|
AreDevToolsEnabled = newState;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.FileNotFound:
|
case SettingsJsonResult.FileNotFound:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.ReadError:
|
case SettingsJsonResult.ReadError:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.InvalidJson:
|
case SettingsJsonResult.InvalidJson:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.WriteError:
|
case SettingsJsonResult.WriteError:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
}
|
}
|
||||||
|
@@ -21,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,149 +1,97 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
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.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
|
using DHT.Server;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Service.Viewer;
|
||||||
using DHT.Server.Database.Export;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Server.Database.Export.Strategy;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
using static DHT.Desktop.Program;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||||
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
|
private static readonly Log Log = Log.ForType<ViewerPageModel>();
|
||||||
|
|
||||||
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 {
|
|
||||||
get => hasFilters;
|
|
||||||
set => Change(ref hasFilters, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public MessageFilterPanelModel FilterModel { get; }
|
public 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
FilterModel.Dispose();
|
FilterModel.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
HasFilters = FilterModel.HasAnyFilters;
|
HasFilters = FilterModel.HasAnyFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
|
|
||||||
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, strategy, db, FilterModel.CreateFilter());
|
|
||||||
|
|
||||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
|
||||||
jsonStream.Position = 0;
|
|
||||||
|
|
||||||
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
|
|
||||||
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
|
|
||||||
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
|
|
||||||
|
|
||||||
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
|
|
||||||
int readBytes;
|
|
||||||
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
|
|
||||||
string jsonChunk = new string(jsonBuffer, 0, readBytes);
|
|
||||||
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File.Delete(jsonTempFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickOpenViewer() {
|
|
||||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
|
||||||
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
|
||||||
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
|
||||||
int counter = 0;
|
|
||||||
|
|
||||||
while (File.Exists(fullPath)) {
|
|
||||||
++counter;
|
|
||||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
|
||||||
}
|
|
||||||
|
|
||||||
TemporaryFiles.Add(fullPath);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(rootPath);
|
|
||||||
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
|
|
||||||
|
|
||||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly FilePickerFileType[] ViewerFileTypes = {
|
|
||||||
FileDialogs.CreateFilter("Discord History Viewer", new string[] { "html" }),
|
|
||||||
};
|
|
||||||
|
|
||||||
public async void OnClickSaveViewer() {
|
public async void OnClickOpenViewer() {
|
||||||
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
try {
|
||||||
Title = "Save Viewer",
|
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
|
||||||
FileTypeChoices = ViewerFileTypes,
|
string serverToken = ServerConfiguration.Token;
|
||||||
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
|
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)),
|
SystemUtils.OpenUrl(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
|
||||||
});
|
} catch (Exception e) {
|
||||||
|
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
|
||||||
if (path != null) {
|
|
||||||
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickApplyFiltersToDatabase() {
|
public async Task OnClickApplyFiltersToDatabase() {
|
||||||
var filter = FilterModel.CreateFilter();
|
MessageFilter filter = FilterModel.CreateFilter();
|
||||||
|
long 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.Show(window, "Apply Filters", async (_, callback) => {
|
||||||
|
await callback.UpdateIndeterminate("Removing messages...");
|
||||||
|
Log.Info("Removed messages: " + await state.Db.Messages.Remove(filter, removalMode));
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up attachments...");
|
||||||
|
Log.Info("Removed orphaned attachments: " + await state.Db.Messages.RemoveUnreachableAttachments());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up users...");
|
||||||
|
Log.Info("Removed orphaned users: " + await state.Db.Users.RemoveUnreachable());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up channels...");
|
||||||
|
Log.Info("Removed orphaned channels: " + await state.Db.Channels.RemoveUnreachable());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up servers...");
|
||||||
|
Log.Info("Removed orphaned servers: " + await state.Db.Servers.RemoveUnreachable());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -40,13 +40,13 @@
|
|||||||
<Style Selector="TabItem:pointerover /template/ Border">
|
<Style Selector="TabItem:pointerover /template/ Border">
|
||||||
<Setter Property="Background" Value="#455785" />
|
<Setter Property="Background" Value="#455785" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:pointerover > TextBlock">
|
<Style Selector="TabItem:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#E9E9E9" />
|
<Setter Property="Foreground" Value="#E9E9E9" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected:pointerover /template/ Border">
|
<Style Selector="TabItem:selected:pointerover /template/ Border">
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected:pointerover > TextBlock">
|
<Style Selector="TabItem:selected:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#1A2234" />
|
<Setter Property="Foreground" Value="#1A2234" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected">
|
<Style Selector="TabItem:selected">
|
||||||
@@ -56,9 +56,11 @@
|
|||||||
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
|
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
|
||||||
<Setter Property="IsVisible" Value="False" />
|
<Setter Property="IsVisible" Value="False" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:disabled > TextBlock">
|
<Style Selector="TabItem:disabled /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#B2B2B2" />
|
<Setter Property="Foreground" Value="#B2B2B2" />
|
||||||
<Setter Property="TextDecorations" Value="Strikethrough" />
|
</Style>
|
||||||
|
<Style Selector="TabItem:disabled /template/ ContentPresenter">
|
||||||
|
<Setter Property="AccessText.TextDecorations" Value="Strikethrough" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem.first">
|
<Style Selector="TabItem.first">
|
||||||
<Setter Property="Margin" Value="0 13 0 0" />
|
<Setter Property="Margin" Value="0 13 0 0" />
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Border Classes="statusBar" DockPanel.Dock="Bottom">
|
<Border Classes="statusBar" DockPanel.Dock="Bottom">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Attachments</TextBlock>
|
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Downloads</TextBlock>
|
||||||
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
|
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -94,9 +96,9 @@
|
|||||||
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem x:Name="TabAttachments" Header="Attachments" Grid.Row="2">
|
<TabItem x:Name="TabDownloads" Header="Downloads" Grid.Row="2">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<ContentPresenter Content="{Binding AttachmentsPage}" Classes="page" />
|
<ContentPresenter Content="{Binding DownloadsPage}" Classes="page" />
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
|
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
|
||||||
|
@@ -1,119 +1,81 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Desktop.Main.Pages;
|
using DHT.Desktop.Main.Pages;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Service;
|
|
||||||
using DHT.Utils.Logging;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens;
|
namespace DHT.Desktop.Main.Screens;
|
||||||
|
|
||||||
sealed class MainContentScreenModel : IDisposable {
|
sealed class MainContentScreenModel : IAsyncDisposable {
|
||||||
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
|
|
||||||
|
|
||||||
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; }
|
||||||
|
|
||||||
public AdvancedPage AdvancedPage { get; }
|
public AdvancedPage AdvancedPage { get; }
|
||||||
private AdvancedPageModel AdvancedPageModel { get; }
|
private AdvancedPageModel AdvancedPageModel { get; }
|
||||||
|
|
||||||
public DebugPage? DebugPage { get; }
|
public DebugPage? DebugPage { get; }
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
public bool HasDebugPage => true;
|
public bool HasDebugPage => true;
|
||||||
private DebugPageModel DebugPageModel { get; }
|
private DebugPageModel DebugPageModel { get; }
|
||||||
#else
|
#else
|
||||||
public bool HasDebugPage => false;
|
public bool HasDebugPage => false;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public StatusBarModel StatusBarModel { get; }
|
public StatusBarModel StatusBarModel { get; }
|
||||||
|
|
||||||
public event EventHandler? DatabaseClosed {
|
public event EventHandler? DatabaseClosed {
|
||||||
add {
|
add => DatabasePageModel.DatabaseClosed += value;
|
||||||
DatabasePageModel.DatabaseClosed += value;
|
remove => DatabasePageModel.DatabaseClosed -= value;
|
||||||
}
|
|
||||||
remove {
|
|
||||||
DatabasePageModel.DatabaseClosed -= value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(window, 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -22,20 +22,21 @@
|
|||||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button">
|
<Style Selector="Grid#ButtonPanel > Button">
|
||||||
<Setter Property="Margin" Value="5 0" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Panel Name="RootPanel">
|
<Panel Name="RootPanel">
|
||||||
<StackPanel Margin="42">
|
<StackPanel Margin="42 30">
|
||||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
|
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
<Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
|
||||||
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>
|
<Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>
|
||||||
<Button Command="{Binding Exit}">Exit</Button>
|
<Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button>
|
||||||
</StackPanel>
|
<Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button>
|
||||||
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
@@ -1,64 +1,76 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using 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.Dialogs.Progress;
|
||||||
|
using DHT.Server.Data.Settings;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Sqlite;
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
using DHT.Utils.Models;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens;
|
namespace DHT.Desktop.Main.Screens;
|
||||||
|
|
||||||
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||||
|
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
private string? dbFilePath;
|
private string? dbFilePath;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public WelcomeScreenModel() : this(null!) {}
|
public WelcomeScreenModel() : this(null!) {}
|
||||||
|
|
||||||
public WelcomeScreenModel(Window window) {
|
public WelcomeScreenModel(Window window) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OpenOrCreateDatabase() {
|
public async Task OpenOrCreateDatabase() {
|
||||||
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
IsOpenOrCreateDatabaseButtonEnabled = false;
|
||||||
if (path != null) {
|
try {
|
||||||
await OpenOrCreateDatabaseFromPath(path);
|
string? path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||||
|
if (path != null) {
|
||||||
|
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, new SchemaUpgradeCallbacks(window));
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Db));
|
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
|
||||||
private readonly Window window;
|
|
||||||
|
|
||||||
public SchemaUpgradeCallbacks(Window window) {
|
bool isNew = !File.Exists(path);
|
||||||
this.window = window;
|
|
||||||
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||||
|
if (db == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, value: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseSelected?.Invoke(this, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
|
||||||
public async Task<bool> CanUpgrade() {
|
public async Task<bool> CanUpgrade() {
|
||||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
var reporter = new ProgressReporter(versionSteps, callbacks);
|
var reporter = new ProgressReporter(versionSteps, callbacks);
|
||||||
@@ -67,40 +79,28 @@ sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
|||||||
await doUpgrade(reporter);
|
await doUpgrade(reporter);
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||||
}
|
}
|
||||||
|
|
||||||
await new ProgressDialog {
|
|
||||||
DataContext = new ProgressDialogModel(StartUpgrade, progressItems: 3) {
|
|
||||||
Title = "Upgrading Database"
|
|
||||||
}
|
|
||||||
}.ShowProgressDialog(window);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
|
||||||
private readonly IReadOnlyList<IProgressCallback> callbacks;
|
|
||||||
|
|
||||||
private readonly int versionSteps;
|
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
private int versionProgress = 0;
|
private int versionProgress = 0;
|
||||||
|
|
||||||
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
|
|
||||||
this.callbacks = callbacks;
|
|
||||||
this.versionSteps = versionSteps;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task NextVersion() {
|
public async Task NextVersion() {
|
||||||
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||||
await HideChildren(0);
|
await HideChildren(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
await callbacks[1].Update(message, finishedItems, totalItems);
|
await callbacks[1].Update(message, finishedItems, totalItems);
|
||||||
await HideChildren(1);
|
await HideChildren(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
await callbacks[2].Update(message, finishedItems, totalItems);
|
await callbacks[2].Update(message, finishedItems, totalItems);
|
||||||
await HideChildren(2);
|
await HideChildren(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HideChildren(int parentIndex) {
|
private async Task HideChildren(int parentIndex) {
|
||||||
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
||||||
await callbacks[i].Hide();
|
await callbacks[i].Hide();
|
||||||
@@ -108,23 +108,59 @@ sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CloseDatabase() {
|
public async Task CheckUpdates() {
|
||||||
Dispose();
|
var latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => {
|
||||||
OnPropertyChanged(nameof(Db));
|
var client = new HttpClient(new SocketsHttpHandler {
|
||||||
OnPropertyChanged(nameof(HasDatabase));
|
AutomaticDecompression = DecompressionMethods.None,
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
UseCookies = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.MaxResponseContentBufferSize = 1024;
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
|
||||||
|
|
||||||
|
string response;
|
||||||
|
try {
|
||||||
|
response = await client.GetStringAsync(Program.Website + "/version");
|
||||||
|
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.Version.TryParse(response, out Version? latestVersion)) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestVersion;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latestVersion == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Program.AssemblyVersion >= latestVersion) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "You are using the latest version.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, "Check Updates", "A newer version is available: v" + Program.VersionToString(latestVersion) + "\nVisit the official website and close the app?") == DialogResult.YesNo.Yes) {
|
||||||
|
SystemUtils.OpenUrl(Program.Website);
|
||||||
|
Exit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void ShowAboutDialog() {
|
public async Task ShowAboutDialog() {
|
||||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -9,33 +9,44 @@ namespace DHT.Desktop;
|
|||||||
|
|
||||||
static class Program {
|
static class Program {
|
||||||
public static string Version { get; }
|
public static string Version { get; }
|
||||||
|
public static Version AssemblyVersion { 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; }
|
public static Arguments Arguments { get; }
|
||||||
|
|
||||||
|
public const string Website = "https://dht.chylex.com";
|
||||||
|
|
||||||
static Program() {
|
static Program() {
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
|
||||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
AssemblyVersion = assembly.GetName().Version ?? new Version(major: 0, minor: 0, build: 0, revision: 0);
|
||||||
while (Version.EndsWith(".0")) {
|
Version = VersionToString(AssemblyVersion);
|
||||||
Version = Version[..^2];
|
|
||||||
}
|
|
||||||
|
|
||||||
Culture = CultureInfo.CurrentCulture;
|
Culture = CultureInfo.CurrentCulture;
|
||||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
||||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
Resources = new ResourceLoader(assembly);
|
Resources = new ResourceLoader(assembly);
|
||||||
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string VersionToString(Version version) {
|
||||||
|
string versionStr = version.ToString();
|
||||||
|
|
||||||
|
while (versionStr.EndsWith(".0")) {
|
||||||
|
versionStr = versionStr[..^2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionStr;
|
||||||
|
}
|
||||||
|
|
||||||
public static void Main(string[] args) {
|
public static void Main(string[] args) {
|
||||||
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||||
WindowsConsole.AllocConsole();
|
WindowsConsole.AllocConsole();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -44,7 +55,7 @@ static class Program {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppBuilder BuildAvaloniaApp() {
|
private static AppBuilder BuildAvaloniaApp() {
|
||||||
return AppBuilder.Configure<App>()
|
return AppBuilder.Configure<App>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
|
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(min: 50000, max: 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,8 +1,8 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<LangVersion>11</LangVersion>
|
<LangVersion>13</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -39,11 +39,12 @@
|
|||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||||
<DebugSymbols>false</DebugSymbols>
|
<DebugSymbols>false</DebugSymbols>
|
||||||
<DebugType>none</DebugType>
|
<DebugType>none</DebugType>
|
||||||
|
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
|
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</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:
|
||||||
|
49
app/Resources/Tracker/bootstrap.js
vendored
49
app/Resources/Tracker/bootstrap.js
vendored
@@ -10,11 +10,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*[IMPORTS]*/
|
||||||
|
|
||||||
|
if (!DISCORD.isCompatible()) {
|
||||||
|
alert("Discord History Tracker is not compatible with this version of Discord.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.DHT_LOADED = true;
|
window.DHT_LOADED = true;
|
||||||
window.DHT_ON_UNLOAD = [];
|
window.DHT_ON_UNLOAD = [];
|
||||||
|
|
||||||
/*[IMPORTS]*/
|
|
||||||
|
|
||||||
const port = 0; /*[PORT]*/
|
const port = 0; /*[PORT]*/
|
||||||
const token = "/*[TOKEN]*/";
|
const token = "/*[TOKEN]*/";
|
||||||
STATE.setup(port, token);
|
STATE.setup(port, token);
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTrackingContinued = function(anyNewMessages) {
|
const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
|
||||||
if (!STATE.isTracking()) {
|
if (!STATE.isTracking()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,7 +68,7 @@
|
|||||||
if (SETTINGS.autoscroll) {
|
if (SETTINGS.autoscroll) {
|
||||||
let action = null;
|
let action = null;
|
||||||
|
|
||||||
if (!DISCORD.hasMoreMessages()) {
|
if (!hasMoreBefore) {
|
||||||
console.debug("[DHT] Reached first message.");
|
console.debug("[DHT] Reached first message.");
|
||||||
action = SETTINGS.afterFirstMsg;
|
action = SETTINGS.afterFirstMsg;
|
||||||
}
|
}
|
||||||
@@ -84,7 +89,7 @@
|
|||||||
|
|
||||||
let waitUntilSendingFinishedTimer = null;
|
let waitUntilSendingFinishedTimer = null;
|
||||||
|
|
||||||
const onMessagesUpdated = async messages => {
|
const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
|
||||||
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,24 +99,16 @@
|
|||||||
|
|
||||||
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
||||||
waitUntilSendingFinishedTimer = null;
|
waitUntilSendingFinishedTimer = null;
|
||||||
onMessagesUpdated(messages);
|
onMessagesUpdated(server, channel, messages, hasMoreBefore);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = DISCORD.getSelectedChannel();
|
|
||||||
|
|
||||||
if (!info) {
|
|
||||||
GUI.setStatus("Error (Unknown Channel)");
|
|
||||||
stopTrackingDelayed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSending = true;
|
isSending = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await STATE.addDiscordChannel(info.server, info.channel);
|
await STATE.addDiscordChannel(server, channel);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
return;
|
return;
|
||||||
@@ -120,35 +117,33 @@
|
|||||||
try {
|
try {
|
||||||
if (!messages.length) {
|
if (!messages.length) {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
onTrackingContinued(false);
|
onTrackingContinued(false, hasMoreBefore);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||||
onTrackingContinued(anyNewMessages);
|
onTrackingContinued(anyNewMessages, hasMoreBefore);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
DISCORD.setupMessageCallback(onMessagesUpdated);
|
const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||||
|
|
||||||
STATE.onTrackingStateChanged(enabled => {
|
STATE.onTrackingStateChanged(enabled => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
const messages = DISCORD.getMessages();
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GUI.setStatus("Starting");
|
GUI.setStatus("Starting");
|
||||||
|
GUI.createTrackingStyles();
|
||||||
hasJustStarted = true;
|
hasJustStarted = true;
|
||||||
// noinspection JSIgnoredPromiseFromCall
|
|
||||||
onMessagesUpdated(messages);
|
if (!starter()) {
|
||||||
|
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
||||||
|
hasJustStarted = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
|
GUI.deleteTrackingStyles();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -4,11 +4,26 @@ class DISCORD {
|
|||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
static CHANNEL_TYPE = {
|
static CHANNEL_TYPE = {
|
||||||
|
GUILD_TEXT: 0,
|
||||||
DM: 1,
|
DM: 1,
|
||||||
GROUP_DM: 3,
|
GROUP_DM: 3,
|
||||||
|
GUILD_ANNOUNCEMENT: 5,
|
||||||
ANNOUNCEMENT_THREAD: 10,
|
ANNOUNCEMENT_THREAD: 10,
|
||||||
PUBLIC_THREAD: 11,
|
PUBLIC_THREAD: 11,
|
||||||
PRIVATE_THREAD: 12
|
PRIVATE_THREAD: 12,
|
||||||
|
|
||||||
|
isPrivate(type) {
|
||||||
|
return type === this.DM
|
||||||
|
|| type === this.GROUP_DM;
|
||||||
|
},
|
||||||
|
|
||||||
|
isNavigableGuildChannel(type) {
|
||||||
|
return type === this.GUILD_TEXT
|
||||||
|
|| type === this.GUILD_ANNOUNCEMENT
|
||||||
|
|| type === this.ANNOUNCEMENT_THREAD
|
||||||
|
|| type === this.PUBLIC_THREAD
|
||||||
|
|| type === this.PRIVATE_THREAD;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
@@ -18,6 +33,74 @@ class DISCORD {
|
|||||||
THREAD_STARTER: 21
|
THREAD_STARTER: 21
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
|
||||||
|
static PERMISSION = {
|
||||||
|
VIEW_CHANNEL: 1n << 10n
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): ?DiscordGuild} getGuild
|
||||||
|
*/
|
||||||
|
static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): Boolean} isOptInEnabled
|
||||||
|
* @property {function(String): Set<String>} getOptedInChannels
|
||||||
|
*/
|
||||||
|
static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): ?DiscordChannel} getChannel
|
||||||
|
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
|
||||||
|
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
|
||||||
|
*/
|
||||||
|
static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(BigInt, Object): Boolean}
|
||||||
|
*/
|
||||||
|
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): MessageData}
|
||||||
|
*/
|
||||||
|
static #getMessages = WEBPACK.findFunction("getMessages");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): void}
|
||||||
|
*/
|
||||||
|
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(): String}
|
||||||
|
*/
|
||||||
|
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): void}
|
||||||
|
*/
|
||||||
|
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String, Object, String=null): void}
|
||||||
|
*/
|
||||||
|
static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync");
|
||||||
|
|
||||||
|
static isCompatible() {
|
||||||
|
return !!this.#guildStore
|
||||||
|
&& !!this.#guildSettings
|
||||||
|
&& !!this.#channelStore
|
||||||
|
&& !!this.#hasPermission
|
||||||
|
&& !!this.#getMessages
|
||||||
|
&& !!this.#jumpToMessage
|
||||||
|
&& !!this.#getCurrentlySelectedChannelId
|
||||||
|
&& !!this.#selectPrivateChannel
|
||||||
|
&& !!this.#transitionToGuildSync;
|
||||||
|
}
|
||||||
|
|
||||||
static getMessageOuterElement() {
|
static getMessageOuterElement() {
|
||||||
return DOM.queryReactClass("messagesWrapper");
|
return DOM.queryReactClass("messagesWrapper");
|
||||||
}
|
}
|
||||||
@@ -26,14 +109,6 @@ class DISCORD {
|
|||||||
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
||||||
}
|
}
|
||||||
|
|
||||||
static getMessageElements() {
|
|
||||||
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
|
|
||||||
}
|
|
||||||
|
|
||||||
static hasMoreMessages() {
|
|
||||||
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static loadOlderMessages() {
|
static loadOlderMessages() {
|
||||||
const view = this.getMessageScrollerElement();
|
const view = this.getMessageScrollerElement();
|
||||||
|
|
||||||
@@ -42,26 +117,43 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getMessagesFromSelectedChannel() {
|
||||||
|
const channelId = this.#getCurrentlySelectedChannelId();
|
||||||
|
return channelId ? this.#getMessages(channelId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||||
|
* @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)}
|
||||||
*/
|
*/
|
||||||
static setupMessageCallback(callback) {
|
static setupMessageCallback(callback) {
|
||||||
const previousMessages = new Set();
|
const previousMessages = new Set();
|
||||||
|
|
||||||
const onMessageElementsChanged = function() {
|
const onMessageElementsChanged = force => {
|
||||||
const messages = DISCORD.getMessages();
|
const messages = this.getMessagesFromSelectedChannel();
|
||||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
if (!messages || !messages.ready || messages.loadingMore) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = this.#channelStore.getChannel(messages.channelId);
|
||||||
|
if (!channel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
|
||||||
if (!hasChanged) {
|
if (!hasChanged) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
previousMessages.clear();
|
previousMessages.clear();
|
||||||
for (const message of messages) {
|
for (const message of messages._array) {
|
||||||
previousMessages.add(message.id);
|
previousMessages.add(message.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(messages);
|
const server = this.#guildStore.getGuild(channel.guild_id);
|
||||||
|
|
||||||
|
callback(server, channel, messages._array, messages.hasMoreBefore);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
let debounceTimer;
|
let debounceTimer;
|
||||||
@@ -74,7 +166,7 @@ class DISCORD {
|
|||||||
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new MutationObserver(function () {
|
const observer = new MutationObserver(function() {
|
||||||
onMessageElementsChangedLater();
|
onMessageElementsChangedLater();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,216 +204,54 @@ class DISCORD {
|
|||||||
observedElement = null;
|
observedElement = null;
|
||||||
window.clearInterval(observerTimer);
|
window.clearInterval(observerTimer);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the message from a message element.
|
|
||||||
* @returns { null | DiscordMessage } }
|
|
||||||
*/
|
|
||||||
static getMessageFromElement(ele) {
|
|
||||||
const props = DOM.getReactProps(ele);
|
|
||||||
|
|
||||||
if (props && Array.isArray(props.children)) {
|
return () => onMessageElementsChanged(true);
|
||||||
for (const child of props.children) {
|
|
||||||
if (!(child instanceof Object)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childProps = child.props;
|
|
||||||
if (childProps instanceof Object && "message" in childProps) {
|
|
||||||
return childProps.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an array containing currently loaded messages.
|
|
||||||
*/
|
|
||||||
static getMessages() {
|
|
||||||
try {
|
|
||||||
const messages = [];
|
|
||||||
|
|
||||||
for (const ele of this.getMessageElements()) {
|
|
||||||
try {
|
|
||||||
const message = this.getMessageFromElement(ele);
|
|
||||||
|
|
||||||
if (message != null) {
|
|
||||||
messages.push(message);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error retrieving messages.", e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an object containing the selected server and channel information.
|
|
||||||
* For types DM and GROUP, the server and channel ids and names are identical.
|
|
||||||
* @returns { {} | null }
|
|
||||||
*/
|
|
||||||
static getSelectedChannel() {
|
|
||||||
try {
|
|
||||||
let obj = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
|
||||||
if (child && child.props && child.props.channel) {
|
|
||||||
obj = child.props.channel;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!obj || typeof obj.id !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dms = DOM.queryReactClass("privateChannels");
|
|
||||||
|
|
||||||
if (dms) {
|
|
||||||
let name;
|
|
||||||
|
|
||||||
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
|
|
||||||
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
|
|
||||||
|
|
||||||
if (node) {
|
|
||||||
name = node.nodeValue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let type;
|
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
|
||||||
switch (obj.type) {
|
|
||||||
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
|
|
||||||
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = obj.id;
|
|
||||||
const server = { id, name, type };
|
|
||||||
const channel = { id, name };
|
|
||||||
|
|
||||||
return { server, channel };
|
|
||||||
}
|
|
||||||
else if (obj.guild_id) {
|
|
||||||
let guild;
|
|
||||||
|
|
||||||
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
|
|
||||||
if (child && child.props && child.props.guild) {
|
|
||||||
guild = child.props.guild;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = {
|
|
||||||
"id": guild.id,
|
|
||||||
"name": guild.name,
|
|
||||||
"type": "SERVER"
|
|
||||||
};
|
|
||||||
|
|
||||||
const channel = {
|
|
||||||
"id": obj.id,
|
|
||||||
"name": obj.name,
|
|
||||||
"extra": {
|
|
||||||
"nsfw": obj.nsfw
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
|
||||||
channel["extra"]["parent"] = obj.parent_id;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
channel["extra"]["position"] = obj.position;
|
|
||||||
channel["extra"]["topic"] = obj.topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { server, channel };
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error retrieving selected channel.", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
||||||
*/
|
*/
|
||||||
static selectNextTextChannel() {
|
static selectNextTextChannel() {
|
||||||
const dms = DOM.queryReactClass("privateChannels");
|
const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
|
||||||
|
if (!currentChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (dms) {
|
if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
|
||||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
const privateChannel = this.#channelStore.getSortedPrivateChannels();
|
||||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
|
const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
|
||||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
|
||||||
|
|
||||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
|
||||||
if (!nextChannelLink) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextChannelLink.click();
|
|
||||||
nextChannelLink.scrollIntoView(true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const channelListEle = document.getElementById("channels");
|
const guildId = currentChannel.guild_id;
|
||||||
if (!channelListEle) {
|
|
||||||
|
let isChannelOptedIn;
|
||||||
|
if (this.#guildSettings.isOptInEnabled(guildId)) {
|
||||||
|
const optedInChannels = this.#guildSettings.getOptedInChannels(guildId);
|
||||||
|
isChannelOptedIn = channel => optedInChannels.has(channel.id);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
isChannelOptedIn = _ => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId);
|
||||||
|
const guildChannels = Object.values(guildChannelMap)
|
||||||
|
.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel))
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || currentIndex === guildChannels.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLinkElement(channel) {
|
this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id);
|
||||||
return channel.querySelector("a[href^='/channels/'][role='link']");
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
|
|
||||||
let nextChannel = null;
|
|
||||||
|
|
||||||
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
|
||||||
if (allTextChannels[index].className.includes("selected_")) {
|
|
||||||
nextChannel = allTextChannels[index + 1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextChannel === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextChannelLink = getLinkElement(nextChannel);
|
|
||||||
if (!nextChannelLink) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextChannelLink.click();
|
|
||||||
nextChannel.scrollIntoView(true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
class DOM {
|
class DOM {
|
||||||
/**
|
/**
|
||||||
* Returns a child element by its ID. Parent defaults to the entire document.
|
* Returns a child element by its ID. Parent defaults to the entire document.
|
||||||
|
* @param {string} id
|
||||||
|
* @param {HTMLElement?} [parent]
|
||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
static id(id, parent) {
|
static id(id, parent) {
|
||||||
@@ -9,6 +11,9 @@ 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.
|
||||||
|
* @param {string} cls
|
||||||
|
* @param {HTMLElement?} [parent]
|
||||||
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
static queryReactClass(cls, parent) {
|
static queryReactClass(cls, parent) {
|
||||||
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
||||||
@@ -55,31 +60,4 @@ class DOM {
|
|||||||
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
||||||
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
|
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns internal React state object of an element.
|
|
||||||
*/
|
|
||||||
static getReactProps(ele) {
|
|
||||||
const keys = Object.keys(ele || {});
|
|
||||||
let key = keys.find(key => key.startsWith("__reactInternalInstance"));
|
|
||||||
|
|
||||||
if (key) {
|
|
||||||
// noinspection JSUnresolvedVariable
|
|
||||||
return ele[key].memoizedProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
key = keys.find(key => key.startsWith("__reactProps$"));
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -2,14 +2,17 @@
|
|||||||
const GUI = (function() {
|
const GUI = (function() {
|
||||||
let controller = null;
|
let controller = null;
|
||||||
let settings = null;
|
let settings = null;
|
||||||
|
let trackingStyles = null;
|
||||||
|
|
||||||
const stateChangedEvent = () => {
|
const stateChangedEvent = () => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
|
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
|
||||||
|
settings.ui.cbHidePreviewsWhileAutoscrolling.checked = SETTINGS.hidePreviewsWhileAutoscrolling;
|
||||||
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
|
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
|
||||||
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
|
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
|
||||||
|
|
||||||
const autoscrollDisabled = !SETTINGS.autoscroll;
|
const autoscrollDisabled = !SETTINGS.autoscroll;
|
||||||
|
settings.ui.cbHidePreviewsWhileAutoscrolling.disabled = autoscrollDisabled;
|
||||||
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
||||||
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
||||||
}
|
}
|
||||||
@@ -54,6 +57,7 @@ const GUI = (function() {
|
|||||||
|
|
||||||
controller.ui.btnClose.addEventListener("click", () => {
|
controller.ui.btnClose.addEventListener("click", () => {
|
||||||
this.hideController();
|
this.hideController();
|
||||||
|
this.deleteTrackingStyles();
|
||||||
window.DHT_ON_UNLOAD.forEach(f => f());
|
window.DHT_ON_UNLOAD.forEach(f => f());
|
||||||
delete window.DHT_ON_UNLOAD;
|
delete window.DHT_ON_UNLOAD;
|
||||||
delete window.DHT_LOADED;
|
delete window.DHT_LOADED;
|
||||||
@@ -84,6 +88,7 @@ const GUI = (function() {
|
|||||||
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
|
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
|
||||||
const html = `
|
const html = `
|
||||||
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
||||||
|
<label><input id='dht-cfg-hide-previews-while-autoscrolling' type='checkbox'> Hide previews to improve browser performance</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", "Continue Tracking")}
|
${radio("afm", "nothing", "Continue Tracking")}
|
||||||
@@ -93,8 +98,7 @@ ${radio("afm", "switch", "Switch to Next Channel")}
|
|||||||
<label>After reaching a previously saved message...</label><br>
|
<label>After reaching a previously saved message...</label><br>
|
||||||
${radio("asm", "nothing", "Continue Tracking")}
|
${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>`;
|
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
|
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
|
||||||
@@ -107,9 +111,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
|||||||
});
|
});
|
||||||
|
|
||||||
settings.ui = {
|
settings.ui = {
|
||||||
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
/** @type {HTMLInputElement} */ cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
||||||
optsAfterFirstMsg: {},
|
/** @type {HTMLInputElement} */ cbHidePreviewsWhileAutoscrolling: DOM.id("dht-cfg-hide-previews-while-autoscrolling"),
|
||||||
optsAfterSavedMsg: {}
|
/** @type {Object.<number, HTMLInputElement>} */ optsAfterFirstMsg: {},
|
||||||
|
/** @type {Object.<number, HTMLInputElement>} */ optsAfterSavedMsg: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
|
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
|
||||||
@@ -124,6 +129,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
|||||||
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
|
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
settings.ui.cbHidePreviewsWhileAutoscrolling.addEventListener("change", () => {
|
||||||
|
SETTINGS.hidePreviewsWhileAutoscrolling = settings.ui.cbHidePreviewsWhileAutoscrolling.checked;
|
||||||
|
});
|
||||||
|
|
||||||
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
|
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
|
||||||
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
|
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
|
||||||
SETTINGS.afterFirstMsg = key;
|
SETTINGS.afterFirstMsg = key;
|
||||||
@@ -152,6 +161,29 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
|||||||
if (controller) {
|
if (controller) {
|
||||||
controller.ui.textStatus.innerText = state;
|
controller.ui.textStatus.innerText = state;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createTrackingStyles() {
|
||||||
|
if (trackingStyles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = "";
|
||||||
|
|
||||||
|
if (SETTINGS.autoscroll && SETTINGS.hidePreviewsWhileAutoscrolling) {
|
||||||
|
style += `div[id^="message-accessories-"] { display: none; }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.length > 0) {
|
||||||
|
trackingStyles = DOM.createStyle(style);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTrackingStyles() {
|
||||||
|
if (trackingStyles) {
|
||||||
|
DOM.removeElement(trackingStyles);
|
||||||
|
trackingStyles = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
@@ -35,17 +35,28 @@ const SETTINGS = (function() {
|
|||||||
obj[name] = value;
|
obj[name] = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
"_autoscroll": true,
|
||||||
|
"_hidePreviewsWhileAutoscrolling": true,
|
||||||
|
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||||
|
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||||
|
};
|
||||||
|
|
||||||
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
|
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
|
||||||
|
let hasChanged = false;
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
loaded = {
|
loaded = defaults;
|
||||||
"_autoscroll": true,
|
|
||||||
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
|
||||||
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
|
|
||||||
};
|
|
||||||
|
|
||||||
IS_FIRST_RUN = true;
|
IS_FIRST_RUN = true;
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
for (const property in defaults) {
|
||||||
|
if (!(property in loaded)) {
|
||||||
|
loaded[property] = defaults[property];
|
||||||
|
hasChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const root = {
|
const root = {
|
||||||
onSettingsChanged(callback) {
|
onSettingsChanged(callback) {
|
||||||
@@ -54,10 +65,11 @@ const SETTINGS = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
|
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
|
||||||
|
defineTriggeringProperty(root, "hidePreviewsWhileAutoscrolling", loaded._hidePreviewsWhileAutoscrolling);
|
||||||
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
|
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
|
||||||
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
|
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
|
||||||
|
|
||||||
if (IS_FIRST_RUN) {
|
if (IS_FIRST_RUN || hasChanged) {
|
||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -48,69 +48,49 @@ const STATE = (function() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDate = function(date) {
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// noinspection JSUnresolvedReference
|
||||||
|
return date.toDate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordChannel} channel
|
||||||
|
*/
|
||||||
|
const getPrivateChannelName = function(channel) {
|
||||||
|
if (channel.name === "") {
|
||||||
|
return channel.rawRecipients.map(user => user.username).join(", ");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return channel.name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Number} type
|
||||||
|
*/
|
||||||
|
const getChannelTypeName = function(type) {
|
||||||
|
if (type === DISCORD.CHANNEL_TYPE.DM) {
|
||||||
|
return "DM";
|
||||||
|
}
|
||||||
|
else if (type === DISCORD.CHANNEL_TYPE.GROUP_DM) {
|
||||||
|
return "GROUP";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "SERVER";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const trackingStateChangedListeners = [];
|
const trackingStateChangedListeners = [];
|
||||||
let isTracking = false;
|
let isTracking = false;
|
||||||
|
|
||||||
const addedChannels = new Set();
|
const addedChannels = new Set();
|
||||||
const addedUsers = new Set();
|
const addedUsers = new Set();
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordUser
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} username
|
|
||||||
* @property {String} discriminator
|
|
||||||
* @property {String} [avatar]
|
|
||||||
* @property {Boolean} [bot]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessage
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} channel_id
|
|
||||||
* @property {DiscordUser} author
|
|
||||||
* @property {String} content
|
|
||||||
* @property {Timestamp} timestamp
|
|
||||||
* @property {Timestamp|null} editedTimestamp
|
|
||||||
* @property {DiscordAttachment[]} attachments
|
|
||||||
* @property {Object[]} embeds
|
|
||||||
* @property {DiscordMessageReaction[]} [reactions]
|
|
||||||
* @property {DiscordMessageReference} [messageReference]
|
|
||||||
* @property {Number} type
|
|
||||||
* @property {String} state
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordAttachment
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} filename
|
|
||||||
* @property {String} [content_type]
|
|
||||||
* @property {String} size
|
|
||||||
* @property {String} url
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessageReaction
|
|
||||||
* @property {DiscordEmoji} emoji
|
|
||||||
* @property {Number} count
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessageReference
|
|
||||||
* @property {String} [message_id]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordEmoji
|
|
||||||
* @property {String|null} id
|
|
||||||
* @property {String|null} name
|
|
||||||
* @property {Boolean} animated
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name Timestamp
|
|
||||||
* @property {Function} toDate
|
|
||||||
*/
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setup(port, token) {
|
setup(port, token) {
|
||||||
serverPort = port;
|
serverPort = port;
|
||||||
@@ -141,32 +121,51 @@ const STATE = (function() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {?DiscordGuild} serverInfo
|
||||||
|
* @param {DiscordChannel} channelInfo
|
||||||
|
*/
|
||||||
async addDiscordChannel(serverInfo, channelInfo) {
|
async addDiscordChannel(serverInfo, channelInfo) {
|
||||||
if (addedChannels.has(channelInfo.id)) {
|
if (addedChannels.has(channelInfo.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = {
|
const server = {
|
||||||
id: serverInfo.id,
|
type: getChannelTypeName(channelInfo.type)
|
||||||
name: serverInfo.name,
|
|
||||||
type: serverInfo.type
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const channel = {
|
const channel = {
|
||||||
id: channelInfo.id,
|
id: channelInfo.id,
|
||||||
name: channelInfo.name
|
extra: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("extra" in channelInfo) {
|
if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
|
||||||
const extra = channelInfo.extra;
|
server.id = channelInfo.id;
|
||||||
|
server.name = channel.name = getPrivateChannelName(channelInfo);
|
||||||
if ("parent" in extra) {
|
}
|
||||||
channel.parent = extra.parent;
|
else if (serverInfo) {
|
||||||
}
|
server.id = serverInfo.id;
|
||||||
|
server.name = serverInfo.name;
|
||||||
channel.position = extra.position;
|
channel.name = channelInfo.name;
|
||||||
channel.topic = extra.topic;
|
}
|
||||||
channel.nsfw = extra.nsfw;
|
else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("nsfw" in channelInfo) {
|
||||||
|
channel.extra.nsfw = channelInfo.nsfw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("topic" in channelInfo) {
|
||||||
|
channel.extra.topic = channelInfo.topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("position" in channelInfo) {
|
||||||
|
channel.extra.position = channelInfo.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelInfo.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||||
|
channel.extra.parent = channelInfo.parent_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await post("/track-channel", { server, channel });
|
await post("/track-channel", { server, channel });
|
||||||
@@ -195,6 +194,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;
|
||||||
}
|
}
|
||||||
@@ -223,12 +226,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) {
|
||||||
@@ -283,7 +286,7 @@ const STATE = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.reactions.length > 0) {
|
if (msg.reactions.length > 0) {
|
||||||
obj.reactions = msg.reactions.map(reaction => {
|
obj.reactions = msg.reactions.filter(reaction => reaction.count > 0).map(reaction => {
|
||||||
const emoji = reaction.emoji;
|
const emoji = reaction.emoji;
|
||||||
|
|
||||||
const mapped = {
|
const mapped = {
|
||||||
|
82
app/Resources/Tracker/scripts/types.js
Normal file
82
app/Resources/Tracker/scripts/types.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @name DiscordGuild
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} name
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordChannel
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} name
|
||||||
|
* @property {Number} type
|
||||||
|
* @property {String} [guild_id]
|
||||||
|
* @property {String} [parent_id]
|
||||||
|
* @property {Number} [position]
|
||||||
|
* @property {String} [topic]
|
||||||
|
* @property {Boolean} [nsfw]
|
||||||
|
* @property {DiscordUser[]} [rawRecipients]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordUser
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} username
|
||||||
|
* @property {String} discriminator
|
||||||
|
* @property {String} [globalName]
|
||||||
|
* @property {String} [avatar]
|
||||||
|
* @property {Boolean} [bot]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessage
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} channel_id
|
||||||
|
* @property {DiscordUser} author
|
||||||
|
* @property {String} content
|
||||||
|
* @property {Date} timestamp
|
||||||
|
* @property {Date|null} editedTimestamp
|
||||||
|
* @property {DiscordAttachment[]} attachments
|
||||||
|
* @property {Object[]} embeds
|
||||||
|
* @property {DiscordMessageReaction[]} [reactions]
|
||||||
|
* @property {DiscordMessageReference} [messageReference]
|
||||||
|
* @property {Number} type
|
||||||
|
* @property {String} state
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordAttachment
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} filename
|
||||||
|
* @property {String} [content_type]
|
||||||
|
* @property {String} size
|
||||||
|
* @property {String} url
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessageReaction
|
||||||
|
* @property {DiscordEmoji} emoji
|
||||||
|
* @property {Number} count
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessageReference
|
||||||
|
* @property {String} [message_id]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordEmoji
|
||||||
|
* @property {String|null} id
|
||||||
|
* @property {String|null} name
|
||||||
|
* @property {Boolean} animated
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name MessageData
|
||||||
|
* @type {Object}
|
||||||
|
* @property {String} channelId
|
||||||
|
* @property {Boolean} ready
|
||||||
|
* @property {Boolean} loadingMore
|
||||||
|
* @property {Boolean} hasMoreAfter
|
||||||
|
* @property {Boolean} hasMoreBefore
|
||||||
|
* @property {Array<DiscordMessage>} _array
|
||||||
|
*/
|
98
app/Resources/Tracker/scripts/webpack.js
Normal file
98
app/Resources/Tracker/scripts/webpack.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Parts copied from Better Discord, licensed under Apache License 2.0.
|
||||||
|
*
|
||||||
|
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/renderer/src/modules/webpackmodules.js
|
||||||
|
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/LICENSE
|
||||||
|
*/
|
||||||
|
class WEBPACK {
|
||||||
|
static get require() {
|
||||||
|
if (this._require) {
|
||||||
|
return this._require;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {Object} m
|
||||||
|
* @property {Object} c
|
||||||
|
*/
|
||||||
|
let hookedRequire;
|
||||||
|
|
||||||
|
const id = "dht-webpackmodules-" + new Date().getTime();
|
||||||
|
if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") {
|
||||||
|
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => hookedRequire = internalRequire ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete hookedRequire.m[id];
|
||||||
|
delete hookedRequire.c[id];
|
||||||
|
return this._require = hookedRequire;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getAllModules() {
|
||||||
|
return this.require.c;
|
||||||
|
}
|
||||||
|
|
||||||
|
static filterByProps(...props) {
|
||||||
|
return module => props.every(prop => prop in module);
|
||||||
|
}
|
||||||
|
|
||||||
|
static filterByPropsWithPredicate(predicate, ...props) {
|
||||||
|
return module => props.every(prop => prop in module && predicate(module[prop]));
|
||||||
|
}
|
||||||
|
|
||||||
|
static findModules(filter) {
|
||||||
|
const defaultExport = true;
|
||||||
|
const moduleFilter = module => (typeof module === "object" || typeof module === "function") && filter(module);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const module of Object.values(this.getAllModules())) {
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property [Z]
|
||||||
|
* @property [ZP]
|
||||||
|
* @property [__esModule]
|
||||||
|
* @property [default]
|
||||||
|
*/
|
||||||
|
const exports = module.exports;
|
||||||
|
if (!exports || exports === window || exports === document.documentElement || exports[Symbol.toStringTag] === "DOMTokenList") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let foundModule = null;
|
||||||
|
if (exports.Z && moduleFilter(exports.Z)) {
|
||||||
|
foundModule = defaultExport ? exports.Z : exports;
|
||||||
|
}
|
||||||
|
if (exports.ZP && moduleFilter(exports.ZP)) {
|
||||||
|
foundModule = defaultExport ? exports.ZP : exports;
|
||||||
|
}
|
||||||
|
if (exports.__esModule && exports.default && moduleFilter(exports.default)) {
|
||||||
|
foundModule = defaultExport ? exports.default : exports;
|
||||||
|
}
|
||||||
|
if (moduleFilter(exports)) {
|
||||||
|
foundModule = exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundModule) {
|
||||||
|
results.push(foundModule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
static findModule(name, filter) {
|
||||||
|
const modules = this.findModules(filter);
|
||||||
|
if (modules.length === 1) {
|
||||||
|
return modules[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[DHT] Cannot find module " + name + ", results found:", modules.length);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static findFunction(name, additionalRequiredProps) {
|
||||||
|
const searchedProps = additionalRequiredProps ? [ name, ...additionalRequiredProps ] : [ name ];
|
||||||
|
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof (prop) === "function", ...searchedProps));
|
||||||
|
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
|
||||||
|
}
|
||||||
|
}
|
@@ -18,11 +18,13 @@
|
|||||||
height: 262px;
|
height: 262px;
|
||||||
margin-left: -400px;
|
margin-left: -400px;
|
||||||
margin-top: -131px;
|
margin-top: -131px;
|
||||||
|
line-height: 120%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
z-index: 1000002;
|
z-index: 1000002;
|
||||||
}
|
}
|
||||||
|
|
||||||
#dht-cfg-note {
|
#dht-cfg label {
|
||||||
margin-top: 22px;
|
display: inline-block;
|
||||||
|
margin: 1px 0;
|
||||||
}
|
}
|
||||||
|
@@ -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,4 +1,10 @@
|
|||||||
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,
|
||||||
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
||||||
@@ -35,10 +41,24 @@ const DISCORD = (function() {
|
|||||||
let templateReaction;
|
let templateReaction;
|
||||||
let templateReactionCustom;
|
let templateReactionCustom;
|
||||||
|
|
||||||
|
const fileUrlProcessor = function(serverToken) {
|
||||||
|
if (typeof serverToken === "string") {
|
||||||
|
return url => "/get-downloaded-file/" + encodeURIComponent(url) + "?token=" + encodeURIComponent(serverToken);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return url => url;
|
||||||
|
}
|
||||||
|
}(window.DHT_SERVER_TOKEN);
|
||||||
|
|
||||||
|
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) {
|
const processMessageContents = function(contents) {
|
||||||
let processed = DOM.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1"));
|
let processed = dom.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1"));
|
||||||
|
|
||||||
if (SETTINGS.enableFormatting) {
|
if (settings.enableFormatting) {
|
||||||
const escapeHtmlMatch = (full, match) => "&#" + match.charCodeAt(0) + ";";
|
const escapeHtmlMatch = (full, match) => "&#" + match.charCodeAt(0) + ";";
|
||||||
|
|
||||||
processed = processed
|
processed = processed
|
||||||
@@ -54,29 +74,33 @@ const DISCORD = (function() {
|
|||||||
.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) {
|
const getImageEmbed = function(url, image) {
|
||||||
if (!SETTINGS.enableImagePreviews) {
|
if (!settings.enableImagePreviews) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (image.width && image.height) {
|
if (image.width && image.height) {
|
||||||
return templateEmbedImageWithSize.apply({ url, src: image.url, width: image.width, height: image.height });
|
return templateEmbedImageWithSize.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url), width: image.width, height: image.height });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return templateEmbedImage.apply({ url, src: image.url });
|
return templateEmbedImage.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,36 +112,36 @@ const DISCORD = (function() {
|
|||||||
|
|
||||||
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>",
|
||||||
@@ -125,50 +149,52 @@ 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 {name}</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 loading'><img src='{src}' alt='' onload='DISCORD.handleImageLoad(this)' onerror='DISCORD.handleImageLoadError(this)'></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
|
||||||
templateEmbedImageWithSize = new TEMPLATE([
|
templateEmbedImageWithSize = new template([
|
||||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='DISCORD.handleImageLoad(this)' onerror='DISCORD.handleImageLoadError(this)'></a><br>"
|
"<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(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedRich = new TEMPLATE([
|
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(""));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -184,7 +210,7 @@ const DISCORD = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
isImageAttachment(attachment) {
|
isImageAttachment(attachment) {
|
||||||
const url = DOM.tryParseUrl(attachment.url);
|
const url = dom.tryParseUrl(attachment.url);
|
||||||
return url != null && isImageUrl(url);
|
return url != null && isImageUrl(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -197,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) : "";
|
||||||
@@ -242,44 +268,47 @@ const DISCORD = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return value.map(attachment => {
|
return value.map(attachment => {
|
||||||
if (!DISCORD.isImageAttachment(attachment) || !SETTINGS.enableImagePreviews) {
|
const url = fileUrlProcessor(attachment.url);
|
||||||
return templateAttachmentDownload.apply(attachment);
|
|
||||||
|
if (!discord.isImageAttachment(attachment) || !settings.enableImagePreviews) {
|
||||||
|
return templateAttachmentDownload.apply({ url, name: attachment.name });
|
||||||
}
|
}
|
||||||
else if ("width" in attachment && "height" in attachment) {
|
else if ("width" in attachment && "height" in attachment) {
|
||||||
return templateEmbedImageWithSize.apply({ url: attachment.url, src: attachment.url, width: attachment.width, height: attachment.height });
|
return templateEmbedImageWithSize.apply({ url, src: url, width: attachment.width, height: attachment.height });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
|
return templateEmbedImage.apply({ url, src: url });
|
||||||
}
|
}
|
||||||
}).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;
|
||||||
}
|
}
|
||||||
else if (property === "reactions"){
|
else if (property === "reactions") {
|
||||||
if (!value){
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
@@ -8,9 +8,12 @@ const HTML_ENTITY_MAP = {
|
|||||||
|
|
||||||
const HTML_ENTITY_REGEX = /[&<>"']/g;
|
const HTML_ENTITY_REGEX = /[&<>"']/g;
|
||||||
|
|
||||||
class DOM {
|
export default class {
|
||||||
/**
|
/**
|
||||||
* Returns a child element by its ID. Parent defaults to the entire document.
|
* Returns a child element by its ID. Parent defaults to the entire document.
|
||||||
|
* @param {string} id
|
||||||
|
* @param {HTMLElement} [parent]
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
*/
|
*/
|
||||||
static id(id, parent) {
|
static id(id, parent) {
|
||||||
return (parent || document).getElementById(id);
|
return (parent || document).getElementById(id);
|
||||||
@@ -18,6 +21,9 @@ class DOM {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an array of all child elements containing the specified class. Parent defaults to the entire document.
|
* Returns an array of all child elements containing the specified class. Parent defaults to the entire document.
|
||||||
|
* @param {string} cls
|
||||||
|
* @param {HTMLElement} [parent]
|
||||||
|
* @returns {HTMLElement[]}
|
||||||
*/
|
*/
|
||||||
static cls(cls, parent) {
|
static cls(cls, parent) {
|
||||||
return Array.prototype.slice.call((parent || document).getElementsByClassName(cls));
|
return Array.prototype.slice.call((parent || document).getElementsByClassName(cls));
|
||||||
@@ -25,6 +31,9 @@ class DOM {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an array of all child elements that have the specified tag. Parent defaults to the entire document.
|
* Returns an array of all child elements that have the specified tag. Parent defaults to the entire document.
|
||||||
|
* @param {string} tag
|
||||||
|
* @param {HTMLElement} [parent]
|
||||||
|
* @returns {HTMLElement[]}
|
||||||
*/
|
*/
|
||||||
static tag(tag, parent) {
|
static tag(tag, parent) {
|
||||||
return Array.prototype.slice.call((parent || document).getElementsByTagName(tag));
|
return Array.prototype.slice.call((parent || document).getElementsByTagName(tag));
|
||||||
@@ -32,6 +41,9 @@ class DOM {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first child element containing the specified class. Parent defaults to the entire document.
|
* Returns the first child element containing the specified class. Parent defaults to the entire document.
|
||||||
|
* @param {string} cls
|
||||||
|
* @param {HTMLElement} [parent]
|
||||||
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
static fcls(cls, parent) {
|
static fcls(cls, parent) {
|
||||||
return (parent || document).getElementsByClassName(cls)[0];
|
return (parent || document).getElementsByClassName(cls)[0];
|
@@ -1,10 +1,16 @@
|
|||||||
const GUI = (function() {
|
import dom from "./dom.mjs";
|
||||||
|
import discord from "./discord.mjs";
|
||||||
|
import settings from "./settings.mjs";
|
||||||
|
import state from "./state.mjs";
|
||||||
|
|
||||||
|
export default (function() {
|
||||||
let eventOnOptMessagesPerPageChanged;
|
let eventOnOptMessagesPerPageChanged;
|
||||||
let eventOnOptMessageFilterChanged;
|
let eventOnOptMessageFilterChanged;
|
||||||
let eventOnNavButtonClicked;
|
let eventOnNavButtonClicked;
|
||||||
|
|
||||||
const getActiveFilter = function() {
|
const getActiveFilter = function() {
|
||||||
const active = DOM.fcls("active", DOM.id("opt-filter-list"));
|
/** @type HTMLSelectElement */
|
||||||
|
const active = dom.fcls("active", dom.id("opt-filter-list"));
|
||||||
|
|
||||||
return active && active.value !== "" ? {
|
return active && active.value !== "" ? {
|
||||||
"type": active.getAttribute("data-filter-type"),
|
"type": active.getAttribute("data-filter-type"),
|
||||||
@@ -17,12 +23,12 @@ const GUI = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showModal = function(width, html) {
|
const showModal = function(width, html) {
|
||||||
const dialog = DOM.id("dialog");
|
const dialog = dom.id("dialog");
|
||||||
dialog.innerHTML = html;
|
dialog.innerHTML = html;
|
||||||
dialog.style.width = width + "px";
|
dialog.style.width = width + "px";
|
||||||
dialog.style.marginLeft = (-width / 2) + "px";
|
dialog.style.marginLeft = (-width / 2) + "px";
|
||||||
|
|
||||||
DOM.id("modal").classList.add("visible");
|
dom.id("modal").classList.add("visible");
|
||||||
return dialog;
|
return dialog;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,9 +44,9 @@ const GUI = (function() {
|
|||||||
<label><input id='dht-cfg-animemoji' type='checkbox'> Animated Emoji</label><br>`);
|
<label><input id='dht-cfg-animemoji' type='checkbox'> Animated Emoji</label><br>`);
|
||||||
|
|
||||||
const setupCheckBox = function(id, settingName) {
|
const setupCheckBox = function(id, settingName) {
|
||||||
const ele = DOM.id(id);
|
const ele = dom.id(id);
|
||||||
ele.checked = SETTINGS[settingName];
|
ele.checked = settings[settingName];
|
||||||
ele.addEventListener("change", () => SETTINGS[settingName] = ele.checked);
|
ele.addEventListener("change", () => settings[settingName] = ele.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
setupCheckBox("dht-cfg-imgpreviews", "enableImagePreviews");
|
setupCheckBox("dht-cfg-imgpreviews", "enableImagePreviews");
|
||||||
@@ -65,20 +71,20 @@ const GUI = (function() {
|
|||||||
// ---------
|
// ---------
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
const inputMessageFilter = DOM.id("opt-messages-filter");
|
const inputMessageFilter = dom.id("opt-messages-filter");
|
||||||
const containerFilterList = DOM.id("opt-filter-list");
|
const containerFilterList = dom.id("opt-filter-list");
|
||||||
|
|
||||||
const resetActiveFilter = function() {
|
const resetActiveFilter = function() {
|
||||||
inputMessageFilter.value = "";
|
inputMessageFilter.value = "";
|
||||||
inputMessageFilter.dispatchEvent(new Event("change"));
|
inputMessageFilter.dispatchEvent(new Event("change"));
|
||||||
|
|
||||||
DOM.id("opt-filter-contents").value = "";
|
dom.id("opt-filter-contents").value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
inputMessageFilter.value = ""; // required to prevent browsers from remembering old value
|
inputMessageFilter.value = ""; // required to prevent browsers from remembering old value
|
||||||
|
|
||||||
inputMessageFilter.addEventListener("change", () => {
|
inputMessageFilter.addEventListener("change", () => {
|
||||||
DOM.cls("active", containerFilterList).forEach(ele => ele.classList.remove("active"));
|
dom.cls("active", containerFilterList).forEach(ele => ele.classList.remove("active"));
|
||||||
|
|
||||||
if (inputMessageFilter.value) {
|
if (inputMessageFilter.value) {
|
||||||
containerFilterList.querySelector("[data-filter-type='" + inputMessageFilter.value + "']").classList.add("active");
|
containerFilterList.querySelector("[data-filter-type='" + inputMessageFilter.value + "']").classList.add("active");
|
||||||
@@ -91,11 +97,11 @@ const GUI = (function() {
|
|||||||
ele.addEventListener(ele.tagName === "SELECT" ? "change" : "input", () => triggerFilterChanged());
|
ele.addEventListener(ele.tagName === "SELECT" ? "change" : "input", () => triggerFilterChanged());
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("opt-messages-per-page").addEventListener("change", () => {
|
dom.id("opt-messages-per-page").addEventListener("change", () => {
|
||||||
eventOnOptMessagesPerPageChanged && eventOnOptMessagesPerPageChanged();
|
eventOnOptMessagesPerPageChanged && eventOnOptMessagesPerPageChanged();
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.tag("button", DOM.fcls("nav")).forEach(button => {
|
dom.tag("button", dom.fcls("nav")).forEach(button => {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
@@ -103,34 +109,34 @@ const GUI = (function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("btn-settings").addEventListener("click", () => {
|
dom.id("btn-settings").addEventListener("click", () => {
|
||||||
showSettingsModal();
|
showSettingsModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("btn-about").addEventListener("click", () => {
|
dom.id("btn-about").addEventListener("click", () => {
|
||||||
showInfoModal();
|
showInfoModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("messages").addEventListener("click", e => {
|
dom.id("messages").addEventListener("click", e => {
|
||||||
const jump = e.target.getAttribute("data-jump");
|
const jump = e.target.getAttribute("data-jump");
|
||||||
|
|
||||||
if (jump) {
|
if (jump) {
|
||||||
resetActiveFilter();
|
resetActiveFilter();
|
||||||
|
|
||||||
const index = STATE.navigateToMessage(jump);
|
const index = state.navigateToMessage(jump);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
alert("Message not found.");
|
alert("Message not found.");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
DOM.id("messages").children[index].scrollIntoView();
|
dom.id("messages").children[index].scrollIntoView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("overlay").addEventListener("click", () => {
|
dom.id("overlay").addEventListener("click", () => {
|
||||||
DOM.id("modal").classList.remove("visible");
|
dom.id("modal").classList.remove("visible");
|
||||||
DOM.id("dialog").innerHTML = "";
|
dom.id("dialog").innerHTML = "";
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -168,19 +174,19 @@ const GUI = (function() {
|
|||||||
*/
|
*/
|
||||||
getOptionMessagesPerPage() {
|
getOptionMessagesPerPage() {
|
||||||
/** @type HTMLInputElement */
|
/** @type HTMLInputElement */
|
||||||
const messagesPerPage = DOM.id("opt-messages-per-page");
|
const messagesPerPage = dom.id("opt-messages-per-page");
|
||||||
return parseInt(messagesPerPage.value, 10);
|
return parseInt(messagesPerPage.value, 10);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNavigation(currentPage, totalPages) {
|
updateNavigation(currentPage, totalPages) {
|
||||||
DOM.id("nav-page-current").innerHTML = currentPage;
|
dom.id("nav-page-current").innerHTML = currentPage;
|
||||||
DOM.id("nav-page-total").innerHTML = totalPages || "?";
|
dom.id("nav-page-total").innerHTML = totalPages || "?";
|
||||||
|
|
||||||
DOM.id("nav-first").disabled = currentPage === 1;
|
dom.id("nav-first").disabled = currentPage === 1;
|
||||||
DOM.id("nav-prev").disabled = currentPage === 1;
|
dom.id("nav-prev").disabled = currentPage === 1;
|
||||||
DOM.id("nav-pick").disabled = (totalPages || 0) <= 1;
|
dom.id("nav-pick").disabled = (totalPages || 0) <= 1;
|
||||||
DOM.id("nav-next").disabled = currentPage === (totalPages || 1);
|
dom.id("nav-next").disabled = currentPage === (totalPages || 1);
|
||||||
DOM.id("nav-last").disabled = currentPage === (totalPages || 1);
|
dom.id("nav-last").disabled = currentPage === (totalPages || 1);
|
||||||
},
|
},
|
||||||
|
|
||||||
// --------------
|
// --------------
|
||||||
@@ -191,7 +197,7 @@ const GUI = (function() {
|
|||||||
* Updates the channel list and sets up their click events. The callback is triggered whenever a channel is selected, and takes the channel ID as its argument.
|
* Updates the channel list and sets up their click events. The callback is triggered whenever a channel is selected, and takes the channel ID as its argument.
|
||||||
*/
|
*/
|
||||||
updateChannelList(channels, selected, callback) {
|
updateChannelList(channels, selected, callback) {
|
||||||
const eleChannels = DOM.id("channels");
|
const eleChannels = dom.id("channels");
|
||||||
|
|
||||||
if (!channels) {
|
if (!channels) {
|
||||||
eleChannels.innerHTML = "";
|
eleChannels.innerHTML = "";
|
||||||
@@ -201,11 +207,11 @@ const GUI = (function() {
|
|||||||
channels = channels.filter(channel => channel.msgcount > 0);
|
channels = channels.filter(channel => channel.msgcount > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
eleChannels.innerHTML = channels.map(channel => DISCORD.getChannelHTML(channel)).join("");
|
eleChannels.innerHTML = channels.map(channel => discord.getChannelHTML(channel)).join("");
|
||||||
|
|
||||||
Array.prototype.forEach.call(eleChannels.children, ele => {
|
Array.prototype.forEach.call(eleChannels.children, ele => {
|
||||||
ele.addEventListener("click", () => {
|
ele.addEventListener("click", () => {
|
||||||
const currentChannel = DOM.fcls("active", eleChannels);
|
const currentChannel = dom.fcls("active", eleChannels);
|
||||||
|
|
||||||
if (currentChannel) {
|
if (currentChannel) {
|
||||||
currentChannel.classList.remove("active");
|
currentChannel.classList.remove("active");
|
||||||
@@ -224,12 +230,12 @@ const GUI = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateMessageList(messages) {
|
updateMessageList(messages) {
|
||||||
DOM.id("messages").innerHTML = messages ? messages.map(message => DISCORD.getMessageHTML(message)).join("") : "";
|
dom.id("messages").innerHTML = messages ? messages.map(message => discord.getMessageHTML(message)).join("") : "";
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUserList(users) {
|
updateUserList(users) {
|
||||||
/** @type HTMLSelectElement */
|
/** @type HTMLSelectElement */
|
||||||
const eleSelect = DOM.id("opt-filter-user");
|
const eleSelect = dom.id("opt-filter-user");
|
||||||
|
|
||||||
while (eleSelect.length > 1) {
|
while (eleSelect.length > 1) {
|
||||||
eleSelect.remove(1);
|
eleSelect.remove(1);
|
||||||
@@ -237,10 +243,11 @@ const GUI = (function() {
|
|||||||
|
|
||||||
const options = [];
|
const options = [];
|
||||||
|
|
||||||
for (const key of Object.keys(users)) {
|
for (const id of Object.keys(users)) {
|
||||||
|
const user = users[id];
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = key;
|
option.value = id;
|
||||||
option.text = users[key].name;
|
option.text = user.displayName ? `${user.displayName} (${user.name})` : user.name;
|
||||||
options.push(option);
|
options.push(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +256,7 @@ const GUI = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
scrollMessagesToTop() {
|
scrollMessagesToTop() {
|
||||||
DOM.id("messages").scrollTop = 0;
|
dom.id("messages").scrollTop = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
35
app/Resources/Viewer/scripts/polyfills.mjs
Normal file
35
app/Resources/Viewer/scripts/polyfills.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// https://gist.github.com/MattiasBuelens/496fc1d37adb50a733edd43853f2f60e/088f061ab79b296f29225467ae9ba86ff990195d
|
||||||
|
|
||||||
|
ReadableStream.prototype.values ??= function({ preventCancel = false } = {}) {
|
||||||
|
const reader = this.getReader();
|
||||||
|
return {
|
||||||
|
async next() {
|
||||||
|
try {
|
||||||
|
const result = await reader.read();
|
||||||
|
if (result.done) {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
reader.releaseLock();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async return(value) {
|
||||||
|
if (!preventCancel) {
|
||||||
|
const cancelPromise = reader.cancel(value);
|
||||||
|
reader.releaseLock();
|
||||||
|
await cancelPromise;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
return { done: true, value };
|
||||||
|
},
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
ReadableStream.prototype[Symbol.asyncIterator] ??= ReadableStream.prototype.values;
|
@@ -1,16 +1,16 @@
|
|||||||
const PROCESSOR = {};
|
import discord from "./discord.mjs";
|
||||||
|
|
||||||
// ------------------------
|
// ------------------------
|
||||||
// Global filter generators
|
// Global filter generators
|
||||||
// ------------------------
|
// ------------------------
|
||||||
|
|
||||||
PROCESSOR.FILTER = {
|
const filter = {
|
||||||
byUser: ((userindex) => message => message.u === userindex),
|
byUser: ((user) => message => message.u === user),
|
||||||
byTime: ((timeStart, timeEnd) => message => message.t >= timeStart && message.t <= timeEnd),
|
byTime: ((timeStart, timeEnd) => message => message.t >= timeStart && message.t <= timeEnd),
|
||||||
byContents: ((substr) => message => ("m" in message ? message.m : "").indexOf(substr) !== -1),
|
byContents: ((substr) => message => ("m" in message ? message.m : "").indexOf(substr) !== -1),
|
||||||
byRegex: ((regex) => message => regex.test("m" in message ? message.m : "")),
|
byRegex: ((regex) => message => regex.test("m" in message ? message.m : "")),
|
||||||
withImages: (() => message => (message.e && message.e.some(embed => embed.type === "image")) || (message.a && message.a.some(DISCORD.isImageAttachment))),
|
withImages: (() => message => (message.e && message.e.some(embed => embed.type === "image")) || (message.a && message.a.some(discord.isImageAttachment))),
|
||||||
withDownloads: (() => message => message.a && message.a.some(attachment => !DISCORD.isImageAttachment(attachment))),
|
withDownloads: (() => message => message.a && message.a.some(attachment => !discord.isImageAttachment(attachment))),
|
||||||
withEmbeds: (() => message => message.e && message.e.length > 0),
|
withEmbeds: (() => message => message.e && message.e.length > 0),
|
||||||
withAttachments: (() => message => message.a && message.a.length > 0),
|
withAttachments: (() => message => message.a && message.a.length > 0),
|
||||||
isEdited: (() => message => ("te" in message) ? message.te : (message.f & 1) === 1)
|
isEdited: (() => message => ("te" in message) ? message.te : (message.f & 1) === 1)
|
||||||
@@ -20,7 +20,7 @@ PROCESSOR.FILTER = {
|
|||||||
// Global sorters
|
// Global sorters
|
||||||
// --------------
|
// --------------
|
||||||
|
|
||||||
PROCESSOR.SORTER = {
|
const sorter = {
|
||||||
oldestToNewest: (key1, key2) => {
|
oldestToNewest: (key1, key2) => {
|
||||||
if (key1.length === key2.length) {
|
if (key1.length === key2.length) {
|
||||||
return key1 > key2 ? 1 : key1 < key2 ? -1 : 0;
|
return key1 > key2 ? 1 : key1 < key2 ? -1 : 0;
|
||||||
@@ -39,3 +39,8 @@ PROCESSOR.SORTER = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
FILTER: filter,
|
||||||
|
SORTER: sorter
|
||||||
|
};
|
@@ -1,4 +1,4 @@
|
|||||||
const SETTINGS = (function() {
|
export default (function() {
|
||||||
/**
|
/**
|
||||||
* @type {{}}
|
* @type {{}}
|
||||||
* @property {Function} onSettingsChanged
|
* @property {Function} onSettingsChanged
|
@@ -1,10 +1,12 @@
|
|||||||
|
import settings from "./settings.mjs";
|
||||||
|
import processor from "./processor.mjs";
|
||||||
|
|
||||||
// noinspection FunctionWithInconsistentReturnsJS
|
// noinspection FunctionWithInconsistentReturnsJS
|
||||||
const STATE = (function() {
|
export default (function() {
|
||||||
/**
|
/**
|
||||||
* @type {{}}
|
* @type {{}}
|
||||||
* @property {{}} users
|
* @property {{}} users
|
||||||
* @property {String[]} userindex
|
* @property {{}} servers
|
||||||
* @property {{}[]} servers
|
|
||||||
* @property {{}} channels
|
* @property {{}} channels
|
||||||
*/
|
*/
|
||||||
let loadedFileMeta;
|
let loadedFileMeta;
|
||||||
@@ -17,20 +19,16 @@ const STATE = (function() {
|
|||||||
let currentPage;
|
let currentPage;
|
||||||
let messagesPerPage;
|
let messagesPerPage;
|
||||||
|
|
||||||
const getUser = function(index) {
|
const getUser = function(id) {
|
||||||
return loadedFileMeta.users[loadedFileMeta.userindex[index]] || { "name": "<unknown>" };
|
return loadedFileMeta.users[id] || { "name": "<unknown>" };
|
||||||
};
|
|
||||||
|
|
||||||
const getUserId = function(index) {
|
|
||||||
return loadedFileMeta.userindex[index];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserList = function() {
|
const getUserList = function() {
|
||||||
return loadedFileMeta ? loadedFileMeta.users : [];
|
return loadedFileMeta ? loadedFileMeta.users : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getServer = function(index) {
|
const getServer = function(id) {
|
||||||
return loadedFileMeta.servers[index] || { "name": "<unknown>", "type": "unknown" };
|
return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" };
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateChannelHierarchy = function() {
|
const generateChannelHierarchy = function() {
|
||||||
@@ -204,7 +202,7 @@ const STATE = (function() {
|
|||||||
*/
|
*/
|
||||||
const message = messages[key];
|
const message = messages[key];
|
||||||
const user = getUser(message.u);
|
const user = getUser(message.u);
|
||||||
const avatar = user.avatar ? { id: getUserId(message.u), path: user.avatar } : null;
|
const avatar = user.avatar ? { id: message.u, path: user.avatar } : null;
|
||||||
|
|
||||||
const obj = {
|
const obj = {
|
||||||
user,
|
user,
|
||||||
@@ -232,7 +230,7 @@ const STATE = (function() {
|
|||||||
if ("r" in message) {
|
if ("r" in message) {
|
||||||
const replyMessage = getMessageById(message.r);
|
const replyMessage = getMessageById(message.r);
|
||||||
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
|
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
|
||||||
const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(replyMessage.u), path: replyUser.avatar } : null;
|
const replyAvatar = replyUser && replyUser.avatar ? { id: replyMessage.u, path: replyUser.avatar } : null;
|
||||||
|
|
||||||
obj["reply"] = replyMessage ? {
|
obj["reply"] = replyMessage ? {
|
||||||
"id": message.r,
|
"id": message.r,
|
||||||
@@ -290,20 +288,17 @@ const STATE = (function() {
|
|||||||
eventOnUsersRefreshed = callback;
|
eventOnUsersRefreshed = callback;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
uploadFile(meta, data) {
|
||||||
* @param {{ meta, data }} file
|
|
||||||
*/
|
|
||||||
uploadFile(file) {
|
|
||||||
if (loadedFileMeta != null) {
|
if (loadedFileMeta != null) {
|
||||||
throw "A file is already loaded!";
|
throw "A file is already loaded!";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file || typeof file.meta !== "object" || typeof file.data !== "object") {
|
if (typeof meta !== "object" || typeof data !== "object") {
|
||||||
throw "Invalid file format!";
|
throw "Invalid file format!";
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedFileMeta = file.meta;
|
loadedFileMeta = meta;
|
||||||
loadedFileData = file.data;
|
loadedFileData = data;
|
||||||
loadedMessages = null;
|
loadedMessages = null;
|
||||||
|
|
||||||
selectedChannel = null;
|
selectedChannel = null;
|
||||||
@@ -313,7 +308,7 @@ const STATE = (function() {
|
|||||||
triggerChannelsRefreshed();
|
triggerChannelsRefreshed();
|
||||||
triggerMessagesRefreshed();
|
triggerMessagesRefreshed();
|
||||||
|
|
||||||
SETTINGS.onSettingsChanged(() => triggerMessagesRefreshed());
|
settings.onSettingsChanged(() => triggerMessagesRefreshed());
|
||||||
},
|
},
|
||||||
|
|
||||||
getChannelName(channel) {
|
getChannelName(channel) {
|
||||||
@@ -321,21 +316,21 @@ const STATE = (function() {
|
|||||||
return (channelObj && channelObj.name) || channel;
|
return (channelObj && channelObj.name) || channel;
|
||||||
},
|
},
|
||||||
|
|
||||||
getUserTag(user) {
|
|
||||||
const userObj = loadedFileMeta.users[user];
|
|
||||||
return (userObj && userObj.tag) || "????";
|
|
||||||
},
|
|
||||||
|
|
||||||
getUserName(user) {
|
getUserName(user) {
|
||||||
const userObj = loadedFileMeta.users[user];
|
const userObj = loadedFileMeta.users[user];
|
||||||
return (userObj && userObj.name) || user;
|
return (userObj && userObj.name) || user;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getUserDisplayName(user) {
|
||||||
|
const userObj = loadedFileMeta.users[user];
|
||||||
|
return (userObj && (userObj.displayName || userObj.name)) || user;
|
||||||
|
},
|
||||||
|
|
||||||
selectChannel(channel) {
|
selectChannel(channel) {
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
selectedChannel = channel;
|
selectedChannel = channel;
|
||||||
|
|
||||||
loadedMessages = getFilteredMessageKeys(channel).sort(PROCESSOR.SORTER.oldestToNewest);
|
loadedMessages = getFilteredMessageKeys(channel).sort(processor.SORTER.oldestToNewest);
|
||||||
triggerMessagesRefreshed();
|
triggerMessagesRefreshed();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -416,23 +411,23 @@ const STATE = (function() {
|
|||||||
setActiveFilter(filter) {
|
setActiveFilter(filter) {
|
||||||
switch (filter ? filter.type : "") {
|
switch (filter ? filter.type : "") {
|
||||||
case "user":
|
case "user":
|
||||||
filterFunction = PROCESSOR.FILTER.byUser(loadedFileMeta.userindex.indexOf(filter.value));
|
filterFunction = processor.FILTER.byUser(filter.value);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "contents":
|
case "contents":
|
||||||
filterFunction = PROCESSOR.FILTER.byContents(filter.value);
|
filterFunction = processor.FILTER.byContents(filter.value);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "withimages":
|
case "withimages":
|
||||||
filterFunction = PROCESSOR.FILTER.withImages();
|
filterFunction = processor.FILTER.withImages();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "withdownloads":
|
case "withdownloads":
|
||||||
filterFunction = PROCESSOR.FILTER.withDownloads();
|
filterFunction = processor.FILTER.withDownloads();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "edited":
|
case "edited":
|
||||||
filterFunction = PROCESSOR.FILTER.isEdited();
|
filterFunction = processor.FILTER.isEdited();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
@@ -1,6 +1,8 @@
|
|||||||
|
import dom from "./dom.mjs";
|
||||||
|
|
||||||
const TEMPLATE_REGEX = /{([^{}]+?)}/g;
|
const TEMPLATE_REGEX = /{([^{}]+?)}/g;
|
||||||
|
|
||||||
class TEMPLATE {
|
export default class {
|
||||||
constructor(contents) {
|
constructor(contents) {
|
||||||
this.contents = contents;
|
this.contents = contents;
|
||||||
};
|
};
|
||||||
@@ -11,10 +13,10 @@ class TEMPLATE {
|
|||||||
|
|
||||||
if (processor) {
|
if (processor) {
|
||||||
const updated = processor(match, value);
|
const updated = processor(match, value);
|
||||||
return typeof updated === "undefined" ? DOM.escapeHTML(value) : updated;
|
return typeof updated === "undefined" ? dom.escapeHTML(value) : updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
return DOM.escapeHTML(value);
|
return dom.escapeHTML(value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -3,18 +3,24 @@
|
|||||||
min-width: 215px;
|
min-width: 215px;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
color: #eee;
|
||||||
background-color: #1c1e22;
|
background-color: #1c1e22;
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#channels > div {
|
#channels > div.loading {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#channels > div.channel {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
color: #eee;
|
|
||||||
font-size: 15px;
|
|
||||||
border-bottom: 1px solid #333333;
|
border-bottom: 1px solid #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
#channels > div:hover, #channels > div.active {
|
#channels > div.channel:hover, #channels > div.channel.active {
|
||||||
background-color: #282b30;
|
background-color: #282b30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -11,3 +11,20 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
position: relative;
|
||||||
|
--loading-backdrop: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading::after {
|
||||||
|
content: "";
|
||||||
|
background: var(--loading-backdrop)
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300' preserveAspectRatio='xMidYMid'%3E %3Ccircle cx='150' cy='150' fill='none' stroke='%237983f5' stroke-width='8' r='42' stroke-dasharray='198 68'%3E %3CanimateTransform attributeName='transform' type='rotate' repeatCount='indefinite' dur='1.25s' values='0 150 150;360 150 150' keyTimes='0;1' /%3E %3C/circle%3E %3C/svg%3E")
|
||||||
|
no-repeat center center;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
@@ -1,15 +1,16 @@
|
|||||||
#menu {
|
#menu {
|
||||||
width: 100%;
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
background-color: #17181c;
|
background-color: #17181c;
|
||||||
border-bottom: 1px dotted #5d626b;
|
border-bottom: 1px dotted #5d626b;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .splitter {
|
#menu .splitter {
|
||||||
width: 1px;
|
flex: 0 0 1px;
|
||||||
margin: 9px 4px;
|
margin: 9px 1px;
|
||||||
background-color: #5d626b;
|
background-color: #5d626b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#menu button, #menu select, #menu input[type="text"] {
|
#menu button, #menu select, #menu input[type="text"] {
|
||||||
margin: 8px;
|
height: 31px;
|
||||||
|
padding: 0 10px;
|
||||||
background-color: #7289da;
|
background-color: #7289da;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
|
||||||
@@ -31,28 +33,25 @@
|
|||||||
|
|
||||||
#menu button {
|
#menu button {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
padding: 0 12px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu select {
|
#menu select {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 6px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu input[type="text"] {
|
#menu input[type="text"] {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 7px 12px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav {
|
#menu .nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
margin: 0 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav > button {
|
#menu .nav > button {
|
||||||
@@ -66,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav > button, #menu .nav > p {
|
#menu .nav > button, #menu .nav > p {
|
||||||
margin: 8px 1px;
|
margin: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#opt-filter-list > select, #opt-filter-list > input {
|
#opt-filter-list > select, #opt-filter-list > input {
|
||||||
@@ -76,3 +75,7 @@
|
|||||||
#opt-filter-list > .active {
|
#opt-filter-list > .active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btn-about {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
#messages {
|
#messages {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background-color: #36393E;
|
background-color: #36393e;
|
||||||
}
|
}
|
||||||
|
|
||||||
#messages > div {
|
#messages > div {
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#messages .username {
|
#messages .username {
|
||||||
color: #FFF;
|
color: #fff;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
@@ -75,12 +75,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message .link, .reply-message .link {
|
.message .link, .reply-message .link {
|
||||||
color: #7289DA;
|
color: #7289da;
|
||||||
background-color: rgba(115, 139, 215, 0.1);
|
background-color: rgba(115, 139, 215, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message a, .reply-message a {
|
.message a, .reply-message a {
|
||||||
color: #0096CF;
|
color: #0096cf;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,23 +107,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message .thumbnail {
|
.message .thumbnail {
|
||||||
position: relative;
|
--loading-backdrop: rgba(0, 0, 0, 0.75);
|
||||||
max-width: calc(100% - 20px);
|
max-width: calc(100% - 20px);
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .thumbnail.loading::after {
|
|
||||||
content: "";
|
|
||||||
background: rgba(0, 0, 0, 0.75)
|
|
||||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300' preserveAspectRatio='xMidYMid'%3E %3Ccircle cx='150' cy='150' fill='none' stroke='%237983f5' stroke-width='8' r='42' stroke-dasharray='198 68'%3E %3CanimateTransform attributeName='transform' type='rotate' repeatCount='indefinite' dur='1.25s' values='0 150 150;360 150 150' keyTimes='0;1' /%3E %3C/circle%3E %3C/svg%3E")
|
|
||||||
no-repeat center center;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .thumbnail img {
|
.message .thumbnail img {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -143,7 +131,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message code {
|
.message code {
|
||||||
background-color: #2E3136;
|
background-color: #2e3136;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-family: Menlo, Consolas, Monaco, monospace;
|
font-family: Menlo, Consolas, Monaco, monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -156,7 +144,7 @@
|
|||||||
|
|
||||||
.message code.block {
|
.message code.block {
|
||||||
display: block;
|
display: block;
|
||||||
border: 2px solid #282B30;
|
border: 2px solid #282b30;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
@@ -210,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reply-username {
|
.reply-username {
|
||||||
color: #FFF;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
@@ -230,7 +218,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reply-contents code {
|
.reply-contents code {
|
||||||
background-color: #2E3136;
|
background-color: #2e3136;
|
||||||
font-family: Menlo, Consolas, Monaco, monospace;
|
font-family: Menlo, Consolas, Monaco, monospace;
|
||||||
padding: 1px 2px;
|
padding: 1px 2px;
|
||||||
}
|
}
|
||||||
|
@@ -1,15 +1,19 @@
|
|||||||
namespace DHT.Server.Data.Aggregations;
|
namespace DHT.Server.Data.Aggregations;
|
||||||
|
|
||||||
public sealed class DownloadStatusStatistics {
|
public sealed class DownloadStatusStatistics {
|
||||||
public int EnqueuedCount { get; internal set; }
|
public int PendingCount { get; internal init; }
|
||||||
public ulong EnqueuedSize { get; internal set; }
|
public ulong PendingTotalSize { get; internal init; }
|
||||||
|
public int PendingWithUnknownSizeCount { get; internal init; }
|
||||||
public int SuccessfulCount { get; internal set; }
|
|
||||||
public ulong SuccessfulSize { get; internal set; }
|
public int SuccessfulCount { get; internal init; }
|
||||||
|
public ulong SuccessfulTotalSize { get; internal init; }
|
||||||
public int FailedCount { get; internal set; }
|
public int SuccessfulWithUnknownSizeCount { get; internal init; }
|
||||||
public ulong FailedSize { get; internal set; }
|
|
||||||
|
public int FailedCount { get; internal init; }
|
||||||
public int SkippedCount { get; internal set; }
|
public ulong FailedTotalSize { get; internal init; }
|
||||||
public ulong SkippedSize { get; internal set; }
|
public int FailedWithUnknownSizeCount { get; internal init; }
|
||||||
|
|
||||||
|
public int SkippedCount { get; internal init; }
|
||||||
|
public ulong SkippedTotalSize { get; internal init; }
|
||||||
|
public int SkippedWithUnknownSizeCount { get; internal init; }
|
||||||
}
|
}
|
||||||
|
@@ -1,33 +1,17 @@
|
|||||||
using System;
|
|
||||||
using System.Net;
|
|
||||||
using DHT.Server.Download;
|
|
||||||
|
|
||||||
namespace DHT.Server.Data;
|
namespace DHT.Server.Data;
|
||||||
|
|
||||||
public readonly struct Download {
|
public sealed class Download {
|
||||||
internal static Download NewSuccess(DownloadItem item, byte[] data) {
|
|
||||||
return new Download(item.NormalizedUrl, item.DownloadUrl, DownloadStatus.Success, (ulong) Math.Max(data.LongLength, 0), data);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Download NewFailure(DownloadItem item, HttpStatusCode? statusCode, ulong size) {
|
|
||||||
return new Download(item.NormalizedUrl, item.DownloadUrl, statusCode.HasValue ? (DownloadStatus) (int) statusCode : DownloadStatus.GenericError, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string NormalizedUrl { get; }
|
public string NormalizedUrl { get; }
|
||||||
public string DownloadUrl { get; }
|
public string DownloadUrl { get; }
|
||||||
public DownloadStatus Status { get; }
|
public DownloadStatus Status { get; }
|
||||||
public ulong Size { get; }
|
public string? Type { get; }
|
||||||
public byte[]? Data { get; }
|
public ulong? Size { get; }
|
||||||
|
|
||||||
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, ulong size, byte[]? data = null) {
|
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) {
|
||||||
NormalizedUrl = normalizedUrl;
|
NormalizedUrl = normalizedUrl;
|
||||||
DownloadUrl = downloadUrl;
|
DownloadUrl = downloadUrl;
|
||||||
Status = status;
|
Status = status;
|
||||||
|
Type = type;
|
||||||
Size = size;
|
Size = size;
|
||||||
Data = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal Download WithData(byte[] data) {
|
|
||||||
return new Download(NormalizedUrl, DownloadUrl, Status, Size, data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,7 +6,9 @@ namespace DHT.Server.Data;
|
|||||||
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum DownloadStatus {
|
public enum DownloadStatus {
|
||||||
Enqueued = 0,
|
Pending = 0,
|
||||||
GenericError = 1,
|
GenericError = 1,
|
||||||
Success = HttpStatusCode.OK
|
Downloading = 2,
|
||||||
|
LastCustomCode = 99,
|
||||||
|
Success = HttpStatusCode.OK,
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user