1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-08-17 19:31:42 +02:00

153 Commits

Author SHA1 Message Date
50d5bdba97 Release v45.0 2025-02-07 16:44:16 +01:00
295c32c7b4 Remove some runtimes from the portable build 2025-02-06 14:12:37 +01:00
5e4bb5b44d Remove osx-x64 from dedicated runtime builds 2025-02-06 14:11:05 +01:00
96c19afa66 Improve reproducibility of builds 2025-02-06 13:36:13 +01:00
a45f60b528 Update to .NET 9 2025-02-06 13:15:11 +01:00
62b97807b8 Update FUNDING.yml 2025-02-06 06:25:39 +01:00
0fff3e8eaf Add option to delete orphaned downloads
Closes #273
2025-02-03 12:29:09 +01:00
02b773db8f Handle uncaught exceptions in progress dialogs 2025-02-03 12:28:07 +01:00
261a583b50 Remove orphaned servers, channels, users, and attachments after removing messages 2025-02-03 12:28:07 +01:00
2ecb658e42 Reformat code 2025-02-03 12:28:07 +01:00
7a6dd23912 Add foreign keys to tables linked to messages 2025-02-03 12:28:07 +01:00
60761d80ab Fix showing wrong keyboard shortcut for opening dev tools on macOS 2025-01-15 03:47:57 +01:00
e11db62015 Update Avalonia to 11.2.3 2024-12-31 10:30:18 +01:00
bfac9b91d9 Release v44.0 2024-12-31 09:23:17 +01:00
4215dc76f4 Add main menu button to check for updates
Closes #165
2024-12-31 08:58:56 +01:00
86adda610b Fix wrong reaction handling in messages with polls
Closes #259
2024-12-31 04:59:33 +01:00
f1c4cd040e Tweak tracking script for easier debugging 2024-12-31 04:55:53 +01:00
737ab43ea4 Add option to hide previews while tracking and autoscrolling 2024-11-05 18:38:49 +01:00
0229c00259 Rewrite tracker to use Discord client functions instead of React properties 2024-10-23 02:01:23 +02:00
95361d2e01 Write downloads to temporary files to reduce blocking concurrent database accesses
References #168
2024-10-22 11:55:27 +02:00
5b7312109b Release v43.1 2024-08-04 21:49:40 +02:00
9f1db9a662 Fix attachment migration error when database contains attachments for deleted messages
Closes #264
2024-08-04 21:47:34 +02:00
d3e1bb901c Release v43.0 2024-07-10 12:28:20 +02:00
9bd105f57e Update About page with a link to issue tracker and libraries 2024-07-10 12:28:20 +02:00
e1eae393c3 Update SQLite provider to 8.0.7 (SQLite version 3.41.2) 2024-07-09 20:39:39 +02:00
fbed74529d Update Avalonia to 11.0.11 2024-07-09 20:39:39 +02:00
8924aa7c06 Ask about automatic downloads when creating a new database 2024-07-09 20:39:39 +02:00
9738880ba7 Remember downloading state and filter settings in database 2024-07-09 20:39:39 +02:00
1700f99bf7 Rename database tables related to messages 2024-07-05 05:02:06 +02:00
84acf5f5d5 Fix error when tracking announcements with attachments across multiple channels
Closes #261
2024-07-04 21:56:23 +02:00
ae56433836 Fix not updating download URLs and metadata when re-tracking messages with failed downloads 2024-07-01 05:45:36 +02:00
c3d4fa5532 Release v42.1 2024-05-09 00:08:15 +02:00
a6225b9721 Hide user discriminators & fix places that still displayed usernames instead of display names
References #216
2024-05-09 00:04:31 +02:00
943163473a Add support for global display names of users
Closes #216
2024-05-08 23:39:13 +02:00
fa00df10d8 Fix viewer menu styling 2024-05-08 21:47:16 +02:00
f54465e5fe Fix viewer compatibility with older browsers and (hopefully) Safari
Closes #260
2024-05-08 21:08:24 +02:00
2d55ca4013 Release v42.0 2024-04-23 03:56:27 +02:00
0072e025f4 Fix broken links to image embeds in viewer 2024-04-21 20:55:06 +02:00
383ee5c90e Stream messages to viewer using JSON lines 2024-04-21 20:55:05 +02:00
2b1c178f49 Handle request cancellation in the integrated HTTP server 2024-04-21 18:48:19 +02:00
16d7e5a6f8 Serve viewer page and JSON data from the integrated HTTP server 2024-04-21 18:48:19 +02:00
052d24d1e7 Remove unused file library in Rider 2024-04-20 02:06:17 +02:00
b058f1fb77 Update Rider code style and inspections 2024-04-20 02:06:17 +02:00
d4da64a5ed Release v41.2 2024-04-17 14:14:46 +02:00
8de309a6c4 Fix some Rider inspections and a typo 2024-04-17 14:13:33 +02:00
de8d6a1e11 Stream downloaded files during database merges 2024-04-17 13:31:25 +02:00
d79e6f53b4 Stream downloaded files from database directly into HTTP server responses 2024-04-17 13:31:25 +02:00
70c04fc986 Stream downloaded files directly into database 2024-04-17 13:31:24 +02:00
c8d8d95daa Fix not rolling back database transactions after unhandled exceptions 2024-04-17 12:30:13 +02:00
daafdbbfaf Prevent active downloads from timing out
Closes #256
2024-04-17 08:51:37 +02:00
07615de87a Fix download timeouts not marking the downloaded file as failed
References #256
2024-04-16 11:00:38 +02:00
7fdc19880e Add -concurrentdownloads program argument to configure number of concurrent download tasks
References #256
2024-04-16 10:50:50 +02:00
67b9c12843 Release v41.1 2024-02-15 13:07:36 +01:00
9030a2f010 Update message timestamp processing for latest Discord update
Closes #249
2024-02-15 13:06:06 +01:00
a6dad6b4c7 Release v41.0 2024-01-11 04:24:34 +01:00
72b8fb7c14 Update viewer to reference downloaded embeds, avatars, and emoji 2024-01-07 19:15:12 +01:00
7173dc6cfc Refactor last change to CDN URL normalization 2024-01-07 06:17:17 +01:00
2c1e5a7603 Rework download storage and start collecting download URLs from embeds, avatars, and reactions
Resolves #200
2024-01-07 05:50:46 +01:00
4929a19397 Fix button to retry failed downloads & show error if downloads fail to start 2024-01-01 16:25:32 +01:00
c5f77872fe Fix some database calls not being asynchronous 2024-01-01 14:37:05 +01:00
c9e50e1a80 Refactor database schema upgrades 2024-01-01 09:29:32 +01:00
ef3e34066a Release v40.0 2023-12-31 20:18:24 +01:00
37374eeb18 Migrate ConfigureAwait to Task.Run 2023-12-31 20:18:08 +01:00
23ddb45a0d Make opening/saving viewer asynchronous 2023-12-31 20:18:08 +01:00
9904a711f7 Make database connection pool asynchronous 2023-12-31 19:47:28 +01:00
d5720c8758 Code cleanup 2023-12-31 19:44:44 +01:00
89161e14b1 Increase default window size slightly 2023-12-31 16:54:14 +01:00
9d208b026c Synchronize publishing downloaded items to UI 2023-12-31 16:51:33 +01:00
119649ef9b Optimize rendering of download statistics table 2023-12-31 15:33:00 +01:00
0bc6232da7 Convert database events to reactive 2023-12-31 14:42:03 +01:00
de266473c5 Migrate models to a custom version of MVVM Community Toolkit 2023-12-31 14:42:03 +01:00
e0f359c15b Fix wrong attachment download progress information after re-enqueueing items 2023-12-30 12:27:44 +01:00
935f11d736 Make database schema upgrades asynchronous 2023-12-30 12:27:44 +01:00
f64141e768 Rewrite database interface to be asynchronous and improve UI 2023-12-30 12:27:44 +01:00
edea3470df Set C# language version to 12 2023-12-28 20:34:45 +01:00
031d521402 Convert attachment download events to reactive 2023-12-28 17:33:30 +01:00
0131f8cb50 Refactor integrated server management 2023-12-28 17:33:30 +01:00
3bf5acfa65 Fix trailing spaces Rider generates in code 2023-12-27 08:22:23 +01:00
f603c861c5 Encapsulate server-side state and ensure graceful shutdown when closing main window 2023-12-27 08:22:23 +01:00
d2934f4d6a Update user agent of downloader 2023-12-25 09:36:14 +01:00
567253d147 Use multiple threads to download attachments 2023-12-25 09:36:14 +01:00
aa6555990c Fix throwing exceptions in UI binding converters 2023-12-25 07:04:31 +01:00
3d9d6a454a Remove unnecessary ASP.NET features 2023-12-23 13:47:33 +01:00
ee39780928 Rewrite token authorization checks in integrated server 2023-12-23 13:47:31 +01:00
7b58f973a0 Disable ASP.NET logging and use custom logging for request duration 2023-12-23 11:19:54 +01:00
93fe018343 Add -console argument to show a console on Windows 2023-12-23 08:46:43 +01:00
4f5e27f651 Release v39.1 2023-12-22 16:31:11 +01:00
cbf81ec95a Fix missing JSON source generator when parsing integrated server requests 2023-12-22 16:31:11 +01:00
8a80cb8c20 Show progress dialog when upgrading database schema 2023-12-22 16:18:03 +01:00
865deb356a Fix progress dialog not propagating exceptions from its task 2023-12-22 14:47:55 +01:00
069ab97196 Disable reflection-based JSON serialization 2023-12-22 05:54:24 +01:00
caab038eaa Use source generators for JSON serialization everywhere 2023-12-22 05:24:28 +01:00
fb837374fc Enable single file compression and disable unnecessary .NET features 2023-12-22 02:30:24 +01:00
65d935cca1 Use compiled bindings in Avalonia XAML 2023-12-21 08:55:56 +01:00
6e64c86d7a Optimize viewer JSON export using source generators 2023-12-21 08:29:07 +01:00
8aeb590bb3 Release v39.0 2023-12-21 05:18:01 +01:00
8dc1adc9f0 Fix viewer converting underscores to italics even when not at the end of a word 2023-12-21 05:09:55 +01:00
ddf70b02e7 Fix not storing channel topic and position if the channel is in a category
Closes #232
2023-12-21 04:49:19 +01:00
ef59fd992e Reword "Do Nothing" option in autoscroll settings to improve clarity 2023-12-21 04:27:24 +01:00
d044627fac Reduce delay in tracker for responding to new messages 2023-12-21 04:27:24 +01:00
a624745602 Force light theme in Avalonia 2023-12-21 04:24:12 +01:00
6da3c185e5 Update Avalonia to 11.0.6 2023-12-21 04:24:01 +01:00
d4d14cab97 Update Rider project configuration files 2023-12-21 00:55:19 +01:00
095c9a061a Update attachment downloads for time-limited Discord CDN URLs
Closes #223
2023-12-21 00:27:08 +01:00
d01f9ed218 Rewrite detection of newly loaded messages 2023-12-20 13:19:11 +01:00
dd6f121059 Fix not seeing messages after a Discord update
Closes #240
2023-12-20 10:51:14 +01:00
8bba33d815 Update SQLite provider to 8.0.0 (SQLite version 3.41.2) 2023-12-20 10:42:17 +01:00
9eab8ac92a Update README section on distribution 2023-11-24 13:47:53 +01:00
fe588686fc Clean up project configuration and build scripts & fix CI builds 2023-11-23 21:45:54 +01:00
7392987165 Remove unused argument in tracker script
Closes #235
2023-11-23 05:22:33 +01:00
492dddb35d Load full tracking script from the integrated server to remove need for minification 2023-11-21 07:08:26 +01:00
d35280a6a6 Release v38.0 2023-10-29 06:42:45 +01:00
ae8a34f938 Log error when reading Discord settings fails 2023-10-29 06:42:45 +01:00
18f5823f2a Fix not seeing messages after a Discord update
Closes #226
2023-10-29 06:42:45 +01:00
37640c97b0 Update to Avalonia 11.0.5 2023-10-29 06:38:26 +01:00
3cc5c75c48 Fix JSON serialization not working in release builds 2023-10-29 06:38:26 +01:00
2424a8ac8d Fix UI freezing due to attachment download statistics being updated on the wrong thread 2023-07-18 18:56:34 +02:00
e7705587c2 Fix .NET 8 warnings and update C# conventions 2023-07-18 18:51:23 +02:00
bd7904b88e Convert namespace declarations to top-level 2023-07-18 16:12:02 +02:00
3db599e901 Update to .NET 8 2023-07-18 15:56:15 +02:00
69a68b463c Update JetBrains.Annotations to 2023.2.0 2023-07-18 15:54:13 +02:00
b2389dd521 Update SQLite provider to 7.0.9 2023-07-18 15:54:13 +02:00
610516de1f Update file dialogs and hide overwrite prompt when opening database files
Closes #198
2023-07-17 23:11:41 +02:00
3a6b83e0ba Update to Avalonia 11 2023-07-17 23:11:41 +02:00
22958536e7 Update Avalonia to 0.10.18 & SQLite provider to 6.0.10 2022-10-22 00:17:48 +02:00
4db8c302d8 Release v37.2 2022-10-07 14:40:37 +02:00
7e7d140957 Fix server name detection after a Discord update
Closes #204
2022-10-07 14:35:30 +02:00
8c68438fbb Release v37.1 2022-07-18 22:06:05 +02:00
f625a39b4d Update SQLite provider to 6.0.7 (SQLite version 3.35.5) 2022-07-18 21:51:19 +02:00
7fd644449c Update JetBrains.Annotations to 2022.1.0 2022-07-18 21:43:45 +02:00
e4a09515b0 Update Avalonia to 0.10.16 2022-07-18 21:29:07 +02:00
9ac9f2246f Fix DHT tracker overlaying part of Discord 2022-07-18 21:20:49 +02:00
bbc734ba9b Track dimensions of image attachments 2022-07-18 21:09:20 +02:00
6837b05b0d Fix missing ToArray call when serializing message reactions to JSON 2022-07-18 21:00:36 +02:00
c94808a15f Show downloaded attachments when viewing via Open Viewer 2022-07-18 20:21:45 +02:00
739e87c5ab Add one decimal place to MB/GB/TB in the table in the Attachments tab 2022-07-18 20:19:51 +02:00
d463b407f4 Fix code for reducing chance of SQLite connection pool livelocks 2022-07-18 20:13:05 +02:00
cd418f4871 Fix viewer image detection and file name parsing when the URL includes a query 2022-07-18 20:13:05 +02:00
176a81e055 Reformat list of allowed origins in CORS 2022-07-15 01:34:08 +02:00
Matojeje
1cf3e76644 Add CORS for Canary and PTB 2022-07-15 01:34:08 +02:00
33f5ab7cce Release v37.0 2022-06-18 14:00:15 +02:00
b9a5664740 Fix not seeing messages after a Discord update
Closes #189
2022-06-18 13:59:29 +02:00
845ac1b0fa Release v36.2 (beta) 2022-06-06 17:08:37 +02:00
1bead42a0e Improve error handling and reporting when extracting message data 2022-06-06 17:05:20 +02:00
8f1c91b2cc Fix not formatting single underscores as italics in the viewer
Closes #142
2022-06-04 22:13:16 +02:00
9ae5ece24b Fix negative numbers & exception with very large numbers in attachment size limit 2022-06-04 21:55:55 +02:00
053ab5b091 Fix exception & wrong download statistics when multiple attachments have the same URL 2022-06-04 21:47:34 +02:00
71c628fdf8 Fix not recomputing download statistics after removing download items 2022-06-04 21:45:40 +02:00
af621b8d46 Fix wrong plural in the Viewer tab if the total amount of messages is zero (properly this time) 2022-06-04 21:26:50 +02:00
31fe6aed35 Stop ignoring removal filters for messages and download items if the filter matches all 2022-06-04 21:25:06 +02:00
c25426af55 Add image loading animation to viewer
Related to #79
2022-06-04 16:56:41 +02:00
59129ba20a Change image alt text in viewer to indicate when images are loading, and when loading fails
Related to #79
2022-06-04 15:50:05 +02:00
f7bfe052ca Add known sizes of images to the viewer
Related to #79
2022-06-04 15:29:42 +02:00
285 changed files with 9255 additions and 48127 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,3 +1 @@
github: chylex
patreon: chylex
ko_fi: chylex

View File

@@ -16,10 +16,9 @@ Fork the repository and clone it to your computer (if you've never used git, you
Folder organization:
* `app/` contains a Visual Studio solution for the desktop app
* `lib/` contains utilities required to build the project
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
To start editing source code for the desktop app, install the [.NET 5 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/5.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
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
@@ -29,27 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
#### Release Windows (64-bit)
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH`
2. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
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.
The `lib/` folder contains an installation of [Node](https://nodejs.org/en) and [uglify-js](https://www.npmjs.com/package/uglify-js), which are used to minify the tracking script. This installation will only work on 64-bit Windows; building on 32-bit Windows is not supported, but you can try.
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
Note: The build script expects `dotnet.exe` to be installed in `C:\Program Files\dotnet`.
#### Release Other Operating Systems
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3
- On Debian and derivatives, you can install `python-is-python3`
- On other distributions, you can create a link manually, for ex. `ln -s /usr/bin/python3 /usr/bin/python`
- If you don't want `python` to mean Python 3, then edit `Desktop.csproj` and change `python` to `python3`
2. Install [Node + npm](https://nodejs.org/en)
3. Install [uglify-js](https://www.npmjs.com/package/uglify-js) globally (`npm install -g uglify-js`)
4. Install the `zip` package from your repository
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
1. Install the `zip` package from your repository.
2. Run the `app/build.sh` script.
3. Read the [Distribution](#distribution) section below.
#### Distribution
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 5 to be installed.
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking `DiscordHistoryTracker`. I tried using Python to re-create the archives with correct file permissions, but found that Linux `zip` tools could not see them. The only working solution is building the Windows + portable version on Windows, and Linux + Mac version on Linux.
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.

View File

@@ -9,15 +9,15 @@
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/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/ServerConfigurationPanel.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/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/DebugPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DownloadsPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />

View File

@@ -13,10 +13,10 @@
<CssCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" />
</CssCodeStyleSettings>
<DB2CodeStyleSettings version="6">
<DB2CodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</DB2CodeStyleSettings>
<DerbyCodeStyleSettings version="6">
<DerbyCodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</DerbyCodeStyleSettings>
<GoCodeStyleSettings>
@@ -26,13 +26,10 @@
<option name="WRAP_FUNC_PARAMS" value="5" />
<option name="WRAP_FUNC_RESULT" value="5" />
</GoCodeStyleSettings>
<H2CodeStyleSettings version="6">
<H2CodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings>
<H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings>
<HSQLCodeStyleSettings version="6">
<HSQLCodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</HSQLCodeStyleSettings>
<HTMLCodeStyleSettings>
@@ -88,10 +85,10 @@
<LessCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" />
</LessCodeStyleSettings>
<MSSQLCodeStyleSettings version="6">
<MSSQLCodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</MSSQLCodeStyleSettings>
<MySQLCodeStyleSettings version="6">
<MySQLCodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</MySQLCodeStyleSettings>
<Objective-C>
@@ -101,7 +98,7 @@
<option name="SPACE_BEFORE_INIT_LIST" value="true" />
<option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" />
</Objective-C>
<OracleCodeStyleSettings version="6">
<OracleCodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</OracleCodeStyleSettings>
<PHPCodeStyleSettings>
@@ -119,7 +116,7 @@
<option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" />
<option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" />
</PHPCodeStyleSettings>
<PostgresCodeStyleSettings version="6">
<PostgresCodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</PostgresCodeStyleSettings>
<Properties>
@@ -146,13 +143,16 @@
<option name="INDENT_WHEN_CASES" value="true" />
<option name="CHAIN_CALLS_ALIGNMENT" value="2" />
</Ruby>
<SQLiteCodeStyleSettings version="6">
<SQLiteCodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</SQLiteCodeStyleSettings>
<ScssCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" />
</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="TYPE_CASE" value="2" />
<option name="CUSTOM_TYPE_CASE" value="2" />
@@ -176,9 +176,10 @@
<option name="CONSTRAINT_WRAP_3" value="true" />
<option name="CONSTRAINT_WRAP_4" 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" />
</SqlCodeStyleSettings>
<SybaseCodeStyleSettings version="6">
<SybaseCodeStyleSettings version="7">
<option name="USE_GENERIC_STYLE" value="true" />
</SybaseCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
@@ -206,6 +207,11 @@
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Gherkin">
<indentOptions>
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Groovy">
<option name="ELSE_ON_NEW_LINE" value="true" />
<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true" />
@@ -285,6 +291,13 @@
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
</indentOptions>
</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">
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
@@ -345,7 +358,6 @@
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Rust">
<option name="RIGHT_MARGIN" value="140" />
<option name="ALIGN_MULTILINE_CHAINED_METHODS" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
@@ -360,6 +372,7 @@
</codeStyleSettings>
<codeStyleSettings language="SCSS">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
</indentOptions>
</codeStyleSettings>
@@ -370,13 +383,6 @@
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
</indentOptions>
</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">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
@@ -430,6 +436,9 @@
</codeStyleSettings>
<codeStyleSettings language="liquid">
<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" />
</indentOptions>
</codeStyleSettings>

View File

@@ -91,7 +91,6 @@
<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="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="ConditionalExpression" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="ignoreSimpleAssignmentsAndReturns" value="true" />
@@ -362,7 +361,7 @@
</inspection_tool>
<inspection_tool class="LocalVariableDeclarationSideOnly" enabled="true" level="ERROR" 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_maxLength" value="99" />
</inspection_tool>

View File

@@ -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>

View File

@@ -1,8 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0/DiscordHistoryTracker.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Desktop/debug/DiscordHistoryTracker.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.artifacts/bin/Desktop/debug" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
@@ -12,7 +12,7 @@
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net5.0" />
<option name="PROJECT_TFM" value="net8.0" />
<method v="2">
<option name="Build" />
</method>

View File

@@ -1,23 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Minify" type="PythonConfigurationType" factoryName="Python">
<module name="rider.module" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="false" />
<option name="ADD_SOURCE_ROOTS" value="false" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/minify.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@@ -2,12 +2,13 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="clr-namespace:DHT.Desktop.Common"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
x:Class="DHT.Desktop.App">
x:Class="DHT.Desktop.App"
RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme Mode="Light" />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml" />
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" />
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="Cursor" Value="Hand" />
@@ -33,9 +34,6 @@
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="TextBox:disabled"><!-- TODO bug in Avalonia (https://github.com/AvaloniaUI/Avalonia/pull/7792) -->
<Setter Property="Foreground" Value="{DynamicResource TextControlForegroundDisabled}" />
</Style>
<Style Selector="TextBox:error DataValidationErrors">
<Style.Resources>
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
@@ -51,68 +49,9 @@
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
</Style>
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="ToggleButtonBackground">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="35">
<Border x:Name="ExpandCollapseChevronBorder"
Grid.Column="0"
Width="35"
Height="35"
Margin="2,0"
RenderTransformOrigin="50%,50%">
<Path x:Name="ExpandCollapseChevron"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderTransformOrigin="50%,50%"
Stretch="None"
Stroke="{DynamicResource ExpanderChevronForeground}"
StrokeThickness="1" />
<Border.RenderTransform>
<RotateTransform />
</Border.RenderTransform>
</Border>
<ContentPresenter x:Name="PART_ContentPresenter"
Grid.Column="1"
Margin="0"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="Center"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
TextBlock.Foreground="{DynamicResource ExpanderForeground}" />
</Grid>
</Border>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
<Style.Animations>
<Animation FillMode="Both" Duration="0:0:0.0625">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
<Style.Animations>
<Animation FillMode="Both" Duration="0:0:0.0625">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
</Animation>
</Style.Animations>
<Style Selector="Expander">
<Setter Property="MinHeight" Value="40" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
</Application.Styles>
@@ -171,12 +110,26 @@
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
<SolidColorBrush x:Key="ExpanderBorderBrush" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderBackground" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPressed" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushDisabled" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBackground" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPointerOver" Color="#536794" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPressed" Color="#47587F" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundDisabled" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundPointerOver" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundPressed" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundDisabled" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronBackground" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundPointerOver" Color="#536794" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundPressed" Color="#47587F" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderDropDownBorderBrush" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForegroundPointerOver" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="#FFFFFF" />
</Application.Resources>

View File

@@ -3,7 +3,8 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Main;
namespace DHT.Desktop {
namespace DHT.Desktop;
sealed class App : Application {
public override void Initialize() {
AvaloniaXamlLoader.Load(this);
@@ -11,10 +12,9 @@ namespace DHT.Desktop {
public override void OnFrameworkInitializationCompleted() {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args));
desktop.MainWindow = new MainWindow(Program.Arguments);
}
base.OnFrameworkInitializationCompleted();
}
}
}

View File

@@ -1,33 +1,42 @@
using System;
using System.Collections.Generic;
using DHT.Utils.Logging;
namespace DHT.Desktop {
namespace DHT.Desktop;
sealed class Arguments {
private static readonly Log Log = Log.ForType<Arguments>();
public static Arguments Empty => new(Array.Empty<string>());
private const int FirstArgument = 1;
public static Arguments Empty => new ([]);
public bool Console { get; }
public string? DatabaseFile { get; }
public ushort? ServerPort { get; }
public string? ServerToken { get; }
public byte? ConcurrentDownloads { get; }
public Arguments(string[] args) {
for (int i = 0; i < args.Length; i++) {
public Arguments(IReadOnlyList<string> args) {
for (int i = FirstArgument; i < args.Count; i++) {
string key = args[i];
switch (key) {
case "-debug":
Log.IsDebugEnabled = true;
continue;
case "-console":
Console = true;
continue;
}
string value;
if (i == 0 && !key.StartsWith('-')) {
if (i == FirstArgument && !key.StartsWith('-')) {
value = key;
key = "-db";
}
else if (i >= args.Length - 1) {
else if (i >= args.Count - 1) {
Log.Warn("Missing value for command line argument: " + key);
continue;
}
@@ -41,11 +50,11 @@ namespace DHT.Desktop {
continue;
case "-port": {
if (ushort.TryParse(value, out var port)) {
ServerPort = port;
if (!ushort.TryParse(value, out ushort port)) {
Log.Warn("Invalid port number: " + value);
}
else {
Log.Warn("Invalid port number: " + value);
ServerPort = port;
}
continue;
@@ -55,6 +64,20 @@ namespace DHT.Desktop {
ServerToken = value;
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:
Log.Warn("Unknown command line argument: " + key);
break;
@@ -62,4 +85,3 @@ namespace DHT.Desktop {
}
}
}
}

View File

@@ -2,28 +2,37 @@ using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace DHT.Desktop.Common {
namespace DHT.Desktop.Common;
sealed class BytesValueConverter : IValueConverter {
private static readonly string[] Units = {
"B",
"kB",
"MB",
"GB",
"TB"
};
private sealed class Unit {
private readonly string label;
private readonly string numberFormat;
public Unit(string label, int decimalPlaces) {
this.label = label;
this.numberFormat = "{0:n" + decimalPlaces + "}";
}
public string Format(double size) {
return string.Format(Program.Culture, numberFormat, size) + " " + label;
}
}
private static readonly Unit[] Units = [
new Unit("B", decimalPlaces: 0),
new Unit("kB", decimalPlaces: 0),
new Unit("MB", decimalPlaces: 1),
new Unit("GB", decimalPlaces: 1),
new Unit("TB", decimalPlaces: 1)
];
private const int Scale = 1000;
private static string Convert(ulong size) {
public static string Convert(ulong size) {
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
int unit = power >= Units.Length ? Units.Length - 1 : power;
if (unit == 0) {
return string.Format(Program.Culture, "{0:n0}", size) + " " + Units[unit];
}
else {
double humanReadableSize = size / Math.Pow(Scale, unit);
return string.Format(Program.Culture, "{0:n0}", humanReadableSize) + " " + Units[unit];
}
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
}
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
@@ -42,4 +51,3 @@ namespace DHT.Desktop.Common {
throw new NotSupportedException();
}
}
}

View File

@@ -3,46 +3,50 @@ using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
using DHT.Server.Database;
using DHT.Server.Database.Exceptions;
using DHT.Server.Database.Sqlite;
using DHT.Server.Database.Sqlite.Schema;
using DHT.Utils.Logging;
namespace DHT.Desktop.Common {
namespace DHT.Desktop.Common;
static class DatabaseGui {
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
private const string DatabaseFileInitialName = "archive.dht";
private static readonly List<FileDialogFilter> DatabaseFileDialogFilter = new() {
new FileDialogFilter {
Name = "Discord History Tracker Database",
Extensions = { "dht" }
}
};
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"]),
];
public static OpenFileDialog NewOpenDatabaseFileDialog() {
return new OpenFileDialog {
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Database File",
InitialFileName = DatabaseFileInitialName,
Filters = DatabaseFileDialogFilter
};
FileTypeFilter = DatabaseFileDialogFilter,
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
AllowMultiple = true,
});
}
public static SaveFileDialog NewOpenOrCreateDatabaseFileDialog() {
return new SaveFileDialog {
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
Title = "Open or Create Database File",
InitialFileName = DatabaseFileInitialName,
Filters = DatabaseFileDialogFilter
};
FileTypeChoices = DatabaseFileDialogFilter,
SuggestedFileName = DatabaseFileInitialName,
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
ShowOverwritePrompt = false,
});
}
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
IDatabaseFile? file = null;
try {
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
} catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) {
@@ -63,4 +67,3 @@ namespace DHT.Desktop.Common {
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
}
}
}

View File

@@ -2,7 +2,8 @@ using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace DHT.Desktop.Common {
namespace DHT.Desktop.Common;
sealed class NumberValueConverter : IValueConverter {
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
@@ -12,4 +13,3 @@ namespace DHT.Desktop.Common {
throw new NotSupportedException();
}
}
}

View 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 });
}
}

View File

@@ -1,4 +1,5 @@
namespace DHT.Desktop.Common {
namespace DHT.Desktop.Common;
static class TextFormat {
public static string Format(this int number) {
return number.ToString("N0", Program.Culture);
@@ -16,4 +17,3 @@ namespace DHT.Desktop.Common {
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
}
}
}

View File

@@ -1,77 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>DHT.Desktop</RootNamespace>
<AssemblyName>DiscordHistoryTracker</AssemblyName>
<PackageId>DiscordHistoryTracker</PackageId>
</PropertyGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyName>DiscordHistoryTracker</AssemblyName>
<RootNamespace>DHT.Desktop</RootNamespace>
<PackageId>DiscordHistoryTracker</PackageId>
<Authors>chylex</Authors>
<Company>DiscordHistoryTracker</Company>
<Product>DiscordHistoryTracker</Product>
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="0.10.14" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.14" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.14" />
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.14" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.3" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Server\Server.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Version.cs" Link="Version.cs" />
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
<DependentUpon>CheckBoxDialog.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Resources/icon.ico" />
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js">
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<EmbeddedResource Include="../Resources/Tracker/scripts/**" Condition=" '$(Configuration)' == 'Debug' ">
<LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<EmbeddedResource Include="../Resources/Tracker/styles/**">
<LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<EmbeddedResource Include="../Resources/Viewer/**">
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
<EmbeddedResource Include="Resources/tracker-loader.js">
<LogicalName>tracker-loader.js</LogicalName>
</EmbeddedResource>
</ItemGroup>
<Target Name="MinifyResources" BeforeTargets="PrepareForBuild" Condition=" '$(Configuration)' == 'Release' ">
<PropertyGroup>
<MinifiedResourceDir>$(ProjectDir)bin/.res/scripts</MinifiedResourceDir>
</PropertyGroup>
<ItemGroup>
<UpToDateCheckInput Include="$(ProjectDir)../Resources/Tracker/scripts/**" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/discord.js" LogicalName="Tracker\scripts\discord.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/dom.js" LogicalName="Tracker\scripts\dom.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/gui.js" LogicalName="Tracker\scripts\gui.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/settings.js" LogicalName="Tracker\scripts\settings.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/state.js" LogicalName="Tracker\scripts\state.js" Visible="false" />
</ItemGroup>
<RemoveDir Directories="$(ProjectDir)bin/.res/scripts" />
<Exec Command="python $(ProjectDir)../Resources/minify.py" WorkingDirectory="$(ProjectDir)../Resources" IgnoreExitCode="false" />
</Target>
</Project>

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
x:DataType="namespace:CheckBoxDialogModel"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False"
@@ -34,10 +35,10 @@
<StackPanel Margin="20">
<ScrollViewer MaxHeight="400">
<ItemsRepeater Items="{Binding Items}">
<ItemsRepeater ItemsSource="{Binding Items}">
<ItemsRepeater.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding Checked}">
<CheckBox IsChecked="{Binding IsChecked}">
<Label>
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
</Label>

View File

@@ -1,22 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.CheckBox {
namespace DHT.Desktop.Dialogs.CheckBox;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class CheckBoxDialog : Window {
public sealed partial class CheckBoxDialog : Window {
public CheckBoxDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public void ClickOk(object? sender, RoutedEventArgs e) {
@@ -27,5 +19,3 @@ namespace DHT.Desktop.Dialogs.CheckBox {
Close(DialogResult.OkCancel.Cancel);
}
}
}

View File

@@ -1,26 +1,26 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using DHT.Utils.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.CheckBox {
class CheckBoxDialogModel : BaseModel {
namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxDialogModel : ObservableObject {
public string Title { get; init; } = "";
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
private IReadOnlyList<CheckBoxItem> items = [];
public IReadOnlyList<CheckBoxItem> Items {
get => items;
protected set {
foreach (var item in items) {
foreach (CheckBoxItem item in items) {
item.PropertyChanged -= OnItemPropertyChanged;
}
items = value;
foreach (var item in items) {
foreach (CheckBoxItem item in items) {
item.PropertyChanged += OnItemPropertyChanged;
}
}
@@ -28,8 +28,8 @@ namespace DHT.Desktop.Dialogs.CheckBox {
private bool pauseCheckEvents = false;
public bool AreAllSelected => Items.All(static item => item.Checked);
public bool AreNoneSelected => Items.All(static item => !item.Checked);
public bool AreAllSelected => Items.All(static item => item.IsChecked);
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
public void SelectAll() => SetAllChecked(true);
public void SelectNone() => SetAllChecked(false);
@@ -37,8 +37,8 @@ namespace DHT.Desktop.Dialogs.CheckBox {
private void SetAllChecked(bool isChecked) {
pauseCheckEvents = true;
foreach (var item in Items) {
item.Checked = isChecked;
foreach (CheckBoxItem item in Items) {
item.IsChecked = isChecked;
}
pauseCheckEvents = false;
@@ -51,20 +51,19 @@ namespace DHT.Desktop.Dialogs.CheckBox {
}
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
UpdateBulkButtons();
}
}
}
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) {
this.Items = new List<CheckBoxItem<T>>(items);
base.Items = this.Items;
}
base.Items = Items;
}
}

View File

@@ -1,16 +1,13 @@
using DHT.Utils.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.CheckBox {
class CheckBoxItem : BaseModel {
namespace DHT.Desktop.Dialogs.CheckBox;
partial class CheckBoxItem : ObservableObject {
public string Title { get; init; } = "";
public object? Item { get; init; } = null;
[ObservableProperty]
private bool isChecked = false;
public bool Checked {
get => isChecked;
set => Change(ref isChecked, value);
}
}
sealed class CheckBoxItem<T> : CheckBoxItem {
@@ -21,4 +18,3 @@ namespace DHT.Desktop.Dialogs.CheckBox {
base.Item = item;
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
namespace DHT.Desktop.Dialogs.File;
static class FileDialogs {
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
}
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
}
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
return new FilePickerFileType(name) {
Patterns = extensions.Select(static ext => "*." + ext).ToArray(),
};
}
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
}
private static string ToLocalPath(this IStorageFile file) {
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
}
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
return files.Select(ToLocalPath).ToArray();
}
}

View File

@@ -2,7 +2,8 @@ using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
namespace DHT.Desktop.Dialogs.Message {
namespace DHT.Desktop.Dialogs.Message;
static class Dialog {
public static async Task ShowOk(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
@@ -14,8 +15,8 @@ namespace DHT.Desktop.Dialogs.Message {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsOkVisible = true
}
IsOkVisible = true,
},
}.ShowDialog<DialogResult.All>(owner);
}
@@ -29,8 +30,8 @@ namespace DHT.Desktop.Dialogs.Message {
Title = title,
Message = message,
IsOkVisible = true,
IsCancelVisible = true
}
IsCancelVisible = true,
},
}.ShowDialog<DialogResult.All?>(owner);
return result.ToOkCancel();
@@ -46,8 +47,8 @@ namespace DHT.Desktop.Dialogs.Message {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true
}
IsNoVisible = true,
},
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNo();
@@ -64,11 +65,10 @@ namespace DHT.Desktop.Dialogs.Message {
Message = message,
IsYesVisible = true,
IsNoVisible = true,
IsCancelVisible = true
}
IsCancelVisible = true,
},
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNoCancel();
}
}
}

View File

@@ -1,31 +1,32 @@
using System;
namespace DHT.Desktop.Dialogs.Message {
namespace DHT.Desktop.Dialogs.Message;
static class DialogResult {
public enum All {
Ok,
Yes,
No,
Cancel
Cancel,
}
public enum OkCancel {
Closed,
Ok,
Cancel
Cancel,
}
public enum YesNo {
Closed,
Yes,
No
No,
}
public enum YesNoCancel {
Closed,
Yes,
No,
Cancel
Cancel,
}
public static OkCancel ToOkCancel(this All? result) {
@@ -33,7 +34,7 @@ namespace DHT.Desktop.Dialogs.Message {
null => OkCancel.Closed,
All.Ok => OkCancel.Ok,
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."),
};
}
@@ -42,7 +43,7 @@ namespace DHT.Desktop.Dialogs.Message {
null => YesNo.Closed,
All.Yes => YesNo.Yes,
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."),
};
}
@@ -52,8 +53,7 @@ namespace DHT.Desktop.Dialogs.Message {
All.Yes => YesNoCancel.Yes,
All.No => YesNoCancel.No,
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."),
};
}
}
}

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
x:DataType="namespace:MessageDialogModel"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False"

View File

@@ -1,21 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Dialogs.Message {
namespace DHT.Desktop.Dialogs.Message;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MessageDialog : Window {
public sealed partial class MessageDialog : Window {
public MessageDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public void ClickOk(object? sender, RoutedEventArgs e) {
@@ -34,4 +26,3 @@ namespace DHT.Desktop.Dialogs.Message {
Close(DialogResult.All.Cancel);
}
}
}

View File

@@ -1,4 +1,5 @@
namespace DHT.Desktop.Dialogs.Message {
namespace DHT.Desktop.Dialogs.Message;
sealed class MessageDialogModel {
public string Title { get; init; } = "";
public string Message { get; init; } = "";
@@ -8,4 +9,3 @@ namespace DHT.Desktop.Dialogs.Message {
public bool IsNoVisible { get; init; } = false;
public bool IsCancelVisible { get; init; } = false;
}
}

View File

@@ -1,7 +1,9 @@
using System.Threading.Tasks;
namespace DHT.Desktop.Dialogs.Progress {
namespace DHT.Desktop.Dialogs.Progress;
interface IProgressCallback {
Task Update(string message, int finishedItems, int totalItems);
}
Task UpdateIndeterminate(string message);
Task Hide();
}

View File

@@ -5,9 +5,10 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
x:DataType="namespace:ProgressDialogModel"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Opened="Loaded"
Opened="OnOpened"
Closing="OnClosing"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner">
@@ -31,12 +32,18 @@
</Style>
</Window.Styles>
<StackPanel Margin="20">
<ItemsRepeater ItemsSource="{Binding Items}" Margin="0 10">
<ItemsRepeater.ItemTemplate>
<DataTemplate>
<StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
</DockPanel>
<ProgressBar Value="{Binding Progress}" />
<ProgressBar IsIndeterminate="{Binding IsIndeterminate}" Value="{Binding Progress}" />
</StackPanel>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Window>

View File

@@ -1,40 +1,98 @@
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Dialogs.Message;
using DHT.Utils.Logging;
namespace DHT.Desktop.Dialogs.Progress;
namespace DHT.Desktop.Dialogs.Progress {
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed 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 Task progressTask = Task.CompletedTask;
public ProgressDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public void OnClosing(object? sender, CancelEventArgs e) {
e.Cancel = !isFinished;
}
public void Loaded(object? sender, EventArgs e) {
public void OnOpened(object? sender, EventArgs e) {
if (DataContext is ProgressDialogModel model) {
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
progressTask = Task.Run(model.StartTask);
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
}
}
public void OnClosing(object? sender, WindowClosingEventArgs e) {
e.Cancel = !isFinished;
}
private void OnFinished(Task task) {
isFinished = true;
Close();
}
public async Task ShowProgressDialog(Window owner) {
await ShowDialog(owner);
try {
await progressTask;
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(owner, "Unexpected Error", e.Message);
}
}
}

View File

@@ -1,65 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Threading;
using DHT.Desktop.Common;
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.Progress {
sealed class ProgressDialogModel : BaseModel {
namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressDialogModel {
public string Title { get; init; } = "";
private string message = "";
public string Message {
get => message;
private set => Change(ref message, value);
}
private string items = "";
public string Items {
get => items;
private set => Change(ref items, value);
}
private int progress = 0;
public int Progress {
get => progress;
private set => Change(ref progress, value);
}
public IReadOnlyList<ProgressItem> Items { get; } = [];
private readonly TaskRunner? task;
[Obsolete("Designer")]
public ProgressDialogModel() {}
public ProgressDialogModel(TaskRunner task) {
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
this.Title = title;
this.task = task;
this.Items = Enumerable.Range(start: 0, progressItems).Select(static _ => new ProgressItem()).ToArray();
}
internal async Task StartTask() {
if (task != null) {
await task(new Callback(this));
await task(Items.Select(static item => new Callback(item)).ToArray());
}
}
public delegate Task TaskRunner(IProgressCallback callback);
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
private sealed class Callback : IProgressCallback {
private readonly ProgressDialogModel model;
private readonly ProgressItem item;
public Callback(ProgressDialogModel model) {
this.model = model;
public Callback(ProgressItem item) {
this.item = item;
}
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
public async Task Update(string message, int finishedItems, int totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => {
model.Message = message;
model.Items = finishedItems.Format() + " / " + totalItems.Format();
model.Progress = 100 * finishedItems / totalItems;
item.Message = message;
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
item.IsIndeterminate = false;
});
}
public async Task UpdateIndeterminate(string message) {
await Dispatcher.UIThread.InvokeAsync(() => {
item.Message = message;
item.Items = string.Empty;
item.Progress = 0;
item.IsIndeterminate = true;
});
}
public Task Hide() {
return Update(string.Empty, finishedItems: 0, totalItems: 0);
}
}
}

View File

@@ -0,0 +1,30 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.Progress;
sealed partial class ProgressItem : ObservableObject {
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(Opacity))]
private bool isVisible = false;
public double Opacity => IsVisible ? 1.0 : 0.0;
private string message = "";
public string Message {
get => message;
set {
SetProperty(ref message, value);
IsVisible = !string.IsNullOrEmpty(value);
}
}
[ObservableProperty]
private string items = "";
[ObservableProperty]
private int progress = 0;
[ObservableProperty]
private bool isIndeterminate;
}

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
x:DataType="namespace:TextBoxDialogModel"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False"
@@ -31,7 +32,7 @@
<ScrollViewer MaxHeight="400">
<StackPanel Spacing="10">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
<ItemsRepeater Items="{Binding Items}">
<ItemsRepeater ItemsSource="{Binding Items}">
<ItemsRepeater.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0 5 25 0">

View File

@@ -1,22 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.TextBox {
namespace DHT.Desktop.Dialogs.TextBox;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class TextBoxDialog : Window {
public sealed partial class TextBoxDialog : Window {
public TextBoxDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public void ClickOk(object? sender, RoutedEventArgs e) {
@@ -27,5 +19,3 @@ namespace DHT.Desktop.Dialogs.TextBox {
Close(DialogResult.OkCancel.Cancel);
}
}
}

View File

@@ -1,27 +1,27 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using DHT.Utils.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.TextBox {
class TextBoxDialogModel : BaseModel {
namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxDialogModel : ObservableObject {
public string Title { get; init; } = "";
public string Description { get; init; } = "";
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
private IReadOnlyList<TextBoxItem> items = [];
public IReadOnlyList<TextBoxItem> Items {
get => items;
protected set {
foreach (var item in items) {
foreach (TextBoxItem item in items) {
item.ErrorsChanged -= OnItemErrorsChanged;
}
items = value;
foreach (var item in items) {
foreach (TextBoxItem item in items) {
item.ErrorsChanged += OnItemErrorsChanged;
}
}
@@ -35,7 +35,7 @@ namespace DHT.Desktop.Dialogs.TextBox {
}
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);
@@ -44,4 +44,3 @@ namespace DHT.Desktop.Dialogs.TextBox {
base.Items = this.Items;
}
}
}

View File

@@ -1,10 +1,11 @@
using System;
using System.Collections;
using System.ComponentModel;
using DHT.Utils.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.TextBox {
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
public string Title { get; init; } = "";
public object? Item { get; init; } = null;
@@ -14,9 +15,9 @@ namespace DHT.Desktop.Dialogs.TextBox {
private string value = string.Empty;
public string Value {
get => this.value;
get => value;
set {
Change(ref this.value, value);
SetProperty(ref this.value, value);
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
}
}
@@ -39,4 +40,3 @@ namespace DHT.Desktop.Dialogs.TextBox {
base.Item = item;
}
}
}

View File

@@ -1,15 +1,16 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DHT.Utils.Logging;
using static System.Environment.SpecialFolder;
using static System.Environment.SpecialFolderOption;
namespace DHT.Desktop.Discord {
namespace DHT.Desktop.Discord;
static class DiscordAppSettings {
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
@@ -38,18 +39,21 @@ namespace DHT.Desktop.Discord {
public static async Task<bool?> AreDevToolsEnabled() {
try {
return AreDevToolsEnabled(await ReadSettingsJson());
} catch (Exception) {
JsonObject settingsJson = await ReadSettingsJson();
return AreDevToolsEnabled(settingsJson);
} catch (Exception e) {
Log.Error("Cannot read settings file.");
Log.Error(e);
return null;
}
}
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) {
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True };
private static bool AreDevToolsEnabled(JsonObject json) {
return json.TryGetPropertyValue(JsonKeyDevTools, out JsonNode? node) && node?.GetValueKind() == JsonValueKind.True;
}
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
Dictionary<string, object?> json;
JsonObject json;
try {
json = await ReadSettingsJson();
@@ -85,7 +89,7 @@ namespace DHT.Desktop.Discord {
if (File.Exists(JsonBackupFilePath)) {
try {
File.Move(JsonBackupFilePath, JsonFilePath, true);
File.Move(JsonBackupFilePath, JsonFilePath, overwrite: true);
Log.Info("Restored settings file from backup.");
} catch (Exception e2) {
Log.Error("Cannot restore settings file from backup.");
@@ -106,14 +110,13 @@ namespace DHT.Desktop.Discord {
return SettingsJsonResult.Success;
}
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
private static async Task<JsonObject> ReadSettingsJson() {
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
}
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
private static async Task WriteSettingsJson(JsonObject json) {
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
}
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
}
}

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace DHT.Desktop.Discord;
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
[JsonSerializable(typeof(JsonObject))]
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext;

View File

@@ -1,10 +1,10 @@
namespace DHT.Desktop.Discord {
namespace DHT.Desktop.Discord;
enum SettingsJsonResult {
Success,
AlreadySet,
FileNotFound,
ReadError,
InvalidJson,
WriteError
}
WriteError,
}

View File

@@ -3,11 +3,12 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:main="clr-namespace:DHT.Desktop.Main"
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:DataType="main:AboutWindowModel"
Title="About Discord History Tracker"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="480" Height="295" CanResize="False"
Width="480" Height="360" CanResize="False"
WindowStartupLocation="CenterOwner">
<Design.DataContext>
@@ -38,15 +39,16 @@
<StackPanel>
<Button Command="{Binding ShowOfficialWebsite}">Official Website</Button>
<Button Command="{Binding ShowIssueTracker}">Issue Tracker</Button>
<Button Command="{Binding ShowSourceCode}">Source Code</Button>
</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="1" FontWeight="Bold">License</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0">.NET 5</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0">.NET 8</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
@@ -54,13 +56,21 @@
<TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock>
<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="1">Public Domain</TextBlock>
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
<TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</TextBlock>
<TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock>
<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="1">Apache-2.0</TextBlock>
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button>
<TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock>
<TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock>
<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>
</StackPanel>

View File

@@ -1,20 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main {
namespace DHT.Desktop.Main;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class AboutWindow : Window {
public sealed partial class AboutWindow : Window {
public AboutWindow() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,33 +1,41 @@
using System.Diagnostics;
using DHT.Desktop.Common;
namespace DHT.Desktop.Main;
namespace DHT.Desktop.Main {
sealed class AboutWindowModel {
public void ShowOfficialWebsite() {
OpenUrl("https://dht.chylex.com");
SystemUtils.OpenUrl(Program.Website);
}
public void ShowIssueTracker() {
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
}
public void ShowSourceCode() {
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");
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
}
public void ShowLibraryNetCore() {
OpenUrl("https://github.com/dotnet/core");
SystemUtils.OpenUrl("https://github.com/dotnet/core");
}
private static void OpenUrl(string url) {
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
}
public void ShowLibraryAvalonia() {
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");
}
}

View File

@@ -1,17 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Controls {
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class AttachmentFilterPanel : UserControl {
public AttachmentFilterPanel() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,125 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using DHT.Desktop.Common;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Utils.Models;
using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Controls {
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
public sealed record Unit(string Name, int Scale);
private static readonly Unit[] AllUnits = {
new ("B", 1),
new ("kB", 1024),
new ("MB", 1024 * 1024)
};
private static readonly HashSet<string> FilterProperties = new () {
nameof(LimitSize),
nameof(MaximumSize),
nameof(MaximumSizeUnit)
};
public string FilterStatisticsText { get; private set; } = "";
private bool limitSize = false;
private int maximumSize = 0;
private Unit maximumSizeUnit = AllUnits[0];
public bool LimitSize {
get => limitSize;
set => Change(ref limitSize, value);
}
public int MaximumSize {
get => maximumSize;
set => Change(ref maximumSize, value);
}
public Unit MaximumSizeUnit {
get => maximumSizeUnit;
set => Change(ref maximumSizeUnit, value);
}
public IEnumerable<Unit> Units => AllUnits;
private readonly IDatabaseFile db;
private readonly string verb;
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
private long? matchingAttachmentCount;
private long? totalAttachmentCount;
[Obsolete("Designer")]
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {}
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") {
this.db = db;
this.verb = verb;
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
UpdateFilterStatistics();
PropertyChanged += OnPropertyChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
UpdateFilterStatistics();
}
}
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
totalAttachmentCount = db.Statistics.TotalAttachments;
UpdateFilterStatistics();
}
}
private void UpdateFilterStatistics() {
var filter = CreateFilter();
if (filter.IsEmpty) {
matchingAttachmentCountComputer.Cancel();
matchingAttachmentCount = totalAttachmentCount;
UpdateFilterStatisticsText();
}
else {
matchingAttachmentCount = null;
UpdateFilterStatisticsText();
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter));
}
}
private void SetAttachmentCounts(long matchingAttachmentCount) {
this.matchingAttachmentCount = matchingAttachmentCount;
UpdateFilterStatisticsText();
}
private void UpdateFilterStatisticsText() {
var matchingAttachmentCountStr = matchingAttachmentCount?.Format() ?? "(...)";
var totalAttachmentCountStr = totalAttachmentCount?.Format() ?? "(...)";
FilterStatisticsText = verb + " " + matchingAttachmentCountStr + " out of " + totalAttachmentCountStr + " attachment" + (totalAttachmentCount is null or 1 ? "." : "s.");
OnPropertyChanged(nameof(FilterStatisticsText));
}
public AttachmentFilter CreateFilter() {
AttachmentFilter filter = new();
if (LimitSize) {
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
}
return filter;
}
}
}

View File

@@ -4,10 +4,11 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel">
x:Class="DHT.Desktop.Main.Controls.DownloadItemFilterPanel"
x:DataType="controls:DownloadItemFilterPanelModel">
<Design.DataContext>
<controls:AttachmentFilterPanelModel />
<controls:DownloadItemFilterPanelModel />
</Design.DataContext>
<UserControl.Styles>
@@ -36,7 +37,7 @@
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
<StackPanel Orientation="Horizontal">
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
<ComboBox IsEnabled="{Binding LimitSize}" Items="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
<ComboBox IsEnabled="{Binding LimitSize}" ItemsSource="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" />

View File

@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
namespace DHT.Desktop.Main.Controls;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class DownloadItemFilterPanel : UserControl {
public DownloadItemFilterPanel() {
InitializeComponent();
}
}

View 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;
}
}

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel">
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
x:DataType="controls:MessageFilterPanelModel">
<Design.DataContext>
<controls:MessageFilterPanelModel />

View File

@@ -1,22 +1,16 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Controls {
namespace DHT.Desktop.Main.Controls;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MessageFilterPanel : UserControl {
private CalendarDatePicker StartDatePicker => this.FindControl<CalendarDatePicker>("StartDatePicker");
private CalendarDatePicker EndDatePicker => this.FindControl<CalendarDatePicker>("EndDatePicker");
public sealed partial class MessageFilterPanel : UserControl {
public MessageFilterPanel() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
var culture = Program.Culture;
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
CultureInfo culture = Program.Culture;
foreach (CalendarDatePicker picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
@@ -31,4 +25,3 @@ namespace DHT.Desktop.Main.Controls {
}
}
}
}

View File

@@ -2,29 +2,33 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reactive.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.CheckBox;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Utils.Models;
using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Controls {
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
private static readonly HashSet<string> FilterProperties = new () {
namespace DHT.Desktop.Main.Controls;
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
private static readonly HashSet<string> FilterProperties = [
nameof(FilterByDate),
nameof(StartDate),
nameof(EndDate),
nameof(FilterByChannel),
nameof(IncludedChannels),
nameof(FilterByUser),
nameof(IncludedUsers)
};
nameof(IncludedUsers),
];
public string FilterStatisticsText { get; private set; } = "";
@@ -32,91 +36,76 @@ namespace DHT.Desktop.Main.Controls {
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
[ObservableProperty]
private bool filterByDate = false;
[ObservableProperty]
private DateTime? startDate = null;
[ObservableProperty]
private DateTime? endDate = null;
[ObservableProperty]
private bool filterByChannel = false;
[ObservableProperty]
private HashSet<ulong>? includedChannels = null;
[ObservableProperty]
private bool filterByUser = false;
[ObservableProperty]
private HashSet<ulong>? includedUsers = null;
public bool FilterByDate {
get => filterByDate;
set => Change(ref filterByDate, value);
}
public DateTime? StartDate {
get => startDate;
set => Change(ref startDate, value);
}
public DateTime? EndDate {
get => endDate;
set => Change(ref endDate, value);
}
public bool FilterByChannel {
get => filterByChannel;
set => Change(ref filterByChannel, value);
}
public HashSet<ulong> IncludedChannels {
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
set => Change(ref includedChannels, value);
}
public bool FilterByUser {
get => filterByUser;
set => Change(ref filterByUser, value);
}
public HashSet<ulong> IncludedUsers {
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
set => Change(ref includedUsers, value);
}
[ObservableProperty]
private string channelFilterLabel = "";
public string ChannelFilterLabel {
get => channelFilterLabel;
set => Change(ref channelFilterLabel, value);
}
[ObservableProperty]
private string userFilterLabel = "";
public string UserFilterLabel {
get => userFilterLabel;
set => Change(ref userFilterLabel, value);
}
private readonly Window window;
private readonly IDatabaseFile db;
private readonly State state;
private readonly string verb;
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
private readonly RestartableTask<long> exportedMessageCountTask;
private long? exportedMessageCount;
private readonly IDisposable messageCountSubscription;
private long? totalMessageCount;
[Obsolete("Designer")]
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
private readonly IDisposable channelCountSubscription;
private long? totalChannelCount;
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
private readonly IDisposable userCountSubscription;
private long? totalUserCount;
[Obsolete("Designer")]
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
this.window = window;
this.db = db;
this.state = state;
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();
UpdateChannelFilterLabel();
UpdateUserFilterLabel();
PropertyChanged += OnPropertyChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
exportedMessageCountTask.Cancel();
messageCountSubscription.Dispose();
channelCountSubscription.Dispose();
userCountSubscription.Dispose();
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
@@ -133,30 +122,54 @@ namespace DHT.Desktop.Main.Controls {
}
}
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
totalMessageCount = db.Statistics.TotalMessages;
private void OnMessageCountChanged(long newMessageCount) {
totalMessageCount = newMessageCount;
UpdateFilterStatistics();
}
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
private void OnChannelCountChanged(long newChannelCount) {
totalChannelCount = newChannelCount;
UpdateChannelFilterLabel();
}
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
private void OnUserCountChanged(long newUserCount) {
totalUserCount = newUserCount;
UpdateUserFilterLabel();
}
private void UpdateChannelFilterLabel() {
if (totalChannelCount.HasValue) {
long total = totalChannelCount.Value;
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
}
else {
ChannelFilterLabel = "Loading...";
}
}
private void UpdateUserFilterLabel() {
if (totalUserCount.HasValue) {
long total = totalUserCount.Value;
long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total;
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
}
else {
UserFilterLabel = "Loading...";
}
}
private void UpdateFilterStatistics() {
var filter = CreateFilter();
MessageFilter filter = CreateFilter();
if (filter.IsEmpty) {
exportedMessageCountComputer.Cancel();
exportedMessageCountTask.Cancel();
exportedMessageCount = totalMessageCount;
UpdateFilterStatisticsText();
}
else {
exportedMessageCount = null;
UpdateFilterStatisticsText();
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
}
}
@@ -166,26 +179,26 @@ namespace DHT.Desktop.Main.Controls {
}
private void UpdateFilterStatisticsText() {
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
string exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
string totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 0 ? "." : "s.");
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
OnPropertyChanged(nameof(FilterStatisticsText));
}
public async void OpenChannelFilterDialog() {
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
public async Task OpenChannelFilterDialog() {
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
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()) {
var channelId = channel.Id;
var channelName = channel.Name;
await foreach (Channel channel in state.Db.Channels.Get()) {
ulong channelId = channel.Id;
string channelName = channel.Name;
string title;
if (servers.TryGetValue(channel.Server, out var server)) {
var titleBuilder = new StringBuilder();
var serverType = server.Type;
ServerType? serverType = server.Type;
titleBuilder.Append('[')
.Append(ServerTypes.ToString(serverType))
@@ -208,46 +221,70 @@ namespace DHT.Desktop.Main.Controls {
items.Add(new CheckBoxItem<ulong>(channelId) {
Title = title,
Checked = included.Contains(channelId)
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId),
});
}
var result = await OpenIdFilterDialog(window, "Included Channels", items);
return items;
}
const string Title = "Included Channels";
List<CheckBoxItem<ulong>> items;
try {
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
} catch (Exception e) {
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
return;
}
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
if (result != null) {
IncludedChannels = result;
}
}
public async void OpenUserFilterDialog() {
var items = new List<CheckBoxItem<ulong>>();
var included = IncludedUsers;
public async Task OpenUserFilterDialog() {
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
var checkBoxItems = new List<CheckBoxItem<ulong>>();
foreach (var user in db.GetAllUsers()) {
var name = user.Name;
var discriminator = user.Discriminator;
items.Add(new CheckBoxItem<ulong>(user.Id) {
Title = discriminator == null ? name : name + " #" + discriminator,
Checked = included.Contains(user.Id)
await foreach (User user in state.Db.Users.Get()) {
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id),
});
}
var result = await OpenIdFilterDialog(window, "Included Users", items);
return checkBoxItems;
}
const string Title = "Included Users";
List<CheckBoxItem<ulong>> items;
try {
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
} catch (Exception e) {
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
return;
}
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
if (result != null) {
IncludedUsers = result;
}
}
private void UpdateChannelFilterLabel() {
long total = db.Statistics.TotalChannels;
long included = FilterByChannel ? IncludedChannels.Count : total;
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
}
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) {
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
private void UpdateUserFilterLabel() {
long total = db.Statistics.TotalUsers;
long included = FilterByUser ? IncludedUsers.Count : total;
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
var model = new CheckBoxDialogModel<ulong>(items) {
Title = title,
};
var dialog = new CheckBoxDialog { DataContext = model };
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
}
public MessageFilter CreateFilter() {
@@ -258,28 +295,14 @@ namespace DHT.Desktop.Main.Controls {
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
}
if (FilterByChannel) {
if (FilterByChannel && IncludedChannels != null) {
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
}
if (FilterByUser) {
if (FilterByUser && IncludedUsers != null) {
filter.UserIds = new HashSet<ulong>(IncludedUsers);
}
return filter;
}
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
var model = new CheckBoxDialogModel<ulong>(items) {
Title = title
};
var dialog = new CheckBoxDialog { DataContext = model };
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
}
}
}

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel">
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
x:DataType="controls:ServerConfigurationPanelModel">
<Design.DataContext>
<controls:ServerConfigurationPanelModel />
@@ -15,6 +16,10 @@
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="Label">
<Setter Property="Margin" Value="0 5" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />

View File

@@ -1,16 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Controls {
namespace DHT.Desktop.Main.Controls;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class ServerConfigurationPanel : UserControl {
public sealed partial class ServerConfigurationPanel : UserControl {
public ServerConfigurationPanel() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,95 +1,95 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Server;
using DHT.Server.Service;
using DHT.Utils.Models;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Controls {
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
namespace DHT.Desktop.Main.Controls;
sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable {
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputPort;
public string InputPort {
get => inputPort;
set {
Change(ref inputPort, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputToken;
public string InputToken {
get => inputToken;
set {
Change(ref inputToken, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
[ObservableProperty(Setter = Access.Private)]
private bool isToggleServerButtonEnabled = true;
public bool IsToggleServerButtonEnabled {
get => isToggleServerButtonEnabled;
set => Change(ref isToggleServerButtonEnabled, value);
}
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
private readonly Window window;
private readonly ServerManager serverManager;
private readonly ServerManager server;
[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.serverManager = serverManager;
this.inputPort = ServerManager.Port.ToString();
this.inputToken = ServerManager.Token;
}
this.server = state.Server;
this.inputPort = ServerConfiguration.Port.ToString();
this.inputToken = ServerConfiguration.Token;
public void Initialize() {
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
server.StatusChanged += OnServerStatusChanged;
}
public void Dispose() {
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
server.StatusChanged -= OnServerStatusChanged;
}
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
}
private void UpdateServerStatus() {
OnPropertyChanged(nameof(ToggleServerButtonText));
}
private async Task StartServer() {
IsToggleServerButtonEnabled = false;
try {
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
}
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
private void BeforeServerStart() {
private async Task StopServer() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
try {
await server.Stop();
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
}
private void StartServer() {
BeforeServerStart();
serverManager.Launch();
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
private void StopServer() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
serverManager.Stop();
}
public void OnClickToggleServerButton() {
if (serverManager.IsRunning) {
StopServer();
public async Task OnClickToggleServerButton() {
if (server.IsRunning) {
await StopServer();
}
else {
StartServer();
await StartServer();
}
}
@@ -97,20 +97,22 @@ namespace DHT.Desktop.Main.Controls {
InputToken = ServerUtils.GenerateRandomToken(20);
}
public async void OnClickApplyChanges() {
public async Task OnClickApplyChanges() {
if (!ushort.TryParse(InputPort, out ushort port)) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return;
}
BeforeServerStart();
serverManager.Relaunch(port, InputToken);
ServerConfiguration.Port = port;
ServerConfiguration.Token = inputToken;
OnPropertyChanged(nameof(HasMadeChanges));
await StartServer();
}
public void OnClickCancelChanges() {
InputPort = ServerManager.Port.ToString();
InputToken = ServerManager.Token;
}
InputPort = ServerConfiguration.Port.ToString();
InputToken = ServerConfiguration.Token;
}
}

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.StatusBar">
x:Class="DHT.Desktop.Main.Controls.StatusBar"
x:DataType="controls:StatusBarModel">
<Design.DataContext>
<controls:StatusBarModel />
@@ -39,22 +40,22 @@
<StackPanel Orientation="Horizontal" Margin="6 3">
<StackPanel Orientation="Vertical" Width="65">
<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>
<Rectangle />
<StackPanel Orientation="Vertical">
<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>
<Rectangle />
<StackPanel Orientation="Vertical">
<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>
<Rectangle />
<StackPanel Orientation="Vertical">
<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>

View File

@@ -1,16 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Controls {
namespace DHT.Desktop.Main.Controls;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class StatusBar : UserControl {
public sealed partial class StatusBar : UserControl {
public StatusBar() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,45 +1,63 @@
using System;
using DHT.Server.Database;
using DHT.Utils.Models;
using System.Reactive.Linq;
using Avalonia.ReactiveUI;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Server;
using DHT.Server.Service;
namespace DHT.Desktop.Main.Controls {
sealed class StatusBarModel : BaseModel {
public DatabaseStatistics DatabaseStatistics { get; }
namespace DHT.Desktop.Main.Controls;
private Status status = Status.Stopped;
sealed partial class StatusBarModel : ObservableObject, IDisposable {
[ObservableProperty(Setter = Access.Private)]
private long? serverCount;
public Status CurrentStatus {
get => status;
set {
status = value;
OnPropertyChanged(nameof(StatusText));
}
}
[ObservableProperty(Setter = Access.Private)]
private long? channelCount;
public string StatusText {
get {
return CurrentStatus switch {
Status.Starting => "STARTING",
Status.Ready => "READY",
Status.Stopping => "STOPPING",
Status.Stopped => "STOPPED",
_ => ""
[ObservableProperty(Setter = Access.Private)]
private long? messageCount;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
private ServerManager.Status serverStatus;
public string ServerStatusText => serverStatus switch {
ServerManager.Status.Starting => "STARTING",
ServerManager.Status.Started => "READY",
ServerManager.Status.Stopping => "STOPPING",
ServerManager.Status.Stopped => "STOPPED",
_ => "",
};
}
}
private readonly State state;
private readonly IDisposable serverCountSubscription;
private readonly IDisposable channelCountSubscription;
private readonly IDisposable messageCountSubscription;
[Obsolete("Designer")]
public StatusBarModel() : this(new DatabaseStatistics()) {}
public StatusBarModel() : this(State.Dummy) {}
public StatusBarModel(DatabaseStatistics databaseStatistics) {
this.DatabaseStatistics = databaseStatistics;
public StatusBarModel(State state) {
this.state = state;
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
state.Server.StatusChanged += OnServerStatusChanged;
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
}
public enum Status {
Starting,
Ready,
Stopping,
Stopped
}
public void Dispose() {
serverCountSubscription.Dispose();
channelCountSubscription.Dispose();
messageCountSubscription.Dispose();
state.Server.StatusChanged -= OnServerStatusChanged;
}
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
}
}

View File

@@ -5,12 +5,13 @@
xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.MainWindow"
x:DataType="main:MainWindowModel"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="800" Height="500"
Width="820" Height="520"
MinWidth="520" MinHeight="300"
WindowStartupLocation="CenterScreen"
Closed="OnClosed">
Closing="OnClosing">
<Design.DataContext>
<main:MainWindowModel />

View File

@@ -1,44 +1,44 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Avalonia;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Main.Pages;
using DHT.Utils.Logging;
using JetBrains.Annotations;
namespace DHT.Desktop.Main {
namespace DHT.Desktop.Main;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MainWindow : Window {
public sealed partial class MainWindow : Window {
private static readonly Log Log = Log.ForType<MainWindow>();
[UsedImplicitly]
public MainWindow() {
InitializeComponent(Arguments.Empty);
InitializeComponent();
DataContext = new MainWindowModel(this, Arguments.Empty);
}
internal MainWindow(Arguments args) {
InitializeComponent(args);
}
private void InitializeComponent(Arguments args) {
AvaloniaXamlLoader.Load(this);
InitializeComponent();
DataContext = new MainWindowModel(this, args);
#if DEBUG
this.AttachDevTools();
#endif
}
public void OnClosed(object? sender, EventArgs e) {
if (DataContext is IDisposable disposable) {
disposable.Dispose();
}
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
e.Cancel = true;
Closing -= OnClosing;
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
try {
File.Delete(temporaryFile);
} catch (Exception) {
// ignored
}
await Dispose();
} finally {
Close();
}
}
private async Task Dispose() {
if (DataContext is MainWindowModel model) {
try {
await model.DisposeAsync();
} catch (Exception ex) {
Log.Error("Caught exception while disposing window: " + ex);
}
}
}

View File

@@ -1,32 +1,38 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Database;
using DHT.Utils.Models;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main {
sealed class MainWindowModel : BaseModel, IDisposable {
namespace DHT.Desktop.Main;
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
private const string DefaultTitle = "Discord History Tracker";
public string Title { get; private set; } = DefaultTitle;
private static readonly Log Log = Log.ForType<MainWindowModel>();
public 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 WelcomeScreenModel welcomeScreenModel;
private MainContentScreen? mainContentScreen;
private MainContentScreenModel? mainContentScreenModel;
private readonly Window window;
private readonly int? concurrentDownloads;
private IDatabaseFile? db;
private State? state;
[Obsolete("Designer")]
public MainWindowModel() : this(null!, Arguments.Empty) {}
@@ -35,12 +41,12 @@ namespace DHT.Desktop.Main {
this.window = window;
welcomeScreenModel = new WelcomeScreenModel(window);
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
CurrentScreen = welcomeScreen;
currentScreen = welcomeScreen;
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
var dbFile = args.DatabaseFile;
string? dbFile = args.DatabaseFile;
if (!string.IsNullOrWhiteSpace(dbFile)) {
async void OnWindowOpened(object? o, EventArgs eventArgs) {
window.Opened -= OnWindowOpened;
@@ -62,55 +68,68 @@ namespace DHT.Desktop.Main {
}
if (args.ServerPort != null) {
ServerManager.Port = args.ServerPort.Value;
ServerConfiguration.Port = args.ServerPort.Value;
}
if (args.ServerToken != null) {
ServerManager.Token = args.ServerToken;
}
ServerConfiguration.Token = args.ServerToken;
}
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
if (mainContentScreenModel != null) {
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
mainContentScreenModel.Dispose();
concurrentDownloads = args.ConcurrentDownloads;
}
db?.Dispose();
db = welcomeScreenModel.Db;
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
if (db == null) {
Title = DefaultTitle;
mainContentScreenModel = null;
mainContentScreen = null;
CurrentScreen = welcomeScreen;
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);
}
else {
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
mainContentScreenModel = new MainContentScreenModel(window, db);
await mainContentScreenModel.Initialize();
mainContentScreenModel = new MainContentScreenModel(window, state);
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
CurrentScreen = mainContentScreen;
}
OnPropertyChanged(nameof(CurrentScreen));
OnPropertyChanged(nameof(Title));
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;
}
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
welcomeScreenModel.CloseDatabase();
await DisposeState();
Title = DefaultTitle;
CurrentScreen = welcomeScreen;
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
}
public void Dispose() {
welcomeScreenModel.Dispose();
mainContentScreenModel?.Dispose();
db?.Dispose();
db = null;
private async Task DisposeState() {
if (state != null) {
await state.DisposeAsync();
state = null;
}
}
public async ValueTask DisposeAsync() {
if (mainContentScreenModel != null) {
await mainContentScreenModel.DisposeAsync();
}
await DisposeState();
}
}

View File

@@ -5,7 +5,8 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AdvancedPage">
x:Class="DHT.Desktop.Main.Pages.AdvancedPage"
x:DataType="pages:AdvancedPageModel">
<Design.DataContext>
<pages:AdvancedPageModel />

View File

@@ -1,16 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Pages {
namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class AdvancedPage : UserControl {
public sealed partial class AdvancedPage : UserControl {
public AdvancedPage() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,39 +1,36 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Utils.Models;
using DHT.Server;
namespace DHT.Desktop.Main.Pages {
sealed class AdvancedPageModel : BaseModel, IDisposable {
namespace DHT.Desktop.Main.Pages;
sealed class AdvancedPageModel : IDisposable {
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
private readonly Window window;
private readonly IDatabaseFile db;
private readonly State state;
[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.db = db;
this.state = state;
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
}
public void Initialize() {
ServerConfigurationModel.Initialize();
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
}
public void Dispose() {
ServerConfigurationModel.Dispose();
}
public async void VacuumDatabase() {
db.Vacuum();
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
}
public async Task VacuumDatabase() {
const string Title = "Vacuum Database";
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
await Dialog.ShowOk(window, Title, "Done.");
}
}

View File

@@ -1,16 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Pages {
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class AttachmentsPage : UserControl {
public AttachmentsPage() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,204 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Main.Controls;
using DHT.Server.Data;
using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Server.Download;
using DHT.Utils.Models;
using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Pages {
sealed class AttachmentsPageModel : BaseModel, IDisposable {
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
IncludeStatuses = new HashSet<DownloadStatus> {
DownloadStatus.Enqueued
}
};
private bool isThreadDownloadButtonEnabled = true;
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
public bool IsToggleDownloadButtonEnabled {
get => isThreadDownloadButtonEnabled;
set => Change(ref isThreadDownloadButtonEnabled, value);
}
public string DownloadMessage { get; set; } = "";
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
public AttachmentFilterPanelModel FilterModel { get; }
private readonly StatisticsRow statisticsEnqueued = new ("Enqueued");
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
private readonly StatisticsRow statisticsFailed = new ("Failed");
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
public List<StatisticsRow> StatisticsRows {
get {
return new List<StatisticsRow> {
statisticsEnqueued,
statisticsDownloaded,
statisticsFailed,
statisticsSkipped
};
}
}
public bool IsDownloading => downloadThread != null;
public bool HasFailedDownloads => statisticsFailed.Items > 0;
private readonly IDatabaseFile db;
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
private BackgroundDownloadThread? downloadThread;
private int doneItemsCount;
private int? allItemsCount;
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {}
public AttachmentsPageModel(IDatabaseFile db) {
this.db = db;
this.FilterModel = new AttachmentFilterPanelModel(db);
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics);
this.downloadStatisticsComputer.Recompute();
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
FilterModel.Dispose();
DisposeDownloadThread();
}
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
if (IsDownloading) {
EnqueueDownloadItems();
}
else {
downloadStatisticsComputer.Recompute();
}
}
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
downloadStatisticsComputer.Recompute();
}
}
private void EnqueueDownloadItems() {
var filter = FilterModel.CreateFilter();
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
db.EnqueueDownloadItems(filter);
downloadStatisticsComputer.Recompute();
}
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
var hadFailedDownloads = HasFailedDownloads;
statisticsEnqueued.Items = statusStatistics.EnqueuedCount;
statisticsEnqueued.Size = statusStatistics.EnqueuedSize;
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
statisticsDownloaded.Size = statusStatistics.SuccessfulSize;
statisticsFailed.Items = statusStatistics.FailedCount;
statisticsFailed.Size = statusStatistics.FailedSize;
statisticsSkipped.Items = statusStatistics.SkippedCount;
statisticsSkipped.Size = statusStatistics.SkippedSize;
OnPropertyChanged(nameof(StatisticsRows));
if (hadFailedDownloads != HasFailedDownloads) {
OnPropertyChanged(nameof(HasFailedDownloads));
}
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
UpdateDownloadMessage();
}
private void UpdateDownloadMessage() {
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
OnPropertyChanged(nameof(DownloadMessage));
OnPropertyChanged(nameof(DownloadProgress));
}
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
++doneItemsCount;
UpdateDownloadMessage();
downloadStatisticsComputer.Recompute();
}
private void DownloadThreadOnOnServerStopped(object? sender, EventArgs e) {
downloadStatisticsComputer.Recompute();
IsToggleDownloadButtonEnabled = true;
}
public void OnClickToggleDownload() {
if (downloadThread == null) {
EnqueueDownloadItems();
downloadThread = new BackgroundDownloadThread(db);
downloadThread.OnItemFinished += DownloadThreadOnOnItemFinished;
downloadThread.OnServerStopped += DownloadThreadOnOnServerStopped;
}
else {
IsToggleDownloadButtonEnabled = false;
DisposeDownloadThread();
db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
doneItemsCount = 0;
allItemsCount = null;
UpdateDownloadMessage();
}
OnPropertyChanged(nameof(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading));
}
public void OnClickRetryFailedDownloads() {
var allExceptFailedFilter = new DownloadItemFilter {
IncludeStatuses = new HashSet<DownloadStatus> {
DownloadStatus.Enqueued,
DownloadStatus.Success
}
};
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
downloadStatisticsComputer.Recompute();
if (IsDownloading) {
EnqueueDownloadItems();
}
}
private void DisposeDownloadThread() {
if (downloadThread != null) {
downloadThread.OnItemFinished -= DownloadThreadOnOnItemFinished;
downloadThread.StopThread();
}
downloadThread = null;
}
public sealed class StatisticsRow {
public string State { get; }
public int Items { get; set; }
public ulong? Size { get; set; }
public StatisticsRow(string state) {
State = state;
}
}
}
}

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DatabasePage">
x:Class="DHT.Desktop.Main.Pages.DatabasePage"
x:DataType="pages:DatabasePageModel">
<Design.DataContext>
<pages:DatabasePageModel />

View File

@@ -1,16 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Pages {
namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class DatabasePage : UserControl {
public sealed partial class DatabasePage : UserControl {
public DatabasePage() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -7,19 +7,23 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Dialogs.TextBox;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Database;
using DHT.Server.Database.Import;
using DHT.Server.Database.Sqlite.Schema;
using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class DatabasePageModel : BaseModel {
namespace DHT.Desktop.Main.Pages;
sealed class DatabasePageModel {
private static readonly Log Log = Log.ForType<DatabasePageModel>();
public IDatabaseFile Db { get; }
@@ -29,14 +33,14 @@ namespace DHT.Desktop.Main.Pages {
private readonly Window window;
[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.Db = db;
this.Db = state.Db;
}
public async void OpenDatabaseFolder() {
public async Task OpenDatabaseFolder() {
string file = Db.Path;
string? folder = Path.GetDirectoryName(file);
@@ -67,74 +71,77 @@ namespace DHT.Desktop.Main.Pages {
DatabaseClosed?.Invoke(this, EventArgs.Empty);
}
public async void MergeWithDatabase() {
var fileDialog = DatabaseGui.NewOpenDatabaseFileDialog();
fileDialog.Directory = Path.GetDirectoryName(Db.Path);
fileDialog.AllowMultiple = true;
string[]? paths = await fileDialog.ShowAsync(window);
if (paths == null || paths.Length == 0) {
return;
public async Task MergeWithDatabase() {
string[] paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
if (paths.Length > 0) {
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
}
ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) {
Title = "Database Merge"
};
await progressDialog.ShowDialog(window);
}
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
int total = paths.Length;
DialogResult.YesNo? upgradeResult = null;
async Task<bool> CheckCanUpgradeDatabase() {
upgradeResult ??= total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
return DialogResult.YesNo.Yes == upgradeResult;
}
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
if (db == null) {
return false;
}
try {
target.AddFrom(db);
await target.AddFrom(db);
return true;
} finally {
db.Dispose();
await db.DisposeAsync();
}
});
}
public async void ImportLegacyArchive() {
var fileDialog = new OpenFileDialog {
Title = "Open Legacy DHT Archive",
Directory = Path.GetDirectoryName(Db.Path),
AllowMultiple = true
};
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly ProgressDialog dialog;
private readonly int total;
private bool? decision;
string[]? paths = await fileDialog.ShowAsync(window);
if (paths == null || paths.Length == 0) {
return;
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
this.total = total;
this.dialog = dialog;
}
ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) {
Title = "Legacy Archive Import"
};
public async Task<bool> CanUpgrade() {
return decision ??= (total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
}
await progressDialog.ShowDialog(window);
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
return doUpgrade(new NullReporter());
}
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
public Task NextVersion() {
return Task.CompletedTask;
}
public Task MainWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
public Task SubWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
}
}
public async Task ImportLegacyArchive() {
string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Legacy DHT Archive",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
AllowMultiple = true,
});
if (paths.Length > 0) {
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
}
}
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
@@ -160,16 +167,16 @@ namespace DHT.Desktop.Main.Pages {
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) {
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
ValidityCheck = IsValidSnowflake
ValidityCheck = IsValidSnowflake,
});
}
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
Title = "Imported Server IDs",
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
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 };
@@ -186,7 +193,7 @@ namespace DHT.Desktop.Main.Pages {
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
int total = paths.Length;
var oldStatistics = target.SnapshotStatistics();
DatabaseStatistics oldStatistics = await DatabaseStatistics.Take(target);
int successful = 0;
int finished = 0;
@@ -217,16 +224,28 @@ namespace DHT.Desktop.Main.Pages {
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) {
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
return new DatabaseStatistics(
await db.Servers.Count(),
await db.Channels.Count(),
await db.Users.Count(),
await db.Messages.Count()
);
}
}
StringBuilder message = new StringBuilder();
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 ");
if (successfulItems == totalItems) {
@@ -245,4 +264,3 @@ namespace DHT.Desktop.Main.Pages {
return message.ToString();
}
}
}

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DebugPage">
x:Class="DHT.Desktop.Main.Pages.DebugPage"
x:DataType="pages:DebugPageModel">
<Design.DataContext>
<pages:DebugPageModel />
@@ -15,6 +16,10 @@
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="Label">
<Setter Property="Margin" Value="0 5" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />

View File

@@ -1,16 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Pages {
namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class DebugPage : UserControl {
public sealed partial class DebugPage : UserControl {
public DebugPage() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -6,26 +6,26 @@ using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel : BaseModel {
namespace DHT.Desktop.Main.Pages;
sealed class DebugPageModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";
private readonly Window window;
private readonly IDatabaseFile db;
private readonly State state;
[Obsolete("Designer")]
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
public DebugPageModel() : this(null!, State.Dummy) {}
public DebugPageModel(Window window, IDatabaseFile db) {
public DebugPageModel(Window window, State state) {
this.window = window;
this.db = db;
this.state = state;
}
public async void OnClickAddRandomDataToDatabase() {
@@ -44,80 +44,72 @@ namespace DHT.Desktop.Main.Pages {
return;
}
ProgressDialog progressDialog = new ProgressDialog {
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
Title = "Generating Random Data"
}
};
await progressDialog.ShowDialog(window);
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
}
private const int BatchSize = 500;
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
await callback.Update("Adding messages in batches of " + BatchSize, finishedItems: 0, batchCount);
var rand = new Random();
var server = new DHT.Server.Data.Server {
Id = RandomId(rand),
Name = RandomName("s"),
Type = ServerType.Server
Type = ServerType.Server,
};
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
Channel[] channels = Enumerable.Range(start: 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
Topic = RandomText(rand, maxWords: 10),
Nsfw = rand.Next(4) == 0,
}).ToArray();
var users = Enumerable.Range(0, userCount).Select(_ => new User {
User[] users = Enumerable.Range(start: 0, userCount).Select(_ => new User {
Id = RandomId(rand),
Name = RandomName("u"),
DisplayName = RandomName("u"),
AvatarUrl = null,
Discriminator = rand.Next(0, 9999).ToString()
Discriminator = rand.Next(minValue: 0, maxValue: 9999).ToString(),
}).ToArray();
db.AddServer(server);
db.AddUsers(users);
await state.Db.Users.Add(users);
await state.Db.Servers.Add([server]);
await state.Db.Channels.Add(channels);
foreach (var channel in channels) {
db.AddChannel(channel);
}
var now = DateTimeOffset.Now;
DateTimeOffset 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;
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;
var timeMillis = time.ToUnixTimeMilliseconds();
var editMillis = edit?.ToUnixTimeMilliseconds();
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, 100),
Text = RandomText(rand, maxWords: 100),
Timestamp = timeMillis,
EditTimestamp = editMillis,
RepliedToId = null,
Attachments = ImmutableArray<Attachment>.Empty,
Embeds = ImmutableArray<Embed>.Empty,
Reactions = ImmutableArray<Reaction>.Empty
Attachments = ImmutableList<Attachment>.Empty,
Embeds = ImmutableList<Embed>.Empty,
Reactions = ImmutableList<Reaction>.Empty,
};
}).ToArray();
db.AddMessages(messages);
await state.Db.Messages.Add(messages);
messageCount -= BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
@@ -138,7 +130,7 @@ namespace DHT.Desktop.Main.Pages {
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
}
private static readonly string[] RandomWords = {
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",
@@ -161,25 +153,22 @@ namespace DHT.Desktop.Main.Pages {
"vanilla",
"watercress", "watermelon",
"yam",
"zucchini"
};
"zucchini",
];
private static string RandomText(Random rand, int maxWords) {
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
}
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
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages;
namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel : BaseModel {
sealed class DebugPageModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";
public void OnClickAddRandomDataToDatabase() {}
}
}
#endif

View File

@@ -5,15 +5,16 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage">
x:Class="DHT.Desktop.Main.Pages.DownloadsPage"
x:DataType="pages:DownloadsPageModel">
<Design.DataContext>
<pages:AttachmentsPageModel />
<pages:DownloadsPageModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="Expander">
<Setter Property="Margin" Value="0 5 0 0" />
<Style Selector="WrapPanel > Button">
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
<Style Selector="DataGridColumnHeader">
<Setter Property="FontWeight" Value="Medium" />
@@ -29,26 +30,26 @@
</Style>
</UserControl.Styles>
<StackPanel Orientation="Vertical" Spacing="20">
<DockPanel>
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
</DockPanel>
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" />
<StackPanel Orientation="Vertical" Spacing="12">
<StackPanel Orientation="Vertical">
<WrapPanel Orientation="Horizontal">
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
<Button Command="{Binding OnClickDeleteOrphanedDownloads}">Delete Orphaned Downloads</Button>
</WrapPanel>
<StackPanel Orientation="Vertical" Spacing="20" Margin="0 10 0 0">
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
<TextBlock TextWrapping="Wrap">
Downloading state and filter settings are remembered per-database.
</TextBlock>
<Expander Header="Download Status" IsExpanded="True">
<DataGrid Items="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid.Columns>
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="Size" Binding="{Binding Size, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" />
<DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" />
</DataGrid.Columns>
</DataGrid>
</Expander>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding HasFailedDownloads}">Retry Failed Downloads</Button>
</StackPanel>
</StackPanel>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class DownloadsPage : UserControl {
public DownloadsPage() {
InitializeComponent();
}
}

View 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);
}
}
}
}
}

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.TrackingPage">
x:Class="DHT.Desktop.Main.Pages.TrackingPage"
x:DataType="pages:TrackingPageModel">
<Design.DataContext>
<pages:TrackingPageModel />
@@ -12,13 +13,21 @@
<StackPanel Spacing="10">
<TextBlock TextWrapping="Wrap">
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
<TextBlock.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>
<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>
<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>
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
</StackPanel>

View File

@@ -3,36 +3,30 @@ using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Pages {
namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class TrackingPage : UserControl {
public sealed partial class TrackingPage : UserControl {
private bool isCopyingScript;
public TrackingPage() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
if (DataContext is TrackingPageModel model) {
var button = this.FindControl<Button>("CopyTrackingScript");
var originalText = button.Content;
button.MinWidth = button.Bounds.Width;
object? originalText = CopyTrackingScript.Content;
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
isCopyingScript = true;
button.Content = "Script Copied!";
CopyTrackingScript.Content = "Script Copied!";
await Task.Delay(TimeSpan.FromSeconds(2));
button.Content = originalText;
CopyTrackingScript.Content = originalText;
isCopyingScript = false;
}
}
}
}
}

View File

@@ -1,35 +1,42 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord;
using DHT.Desktop.Server;
using DHT.Utils.Models;
using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages {
sealed class TrackingPageModel : BaseModel {
private bool areDevToolsEnabled;
namespace DHT.Desktop.Main.Pages;
private bool AreDevToolsEnabled {
get => areDevToolsEnabled;
set {
Change(ref areDevToolsEnabled, value);
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
}
}
sealed partial class TrackingPageModel : ObservableObject {
[ObservableProperty(Setter = Access.Private)]
private bool isCopyTrackingScriptButtonEnabled = true;
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool? areDevToolsEnabled = null;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool isToggleAppDevToolsButtonEnabled = false;
public string OpenDevToolsShortcutText { get; } = OperatingSystem.IsMacOS() ? "Cmd+Shift+I" : "Ctrl+Shift+I";
public string ToggleAppDevToolsButtonText {
get {
if (!AreDevToolsEnabled.HasValue) {
return "Loading...";
}
if (!IsToggleAppDevToolsButtonEnabled) {
return "Unavailable";
}
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
return (AreDevToolsEnabled.Value ? "Disable" : "Enable") + " " + OpenDevToolsShortcutText;
}
}
@@ -40,28 +47,25 @@ namespace DHT.Desktop.Main.Pages {
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));
}
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
}
public async Task<bool> OnClickCopyTrackingScript() {
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
IsCopyTrackingScriptButtonEnabled = false;
var clipboard = Application.Current?.Clipboard;
try {
return await CopyTrackingScript();
} finally {
IsCopyTrackingScriptButtonEnabled = true;
}
}
private async Task<bool> CopyTrackingScript() {
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
IClipboard? clipboard = window.Clipboard;
if (clipboard == null) {
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
return false;
@@ -76,20 +80,36 @@ namespace DHT.Desktop.Main.Pages {
}
}
public async void OnClickToggleAppDevTools() {
private async Task InitializeDevToolsToggle() {
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
IsToggleAppDevToolsButtonEnabled = true;
}
else {
IsToggleAppDevToolsButtonEnabled = false;
}
}
public async Task OnClickToggleAppDevTools() {
const string DialogTitle = "Discord App Settings File";
bool oldState = AreDevToolsEnabled;
if (!AreDevToolsEnabled.HasValue) {
return;
}
bool oldState = AreDevToolsEnabled.Value;
bool newState = !oldState;
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
case SettingsJsonResult.Success:
AreDevToolsEnabled = newState;
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
break;
case SettingsJsonResult.AlreadySet:
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " is already " + (newState ? "enabled." : "disabled."));
AreDevToolsEnabled = newState;
break;
@@ -114,4 +134,3 @@ namespace DHT.Desktop.Main.Pages {
}
}
}
}

View File

@@ -5,7 +5,8 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.ViewerPage">
x:Class="DHT.Desktop.Main.Pages.ViewerPage"
x:DataType="pages:ViewerPageModel">
<Design.DataContext>
<pages:ViewerPageModel />
@@ -20,7 +21,6 @@
<StackPanel Orientation="Vertical" Spacing="20">
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
<Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button>
</StackPanel>
<controls:MessageFilterPanel DataContext="{Binding FilterModel}" />
<Expander Header="Database Tools">

View File

@@ -1,16 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Pages {
namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class ViewerPage : UserControl {
public sealed partial class ViewerPage : UserControl {
public ViewerPage() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,49 +1,43 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Server.Database.Export;
using DHT.Utils.Models;
using static DHT.Desktop.Program;
using DHT.Server.Service.Viewer;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Pages {
sealed class ViewerPageModel : BaseModel, IDisposable {
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
namespace DHT.Desktop.Main.Pages;
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
private static readonly Log Log = Log.ForType<ViewerPageModel>();
public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false;
[ObservableProperty]
private bool hasFilters = false;
public bool HasFilters {
get => hasFilters;
set => Change(ref hasFilters, value);
}
private MessageFilterPanelModel FilterModel { get; }
public MessageFilterPanelModel FilterModel { get; }
private readonly Window window;
private readonly IDatabaseFile db;
private readonly State state;
[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.db = db;
this.state = state;
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
}
@@ -55,94 +49,49 @@ namespace DHT.Desktop.Main.Pages {
HasFilters = FilterModel.HasAnyFilters;
}
private async Task WriteViewerFile(string path) {
const string ArchiveTag = "/*[ARCHIVE]*/";
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
string jsonTempFile = path + ".tmp";
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
await ViewerJsonExport.Generate(jsonStream, db, FilterModel.CreateFilter());
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
jsonStream.Position = 0;
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
int readBytes;
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
string jsonChunk = new string(jsonBuffer, 0, readBytes);
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
}
}
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
}
}
File.Delete(jsonTempFile);
}
public async void OnClickOpenViewer() {
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
int counter = 0;
while (File.Exists(fullPath)) {
++counter;
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
}
TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath);
await WriteViewerFile(fullPath);
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
}
public async void OnClickSaveViewer() {
var dialog = new SaveFileDialog {
Title = "Save Viewer",
InitialFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
Directory = Path.GetDirectoryName(db.Path),
Filters = new List<FileDialogFilter> {
new() {
Name = "Discord History Viewer",
Extensions = { "html" }
}
}
}.ShowAsync(window);
string? path = await dialog;
if (!string.IsNullOrEmpty(path)) {
await WriteViewerFile(path);
try {
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
string serverToken = ServerConfiguration.Token;
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
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);
}
}
public async void OnClickApplyFiltersToDatabase() {
var filter = FilterModel.CreateFilter();
public async Task OnClickApplyFiltersToDatabase() {
MessageFilter filter = FilterModel.CreateFilter();
long messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
}
}
else if (DatabaseToolFilterModeRemove) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
}
}
}
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
await ProgressDialog.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());
});
}
}

View File

@@ -5,7 +5,8 @@
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.MainContentScreen">
x:Class="DHT.Desktop.Main.Screens.MainContentScreen"
x:DataType="screens:MainContentScreenModel">
<Design.DataContext>
<screens:MainContentScreenModel />
@@ -39,13 +40,13 @@
<Style Selector="TabItem:pointerover /template/ Border">
<Setter Property="Background" Value="#455785" />
</Style>
<Style Selector="TabItem:pointerover > TextBlock">
<Style Selector="TabItem:pointerover /template/ ContentPresenter">
<Setter Property="Foreground" Value="#E9E9E9" />
</Style>
<Style Selector="TabItem:selected:pointerover /template/ Border">
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected:pointerover > TextBlock">
<Style Selector="TabItem:selected:pointerover /template/ ContentPresenter">
<Setter Property="Foreground" Value="#1A2234" />
</Style>
<Style Selector="TabItem:selected">
@@ -55,9 +56,11 @@
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="TabItem:disabled > TextBlock">
<Style Selector="TabItem:disabled /template/ ContentPresenter">
<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 Selector="TabItem.first">
<Setter Property="Margin" Value="0 13 0 0" />
@@ -73,7 +76,7 @@
<DockPanel>
<Border Classes="statusBar" DockPanel.Dock="Bottom">
<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" />
</DockPanel>
</Border>
@@ -93,9 +96,9 @@
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabAttachments" Header="Attachments" Grid.Row="2">
<TabItem x:Name="TabDownloads" Header="Downloads" Grid.Row="2">
<ScrollViewer>
<ContentPresenter Content="{Binding AttachmentsPage}" Classes="page" />
<ContentPresenter Content="{Binding DownloadsPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">

View File

@@ -1,16 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Screens {
namespace DHT.Desktop.Main.Screens;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MainContentScreen : UserControl {
public sealed partial class MainContentScreen : UserControl {
public MainContentScreen() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,26 +1,21 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Main.Pages;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Logging;
using DHT.Server;
namespace DHT.Desktop.Main.Screens {
sealed class MainContentScreenModel : IDisposable {
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
namespace DHT.Desktop.Main.Screens;
sealed class MainContentScreenModel : IAsyncDisposable {
public DatabasePage DatabasePage { get; }
private DatabasePageModel DatabasePageModel { get; }
public TrackingPage TrackingPage { get; }
private TrackingPageModel TrackingPageModel { get; }
public AttachmentsPage AttachmentsPage { get; }
private AttachmentsPageModel AttachmentsPageModel { get; }
public DownloadsPage DownloadsPage { get; }
private DownloadsPageModel DownloadsPageModel { get; }
public ViewerPage ViewerPage { get; }
private ViewerPageModel ViewerPageModel { get; }
@@ -40,80 +35,47 @@ namespace DHT.Desktop.Main.Screens {
public StatusBarModel StatusBarModel { get; }
public event EventHandler? DatabaseClosed {
add {
DatabasePageModel.DatabaseClosed += value;
add => DatabasePageModel.DatabaseClosed += value;
remove => DatabasePageModel.DatabaseClosed -= value;
}
remove {
DatabasePageModel.DatabaseClosed -= value;
}
}
private readonly Window window;
private readonly ServerManager serverManager;
[Obsolete("Designer")]
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
public MainContentScreenModel() : this(null!, State.Dummy) {}
public MainContentScreenModel(Window window, IDatabaseFile db) {
this.window = window;
this.serverManager = new ServerManager(db);
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
DatabasePageModel = new DatabasePageModel(window, db);
public MainContentScreenModel(Window window, State state) {
DatabasePageModel = new DatabasePageModel(window, state);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
TrackingPageModel = new TrackingPageModel(window);
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
AttachmentsPageModel = new AttachmentsPageModel(db);
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
DownloadsPageModel = new DownloadsPageModel(window, state);
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
ViewerPageModel = new ViewerPageModel(window, db);
ViewerPageModel = new ViewerPageModel(window, state);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
AdvancedPageModel = new AdvancedPageModel(window, state);
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
#if DEBUG
DebugPageModel = new DebugPageModel(window, db);
DebugPageModel = new DebugPageModel(window, state);
DebugPage = new DebugPage { DataContext = DebugPageModel };
#else
DebugPage = null;
#endif
StatusBarModel = new StatusBarModel(db.Statistics);
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
DatabaseClosed += OnDatabaseClosed;
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
StatusBarModel = new StatusBarModel(state);
}
public async Task Initialize() {
await TrackingPageModel.Initialize();
AdvancedPageModel.Initialize();
serverManager.Launch();
await DownloadsPageModel.Initialize();
}
public void Dispose() {
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
AttachmentsPageModel.Dispose();
public async ValueTask DisposeAsync() {
await DownloadsPageModel.DisposeAsync();
ViewerPageModel.Dispose();
serverManager.Dispose();
}
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
StatusBarModel.CurrentStatus = e;
}
private void OnDatabaseClosed(object? sender, EventArgs e) {
serverManager.Stop();
}
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
}
AdvancedPageModel.Dispose();
StatusBarModel.Dispose();
}
}

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen">
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
x:DataType="screens:WelcomeScreenModel">
<Design.DataContext>
<screens:WelcomeScreenModel />
@@ -21,20 +22,21 @@
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button">
<Setter Property="Margin" Value="5 0" />
<Style Selector="Grid#ButtonPanel > Button">
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
</UserControl.Styles>
<Panel Name="RootPanel">
<StackPanel Margin="42">
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
<StackPanel Margin="42 30">
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>
<Button Command="{Binding ShowAboutDialog}">About</Button>
<Button Command="{Binding Exit}">Exit</Button>
</StackPanel>
<Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
<Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>
<Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button>
<Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button>
</Grid>
</StackPanel>
</Panel>
</UserControl>

View File

@@ -1,16 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Screens {
namespace DHT.Desktop.Main.Screens;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class WelcomeScreen : UserControl {
public sealed partial class WelcomeScreen : UserControl {
public WelcomeScreen() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,18 +1,30 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Data.Settings;
using DHT.Server.Database;
using DHT.Utils.Models;
using DHT.Server.Database.Sqlite.Schema;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Screens;
sealed partial class WelcomeScreenModel : ObservableObject {
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
namespace DHT.Desktop.Main.Screens {
sealed class WelcomeScreenModel : BaseModel, IDisposable {
public string Version => Program.Version;
public IDatabaseFile? Db { get; private set; }
public bool HasDatabase => Db != null;
[ObservableProperty(Setter = Access.Private)]
private bool isOpenOrCreateDatabaseButtonEnabled = true;
public event EventHandler<IDatabaseFile>? DatabaseSelected;
private readonly Window window;
@@ -25,49 +37,130 @@ namespace DHT.Desktop.Main.Screens {
this.window = window;
}
public async void OpenOrCreateDatabase() {
var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog();
dialog.Directory = Path.GetDirectoryName(dbFilePath);
string? path = await dialog.ShowAsync(window);
if (!string.IsNullOrWhiteSpace(path)) {
public async Task OpenOrCreateDatabase() {
IsOpenOrCreateDatabaseButtonEnabled = false;
try {
string? path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
if (path != null) {
await OpenOrCreateDatabaseFromPath(path);
}
} finally {
IsOpenOrCreateDatabaseButtonEnabled = true;
}
}
public async Task OpenOrCreateDatabaseFromPath(string path) {
if (Db != null) {
Db = null;
}
dbFilePath = path;
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
bool isNew = !File.Exists(path);
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
if (db == null) {
return;
}
private async Task<bool> CheckCanUpgradeDatabase() {
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
await db.Settings.Set(SettingsKey.DownloadsAutoStart, value: true);
}
DatabaseSelected?.Invoke(this, db);
}
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
public async Task<bool> CanUpgrade() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
}
public void CloseDatabase() {
Dispose();
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
var reporter = new ProgressReporter(versionSteps, callbacks);
await reporter.NextVersion();
await Task.Delay(TimeSpan.FromMilliseconds(800));
await doUpgrade(reporter);
await Task.Delay(TimeSpan.FromMilliseconds(600));
}
public async void ShowAboutDialog() {
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
}
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
private int versionProgress = 0;
public async Task NextVersion() {
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
await HideChildren(0);
}
public async Task MainWork(string message, int finishedItems, int totalItems) {
await callbacks[1].Update(message, finishedItems, totalItems);
await HideChildren(1);
}
public async Task SubWork(string message, int finishedItems, int totalItems) {
await callbacks[2].Update(message, finishedItems, totalItems);
await HideChildren(2);
}
private async Task HideChildren(int parentIndex) {
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
await callbacks[i].Hide();
}
}
}
}
public async Task CheckUpdates() {
var latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => {
var client = new HttpClient(new SocketsHttpHandler {
AutomaticDecompression = DecompressionMethods.None,
AllowAutoRedirect = false,
UseCookies = false,
});
client.Timeout = TimeSpan.FromSeconds(30);
client.MaxResponseContentBufferSize = 1024;
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
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 Task ShowAboutDialog() {
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
}
public void Exit() {
window.Close();
}
public void Dispose() {
Db?.Dispose();
Db = null;
}
}
}

View File

@@ -1,21 +1,26 @@
using System.Globalization;
using System;
using System.Globalization;
using System.Reflection;
using Avalonia;
using DHT.Utils.Logging;
using DHT.Utils.Resources;
namespace DHT.Desktop {
namespace DHT.Desktop;
static class Program {
public static string Version { get; }
public static Version AssemblyVersion { get; }
public static CultureInfo Culture { get; }
public static ResourceLoader Resources { get; }
public static Arguments Arguments { get; }
public const string Website = "https://dht.chylex.com";
static Program() {
var assembly = Assembly.GetExecutingAssembly();
Version = assembly.GetName().Version?.ToString() ?? "";
while (Version.EndsWith(".0")) {
Version = Version[..^2];
}
AssemblyVersion = assembly.GetName().Version ?? new Version(major: 0, minor: 0, build: 0, revision: 0);
Version = VersionToString(AssemblyVersion);
Culture = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
@@ -24,16 +29,37 @@ namespace DHT.Desktop {
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
Resources = new ResourceLoader(assembly);
Arguments = new Arguments(Environment.GetCommandLineArgs());
}
public static string VersionToString(Version version) {
string versionStr = version.ToString();
while (versionStr.EndsWith(".0")) {
versionStr = versionStr[..^2];
}
return versionStr;
}
public static void Main(string[] args) {
if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.AllocConsole();
}
try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} finally {
if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.FreeConsole();
}
}
}
private static AppBuilder BuildAvaloniaApp() {
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}
}

View File

@@ -0,0 +1 @@
fetch("{url}").then(r => r.ok ? (r.headers.get("X-DHT") === "1" ? r.text() : Promise.reject("Invalid response")) : Promise.reject(r.status + " " + r.statusText)).then(s => eval(s)).catch(e => alert("Could not load tracking script:\n" + e));

View 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);
}

View File

@@ -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;
}
}
}
}

50
app/Directory.Build.props Normal file
View File

@@ -0,0 +1,50 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>13</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<Authors>chylex</Authors>
<Company>DiscordHistoryTracker</Company>
<Product>DiscordHistoryTracker</Product>
</PropertyGroup>
<PropertyGroup>
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
</PropertyGroup>
<PropertyGroup>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
<DebugType>none</DebugType>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<PropertyGroup>
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
</PropertyGroup>
</Project>

6
app/NuGet.Config Normal file
View 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>

View File

@@ -9,6 +9,8 @@ items:
pattern: "^[0-9]+$"
name:
type: string
displayName:
type: string
avatar:
type: string
discriminator:

View File

@@ -10,11 +10,16 @@
return;
}
/*[IMPORTS]*/
if (!DISCORD.isCompatible()) {
alert("Discord History Tracker is not compatible with this version of Discord.");
return;
}
window.DHT_LOADED = true;
window.DHT_ON_UNLOAD = [];
/*[IMPORTS]*/
const port = 0; /*[PORT]*/
const token = "/*[TOKEN]*/";
STATE.setup(port, token);
@@ -46,7 +51,7 @@
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
};
const onTrackingContinued = function(anyNewMessages) {
const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
if (!STATE.isTracking()) {
return;
}
@@ -63,10 +68,12 @@
if (SETTINGS.autoscroll) {
let action = null;
if (!DISCORD.hasMoreMessages()) {
if (!hasMoreBefore) {
console.debug("[DHT] Reached first message.");
action = SETTINGS.afterFirstMsg;
}
if (isNoAction(action) && !anyNewMessages) {
console.debug("[DHT] No new messages.");
action = SETTINGS.afterSavedMsg;
}
@@ -82,7 +89,7 @@
let waitUntilSendingFinishedTimer = null;
const onMessagesUpdated = async messages => {
const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
if (!STATE.isTracking() || delayedStopRequests > 0) {
return;
}
@@ -92,24 +99,16 @@
waitUntilSendingFinishedTimer = window.setTimeout(() => {
waitUntilSendingFinishedTimer = null;
onMessagesUpdated(messages);
onMessagesUpdated(server, channel, messages, hasMoreBefore);
}, 100);
return;
}
const info = DISCORD.getSelectedChannel();
if (!info) {
GUI.setStatus("Error (Unknown Channel)");
stopTrackingDelayed();
return;
}
isSending = true;
try {
await STATE.addDiscordChannel(info.server, info.channel);
await STATE.addDiscordChannel(server, channel);
} catch (e) {
onError(e);
return;
@@ -118,35 +117,33 @@
try {
if (!messages.length) {
isSending = false;
onTrackingContinued(false);
onTrackingContinued(false, hasMoreBefore);
}
else {
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);
onTrackingContinued(anyNewMessages);
const anyNewMessages = await STATE.addDiscordMessages(messages);
onTrackingContinued(anyNewMessages, hasMoreBefore);
}
} catch (e) {
onError(e);
}
};
DISCORD.setupMessageCallback(onMessagesUpdated);
const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
STATE.onTrackingStateChanged(enabled => {
if (enabled) {
const messages = DISCORD.getMessages();
if (messages.length === 0) {
stopTrackingDelayed(() => alert("Cannot see any messages."));
return;
}
GUI.setStatus("Starting");
GUI.createTrackingStyles();
hasJustStarted = true;
// noinspection JSIgnoredPromiseFromCall
onMessagesUpdated(messages);
if (!starter()) {
stopTrackingDelayed(() => alert("Cannot see any messages."));
hasJustStarted = false;
}
}
else {
isSending = false;
GUI.deleteTrackingStyles();
}
});
@@ -156,3 +153,4 @@
GUI.showSettings();
}
})();
/*[DEBUGGER]*/

View File

@@ -1,5 +1,106 @@
// noinspection JSUnresolvedVariable
// noinspection LocalVariableNamingConventionJS
class DISCORD {
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
static CHANNEL_TYPE = {
GUILD_TEXT: 0,
DM: 1,
GROUP_DM: 3,
GUILD_ANNOUNCEMENT: 5,
ANNOUNCEMENT_THREAD: 10,
PUBLIC_THREAD: 11,
PRIVATE_THREAD: 12,
isPrivate(type) {
return type === this.DM
|| type === this.GROUP_DM;
},
isNavigableGuildChannel(type) {
return type === this.GUILD_TEXT
|| type === this.GUILD_ANNOUNCEMENT
|| type === this.ANNOUNCEMENT_THREAD
|| type === this.PUBLIC_THREAD
|| type === this.PRIVATE_THREAD;
}
};
// https://discord.com/developers/docs/resources/channel#message-object-message-types
static MESSAGE_TYPE = {
DEFAULT: 0,
REPLY: 19,
THREAD_STARTER: 21
};
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
static PERMISSION = {
VIEW_CHANNEL: 1n << 10n
};
/**
* @type {Object}
* @property {function(String): ?DiscordGuild} getGuild
*/
static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
/**
* @type {Object}
* @property {function(String): Boolean} isOptInEnabled
* @property {function(String): Set<String>} getOptedInChannels
*/
static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
/**
* @type {Object}
* @property {function(String): ?DiscordChannel} getChannel
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
*/
static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
/**
* @type {function(BigInt, Object): Boolean}
*/
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
/**
* @type {function(String): MessageData}
*/
static #getMessages = WEBPACK.findFunction("getMessages");
/**
* @type {function(String): void}
*/
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
/**
* @type {function(): String}
*/
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
/**
* @type {function(String): void}
*/
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
/**
* @type {function(String, Object, String=null): void}
*/
static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync");
static isCompatible() {
return !!this.#guildStore
&& !!this.#guildSettings
&& !!this.#channelStore
&& !!this.#hasPermission
&& !!this.#getMessages
&& !!this.#jumpToMessage
&& !!this.#getCurrentlySelectedChannelId
&& !!this.#selectPrivateChannel
&& !!this.#transitionToGuildSync;
}
static getMessageOuterElement() {
return DOM.queryReactClass("messagesWrapper");
}
@@ -8,14 +109,6 @@ class DISCORD {
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() {
const view = this.getMessageScrollerElement();
@@ -24,15 +117,63 @@ class DISCORD {
}
}
static getMessagesFromSelectedChannel() {
const channelId = this.#getCurrentlySelectedChannelId();
return channelId ? this.#getMessages(channelId) : null;
}
/**
* Calls the provided function with a list of messages whenever the currently loaded messages change.
* @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)}
*/
static setupMessageCallback(callback) {
let skipsLeft = 0;
let waitForCleanup = false;
const previousMessages = new Set();
const timer = window.setInterval(() => {
const onMessageElementsChanged = force => {
const messages = this.getMessagesFromSelectedChannel();
if (!messages || !messages.ready || messages.loadingMore) {
return false;
}
const channel = this.#channelStore.getChannel(messages.channelId);
if (!channel) {
return false;
}
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
if (!hasChanged) {
return false;
}
previousMessages.clear();
for (const message of messages._array) {
previousMessages.add(message.id);
}
const server = this.#guildStore.getGuild(channel.guild_id);
callback(server, channel, messages._array, messages.hasMoreBefore);
return true;
};
let debounceTimer;
/**
* Do not trigger the callback too often due to autoscrolling.
*/
const onMessageElementsChangedLater = function() {
window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
};
const observer = new MutationObserver(function() {
onMessageElementsChangedLater();
});
let skipsLeft = 0;
let observedElement = null;
const observerTimer = window.setInterval(() => {
if (skipsLeft > 0) {
--skipsLeft;
return;
@@ -41,244 +182,76 @@ class DISCORD {
const view = this.getMessageOuterElement();
if (!view) {
skipsLeft = 2;
skipsLeft = 1;
return;
}
const anyMessage = DOM.queryReactClass("message", this.getMessageOuterElement());
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
if (messageCount > 300) {
if (waitForCleanup) {
if (observedElement !== null && observedElement.isConnected) {
return;
}
skipsLeft = 3;
waitForCleanup = true;
observedElement = view.querySelector("[data-list-id='chat-messages']");
window.setTimeout(() => {
const view = this.getMessageScrollerElement();
// noinspection JSUnusedGlobalSymbols
view.scrollTop = view.scrollHeight / 2;
}, 1);
}
else {
waitForCleanup = false;
if (observedElement) {
console.debug("[DHT] Observed message container.");
observer.observe(observedElement, { childList: true });
onMessageElementsChangedLater();
}
}, 400);
const messages = this.getMessages();
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
window.DHT_ON_UNLOAD.push(() => {
observer.disconnect();
observedElement = null;
window.clearInterval(observerTimer);
});
if (!hasChanged) {
return;
}
previousMessages.clear();
for (const message of messages) {
previousMessages.add(message.id);
}
callback(messages);
}, 200);
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer));
}
/**
* Returns the property object of a message element.
* @returns { null | { message: DiscordMessage, channel: Object } }
*/
static getMessageElementProps(ele) {
const props = DOM.getReactProps(ele);
if (props.children && props.children.length >= 4) {
const childProps = props.children[3].props;
if ("message" in childProps && "channel" in childProps) {
return childProps;
}
}
return null;
}
/**
* Returns an array containing currently loaded messages.
*/
static getMessages() {
try {
const messages = [];
for (const ele of this.getMessageElements()) {
const props = this.getMessageElementProps(ele);
if (props != null) {
messages.push(props.message);
}
}
return messages;
} catch (e) {
console.error(e);
return [];
}
}
/**
* Returns an object containing the selected server and channel information.
* For types DM and GROUP, the server and channel ids and names are identical.
* @returns { {} | null }
*/
static getSelectedChannel() {
try {
let obj;
try {
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
if (child && child.props && child.props.channel) {
obj = child.props.channel;
break;
}
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
for (const ele of this.getMessageElements()) {
const props = this.getMessageElementProps(ele);
if (props != null) {
obj = props.channel;
break;
}
}
}
if (!obj || typeof obj.id !== "string") {
return null;
}
const dms = DOM.queryReactClass("privateChannels");
if (dms) {
let name;
for (const ele of dms.querySelectorAll("[class*='channel-'] [class*='selected-'] [class^='name-'] *, [class*='channel-'][class*='selected-'] [class^='name-'] *")) {
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
if (node) {
name = node.nodeValue;
break;
}
}
if (!name) {
return null;
}
let type;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
switch (obj.type) {
case 1: type = "DM"; break;
case 3: type = "GROUP"; break;
default: return null;
}
const id = obj.id;
const server = { id, name, type };
const channel = { id, name };
return { server, channel };
}
else if (obj.guild_id) {
const server = {
"id": obj.guild_id,
"name": document.querySelector("nav header h1[class*='name-']").innerText,
"type": "SERVER"
};
const channel = {
"id": obj.id,
"name": obj.name,
"extra": {
"nsfw": obj.nsfw
}
};
if (obj.parent_id) {
channel["extra"]["parent"] = obj.parent_id;
}
else {
channel["extra"]["position"] = obj.position;
channel["extra"]["topic"] = obj.topic;
}
return { server, channel };
}
else {
return null;
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel.", e);
return null;
}
return () => onMessageElementsChanged(true);
}
/**
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
*/
static selectNextTextChannel() {
const dms = DOM.queryReactClass("privateChannels");
if (dms) {
const currentChannel = DOM.queryReactClass("selected", dms);
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel-']");
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-")) {
const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
if (!currentChannel) {
return false;
}
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
if (!nextChannelLink) {
if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
const privateChannel = this.#channelStore.getSortedPrivateChannels();
const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
return false;
}
nextChannelLink.click();
nextChannelLink.scrollIntoView(true);
this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
return true;
}
else {
const channelListEle = document.getElementById("channels");
if (!channelListEle) {
const guildId = currentChannel.guild_id;
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;
}
function getLinkElement(channel) {
return channel.querySelector("a[href^='/channels/'][role='link']");
}
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
let nextChannel = null;
for (let index = 0; index < 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);
this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id);
return true;
}
}

View File

@@ -1,6 +1,8 @@
class DOM {
/**
* Returns a child element by its ID. Parent defaults to the entire document.
* @param {string} id
* @param {HTMLElement?} [parent]
* @returns {HTMLElement}
*/
static id(id, parent) {
@@ -9,9 +11,12 @@ class DOM {
/**
* 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) {
return (parent || document).querySelector(`[class*="${cls}-"]`);
return (parent || document).querySelector(`[class*="${cls}_"]`);
}
/**
@@ -55,20 +60,4 @@ class DOM {
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
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;
}
}

View File

@@ -2,14 +2,17 @@
const GUI = (function() {
let controller = null;
let settings = null;
let trackingStyles = null;
const stateChangedEvent = () => {
if (settings) {
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
settings.ui.cbHidePreviewsWhileAutoscrolling.checked = SETTINGS.hidePreviewsWhileAutoscrolling;
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
const autoscrollDisabled = !SETTINGS.autoscroll;
settings.ui.cbHidePreviewsWhileAutoscrolling.disabled = autoscrollDisabled;
Object.values(settings.ui.optsAfterFirstMsg).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", () => {
this.hideController();
this.deleteTrackingStyles();
window.DHT_ON_UNLOAD.forEach(f => f());
delete window.DHT_ON_UNLOAD;
delete window.DHT_LOADED;
@@ -84,17 +88,17 @@ const GUI = (function() {
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
const html = `
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<label><input id='dht-cfg-hide-previews-while-autoscrolling' type='checkbox'> Hide previews to improve browser performance</label><br>
<br>
<label>After reaching the first message in channel...</label><br>
${radio("afm", "nothing", "Do Nothing")}
${radio("afm", "nothing", "Continue Tracking")}
${radio("afm", "pause", "Pause Tracking")}
${radio("afm", "switch", "Switch to Next Channel")}
<br>
<label>After reaching a previously saved message...</label><br>
${radio("asm", "nothing", "Do Nothing")}
${radio("asm", "nothing", "Continue Tracking")}
${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "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>`;
${radio("asm", "switch", "Switch to Next Channel")}`;
settings = {
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
@@ -107,9 +111,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
});
settings.ui = {
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
optsAfterFirstMsg: {},
optsAfterSavedMsg: {}
/** @type {HTMLInputElement} */ cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
/** @type {HTMLInputElement} */ cbHidePreviewsWhileAutoscrolling: DOM.id("dht-cfg-hide-previews-while-autoscrolling"),
/** @type {Object.<number, HTMLInputElement>} */ optsAfterFirstMsg: {},
/** @type {Object.<number, HTMLInputElement>} */ optsAfterSavedMsg: {}
};
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.ui.cbHidePreviewsWhileAutoscrolling.addEventListener("change", () => {
SETTINGS.hidePreviewsWhileAutoscrolling = settings.ui.cbHidePreviewsWhileAutoscrolling.checked;
});
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
SETTINGS.afterFirstMsg = key;
@@ -152,6 +161,29 @@ ${radio("asm", "switch", "Switch to Next Channel")}
if (controller) {
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;
}
}
};
})();

View File

@@ -35,17 +35,28 @@ const SETTINGS = (function() {
obj[name] = value;
};
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
if (!loaded) {
loaded = {
const defaults = {
"_autoscroll": true,
"_hidePreviewsWhileAutoscrolling": true,
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
};
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
let hasChanged = false;
if (!loaded) {
loaded = defaults;
IS_FIRST_RUN = true;
}
else {
for (const property in defaults) {
if (!(property in loaded)) {
loaded[property] = defaults[property];
hasChanged = true;
}
}
}
const root = {
onSettingsChanged(callback) {
@@ -54,10 +65,11 @@ const SETTINGS = (function() {
};
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
defineTriggeringProperty(root, "hidePreviewsWhileAutoscrolling", loaded._hidePreviewsWhileAutoscrolling);
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
if (IS_FIRST_RUN) {
if (IS_FIRST_RUN || hasChanged) {
saveSettings();
}

View File

@@ -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 = [];
let isTracking = false;
const addedChannels = 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 {
setup(port, token) {
serverPort = port;
@@ -141,32 +121,51 @@ const STATE = (function() {
}
},
/**
* @param {?DiscordGuild} serverInfo
* @param {DiscordChannel} channelInfo
*/
async addDiscordChannel(serverInfo, channelInfo) {
if (addedChannels.has(channelInfo.id)) {
return;
}
const server = {
id: serverInfo.id,
name: serverInfo.name,
type: serverInfo.type
type: getChannelTypeName(channelInfo.type)
};
const channel = {
id: channelInfo.id,
name: channelInfo.name
extra: {}
};
if ("extra" in channelInfo) {
const extra = channelInfo.extra;
if ("parent" in extra) {
channel.parent = extra.parent;
if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
server.id = channelInfo.id;
server.name = channel.name = getPrivateChannelName(channelInfo);
}
else if (serverInfo) {
server.id = serverInfo.id;
server.name = serverInfo.name;
channel.name = channelInfo.name;
}
else {
return;
}
channel.position = extra.position;
channel.topic = extra.topic;
channel.nsfw = extra.nsfw;
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 });
@@ -174,12 +173,10 @@ const STATE = (function() {
},
/**
* @param {String} channelId
* @param {DiscordMessage[]} discordMessageArray
*/
async addDiscordMessages(channelId, discordMessageArray) {
// https://discord.com/developers/docs/resources/channel#message-object-message-types
discordMessageArray = discordMessageArray.filter(msg => (msg.type === 0 || msg.type === 19 || msg.type === 21) && msg.state === "SENT");
async addDiscordMessages(discordMessageArray) {
discordMessageArray = discordMessageArray.filter(msg => (msg.type === DISCORD.MESSAGE_TYPE.DEFAULT || msg.type === DISCORD.MESSAGE_TYPE.REPLY || msg.type === DISCORD.MESSAGE_TYPE.THREAD_STARTER) && msg.state === "SENT");
if (discordMessageArray.length === 0) {
return false;
@@ -197,6 +194,10 @@ const STATE = (function() {
name: user.username
};
if (user.globalName) {
obj.displayName = user.globalName;
}
if (user.avatar) {
obj.avatar = user.avatar;
}
@@ -225,12 +226,12 @@ const STATE = (function() {
sender: msg.author.id,
channel: msg.channel_id,
text: msg.content,
timestamp: msg.timestamp.toDate().getTime()
timestamp: getDate(msg.timestamp).getTime()
};
if (msg.editedTimestamp !== null) {
// noinspection JSUnusedGlobalSymbols
obj.editTimestamp = msg.editedTimestamp.toDate().getTime();
obj.editTimestamp = getDate(msg.editedTimestamp).getTime();
}
if (msg.messageReference !== null) {
@@ -251,6 +252,11 @@ const STATE = (function() {
mapped.type = attachment.content_type;
}
if (attachment.width && attachment.height) {
mapped.width = attachment.width;
mapped.height = attachment.height;
}
return mapped;
});
}
@@ -280,7 +286,7 @@ const STATE = (function() {
}
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 mapped = {

View 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
*/

View 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);
}
}

Some files were not shown because too many files have changed in this diff Show More