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

164 Commits
v32 ... v38

Author SHA1 Message Date
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
c9bb46c8c7 Release v36.1 (beta) 2022-06-04 13:31:48 +02:00
73f4c70325 Implement legacy archive file import 2022-06-04 13:31:48 +02:00
de5a8b690b Add TextBoxDialog 2022-06-03 16:44:43 +02:00
daa2feb445 Add support for merging downloaded attachments from other databases 2022-05-29 16:37:18 +02:00
4e94e788bc Fix not closing connections to database files incompatible with the current app version (including rejected upgrade prompts) 2022-05-29 16:37:18 +02:00
133ec532d2 Refactor database merging & add user count to final report 2022-05-29 16:37:18 +02:00
3d435d0165 Release v36 (beta) 2022-05-29 12:40:13 +02:00
3e8151e1f3 Fix empty space between DHT tracker and app after a recent Discord update 2022-05-29 12:14:27 +02:00
9f98eba9c1 Update Avalonia to 0.10.14 2022-05-29 12:10:34 +02:00
6b54a80be1 Implement attachment downloads in desktop app 2022-05-29 12:09:47 +02:00
1e6e5c6f92 Refactor generating SQL "WHERE" clauses 2022-05-28 21:40:13 +02:00
2459c8ee1a Add randomization of SQLite connection pool delays to reduce chance of livelocks 2022-05-28 21:40:13 +02:00
d129a60d1c Add BytesValueConverter 2022-05-28 21:40:13 +02:00
65ecb0177c Fix wrong plural in the Viewer tab if the total amount of messages is zero 2022-05-28 21:40:13 +02:00
d51dcb0a84 Fix Viewer tab statistics (not computing in constructor, not cancelling after resetting filter) & tweak code 2022-05-28 21:40:12 +02:00
b13b85dedd Add support for cancelling async value computation 2022-05-28 21:38:42 +02:00
15e8b9da63 Tweak wording in the Advanced tab to reduce text length 2022-05-28 11:22:55 +02:00
9572f0f002 Rename MessageFilterRemovalMode to FilterRemovalMode 2022-05-24 22:02:36 +02:00
2f3b8b974c Rename MessageFilterPanel to FilterPanel 2022-05-24 22:02:36 +02:00
bff86b09c7 Update SQLite version to 3.35.5 2022-05-22 16:19:19 +02:00
5ca7cf09e8 Clarify and fix instructions for platform-portable releases
Closes #182
2022-05-21 22:20:26 +02:00
a1c93232d0 Cleanup temporary files when DHT is closed 2022-05-21 21:32:32 +02:00
db5f9d65db Change default viewer file name to the name of the database file 2022-05-21 20:11:09 +02:00
4cbf387e2a Optimize viewer export to support exporting large databases 2022-05-21 20:11:09 +02:00
64cf3c9fbb Calculate amount of exported messages asynchronously 2022-05-21 20:11:09 +02:00
a4ebd5eed6 Replace message statistics thread with new async value computer 2022-05-21 03:27:57 +02:00
06716330d6 Add utility for asynchronous value computation 2022-05-21 03:03:32 +02:00
1a6346677e Improve performance of check box dialogs by using ItemsRepeater instead of ItemsControl 2022-05-19 22:07:38 +02:00
261be50463 Release v35.3 2022-05-19 12:30:25 +02:00
f93f5c8fdd Fix DHT tracker overlaying bottom of the app & set z-index to force it on top if it happens again
Closes #181
2022-05-19 12:28:24 +02:00
039c55eb1e Release v35.2 2022-05-03 21:31:22 +02:00
a54242de8a Work around some Discord messages having duplicate attachments with the same ID
Closes #177
2022-05-03 21:28:29 +02:00
578e51dc17 Add information about building Linux / Mac versions 2022-03-31 15:29:05 +02:00
8e2ec4dfe2 Release v35.1 2022-03-31 14:30:47 +02:00
3431f091ad Update build instructions 2022-03-31 10:54:04 +02:00
a988003bdd Fix server name detection broken by a Discord update
Closes #172
2022-03-31 09:08:57 +02:00
5561f574cf Release v35 (app) 2022-03-24 00:03:12 +01:00
8fd4561721 Try to fix inconsistency with embedded resource generation 2022-03-24 00:03:12 +01:00
9fe68be3f4 Fix bad command for executing python script in .csproj 2022-03-23 20:40:38 +01:00
90dac674eb Update website 2022-03-22 03:52:17 +01:00
1ebf15b039 Add '/app' to excluded folder in root IDEA project 2022-03-22 01:30:38 +01:00
86acef1a22 Add option to Advanced tab to vacuum the database 2022-03-21 14:18:21 +01:00
277e241183 Speed up opening database by deferring the initial refresh of total message count 2022-03-21 14:18:21 +01:00
3b41ea7b5f Fix showing potentially outdated message count when merging databases 2022-03-21 14:03:21 +01:00
6ce0ef7d55 Refresh message statistics after tracking messages in a background thread 2022-03-21 01:38:41 +01:00
fd09ac496e Increase batch size when adding randomly generated data & fix magic numbers 2022-03-21 01:19:20 +01:00
9ca56bd910 Enable write-ahead log for SQLite connections to prevent blocking concurrent writes and reads 2022-03-21 00:38:24 +01:00
3e891e19c3 Avoid redundant database queries when the Viewer tab is not visible 2022-03-20 23:11:39 +01:00
9341988017 Add debug tab with random database data generator 2022-03-20 23:11:38 +01:00
461e403733 Fix viewer filter not including the whole last day 2022-03-20 21:20:10 +01:00
c03e2d328d Fix redundant XAML 2022-03-20 20:43:43 +01:00
f3723ee43b Split browser-only version into a separate branch & update README 2022-03-20 16:46:08 +01:00
8f7b566db7 Release v34 (app) 2022-03-20 13:40:31 +01:00
70a2a01ec3 Fix switching to next channel in DMs after a recent Discord update 2022-03-20 13:40:31 +01:00
c31155738e Disable debug symbols for Utils project in Release configuration 2022-03-19 21:50:45 +01:00
c23fac465f Rework app build setup to call minification script automatically 2022-03-19 21:49:56 +01:00
51a2ac2d66 Fix app minification script on non-Windows systems 2022-03-19 21:18:51 +01:00
a5e8366f1b Redesign status bar in the app 2022-03-17 20:38:25 +01:00
3b698dbf33 Change assembly names of subprojects 2022-03-15 12:57:55 +01:00
dc2c2d7ce8 Remove unnecessary .csproj tags and hide embedded resources from IDEs 2022-03-15 12:55:39 +01:00
bb5634adc4 Move screens to a different namespace 2022-03-15 12:55:27 +01:00
d26e16eadf Move advanced tracking settings into a separate tab 2022-03-14 18:17:17 +01:00
8f5f6065d8 Refactor text channel switching to detect more types of text channels
Closes #119
Closes #159
2022-03-13 18:31:28 +01:00
ad299bf762 Fix stalling on empty channels
Closes #164
2022-03-13 17:42:38 +01:00
f70bbd53d9 Fix ignoring settings for reaching the first message in a channel if no new messages were saved 2022-03-13 17:05:27 +01:00
ae821f738e Fix app memory leaks caused by UI code 2022-03-13 14:47:25 +01:00
ab7f5d0a41 Add SQLite connection pooling and fix not releasing file lock after closing database
Closes #167
2022-03-13 13:50:26 +01:00
1bddde7ccd Fix not fully disposing internal app server when stopped 2022-03-13 13:17:58 +01:00
58259c0bb4 Update Avalonia to 0.10.13 2022-03-12 18:12:31 +01:00
a84a453990 Redesign the app 2022-03-12 18:12:30 +01:00
563c644f48 Fix new databases containing columns that were removed in an earlier commit 2022-03-12 18:12:30 +01:00
f8331a571e Fix right margins in Viewer filter panel, causing early wrapping 2022-03-10 03:39:48 +01:00
1ed26a16ea Add more performance logging to the app 2022-03-06 15:49:44 +01:00
72c13cbb58 Fix more database disposal issues 2022-03-06 15:22:03 +01:00
e420add646 Split edit timestamps and message reply ids to separate tables to reduce database size 2022-03-06 15:11:23 +01:00
6f1149ad5e Add utilities to simplify working with SQLite 2022-03-05 22:58:47 +01:00
b9899922e0 Optimize viewer export in the app 2022-03-05 21:35:56 +01:00
6a2933ea0a Add utilities for performance logging 2022-03-05 21:05:43 +01:00
be5c76c3bd Add debug log level and reset console colors after logging 2022-03-05 20:09:24 +01:00
217c1f9e10 Tell users to backup database file(s) before a schema upgrade 2022-03-05 18:43:48 +01:00
725ab7accf Update SQLite version to 3.35.0 2022-03-05 17:18:33 +01:00
9a7a2cffc2 Allow database file path to be passed as the first command line argument to the app
This adds support for directly opening files with the DHT app, for ex. in Windows Explorer by using "Open With", or by associating the ".dht" extension with the app.
2022-03-05 16:43:58 +01:00
6d3db23f80 Fix not manually disposing of resources when the app window is closed 2022-03-05 13:36:04 +01:00
4bc9626013 Add name to server management thread 2022-03-05 13:36:04 +01:00
8002236c1f Release v33.2 (app) 2022-02-27 17:09:51 +01:00
c4fe6c4391 Move app version info out of .csproj and into a single linked .cs file 2022-02-27 16:20:39 +01:00
ebfe972a98 Update uses of Avalonia APIs & safeguard clipboard code 2022-02-27 15:29:54 +01:00
20aac4c47a Update Avalonia to 0.10.12 2022-02-27 15:21:38 +01:00
35308e0995 Add option to re-enable Ctrl+Shift+I in the Discord app 2022-02-27 15:08:56 +01:00
f7f32c3f6a Fix Avalonia designer not seeing custom windows and controls 2022-02-27 11:37:08 +01:00
4dc781b35c Address Rider inspections 2022-02-21 22:27:29 +01:00
849ef18adb Reorganize namespaces and move some classes to a separate Utils project 2022-02-21 22:27:01 +01:00
77aa15e557 Add database file name to the app title
References #165
2022-02-20 20:03:59 +01:00
47b106503d Fix database path being editable in the Database tab in the app 2022-02-20 19:55:32 +01:00
bde4cb06f4 Dispose of all window properties when the DHT panel is closed (app) 2022-02-12 20:43:35 +01:00
d772f7ed71 Fix calling clearTimeout instead of clearInterval in app script (no difference according to spec, but cleaner) 2022-02-12 20:40:57 +01:00
0662af9b1a Update website (put desktop app on top, add anchor links) 2022-02-12 19:17:37 +01:00
03fd730139 Release v.31a (browser script) 2022-02-12 18:37:39 +01:00
d362c96b80 Fix skipping to next channel not working after a Discord update (browser script) 2022-02-12 18:35:47 +01:00
9f34c9dffa Fix occasional skipping of messages when autoscrolling in unfocused browser (browser script) 2022-02-12 18:34:43 +01:00
cacf43d1d8 Fix broken channel detection after a Discord update (browser script)
#161
2022-02-12 17:38:45 +01:00
edc23d616d Release v33.1 (app) 2022-02-12 11:23:24 +01:00
db191f87fd Fix not finding selected DM channel after a Discord update
Closes #161
2022-02-12 11:15:33 +01:00
e8677c09b4 Release v33 (app) 2021-12-01 08:13:19 +01:00
378c54908e Add tools to remove/keep filtered messages in the app 2021-11-29 23:43:56 +01:00
07af4ae00f Improve localization for dates and numbers in the app UI 2021-11-29 17:52:07 +01:00
c262e5aaa4 Minor visual fixes for app UI 2021-11-29 17:52:07 +01:00
96e125b812 Add channel & user filters to the app 2021-11-29 15:16:51 +01:00
c0123b9f91 Add check box dialog 2021-11-29 15:12:35 +01:00
2ec9c7cbc3 Fix Rider inspections 2021-11-28 16:20:34 +01:00
4cfe19d369 Tweak button styles in the app 2021-11-28 14:09:56 +01:00
f449859ad9 Update Avalonia to 0.10.10 2021-11-28 13:57:49 +01:00
ae5630cf96 Fix missing app icon in dialog windows 2021-11-27 20:59:16 +01:00
75c8202c77 Disable resizing of message dialogs 2021-11-27 20:58:57 +01:00
e9e8c95a19 Add feature to merge multiple database files into one 2021-11-27 20:50:01 +01:00
879a69608c Add progress dialog 2021-11-27 19:41:45 +01:00
560c515ecf Make message dialogs callable from non-UI threads 2021-11-27 19:41:45 +01:00
ec8c00853a Add empty archive file w/ Rider data source 2021-11-27 13:40:53 +01:00
5888d13d7c Add app build script for Linux 2021-11-26 13:48:52 +01:00
15db0cca8e Update Rider project files 2021-11-20 09:32:55 +01:00
52998f7d7a Release v32.1 (app) 2021-09-14 02:03:08 +02:00
c2e302f8bb Allow tracking from old Discord domain in the app (discordapp.com) 2021-09-14 02:03:08 +02:00
217 changed files with 7195 additions and 7724 deletions

View File

@@ -8,6 +8,7 @@
<excludeFolder url="file://$MODULE_DIR$/.vscode" /> <excludeFolder url="file://$MODULE_DIR$/.vscode" />
<excludeFolder url="file://$MODULE_DIR$/bld" /> <excludeFolder url="file://$MODULE_DIR$/bld" />
<excludeFolder url="file://$MODULE_DIR$/lib" /> <excludeFolder url="file://$MODULE_DIR$/lib" />
<excludeFolder url="file://$MODULE_DIR$/app" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.8" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.8" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

52
.vscode/tasks.json vendored
View File

@@ -1,52 +0,0 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Build Raw",
"type": "shell",
"command": "python ./build.py --nominify --copytracker",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true,
"focus": false,
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Build Minified",
"type": "shell",
"command": "python ./build.py --copytracker",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true,
"focus": false,
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Build Website",
"type": "shell",
"command": "python ./build.py --website",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true,
"focus": false,
"showReuseMessage": false
},
"problemMatcher": []
}
]
}

View File

@@ -1,48 +1,55 @@
# Welcome # Welcome
All you need to **use Discord History Tracker** is either an up-to-date browser, or the [Discord desktop client](https://discord.com/download). Visit the [official website](https://dht.chylex.com) for instructions. For instructions on how to **use Discord History Tracker**, visit the [official website](https://dht.chylex.com).
To **report an issue or suggestion**, first please see the [issues](https://github.com/chylex/Discord-History-Tracker/issues) page and make sure someone else hasn't already created a similar issue report. If you do find an existing issue, comment on it or add a reaction. Otherwise, either click [New Issue](https://github.com/chylex/Discord-History-Tracker/issues/new), or contact me via email [contact@chylex.com](mailto:contact@chylex.com) or Twitter [@chylexmc](https://twitter.com/chylexmc). To **report an issue or suggestion**, first please see the [issues](https://github.com/chylex/Discord-History-Tracker/issues) page and make sure someone else hasn't already created a similar issue report. If you do find an existing issue, comment on it or add a reaction. Otherwise, either click [New Issue](https://github.com/chylex/Discord-History-Tracker/issues/new), or contact me via email [contact@chylex.com](mailto:contact@chylex.com) or Twitter [@chylexmc](https://twitter.com/chylexmc).
If you are interested in **creating your own version** from the source code, continue reading the [build instructions](#Build-Instructions) below. If you are interested in **building from source code**, continue reading the [build instructions](#Build-Instructions) below.
This branch is dedicated to the Discord History Tracker desktop app. If you are looking for the older browser-only version, visit the [master-browser-only](https://github.com/chylex/Discord-History-Tracker/tree/master-browser-only) branch.
# Build Instructions # Build Instructions
Follow the steps below to create your own version of Discord History Tracker.
### Setup ### Setup
Fork the repository and clone it to your computer (if you've never used git, you can download the [GitHub Desktop](https://desktop.github.com) client to get started quickly). Fork the repository and clone it to your computer (if you've never used git, you can download the [GitHub Desktop](https://desktop.github.com) client to get started quickly).
Now you can modify the source code: Folder organization:
* `src/tracker/` contains JS files that are automatically combined into the **tracker bookmark/script** * `app/` contains a Visual Studio solution for the desktop app
* `src/viewer/` contains HTML, CSS, JS files that are then combined into the **offline viewer page**
* `lib/` contains utilities required to build the project * `lib/` contains utilities required to build the project
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website * `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
### Building ### Building
After you've done changes to the source code, you will need to build it. Before that, download and install: To build a `Debug` version of the desktop app, there are no additional requirements.
* (**required**) [Python 3](https://www.python.org/downloads)
* Use to run the build script
* (optional) [Node + npm](https://nodejs.org/en) & command line [uglify-js](https://www.npmjs.com/package/uglify-js)
* Not required on Windows
* Only required for optional [JS minification](#Minification) on Linux/Mac
Now open the folder that contains `build.py` in a command line, and run `python build.py` to create a build with default settings. The following files will be created: To build a `Release` version of the desktop app, follow the instructions for your operating system.
* `bld/track.js` is the raw tracker script that can be pasted into a browser console
* `bld/track.html` is the tracker script but sanitized for inclusion in HTML (see `web/index.php` for examples)
* `bld/viewer.html` is the complete offline viewer
You can tweak the build process using the following flags: #### Release Windows (64-bit)
* `python build.py --nominify` to disable [minification](#Minification)
### Minification 1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH`
2. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
The build process automatically minifies JS using `UglifyJS@3`, and CSS using a custom minifier. The `lib/` folder contains an installation of [Node](https://nodejs.org/en) and [uglify-js](https://www.npmjs.com/package/uglify-js), which are used to minify the tracking script. This installation will only work on 64-bit Windows; building on 32-bit Windows is not supported, but you can try.
* If the `--nominify` flag is used, minification will be completely disabled Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
* If `uglify-js` is not available from the command line, JS minification will be skipped
* When building on Windows 64-bit, the build script will use the included Node runner and packages #### Release Other Operating Systems
* When building on Windows 32-bit, you will need to download [Node 32-bit](https://nodejs.org/en/download) and replace the included one in `lib/`
* When building on Linux/Mac, the build script will attempt to find `uglifyjs` in the command line 1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3
- On Debian and derivatives, you can install `python-is-python3`
- On other distributions, you can create a link manually, for ex. `ln -s /usr/bin/python3 /usr/bin/python`
- If you don't want `python` to mean Python 3, then edit `Desktop.csproj` and change `python` to `python3`
2. Install [Node + npm](https://nodejs.org/en)
3. Install [uglify-js](https://www.npmjs.com/package/uglify-js) globally (`npm install -g uglify-js`)
4. Install the `zip` package from your repository
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
#### Distribution
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 8 to be installed.
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking `DiscordHistoryTracker`. I tried using Python to re-create the archives with correct file permissions, but found that Linux `zip` tools could not see them. The only working solution is building the Windows + portable version on Windows, and Linux + Mac version on Linux.

View File

@@ -2,7 +2,6 @@
/contentModel.xml /contentModel.xml
/dataSources /dataSources
/dataSources.local.xml /dataSources.local.xml
/dataSources.xml
/dictionaries /dictionaries
/httpRequests/ /httpRequests/
/misc.xml /misc.xml

View File

@@ -4,15 +4,24 @@
<option name="projectPerEditor"> <option name="projectPerEditor">
<map> <map>
<entry key="Desktop/App.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/App.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/MessageDialog.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Dialogs/CheckBox/CheckBoxDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/Message/MessageDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/AttachmentFilterPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/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/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/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/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DebugPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/WelcomeScreen.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Screens/WelcomeScreen.axaml" value="Desktop/Desktop.csproj" />
</map> </map>
</option> </option>
</component> </component>

View File

@@ -13,10 +13,10 @@
<CssCodeStyleSettings> <CssCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" /> <option name="HEX_COLOR_LOWER_CASE" value="true" />
</CssCodeStyleSettings> </CssCodeStyleSettings>
<DB2CodeStyleSettings version="5"> <DB2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</DB2CodeStyleSettings> </DB2CodeStyleSettings>
<DerbyCodeStyleSettings version="5"> <DerbyCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</DerbyCodeStyleSettings> </DerbyCodeStyleSettings>
<GoCodeStyleSettings> <GoCodeStyleSettings>
@@ -26,10 +26,13 @@
<option name="WRAP_FUNC_PARAMS" value="5" /> <option name="WRAP_FUNC_PARAMS" value="5" />
<option name="WRAP_FUNC_RESULT" value="5" /> <option name="WRAP_FUNC_RESULT" value="5" />
</GoCodeStyleSettings> </GoCodeStyleSettings>
<H2CodeStyleSettings version="5"> <H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings> </H2CodeStyleSettings>
<HSQLCodeStyleSettings version="5"> <H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings>
<HSQLCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</HSQLCodeStyleSettings> </HSQLCodeStyleSettings>
<HTMLCodeStyleSettings> <HTMLCodeStyleSettings>
@@ -85,10 +88,10 @@
<LessCodeStyleSettings> <LessCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" /> <option name="HEX_COLOR_LOWER_CASE" value="true" />
</LessCodeStyleSettings> </LessCodeStyleSettings>
<MSSQLCodeStyleSettings version="5"> <MSSQLCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</MSSQLCodeStyleSettings> </MSSQLCodeStyleSettings>
<MySQLCodeStyleSettings version="5"> <MySQLCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</MySQLCodeStyleSettings> </MySQLCodeStyleSettings>
<Objective-C> <Objective-C>
@@ -98,7 +101,7 @@
<option name="SPACE_BEFORE_INIT_LIST" value="true" /> <option name="SPACE_BEFORE_INIT_LIST" value="true" />
<option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" /> <option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" />
</Objective-C> </Objective-C>
<OracleCodeStyleSettings version="5"> <OracleCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</OracleCodeStyleSettings> </OracleCodeStyleSettings>
<PHPCodeStyleSettings> <PHPCodeStyleSettings>
@@ -116,7 +119,7 @@
<option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" /> <option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" />
<option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" /> <option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" />
</PHPCodeStyleSettings> </PHPCodeStyleSettings>
<PostgresCodeStyleSettings version="5"> <PostgresCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</PostgresCodeStyleSettings> </PostgresCodeStyleSettings>
<Properties> <Properties>
@@ -143,13 +146,13 @@
<option name="INDENT_WHEN_CASES" value="true" /> <option name="INDENT_WHEN_CASES" value="true" />
<option name="CHAIN_CALLS_ALIGNMENT" value="2" /> <option name="CHAIN_CALLS_ALIGNMENT" value="2" />
</Ruby> </Ruby>
<SQLiteCodeStyleSettings version="5"> <SQLiteCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</SQLiteCodeStyleSettings> </SQLiteCodeStyleSettings>
<ScssCodeStyleSettings> <ScssCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" /> <option name="HEX_COLOR_LOWER_CASE" value="true" />
</ScssCodeStyleSettings> </ScssCodeStyleSettings>
<SqlCodeStyleSettings version="5"> <SqlCodeStyleSettings version="6">
<option name="KEYWORD_CASE" value="2" /> <option name="KEYWORD_CASE" value="2" />
<option name="TYPE_CASE" value="2" /> <option name="TYPE_CASE" value="2" />
<option name="CUSTOM_TYPE_CASE" value="2" /> <option name="CUSTOM_TYPE_CASE" value="2" />
@@ -175,7 +178,7 @@
<option name="VIEW_INDENT_QUERY" value="true" /> <option name="VIEW_INDENT_QUERY" value="true" />
<option name="EXPR_CASE_WHEN_WRAP" value="false" /> <option name="EXPR_CASE_WHEN_WRAP" value="false" />
</SqlCodeStyleSettings> </SqlCodeStyleSettings>
<SybaseCodeStyleSettings version="5"> <SybaseCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</SybaseCodeStyleSettings> </SybaseCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0"> <TypeScriptCodeStyleSettings version="0">

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="empty.dht" read-only="true" uuid="e01b8deb-489e-470a-9011-f6548927191e">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/empty.dht</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project"> <configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0/Desktop.exe" /> <option name="EXE_PATH" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0/DiscordHistoryTracker.exe" />
<option name="PROGRAM_PARAMETERS" value="" /> <option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<enabled-global>
<option value="UglifyJS (Tracker)" />
</enabled-global>
</component>
</project>

View File

@@ -1,73 +1,135 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="clr-namespace:DHT.Desktop.Common"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
x:Class="DHT.Desktop.App"> x:Class="DHT.Desktop.App">
<Application.Styles> <Application.Styles>
<FluentTheme Mode="Light" /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" />
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button"> <Style Selector="Button">
<Setter Property="Padding" Value="10 7 10 6" /> <Setter Property="Padding" Value="10 7 10 6" />
<Setter Property="Background" Value="#D2D2D2" />
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="HorizontalContentAlignment" Value="Center" /> <Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" /> <Setter Property="VerticalContentAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Button /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="#070707" />
<Setter Property="Background" Value="#D2D2D2" />
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#DEDEDE" />
<Setter Property="BorderBrush" Value="#A2A2A2" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button:pressed"> <Style Selector="Button:pressed">
<Setter Property="RenderTransform" Value="none" /> <Setter Property="RenderTransform" Value="none" />
</Style> </Style>
<Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#E5E5E5" />
<Setter Property="BorderBrush" Value="#A5A5A5" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="#909090" />
<Setter Property="Background" Value="#E9E9E9" />
<Setter Property="BorderBrush" Value="#BFBFBF" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="TextBox"> <Style Selector="TextBox">
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="SelectionBrush" Value="#72C0FF" />
<Setter Property="Background" Value="#F4F4F4" />
<Setter Property="Padding" Value="6 0" /> <Setter Property="Padding" Value="6 0" />
<Setter Property="VerticalContentAlignment" Value="Center" /> <Setter Property="VerticalContentAlignment" Value="Center" />
</Style> </Style>
<Style Selector="TextBox /template/ Border#PART_BorderElement">
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="TextBox:pointerover /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="Background" Value="#F8F8F8" />
</Style>
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="#546A9F" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Background" Value="#FBFBFB" />
</Style>
<Style Selector="TextBox:disabled /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="#999999" />
<Setter Property="Background" Value="#BBBBBB" />
</Style>
<Style Selector="TextBox /template/ TextBlock#PART_Watermark"> <Style Selector="TextBox /template/ TextBlock#PART_Watermark">
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
</Style> </Style>
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="TextBox:error DataValidationErrors">
<Style.Resources>
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
<ContentPresenter Name="PART_ContentPresenter"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
</ControlTemplate>
</Style.Resources>
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
</Style>
<Style Selector="Expander">
<Setter Property="MinHeight" Value="40" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
</Application.Styles> </Application.Styles>
<Application.Resources>
<common:NumberValueConverter x:Key="NumberValueConverter" />
<common:BytesValueConverter x:Key="BytesValueConverter" />
<system:Double x:Key="ControlContentThemeFontSize">14</system:Double>
<CornerRadius x:Key="ControlCornerRadius">0</CornerRadius>
<Color x:Key="SystemAccentColor">#3C5F95</Color>
<Color x:Key="SystemAccentColorDark1">#3C5F95</Color>
<Color x:Key="SystemAccentColorDark2">#3C5F95</Color>
<Color x:Key="SystemAccentColorDark3">#3C5F95</Color>
<Color x:Key="SystemAccentColorLight1">#3C5F95</Color>
<Color x:Key="SystemAccentColorLight2">#3C5F95</Color>
<Color x:Key="SystemAccentColorLight3">#3C5F95</Color>
<system:Double x:Key="ScrollBarSize">14</system:Double>
<TransformOperations x:Key="VerticalSmallScrollThumbScaleTransform">scaleX(0.5) translateX(-3px)</TransformOperations>
<TransformOperations x:Key="HorizontalSmallScrollThumbScaleTransform">scaleY(0.5) translateY(-3px)</TransformOperations>
<SolidColorBrush x:Key="ScrollBarPanningThumbBackground" Color="#8F8F8F" />
<SolidColorBrush x:Key="ButtonBorderBrush" Color="#3C5F95" />
<SolidColorBrush x:Key="ButtonBorderBrushPointerOver" Color="#0E2B59" />
<SolidColorBrush x:Key="ButtonBorderBrushPressed" Color="#061742" />
<SolidColorBrush x:Key="ButtonBorderBrushDisabled" Color="#9B9B9B" />
<SolidColorBrush x:Key="ButtonBackground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ButtonBackgroundPointerOver" Color="#F6F9FD" />
<SolidColorBrush x:Key="ButtonBackgroundPressed" Color="#EDF3FD" />
<SolidColorBrush x:Key="ButtonBackgroundDisabled" Color="#FFFFFF" />
<SolidColorBrush x:Key="ButtonForeground" Color="#112961" />
<SolidColorBrush x:Key="ButtonForegroundPointerOver" Color="#050E41" />
<SolidColorBrush x:Key="ButtonForegroundPressed" Color="#010320" />
<SolidColorBrush x:Key="ButtonForegroundDisabled" Color="#8C8C8C" />
<SolidColorBrush x:Key="TextControlBorderBrush" Color="#515151" />
<SolidColorBrush x:Key="TextControlBorderBrushPointerOver" Color="#000000" />
<SolidColorBrush x:Key="TextControlBorderBrushFocused" Color="#3C5F95" />
<SolidColorBrush x:Key="TextControlBorderBrushDisabled" Color="#9B9B9B" />
<SolidColorBrush x:Key="TextControlBackground" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextControlBackgroundPointerOver" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextControlBackgroundFocused" Color="#F8FCFF" />
<SolidColorBrush x:Key="TextControlBackgroundDisabled" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextControlForeground" Color="#000000" />
<SolidColorBrush x:Key="TextControlForegroundPointerOver" Color="#000000" />
<SolidColorBrush x:Key="TextControlForegroundFocused" Color="#000000" />
<SolidColorBrush x:Key="TextControlForegroundDisabled" Color="#8C8C8C" />
<SolidColorBrush x:Key="TextControlSelectionHighlightColor" Color="#DEE9F8" />
<SolidColorBrush x:Key="TextControlPlaceholderForeground" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
<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="ExpanderChevronForegroundPointerOver" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="#FFFFFF" />
</Application.Resources>
</Application> </Application>

View File

@@ -1,20 +1,21 @@
using System;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using DHT.Desktop.Main; using DHT.Desktop.Main;
namespace DHT.Desktop { namespace DHT.Desktop;
public class App : Application {
public override void Initialize() { sealed class App : Application {
AvaloniaXamlLoader.Load(this); public override void Initialize() {
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted() {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args ?? Array.Empty<string>()));
} }
public override void OnFrameworkInitializationCompleted() { base.OnFrameworkInitializationCompleted();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args));
}
base.OnFrameworkInitializationCompleted();
}
} }
} }

View File

@@ -1,49 +1,64 @@
using System; using System;
using DHT.Server.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop { namespace DHT.Desktop;
public class Arguments {
public static Arguments Empty => new(Array.Empty<string>());
public string? DatabaseFile { get; } sealed class Arguments {
public ushort? ServerPort { get; } private static readonly Log Log = Log.ForType<Arguments>();
public string? ServerToken { get; }
public Arguments(string[] args) { public static Arguments Empty => new(Array.Empty<string>());
for (int i = 0; i < args.Length; i++) {
string key = args[i]; public string? DatabaseFile { get; }
public ushort? ServerPort { get; }
public string? ServerToken { get; }
public Arguments(string[] args) {
for (int i = 0; i < args.Length; i++) {
string key = args[i];
switch (key) {
case "-debug":
Log.IsDebugEnabled = true;
continue;
}
string value;
if (i == 0 && !key.StartsWith('-')) {
value = key;
key = "-db";
}
else if (i >= args.Length - 1) {
Log.Warn("Missing value for command line argument: " + key);
continue;
}
else {
value = args[++i];
}
switch (key) {
case "-db":
DatabaseFile = value;
continue;
case "-port": {
if (ushort.TryParse(value, out var port)) {
ServerPort = port;
}
else {
Log.Warn("Invalid port number: " + value);
}
if (i >= args.Length - 1) {
Log.Warn("Missing value for command line argument: " + key);
continue; continue;
} }
string value = args[++i]; case "-token":
ServerToken = value;
continue;
switch (key) { default:
case "-db": Log.Warn("Unknown command line argument: " + key);
DatabaseFile = value; break;
continue;
case "-port": {
if (ushort.TryParse(value, out var port)) {
ServerPort = port;
}
else {
Log.Warn("Invalid port number: " + value);
}
continue;
}
case "-token":
ServerToken = value;
continue;
default:
Log.Warn("Unknown command line argument: " + key);
break;
}
} }
} }
} }

View File

@@ -0,0 +1,53 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace DHT.Desktop.Common;
sealed class BytesValueConverter : IValueConverter {
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 ("B", decimalPlaces: 0),
new ("kB", decimalPlaces: 0),
new ("MB", decimalPlaces: 1),
new ("GB", decimalPlaces: 1),
new ("TB", decimalPlaces: 1)
};
private const int Scale = 1000;
private static string Convert(ulong size) {
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
int unit = power >= Units.Length ? Units.Length - 1 : power;
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
}
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
if (value is long size and >= 0L) {
return Convert((ulong) size);
}
else if (value is ulong usize) {
return Convert(usize);
}
else {
return "-";
}
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,68 @@
using System;
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.Utils.Logging;
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 IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = new List<FilePickerFileType> {
FileDialogs.CreateFilter("Discord History Tracker Database", new [] { "dht" })
};
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Database File",
FileTypeFilter = DatabaseFileDialogFilter,
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
AllowMultiple = true
});
}
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
Title = "Open or Create Database File",
FileTypeChoices = DatabaseFileDialogFilter,
SuggestedFileName = DatabaseFileInitialName,
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
ShowOverwritePrompt = false
});
}
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) {
IDatabaseFile? file = null;
try {
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase);
} catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
}
return file;
}
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
}
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
}
}

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyName>DiscordHistoryTracker</AssemblyName>
<RootNamespace>DHT.Desktop</RootNamespace> <RootNamespace>DHT.Desktop</RootNamespace>
<PackageId>DiscordHistoryTracker</PackageId> <PackageId>DiscordHistoryTracker</PackageId>
<Authors>chylex</Authors> <Authors>chylex</Authors>
@@ -11,56 +11,69 @@
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon> <ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<AssemblyName>DiscordHistoryTracker</AssemblyName> <GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<Version>32.0.0.0</Version> <GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
<AssemblyVersion>$(Version)</AssemblyVersion> <GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
<FileVersion>$(Version)</FileVersion>
<PackageVersion>$(Version)</PackageVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>true</DebugSymbols> <DebugSymbols>true</DebugSymbols>
<DebugType>none</DebugType> <DebugType>none</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="0.10.3" /> <PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.3" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.5" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.5" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.5" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Server\Server.csproj" /> <ProjectReference Include="..\Server\Server.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.3" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Update="Windows\MainWindow.axaml.cs"> <Compile Include="..\Version.cs" Link="Version.cs" />
<DependentUpon>MainWindow.axaml</DependentUpon> <Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
<SubType>Code</SubType> <DependentUpon>CheckBoxDialog.axaml</DependentUpon>
</Compile>
<Compile Update="Dialogs\MessageDialog.axaml.cs">
<DependentUpon>MessageDialog.axaml</DependentUpon>
<SubType>Code</SubType> <SubType>Code</SubType>
</Compile> </Compile>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<UpToDateCheckInput Remove="Pages\DatabasePage.axaml" /> <AvaloniaResource Include="Resources/icon.ico" />
<UpToDateCheckInput Remove="Pages\TrackingPage.axaml" />
<UpToDateCheckInput Remove="Pages\ViewerPage.axaml" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js"> <EmbeddedResource Include="../Resources/Tracker/bootstrap.js">
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName> <LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link> <Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="../Resources/Tracker/scripts.min/**"> <EmbeddedResource Include="../Resources/Tracker/scripts/**" Condition=" '$(Configuration)' == 'Debug' ">
<LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName> <LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link> <Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="../Resources/Tracker/styles/**"> <EmbeddedResource Include="../Resources/Tracker/styles/**">
<LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName> <LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link> <Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="../Resources/Viewer/**"> <EmbeddedResource Include="../Resources/Viewer/**">
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName> <LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link> <Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource> </EmbeddedResource>
<AvaloniaResource Include="Resources/icon.ico" />
</ItemGroup> </ItemGroup>
<Target Name="MinifyResources" BeforeTargets="PrepareForBuild" Condition=" '$(Configuration)' == 'Release' ">
<PropertyGroup>
<MinifiedResourceDir>$(ProjectDir)bin/.res/scripts</MinifiedResourceDir>
</PropertyGroup>
<ItemGroup>
<UpToDateCheckInput Include="$(ProjectDir)../Resources/Tracker/scripts/**" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/discord.js" LogicalName="Tracker\scripts\discord.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/dom.js" LogicalName="Tracker\scripts\dom.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/gui.js" LogicalName="Tracker\scripts\gui.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/settings.js" LogicalName="Tracker\scripts\settings.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/state.js" LogicalName="Tracker\scripts\state.js" Visible="false" />
</ItemGroup>
<RemoveDir Directories="$(ProjectDir)bin/.res/scripts" />
<Exec Command="python $(ProjectDir)../Resources/minify.py" WorkingDirectory="$(ProjectDir)../Resources" IgnoreExitCode="false" />
</Target>
</Project> </Project>

View File

@@ -0,0 +1,61 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner">
<Window.DataContext>
<namespace:CheckBoxDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="Panel.buttons">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.left Button">
<Setter Property="Margin" Value="0 0 8 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right Button">
<Setter Property="Margin" Value="8 0 0 0" />
</Style>
</Window.Styles>
<StackPanel Margin="20">
<ScrollViewer MaxHeight="400">
<ItemsRepeater ItemsSource="{Binding Items}">
<ItemsRepeater.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding Checked}">
<Label>
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
</Label>
</CheckBox>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</ScrollViewer>
<Panel Classes="buttons">
<WrapPanel Classes="left">
<Button Command="{Binding SelectAll}" IsEnabled="{Binding !AreAllSelected}">Select All</Button>
<Button Command="{Binding SelectNone}" IsEnabled="{Binding !AreNoneSelected}">Select None</Button>
</WrapPanel>
<WrapPanel Classes="right">
<Button Click="ClickOk">OK</Button>
<Button Click="ClickCancel">Cancel</Button>
</WrapPanel>
</Panel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Interactivity;
using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.CheckBox;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class CheckBoxDialog : Window {
public CheckBoxDialog() {
InitializeComponent();
}
public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Ok);
}
public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel);
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxDialogModel : BaseModel {
public string Title { get; init; } = "";
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
public IReadOnlyList<CheckBoxItem> Items {
get => items;
protected set {
foreach (var item in items) {
item.PropertyChanged -= OnItemPropertyChanged;
}
items = value;
foreach (var item in items) {
item.PropertyChanged += OnItemPropertyChanged;
}
}
}
private bool pauseCheckEvents = false;
public bool AreAllSelected => Items.All(static item => item.Checked);
public bool AreNoneSelected => Items.All(static item => !item.Checked);
public void SelectAll() => SetAllChecked(true);
public void SelectNone() => SetAllChecked(false);
private void SetAllChecked(bool isChecked) {
pauseCheckEvents = true;
foreach (var item in Items) {
item.Checked = isChecked;
}
pauseCheckEvents = false;
UpdateBulkButtons();
}
private void UpdateBulkButtons() {
OnPropertyChanged(nameof(AreAllSelected));
OnPropertyChanged(nameof(AreNoneSelected));
}
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
UpdateBulkButtons();
}
}
}
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
this.Items = new List<CheckBoxItem<T>>(items);
base.Items = this.Items;
}
}

View File

@@ -0,0 +1,24 @@
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxItem : BaseModel {
public string Title { get; init; } = "";
public object? Item { get; init; } = null;
private bool isChecked = false;
public bool Checked {
get => isChecked;
set => Change(ref isChecked, value);
}
}
sealed class CheckBoxItem<T> : CheckBoxItem {
public new T Item { get; }
public CheckBoxItem(T item) {
this.Item = item;
base.Item = item;
}
}

View File

@@ -1,56 +0,0 @@
using System.Threading.Tasks;
using Avalonia.Controls;
namespace DHT.Desktop.Dialogs {
public static class Dialog {
public static async Task ShowOk(Window owner, string title, string message) {
await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsOkVisible = true
}
}.ShowDialog<DialogResult.All>(owner);
}
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsOkVisible = true,
IsCancelVisible = true
}
}.ShowDialog<DialogResult.All?>(owner);
return result.ToOkCancel();
}
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true
}
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNo();
}
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true,
IsCancelVisible = true
}
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNoCancel();
}
}
}

View File

@@ -1,59 +0,0 @@
using System;
namespace DHT.Desktop.Dialogs {
public static class DialogResult {
public enum All {
Ok,
Yes,
No,
Cancel
}
public enum OkCancel {
Closed,
Ok,
Cancel
}
public enum YesNo {
Closed,
Yes,
No
}
public enum YesNoCancel {
Closed,
Yes,
No,
Cancel
}
public static OkCancel ToOkCancel(this All? result) {
return result switch {
null => OkCancel.Closed,
All.Ok => OkCancel.Ok,
All.Cancel => OkCancel.Cancel,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
};
}
public static YesNo ToYesNo(this All? result) {
return result switch {
null => YesNo.Closed,
All.Yes => YesNo.Yes,
All.No => YesNo.No,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
};
}
public static YesNoCancel ToYesNoCancel(this All? result) {
return result switch {
null => YesNoCancel.Closed,
All.Yes => YesNoCancel.Yes,
All.No => YesNoCancel.No,
All.Cancel => YesNoCancel.Cancel,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
};
}
}
}

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

@@ -0,0 +1,74 @@
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
namespace DHT.Desktop.Dialogs.Message;
static class Dialog {
public static async Task ShowOk(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
return;
}
await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsOkVisible = true
}
}.ShowDialog<DialogResult.All>(owner);
}
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
}
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsOkVisible = true,
IsCancelVisible = true
}
}.ShowDialog<DialogResult.All?>(owner);
return result.ToOkCancel();
}
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
}
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true
}
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNo();
}
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
}
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true,
IsCancelVisible = true
}
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNoCancel();
}
}

View File

@@ -0,0 +1,59 @@
using System;
namespace DHT.Desktop.Dialogs.Message;
static class DialogResult {
public enum All {
Ok,
Yes,
No,
Cancel
}
public enum OkCancel {
Closed,
Ok,
Cancel
}
public enum YesNo {
Closed,
Yes,
No
}
public enum YesNoCancel {
Closed,
Yes,
No,
Cancel
}
public static OkCancel ToOkCancel(this All? result) {
return result switch {
null => OkCancel.Closed,
All.Ok => OkCancel.Ok,
All.Cancel => OkCancel.Cancel,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
};
}
public static YesNo ToYesNo(this All? result) {
return result switch {
null => YesNo.Closed,
All.Yes => YesNo.Yes,
All.No => YesNo.No,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
};
}
public static YesNoCancel ToYesNoCancel(this All? result) {
return result switch {
null => YesNoCancel.Closed,
All.Yes => YesNoCancel.Yes,
All.No => YesNoCancel.No,
All.Cancel => YesNoCancel.Cancel,
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
};
}
}

View File

@@ -2,15 +2,16 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:dialogs="clr-namespace:DHT.Desktop.Dialogs" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.MessageDialog" x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
Title="{Binding Title}" Title="{Binding Title}"
Width="500" SizeToContent="Height" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<Window.DataContext> <Window.DataContext>
<dialogs:MessageDialogModel /> <namespace:MessageDialogModel />
</Window.DataContext> </Window.DataContext>
<Window.Styles> <Window.Styles>

View File

@@ -0,0 +1,28 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace DHT.Desktop.Dialogs.Message;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class MessageDialog : Window {
public MessageDialog() {
InitializeComponent();
}
public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Ok);
}
public void ClickYes(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Yes);
}
public void ClickNo(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.No);
}
public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Cancel);
}
}

View File

@@ -0,0 +1,11 @@
namespace DHT.Desktop.Dialogs.Message;
sealed class MessageDialogModel {
public string Title { get; init; } = "";
public string Message { get; init; } = "";
public bool IsOkVisible { get; init; } = false;
public bool IsYesVisible { get; init; } = false;
public bool IsNoVisible { get; init; } = false;
public bool IsCancelVisible { get; init; } = false;
}

View File

@@ -1,35 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Dialogs {
public class MessageDialog : Window {
public MessageDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Ok);
}
public void ClickYes(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Yes);
}
public void ClickNo(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.No);
}
public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Cancel);
}
}
}

View File

@@ -1,11 +0,0 @@
namespace DHT.Desktop.Dialogs {
public class MessageDialogModel {
public string Title { get; init; } = "";
public string Message { get; init; } = "";
public bool IsOkVisible { get; init; } = false;
public bool IsYesVisible { get; init; } = false;
public bool IsNoVisible { get; init; } = false;
public bool IsCancelVisible { get; init; } = false;
}
}

View File

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

View File

@@ -0,0 +1,42 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Opened="OnOpened"
Closing="OnClosing"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner">
<Window.DataContext>
<namespace:ProgressDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="TextBlock">
<Setter Property="FontSize" Value="15" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.items">
<Setter Property="Margin" Value="10 0 0 0" />
<Setter Property="TextAlignment" Value="Right" />
<Setter Property="TextWrapping" Value="NoWrap" />
</Style>
<Style Selector="ProgressBar">
<Setter Property="Margin" Value="0 15 0 0" />
</Style>
</Window.Styles>
<StackPanel Margin="20">
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
</DockPanel>
<ProgressBar Value="{Binding Progress}" />
</StackPanel>
</Window>

View File

@@ -0,0 +1,30 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Controls;
namespace DHT.Desktop.Dialogs.Progress;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class ProgressDialog : Window {
private bool isFinished = false;
public ProgressDialog() {
InitializeComponent();
}
public void OnOpened(object? sender, EventArgs e) {
if (DataContext is ProgressDialogModel model) {
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
}
}
public void OnClosing(object? sender, WindowClosingEventArgs e) {
e.Cancel = !isFinished;
}
private void OnFinished(Task task) {
isFinished = true;
Close();
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Threading.Tasks;
using Avalonia.Threading;
using DHT.Desktop.Common;
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressDialogModel : BaseModel {
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);
}
private readonly TaskRunner? task;
[Obsolete("Designer")]
public ProgressDialogModel() {}
public ProgressDialogModel(TaskRunner task) {
this.task = task;
}
internal async Task StartTask() {
if (task != null) {
await task(new Callback(this));
}
}
public delegate Task TaskRunner(IProgressCallback callback);
private sealed class Callback : IProgressCallback {
private readonly ProgressDialogModel model;
public Callback(ProgressDialogModel model) {
this.model = model;
}
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => {
model.Message = message;
model.Items = finishedItems.Format() + " / " + totalItems.Format();
model.Progress = 100 * finishedItems / totalItems;
});
}
}
}

View File

@@ -0,0 +1,56 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner">
<Window.DataContext>
<namespace:TextBoxDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="Panel.buttons">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
<Setter Property="Margin" Value="8 0 0 0" />
</Style>
</Window.Styles>
<StackPanel Margin="20">
<ScrollViewer MaxHeight="400">
<StackPanel Spacing="10">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
<ItemsRepeater ItemsSource="{Binding Items}">
<ItemsRepeater.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0 5 25 0">
<TextBox Name="Input" Text="{Binding Value}" Width="180" VerticalAlignment="Top" DockPanel.Dock="Right" />
<Label Target="Input" VerticalAlignment="Center" DockPanel.Dock="Left">
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
</Label>
</DockPanel>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</StackPanel>
</ScrollViewer>
<Panel Classes="buttons">
<WrapPanel Classes="right">
<Button Click="ClickOk" IsEnabled="{Binding !HasErrors}">OK</Button>
<Button Click="ClickCancel">Cancel</Button>
</WrapPanel>
</Panel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Interactivity;
using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.TextBox;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class TextBoxDialog : Window {
public TextBoxDialog() {
InitializeComponent();
}
public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Ok);
}
public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel);
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxDialogModel : BaseModel {
public string Title { get; init; } = "";
public string Description { get; init; } = "";
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
public IReadOnlyList<TextBoxItem> Items {
get => items;
protected set {
foreach (var item in items) {
item.ErrorsChanged -= OnItemErrorsChanged;
}
items = value;
foreach (var item in items) {
item.ErrorsChanged += OnItemErrorsChanged;
}
}
}
public bool HasErrors => Items.Any(static item => !item.IsValid);
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
OnPropertyChanged(nameof(HasErrors));
}
}
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
this.Items = new List<TextBoxItem<T>>(items);
base.Items = this.Items;
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections;
using System.ComponentModel;
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
public string Title { get; init; } = "";
public object? Item { get; init; } = null;
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
public bool IsValid => ValidityCheck(Value);
private string value = string.Empty;
public string Value {
get => this.value;
set {
Change(ref this.value, value);
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
}
}
public IEnumerable GetErrors(string? propertyName) {
if (propertyName == nameof(Value) && !IsValid) {
yield return string.Empty;
}
}
public bool HasErrors => !IsValid;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
}
sealed class TextBoxItem<T> : TextBoxItem {
public new T Item { get; }
public TextBoxItem(T item) {
this.Item = item;
base.Item = item;
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading.Tasks;
using DHT.Utils.Logging;
using static System.Environment.SpecialFolder;
using static System.Environment.SpecialFolderOption;
namespace DHT.Desktop.Discord;
static class DiscordAppSettings {
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
public static string JsonFilePath { get; }
private static string JsonBackupFilePath { get; }
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
static DiscordAppSettings() {
string rootFolder;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
rootFolder = Path.Combine(Environment.GetFolderPath(UserProfile, DoNotVerify), "Library", "Application Support", "Discord");
}
else {
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
}
JsonFilePath = Path.Combine(rootFolder, "settings.json");
JsonBackupFilePath = JsonFilePath + ".bak";
}
public static async Task<bool?> AreDevToolsEnabled() {
try {
return AreDevToolsEnabled(await ReadSettingsJson());
} catch (Exception 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 };
}
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
Dictionary<string, object?> json;
try {
json = await ReadSettingsJson();
} catch (FileNotFoundException) {
return SettingsJsonResult.FileNotFound;
} catch (JsonException) {
return SettingsJsonResult.InvalidJson;
} catch (Exception e) {
Log.Error(e);
return SettingsJsonResult.ReadError;
}
if (enable == AreDevToolsEnabled(json)) {
return SettingsJsonResult.AlreadySet;
}
if (enable) {
json[JsonKeyDevTools] = true;
}
else {
json.Remove(JsonKeyDevTools);
}
try {
if (!File.Exists(JsonBackupFilePath)) {
File.Copy(JsonFilePath, JsonBackupFilePath);
}
await WriteSettingsJson(json);
} catch (Exception e) {
Log.Error("An error occurred when writing settings file.");
Log.Error(e);
if (File.Exists(JsonBackupFilePath)) {
try {
File.Move(JsonBackupFilePath, JsonFilePath, true);
Log.Info("Restored settings file from backup.");
} catch (Exception e2) {
Log.Error("Cannot restore settings file from backup.");
Log.Error(e2);
}
}
return SettingsJsonResult.WriteError;
}
try {
File.Delete(JsonBackupFilePath);
} catch (Exception e) {
Log.Error("Cannot delete backup file.");
Log.Error(e);
}
return SettingsJsonResult.Success;
}
private static async Task<Dictionary<string, object?>> ReadSettingsJson() {
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException();
}
private static async Task WriteSettingsJson(Dictionary<string, object?> json) {
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true });
}
}

View File

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

View File

@@ -46,7 +46,7 @@
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock> <TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock> <TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0">.NET 5</TextBlock> <TextBlock Grid.Row="2" Grid.Column="0">.NET 8</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock> <TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button> <Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>

View File

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

View File

@@ -1,33 +1,33 @@
using System.Diagnostics; using System.Diagnostics;
namespace DHT.Desktop.Main { namespace DHT.Desktop.Main;
public class AboutWindowModel {
public void ShowOfficialWebsite() {
OpenUrl("https://dht.chylex.com");
}
public void ShowSourceCode() { sealed class AboutWindowModel {
OpenUrl("https://github.com/chylex/Discord-History-Tracker"); public void ShowOfficialWebsite() {
} OpenUrl("https://dht.chylex.com");
}
public void ShowLibraryAvalonia() { public void ShowSourceCode() {
OpenUrl("https://www.nuget.org/packages/Avalonia"); OpenUrl("https://github.com/chylex/Discord-History-Tracker");
} }
public void ShowLibrarySqlite() { public void ShowLibraryAvalonia() {
OpenUrl("https://www.sqlite.org"); OpenUrl("https://www.nuget.org/packages/Avalonia");
} }
public void ShowLibrarySqliteAdoNet() { public void ShowLibrarySqlite() {
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite"); OpenUrl("https://www.sqlite.org");
} }
public void ShowLibraryNetCore() { public void ShowLibrarySqliteAdoNet() {
OpenUrl("https://github.com/dotnet/core"); OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
} }
private static void OpenUrl(string url) { public void ShowLibraryNetCore() {
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); OpenUrl("https://github.com/dotnet/core");
} }
private static void OpenUrl(string url) {
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
} }
} }

View File

@@ -0,0 +1,51 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel">
<Design.DataContext>
<controls:AttachmentFilterPanelModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Margin" Value="0 20 40 0" />
<Setter Property="Spacing" Value="4" />
</Style>
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Grid > Label">
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="ComboBox">
<Setter Property="Margin" Value="8 0 0 0" />
</Style>
<Style Selector="Button">
<Setter Property="Margin" Value="0 0 0 8" />
</Style>
</UserControl.Styles>
<StackPanel>
<TextBlock Text="{Binding FilterStatisticsText}" />
<WrapPanel>
<StackPanel>
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
<StackPanel Orientation="Horizontal">
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
<ComboBox IsEnabled="{Binding LimitSize}" ItemsSource="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</StackPanel>
</WrapPanel>
</StackPanel>
</UserControl>

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 AttachmentFilterPanel : UserControl {
public AttachmentFilterPanel() {
InitializeComponent();
}
}

View File

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

View File

@@ -0,0 +1,63 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel">
<Design.DataContext>
<controls:MessageFilterPanelModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Margin" Value="0 20 40 0" />
<Setter Property="Spacing" Value="4" />
</Style>
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Grid > Label">
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Grid > CalendarDatePicker">
<Setter Property="CornerRadius" Value="0" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="IsTodayHighlighted" Value="True" />
<Setter Property="SelectedDateFormat" Value="Short" />
</Style>
<Style Selector="Button">
<Setter Property="Margin" Value="0 0 0 8" />
</Style>
</UserControl.Styles>
<StackPanel>
<TextBlock Text="{Binding FilterStatisticsText}" />
<WrapPanel>
<StackPanel>
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox>
<Grid ColumnDefinitions="Auto, 4, 125" RowDefinitions="Auto, 4, Auto" Margin="4 0">
<Label Grid.Row="0" Grid.Column="0">From:</Label>
<CalendarDatePicker Grid.Row="0" Grid.Column="2" x:Name="StartDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" />
<Label Grid.Row="2" Grid.Column="0">To:</Label>
<CalendarDatePicker Grid.Row="2" Grid.Column="2" x:Name="EndDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" />
</Grid>
</StackPanel>
<StackPanel>
<CheckBox IsChecked="{Binding FilterByChannel}">Filter by Channel</CheckBox>
<Button Command="{Binding OpenChannelFilterDialog}" IsEnabled="{Binding FilterByChannel}">Select Channels...</Button>
<TextBlock Text="{Binding ChannelFilterLabel}" />
</StackPanel>
<StackPanel>
<CheckBox IsChecked="{Binding FilterByUser}">Filter by User</CheckBox>
<Button Command="{Binding OpenUserFilterDialog}" IsEnabled="{Binding FilterByUser}">Select Users...</Button>
<TextBlock Text="{Binding UserFilterLabel}" />
</StackPanel>
</WrapPanel>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,26 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
namespace DHT.Desktop.Main.Controls;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class MessageFilterPanel : UserControl {
public MessageFilterPanel() {
InitializeComponent();
var culture = Program.Culture;
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
}
}
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
if (DataContext is MessageFilterPanelModel model) {
model.StartDate = StartDatePicker.SelectedDate;
model.EndDate = EndDatePicker.SelectedDate;
}
}
}

View File

@@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.CheckBox;
using DHT.Desktop.Dialogs.Message;
using DHT.Server.Data;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Utils.Models;
using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Controls;
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
private static readonly HashSet<string> FilterProperties = new () {
nameof(FilterByDate),
nameof(StartDate),
nameof(EndDate),
nameof(FilterByChannel),
nameof(IncludedChannels),
nameof(FilterByUser),
nameof(IncludedUsers)
};
public string FilterStatisticsText { get; private set; } = "";
public event PropertyChangedEventHandler? FilterPropertyChanged;
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
private bool filterByDate = false;
private DateTime? startDate = null;
private DateTime? endDate = null;
private bool filterByChannel = false;
private HashSet<ulong>? includedChannels = null;
private bool filterByUser = false;
private HashSet<ulong>? includedUsers = null;
public bool FilterByDate {
get => filterByDate;
set => Change(ref filterByDate, value);
}
public DateTime? StartDate {
get => startDate;
set => Change(ref startDate, value);
}
public DateTime? EndDate {
get => endDate;
set => Change(ref endDate, value);
}
public bool FilterByChannel {
get => filterByChannel;
set => Change(ref filterByChannel, value);
}
public HashSet<ulong> IncludedChannels {
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
set => Change(ref includedChannels, value);
}
public bool FilterByUser {
get => filterByUser;
set => Change(ref filterByUser, value);
}
public HashSet<ulong> IncludedUsers {
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
set => Change(ref includedUsers, value);
}
private string channelFilterLabel = "";
public string ChannelFilterLabel {
get => channelFilterLabel;
set => Change(ref channelFilterLabel, value);
}
private string userFilterLabel = "";
public string UserFilterLabel {
get => userFilterLabel;
set => Change(ref userFilterLabel, value);
}
private readonly Window window;
private readonly IDatabaseFile db;
private readonly string verb;
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
private long? exportedMessageCount;
private long? totalMessageCount;
[Obsolete("Designer")]
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
this.window = window;
this.db = db;
this.verb = verb;
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
UpdateFilterStatistics();
UpdateChannelFilterLabel();
UpdateUserFilterLabel();
PropertyChanged += OnPropertyChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
}
public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
UpdateFilterStatistics();
FilterPropertyChanged?.Invoke(sender, e);
}
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
UpdateChannelFilterLabel();
}
else if (e.PropertyName is nameof(FilterByUser) or nameof(IncludedUsers)) {
UpdateUserFilterLabel();
}
}
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
totalMessageCount = db.Statistics.TotalMessages;
UpdateFilterStatistics();
}
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
UpdateChannelFilterLabel();
}
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
UpdateUserFilterLabel();
}
}
private void UpdateFilterStatistics() {
var filter = CreateFilter();
if (filter.IsEmpty) {
exportedMessageCountComputer.Cancel();
exportedMessageCount = totalMessageCount;
UpdateFilterStatisticsText();
}
else {
exportedMessageCount = null;
UpdateFilterStatisticsText();
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
}
}
private void SetExportedMessageCount(long exportedMessageCount) {
this.exportedMessageCount = exportedMessageCount;
UpdateFilterStatisticsText();
}
private void UpdateFilterStatisticsText() {
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
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);
var items = new List<CheckBoxItem<ulong>>();
var included = IncludedChannels;
foreach (var channel in db.GetAllChannels()) {
var channelId = channel.Id;
var channelName = channel.Name;
string title;
if (servers.TryGetValue(channel.Server, out var server)) {
var titleBuilder = new StringBuilder();
var serverType = server.Type;
titleBuilder.Append('[')
.Append(ServerTypes.ToString(serverType))
.Append("] ");
if (serverType == ServerType.DirectMessage) {
titleBuilder.Append(channelName);
}
else {
titleBuilder.Append(server.Name)
.Append(" - ")
.Append(channelName);
}
title = titleBuilder.ToString();
}
else {
title = channelName;
}
items.Add(new CheckBoxItem<ulong>(channelId) {
Title = title,
Checked = included.Contains(channelId)
});
}
var result = await OpenIdFilterDialog(window, "Included Channels", items);
if (result != null) {
IncludedChannels = result;
}
}
public async void OpenUserFilterDialog() {
var items = new List<CheckBoxItem<ulong>>();
var included = IncludedUsers;
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)
});
}
var result = await OpenIdFilterDialog(window, "Included Users", 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 void UpdateUserFilterLabel() {
long total = db.Statistics.TotalUsers;
long included = FilterByUser ? IncludedUsers.Count : total;
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
}
public MessageFilter CreateFilter() {
MessageFilter filter = new();
if (FilterByDate) {
filter.StartDate = StartDate;
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
}
if (FilterByChannel) {
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
}
if (FilterByUser) {
filter.UserIds = new HashSet<ulong>(IncludedUsers);
}
return filter;
}
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
var model = new CheckBoxDialogModel<ulong>(items) {
Title = title
};
var dialog = new CheckBoxDialog { DataContext = model };
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
}
}

View File

@@ -0,0 +1,48 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel">
<Design.DataContext>
<controls:ServerConfigurationPanelModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="TextBox">
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles>
<StackPanel>
<Button Command="{Binding OnClickToggleServerButton}" Content="{Binding ToggleServerButtonText}" IsEnabled="{Binding IsToggleServerButtonEnabled}" />
<TextBlock TextWrapping="Wrap" Margin="0 15">
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script again.
</TextBlock>
<WrapPanel>
<StackPanel>
<Label Target="Port">Port</Label>
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />
</StackPanel>
<StackPanel>
<Label Target="Token">Token</Label>
<TextBox x:Name="Token" Width="200" Text="{Binding InputToken}" />
</StackPanel>
<StackPanel VerticalAlignment="Bottom">
<Button Command="{Binding OnClickRandomizeToken}">Randomize Token</Button>
</StackPanel>
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickApplyChanges}">Apply &amp; Restart</Button>
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickCancelChanges}">Cancel</Button>
</StackPanel>
</StackPanel>
</UserControl>

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 ServerConfigurationPanel : UserControl {
public ServerConfigurationPanel() {
InitializeComponent();
}
}

View File

@@ -0,0 +1,116 @@
using System;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls;
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
private string inputPort;
public string InputPort {
get => inputPort;
set {
Change(ref inputPort, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
private string inputToken;
public string InputToken {
get => inputToken;
set {
Change(ref inputToken, value);
OnPropertyChanged(nameof(HasMadeChanges));
}
}
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
private bool isToggleServerButtonEnabled = true;
public bool IsToggleServerButtonEnabled {
get => isToggleServerButtonEnabled;
set => Change(ref isToggleServerButtonEnabled, value);
}
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
private readonly Window window;
private readonly ServerManager serverManager;
[Obsolete("Designer")]
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
this.window = window;
this.serverManager = serverManager;
this.inputPort = ServerManager.Port.ToString();
this.inputToken = ServerManager.Token;
}
public void Initialize() {
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
}
public void Dispose() {
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
}
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
OnPropertyChanged(nameof(ToggleServerButtonText));
IsToggleServerButtonEnabled = true;
}
private void BeforeServerStart() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
}
private void StartServer() {
BeforeServerStart();
serverManager.Launch();
}
private void StopServer() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
serverManager.Stop();
}
public void OnClickToggleServerButton() {
if (serverManager.IsRunning) {
StopServer();
}
else {
StartServer();
}
}
public void OnClickRandomizeToken() {
InputToken = ServerUtils.GenerateRandomToken(20);
}
public async void OnClickApplyChanges() {
if (!ushort.TryParse(InputPort, out ushort port)) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return;
}
BeforeServerStart();
serverManager.Relaunch(port, InputToken);
OnPropertyChanged(nameof(HasMadeChanges));
}
public void OnClickCancelChanges() {
InputPort = ServerManager.Port.ToString();
InputToken = ServerManager.Token;
}
}

View File

@@ -11,47 +11,50 @@
</Design.DataContext> </Design.DataContext>
<UserControl.Background> <UserControl.Background>
<SolidColorBrush>#546A9F</SolidColorBrush> <SolidColorBrush>#3C4F79</SolidColorBrush>
</UserControl.Background> </UserControl.Background>
<UserControl.Styles> <UserControl.Styles>
<Style Selector="StackPanel > TextBlock"> <Style Selector="StackPanel > TextBlock">
<Setter Property="Foreground" Value="#E0E0E0" /> <Setter Property="Foreground" Value="#E7E7E7" />
</Style> </Style>
<Style Selector="StackPanel > TextBlock.label"> <Style Selector="StackPanel > TextBlock.label">
<Setter Property="FontSize" Value="15" /> <Setter Property="FontSize" Value="15" />
<Setter Property="FontWeight" Value="SemiLight" />
</Style> </Style>
<Style Selector="StackPanel > TextBlock.value"> <Style Selector="StackPanel > TextBlock.value">
<Setter Property="FontSize" Value="16" /> <Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiLight" />
<Setter Property="TextAlignment" Value="Right" /> <Setter Property="TextAlignment" Value="Right" />
<Setter Property="Margin" Value="0 1" />
</Style> </Style>
<Style Selector="StackPanel > Rectangle"> <Style Selector="StackPanel > Rectangle">
<Setter Property="Margin" Value="14 0" /> <Setter Property="Margin" Value="14 1" />
<Setter Property="Stroke" Value="#3B5287" /> <Setter Property="Stroke" Value="#697899" />
<Setter Property="StrokeThickness" Value="2" /> <Setter Property="StrokeThickness" Value="2" />
<Setter Property="VerticalAlignment" Value="Stretch" /> <Setter Property="VerticalAlignment" Value="Stretch" />
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<StackPanel Orientation="Horizontal" Margin="4 3"> <StackPanel Orientation="Horizontal" Margin="6 3">
<StackPanel Orientation="Vertical" Width="65"> <StackPanel Orientation="Vertical" Width="65">
<TextBlock Classes="label">Status</TextBlock> <TextBlock Classes="label">Status</TextBlock>
<TextBlock FontSize="12" Margin="0 2 0 0" Text="{Binding StatusText}" /> <TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Servers</TextBlock> <TextBlock Classes="label">Servers</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, StringFormat={}{0:n0}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Channels</TextBlock> <TextBlock Classes="label">Channels</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, StringFormat={}{0:n0}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Messages</TextBlock> <TextBlock Classes="label">Messages</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, StringFormat={}{0:n0}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

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

View File

@@ -1,45 +1,45 @@
using System; using System;
using DHT.Desktop.Models;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls { namespace DHT.Desktop.Main.Controls;
public class StatusBarModel : BaseModel {
public DatabaseStatistics DatabaseStatistics { get; }
private Status status = Status.Stopped; sealed class StatusBarModel : BaseModel {
public DatabaseStatistics DatabaseStatistics { get; }
public Status CurrentStatus { private Status status = Status.Stopped;
get => status;
set {
status = value;
OnPropertyChanged(nameof(StatusText));
}
}
public string StatusText { public Status CurrentStatus {
get { get => status;
return CurrentStatus switch { set {
Status.Starting => "STARTING", status = value;
Status.Ready => "READY", OnPropertyChanged(nameof(StatusText));
Status.Stopping => "STOPPING",
Status.Stopped => "STOPPED",
_ => ""
};
}
}
[Obsolete("Designer")]
public StatusBarModel() : this(new DatabaseStatistics()) {}
public StatusBarModel(DatabaseStatistics databaseStatistics) {
this.DatabaseStatistics = databaseStatistics;
}
public enum Status {
Starting,
Ready,
Stopping,
Stopped
} }
} }
public string StatusText {
get {
return CurrentStatus switch {
Status.Starting => "STARTING",
Status.Ready => "READY",
Status.Stopping => "STOPPING",
Status.Stopped => "STOPPED",
_ => ""
};
}
}
[Obsolete("Designer")]
public StatusBarModel() : this(new DatabaseStatistics()) {}
public StatusBarModel(DatabaseStatistics databaseStatistics) {
this.DatabaseStatistics = databaseStatistics;
}
public enum Status {
Starting,
Ready,
Stopping,
Stopped
}
} }

View File

@@ -1,91 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:main="clr-namespace:DHT.Desktop.Main"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.MainContentScreen">
<Design.DataContext>
<main:MainContentScreenModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="TabControl WrapPanel">
<Setter Property="Background" Value="#546A9F" />
</Style>
<Style Selector="TabItem">
<Setter Property="Foreground" Value="#E9E9E9" />
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="TabItem[TabStripPlacement=Left] /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Margin" Value="5 0" />
</Style>
<Style Selector="TabItem:pointerover">
<Setter Property="Background" Value="#1F2E45" />
</Style>
<Style Selector="TabItem:pointerover /template/ Border">
<Setter Property="Background" Value="#1F2E45" />
</Style>
<Style Selector="TabItem:pointerover > TextBlock">
<Setter Property="Foreground" Value="#E9E9E9" />
</Style>
<Style Selector="TabItem:selected:pointerover /template/ Border">
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected:pointerover > TextBlock">
<Setter Property="Foreground" Value="#1A2234" />
</Style>
<Style Selector="TabItem:selected">
<Setter Property="Foreground" Value="#1A2234" />
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="TabItem:disabled > TextBlock">
<Setter Property="Foreground" Value="#B2B2B2" />
<Setter Property="TextDecorations" Value="Strikethrough" />
</Style>
<Style Selector="TabItem.first">
<Setter Property="Margin" Value="0 13 0 0" />
</Style>
<Style Selector="TabControl">
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="ContentPresenter.page">
<Setter Property="Margin" Value="15 21" />
</Style>
</UserControl.Styles>
<DockPanel>
<TabControl x:Name="TabControl" TabStripPlacement="Left">
<TabItem x:Name="TabDatabase" Header="Database" Classes="first">
<DockPanel>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
<ScrollViewer>
<ContentPresenter Content="{Binding DatabasePage}" Classes="page" />
</ScrollViewer>
</DockPanel>
</TabItem>
<TabItem x:Name="TabTracking" Header="Tracking">
<DockPanel>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
<ScrollViewer>
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
</ScrollViewer>
</DockPanel>
</TabItem>
<TabItem x:Name="TabViewer" Header="Viewer">
<DockPanel>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Bottom" />
<ScrollViewer>
<ContentPresenter Content="{Binding ViewerPage}" Classes="page" />
</ScrollViewer>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</UserControl>

View File

@@ -1,14 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main {
public class MainContentScreen : UserControl {
public MainContentScreen() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,57 +0,0 @@
using System;
using Avalonia.Controls;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Main.Pages;
using DHT.Server.Database;
using DHT.Server.Service;
namespace DHT.Desktop.Main {
public class MainContentScreenModel : IDisposable {
public DatabasePage DatabasePage { get; }
private DatabasePageModel DatabasePageModel { get; }
public TrackingPage TrackingPage { get; }
private TrackingPageModel TrackingPageModel { get; }
public ViewerPage ViewerPage { get; }
private ViewerPageModel ViewerPageModel { get; }
public StatusBarModel StatusBarModel { get; }
public event EventHandler? DatabaseClosed {
add { DatabasePageModel.DatabaseClosed += value; }
remove { DatabasePageModel.DatabaseClosed -= value; }
}
[Obsolete("Designer")]
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
public MainContentScreenModel(Window window, IDatabaseFile db) {
DatabasePageModel = new DatabasePageModel(window, db);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
TrackingPageModel = new TrackingPageModel(window, db);
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
ViewerPageModel = new ViewerPageModel(window, db);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
StatusBarModel = new StatusBarModel(db.Statistics);
TrackingPageModel.ServerStatusChanged += TrackingPageModelOnServerStatusChanged;
StatusBarModel.CurrentStatus = ServerLauncher.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
}
public void Initialize() {
TrackingPageModel.Initialize();
}
private void TrackingPageModelOnServerStatusChanged(object? sender, StatusBarModel.Status e) {
StatusBarModel.CurrentStatus = e;
}
public void Dispose() {
TrackingPageModel.Dispose();
GC.SuppressFinalize(this);
}
}
}

View File

@@ -5,18 +5,18 @@
xmlns:main="clr-namespace:DHT.Desktop.Main" xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.MainWindow" x:Class="DHT.Desktop.Main.MainWindow"
Title="Discord History Tracker" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="800" Height="450" Width="800" Height="500"
MinWidth="480" MinHeight="240" MinWidth="520" MinHeight="300"
WindowStartupLocation="CenterScreen"> WindowStartupLocation="CenterScreen"
Closed="OnClosed">
<Design.DataContext> <Design.DataContext>
<main:MainWindowModel /> <main:MainWindowModel />
</Design.DataContext> </Design.DataContext>
<Panel> <Panel>
<ContentPresenter Content="{Binding WelcomeScreen}" IsVisible="{Binding ShowWelcomeScreen}" /> <ContentPresenter Content="{Binding CurrentScreen}" />
<ContentPresenter Content="{Binding MainContentScreen}" IsVisible="{Binding ShowMainContentScreen}" />
</Panel> </Panel>
</Window> </Window>

View File

@@ -1,26 +1,36 @@
using Avalonia; using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using DHT.Desktop.Main.Pages;
using JetBrains.Annotations; using JetBrains.Annotations;
namespace DHT.Desktop.Main { namespace DHT.Desktop.Main;
public class MainWindow : Window {
[UsedImplicitly] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public MainWindow() { public sealed partial class MainWindow : Window {
InitializeComponent(Arguments.Empty); [UsedImplicitly]
public MainWindow() {
InitializeComponent();
DataContext = new MainWindowModel(this, Arguments.Empty);
}
internal MainWindow(Arguments args) {
InitializeComponent();
DataContext = new MainWindowModel(this, args);
}
public void OnClosed(object? sender, EventArgs e) {
if (DataContext is IDisposable disposable) {
disposable.Dispose();
} }
public MainWindow(Arguments args) { foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
InitializeComponent(args); try {
} File.Delete(temporaryFile);
} catch (Exception) {
private void InitializeComponent(Arguments args) { // ignored
AvaloniaXamlLoader.Load(this); }
DataContext = new MainWindowModel(this, args);
#if DEBUG
this.AttachDevTools();
#endif
} }
} }
} }

View File

@@ -4,98 +4,113 @@ using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Pages; using DHT.Desktop.Main.Screens;
using DHT.Desktop.Models; using DHT.Desktop.Server;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main { namespace DHT.Desktop.Main;
public class MainWindowModel : BaseModel {
public WelcomeScreen WelcomeScreen { get; }
private WelcomeScreenModel WelcomeScreenModel { get; }
public MainContentScreen? MainContentScreen { get; private set; } sealed class MainWindowModel : BaseModel, IDisposable {
private MainContentScreenModel? MainContentScreenModel { get; set; } private const string DefaultTitle = "Discord History Tracker";
public bool ShowWelcomeScreen => db == null; public string Title { get; private set; } = DefaultTitle;
public bool ShowMainContentScreen => db != null;
private readonly Window window; public UserControl CurrentScreen { get; private set; }
private IDatabaseFile? db; private readonly WelcomeScreen welcomeScreen;
private readonly WelcomeScreenModel welcomeScreenModel;
[Obsolete("Designer")] private MainContentScreen? mainContentScreen;
public MainWindowModel() : this(null!, Arguments.Empty) {} private MainContentScreenModel? mainContentScreenModel;
public MainWindowModel(Window window, Arguments args) { private readonly Window window;
this.window = window;
WelcomeScreenModel = new WelcomeScreenModel(window); private IDatabaseFile? db;
WelcomeScreen = new WelcomeScreen { DataContext = WelcomeScreenModel };
WelcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged; [Obsolete("Designer")]
public MainWindowModel() : this(null!, Arguments.Empty) {}
var dbFile = args.DatabaseFile; public MainWindowModel(Window window, Arguments args) {
if (!string.IsNullOrWhiteSpace(dbFile)) { this.window = window;
async void OnWindowOpened(object? o, EventArgs eventArgs) {
window.Opened -= OnWindowOpened;
// https://github.com/AvaloniaUI/Avalonia/issues/3071 welcomeScreenModel = new WelcomeScreenModel(window);
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
await Task.Delay(500); CurrentScreen = welcomeScreen;
}
if (File.Exists(dbFile)) { welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
await WelcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
} var dbFile = args.DatabaseFile;
else { if (!string.IsNullOrWhiteSpace(dbFile)) {
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile); async void OnWindowOpened(object? o, EventArgs eventArgs) {
} window.Opened -= OnWindowOpened;
// https://github.com/AvaloniaUI/Avalonia/issues/3071
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
await Task.Delay(500);
} }
window.Opened += OnWindowOpened; if (File.Exists(dbFile)) {
} await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
if (args.ServerPort != null) {
TrackingPageModel.ServerPort = args.ServerPort.ToString()!;
}
if (args.ServerToken != null) {
TrackingPageModel.ServerToken = args.ServerToken;
}
}
private void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(WelcomeScreenModel.Db)) {
if (MainContentScreenModel != null) {
MainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
MainContentScreenModel.Dispose();
}
db?.Dispose();
db = WelcomeScreenModel.Db;
if (db == null) {
MainContentScreenModel = null;
MainContentScreen = null;
} }
else { else {
MainContentScreenModel = new MainContentScreenModel(window, db); await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
MainContentScreenModel.Initialize();
MainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
MainContentScreen = new MainContentScreen { DataContext = MainContentScreenModel };
OnPropertyChanged(nameof(MainContentScreen));
} }
OnPropertyChanged(nameof(ShowWelcomeScreen));
OnPropertyChanged(nameof(ShowMainContentScreen));
window.Focus();
} }
window.Opened += OnWindowOpened;
} }
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) { if (args.ServerPort != null) {
WelcomeScreenModel.CloseDatabase(); ServerManager.Port = args.ServerPort.Value;
}
if (args.ServerToken != null) {
ServerManager.Token = args.ServerToken;
} }
} }
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
if (mainContentScreenModel != null) {
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
mainContentScreenModel.Dispose();
}
db?.Dispose();
db = welcomeScreenModel.Db;
if (db == null) {
Title = DefaultTitle;
mainContentScreenModel = null;
mainContentScreen = null;
CurrentScreen = welcomeScreen;
}
else {
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
mainContentScreenModel = new MainContentScreenModel(window, db);
await mainContentScreenModel.Initialize();
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
CurrentScreen = mainContentScreen;
}
OnPropertyChanged(nameof(CurrentScreen));
OnPropertyChanged(nameof(Title));
window.Focus();
}
}
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
welcomeScreenModel.CloseDatabase();
}
public void Dispose() {
welcomeScreenModel.Dispose();
mainContentScreenModel?.Dispose();
db?.Dispose();
db = null;
}
} }

View File

@@ -0,0 +1,25 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AdvancedPage">
<Design.DataContext>
<pages:AdvancedPageModel />
</Design.DataContext>
<StackPanel Orientation="Vertical" Spacing="10">
<Expander Header="Internal Server Configuration" IsExpanded="True">
<controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" />
</Expander>
<Expander Header="Database Tools" IsExpanded="True">
<StackPanel Orientation="Vertical" Spacing="10">
<TextBlock TextWrapping="Wrap">Recreates the database to remove any traces of deleted data, which frees up space and prevents forensic recovery.</TextBlock>
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
</StackPanel>
</Expander>
</StackPanel>
</UserControl>

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 AdvancedPage : UserControl {
public AdvancedPage() {
InitializeComponent();
}
}

View File

@@ -0,0 +1,39 @@
using System;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server.Database;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages;
sealed class AdvancedPageModel : BaseModel, IDisposable {
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
private readonly Window window;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
this.window = window;
this.db = db;
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
}
public void Initialize() {
ServerConfigurationModel.Initialize();
}
public void Dispose() {
ServerConfigurationModel.Dispose();
}
public async void VacuumDatabase() {
db.Vacuum();
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
}
}

View File

@@ -0,0 +1,54 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage">
<Design.DataContext>
<pages:AttachmentsPageModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="Expander">
<Setter Property="Margin" Value="0 5 0 0" />
</Style>
<Style Selector="DataGridColumnHeader">
<Setter Property="FontWeight" Value="Medium" />
</Style>
<Style Selector="DataGridColumnHeader:nth-child(2)">
<Setter Property="HorizontalContentAlignment" Value="Right" />
</Style>
<Style Selector="DataGridColumnHeader:nth-child(3)">
<Setter Property="HorizontalContentAlignment" Value="Right" />
</Style>
<Style Selector="DataGridCell.right">
<Setter Property="HorizontalContentAlignment" Value="Right" />
</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">
<Expander Header="Download Status" IsExpanded="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" />
</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 AttachmentsPage : UserControl {
public AttachmentsPage() {
InitializeComponent();
}
}

View File

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

View File

@@ -10,12 +10,22 @@
<pages:DatabasePageModel /> <pages:DatabasePageModel />
</Design.DataContext> </Design.DataContext>
<UserControl.Styles>
<Style Selector="WrapPanel > Button">
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles>
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<DockPanel> <DockPanel>
<Button Command="{Binding CloseDatabase}" DockPanel.Dock="Right">Close Database</Button> <Button Command="{Binding CloseDatabase}" DockPanel.Dock="Right">Close Database</Button>
<TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsEnabled="True" /> <TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsReadOnly="True" />
</DockPanel> </DockPanel>
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button> <WrapPanel>
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
<Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button>
</WrapPanel>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

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

View File

@@ -1,58 +1,245 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs; using Avalonia.Platform.Storage;
using DHT.Desktop.Models; 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.Data;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Service; using DHT.Server.Database.Import;
using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
public class DatabasePageModel : BaseModel {
public IDatabaseFile Db { get; }
public event EventHandler? DatabaseClosed; sealed class DatabasePageModel : BaseModel {
private static readonly Log Log = Log.ForType<DatabasePageModel>();
private readonly Window window; public IDatabaseFile Db { get; }
[Obsolete("Designer")] public event EventHandler? DatabaseClosed;
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
public DatabasePageModel(Window window, IDatabaseFile db) { private readonly Window window;
this.window = window;
this.Db = db; [Obsolete("Designer")]
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
public DatabasePageModel(Window window, IDatabaseFile db) {
this.window = window;
this.Db = db;
}
public async void OpenDatabaseFolder() {
string file = Db.Path;
string? folder = Path.GetDirectoryName(file);
if (folder == null) {
return;
} }
public async void OpenDatabaseFolder() { switch (Environment.OSVersion.Platform) {
string file = Db.Path; case PlatformID.Win32NT:
string? folder = Path.GetDirectoryName(file); Process.Start("explorer.exe", "/select,\"" + file + "\"");
break;
if (folder == null) { case PlatformID.Unix:
return; Process.Start("xdg-open", new string[] { folder });
} break;
switch (Environment.OSVersion.Platform) { case PlatformID.MacOSX:
case PlatformID.Win32NT: Process.Start("open", new string[] { folder });
Process.Start("explorer.exe", "/select,\"" + file + "\""); break;
break;
case PlatformID.Unix: default:
Process.Start("xdg-open", new string[] { folder }); await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
break; break;
case PlatformID.MacOSX:
Process.Start("open", new string[] { folder });
break;
default:
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
break;
}
}
public void CloseDatabase() {
ServerLauncher.Stop();
DatabaseClosed?.Invoke(this, EventArgs.Empty);
} }
} }
public void CloseDatabase() {
DatabaseClosed?.Invoke(this, EventArgs.Empty);
}
public async void MergeWithDatabase() {
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
if (paths.Length == 0) {
return;
}
ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) {
Title = "Database Merge"
};
await progressDialog.ShowDialog(window);
}
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
int total = paths.Length;
DialogResult.YesNo? upgradeResult = null;
async Task<bool> CheckCanUpgradeDatabase() {
upgradeResult ??= total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
return DialogResult.YesNo.Yes == upgradeResult;
}
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
if (db == null) {
return false;
}
try {
target.AddFrom(db);
return true;
} finally {
db.Dispose();
}
});
}
public async void ImportLegacyArchive() {
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Legacy DHT Archive",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
AllowMultiple = true
});
if (paths.Length == 0) {
return;
}
ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) {
Title = "Legacy Archive Import"
};
await progressDialog.ShowDialog(window);
}
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
var fakeSnowflake = new FakeSnowflake();
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
Dictionary<DHT.Server.Data.Server, ulong>? result = await Dispatcher.UIThread.InvokeAsync(() => AskForServerIds(dialog, servers));
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
return result;
});
});
}
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
static bool IsValidSnowflake(string value) {
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
}
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
ValidityCheck = IsValidSnowflake
});
}
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
Title = "Imported Server IDs",
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
};
var dialog = new TextBoxDialog { DataContext = model };
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
if (result != DialogResult.OkCancel.Ok) {
return null;
}
return model.ValidItems
.Where(static item => !string.IsNullOrEmpty(item.Value))
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
}
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
int total = paths.Length;
var oldStatistics = target.SnapshotStatistics();
int successful = 0;
int finished = 0;
foreach (string path in paths) {
await callback.Update(Path.GetFileName(path), finished, total);
++finished;
if (!File.Exists(path)) {
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
continue;
}
try {
if (await performImport(path)) {
++successful;
}
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
}
}
await callback.Update("Done", finished, total);
if (successful == 0) {
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
return;
}
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName));
}
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
StringBuilder message = new StringBuilder();
message.Append("Processed ");
if (successfulItems == totalItems) {
message.Append(successfulItems.Pluralize(itemName));
}
else {
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
}
message.Append(" and added:\n\n \u2022 ");
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
message.Append(newMessages.Pluralize("message"));
return message.ToString();
}
} }

View File

@@ -0,0 +1,45 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DebugPage">
<Design.DataContext>
<pages:DebugPageModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="TextBox">
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
</UserControl.Styles>
<StackPanel Orientation="Vertical" Spacing="10">
<Expander Header="Generate Random Data" IsExpanded="True">
<WrapPanel>
<StackPanel>
<Label Target="Channels">Channels</Label>
<TextBox x:Name="Channels" Width="100" Text="{Binding GenerateChannels}" />
</StackPanel>
<StackPanel>
<Label Target="Users">Users</Label>
<TextBox x:Name="Users" Width="100" Text="{Binding GenerateUsers}" />
</StackPanel>
<StackPanel>
<Label Target="Messages">Messages</Label>
<TextBox x:Name="Messages" Width="100" Text="{Binding GenerateMessages}" />
</StackPanel>
<StackPanel VerticalAlignment="Bottom">
<Button Command="{Binding OnClickAddRandomDataToDatabase}">Add to Database</Button>
</StackPanel>
</WrapPanel>
</Expander>
</StackPanel>
</UserControl>

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 DebugPage : UserControl {
public DebugPage() {
InitializeComponent();
}
}

View File

@@ -0,0 +1,185 @@
#if DEBUG
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Data;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel : BaseModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";
private readonly Window window;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
public DebugPageModel(Window window, IDatabaseFile db) {
this.window = window;
this.db = db;
}
public async void OnClickAddRandomDataToDatabase() {
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
return;
}
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
return;
}
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
return;
}
ProgressDialog progressDialog = new ProgressDialog {
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) {
Title = "Generating Random Data"
}
};
await progressDialog.ShowDialog(window);
}
private const int BatchSize = 500;
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
var rand = new Random();
var server = new DHT.Server.Data.Server {
Id = RandomId(rand),
Name = RandomName("s"),
Type = ServerType.Server,
};
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
Id = RandomId(rand),
Server = server.Id,
Name = RandomName("c"),
ParentId = null,
Position = i,
Topic = RandomText(rand, 10),
Nsfw = rand.Next(4) == 0,
}).ToArray();
var users = Enumerable.Range(0, userCount).Select(_ => new User {
Id = RandomId(rand),
Name = RandomName("u"),
AvatarUrl = null,
Discriminator = rand.Next(0, 9999).ToString(),
}).ToArray();
db.AddServer(server);
db.AddUsers(users);
foreach (var channel in channels) {
db.AddChannel(channel);
}
var now = DateTimeOffset.Now;
int batchIndex = 0;
while (messageCount > 0) {
int hourOffset = batchIndex;
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
var timeMillis = time.ToUnixTimeMilliseconds();
var editMillis = edit?.ToUnixTimeMilliseconds();
return new Message {
Id = (ulong) timeMillis,
Sender = RandomBiasedIndex(rand, users).Id,
Channel = RandomBiasedIndex(rand, channels).Id,
Text = RandomText(rand, 100),
Timestamp = timeMillis,
EditTimestamp = editMillis,
RepliedToId = null,
Attachments = ImmutableArray<Attachment>.Empty,
Embeds = ImmutableArray<Embed>.Empty,
Reactions = ImmutableArray<Reaction>.Empty,
};
}).ToArray();
db.AddMessages(messages);
messageCount -= BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
}
}
private static ulong RandomId(Random rand) {
ulong h = unchecked((ulong) rand.Next());
ulong l = unchecked((ulong) rand.Next());
return (h << 32) | l;
}
private static string RandomName(string prefix) {
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
}
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
}
private static readonly string[] RandomWords = {
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
"daikon", "date", "dewberry", "durian",
"edamame", "eggplant", "elderberry", "endive",
"fig",
"garlic", "ginger", "gooseberry", "grape", "grapefruit", "guava",
"honeysuckle", "horseradish", "huckleberry",
"jackfruit", "jicama",
"kale", "kiwi", "kohlrabi", "kumquat",
"leek", "lemon", "lentil", "lettuce", "lime",
"mandarin", "mango", "mushroom", "myrtle",
"nectarine", "nut",
"olive", "okra", "onion", "orange",
"papaya", "parsnip", "pawpaw", "peach", "pear", "pea", "pepper", "persimmon", "pineapple", "plum", "plantain", "pomegranate", "pomelo", "potato", "prune", "pumpkin",
"quandong", "quinoa",
"radicchio", "radish", "raisin", "raspberry", "redcurrant", "rhubarb", "rutabaga",
"spinach", "strawberry", "squash",
"tamarind", "tangerine", "tomatillo", "tomato", "turnip",
"vanilla",
"watercress", "watermelon",
"yam",
"zucchini",
};
private static string RandomText(Random rand, int maxWords) {
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
}
}
}
#else
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages {
sealed class DebugPageModel : BaseModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";
public void OnClickAddRandomDataToDatabase() {}
}
}
#endif

View File

@@ -10,45 +10,17 @@
<pages:TrackingPageModel /> <pages:TrackingPageModel />
</Design.DataContext> </Design.DataContext>
<UserControl.Styles>
<Style Selector="TextBox">
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
</UserControl.Styles>
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<TextBlock TextWrapping="Wrap"> <TextBlock TextWrapping="Wrap">
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app (Ctrl+Shift+I), or your browser with Discord open. To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
</TextBlock> </TextBlock>
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button> <Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
<Button Command="{Binding OnClickToggleButton}" Content="{Binding ToggleButtonText}" IsEnabled="{Binding IsToggleButtonEnabled}" />
</StackPanel> </StackPanel>
<Expander Header="Advanced Settings"> <TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
<StackPanel Spacing="10" Margin="0 10 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 TextWrapping="Wrap"> </TextBlock>
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy and apply the tracking script again. <Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="10">
<StackPanel Orientation="Vertical">
<Label Target="Port">Port</Label>
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />
</StackPanel>
<StackPanel Orientation="Vertical">
<Label Target="Token">Token</Label>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="Token" Width="200" Text="{Binding InputToken}" />
</StackPanel>
</StackPanel>
<Button Command="{Binding OnClickRandomizeToken}" VerticalAlignment="Bottom">Randomize Token</Button>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickApplyChanges}">Apply &amp; Restart</Button>
<Button IsEnabled="{Binding HasMadeChanges}" Command="{Binding OnClickCancelChanges}">Cancel</Button>
</StackPanel>
</StackPanel>
</Expander>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

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

View File

@@ -1,150 +1,116 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs; using DHT.Desktop.Discord;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Server;
using DHT.Desktop.Models; using DHT.Utils.Models;
using DHT.Desktop.Resources; using static DHT.Desktop.Program;
using DHT.Server.Database;
using DHT.Server.Logging;
using DHT.Server.Service;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
public class TrackingPageModel : BaseModel, IDisposable {
internal static string ServerPort { get; set; } = ServerUtils.FindAvailablePort(50000, 60000).ToString();
internal static string ServerToken { get; set; } = ServerUtils.GenerateRandomToken(20);
private string inputPort = ServerPort; sealed class TrackingPageModel : BaseModel {
private bool areDevToolsEnabled;
public string InputPort { private bool AreDevToolsEnabled {
get => inputPort; get => areDevToolsEnabled;
set { set {
Change(ref inputPort, value); Change(ref areDevToolsEnabled, value);
OnPropertyChanged(nameof(HasMadeChanges)); OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
}
} }
}
private string inputToken = ServerToken; public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
public string InputToken { public string ToggleAppDevToolsButtonText {
get => inputToken; get {
set { if (!IsToggleAppDevToolsButtonEnabled) {
Change(ref inputToken, value); return "Unavailable";
OnPropertyChanged(nameof(HasMadeChanges));
}
}
public bool HasMadeChanges => ServerPort != InputPort || ServerToken != InputToken;
private bool isToggleButtonEnabled = true;
public bool IsToggleButtonEnabled {
get => isToggleButtonEnabled;
set => Change(ref isToggleButtonEnabled, value);
}
public string ToggleButtonText => ServerLauncher.IsRunning ? "Pause Tracking" : "Resume Tracking";
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
private readonly Window window;
private readonly IDatabaseFile db;
[Obsolete("Designer")]
public TrackingPageModel() : this(null!, DummyDatabaseFile.Instance) {}
public TrackingPageModel(Window window, IDatabaseFile db) {
this.window = window;
this.db = db;
}
public void Initialize() {
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
if (int.TryParse(ServerPort, out int port)) {
string token = ServerToken;
ServerLauncher.Relaunch(port, token, db);
}
}
public void Dispose() {
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
ServerLauncher.Stop();
GC.SuppressFinalize(this);
}
private async Task<bool> StartServer() {
if (!int.TryParse(InputPort, out int port) || port is < 0 or > 65535) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return false;
} }
IsToggleButtonEnabled = false; return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting); }
ServerLauncher.Relaunch(port, InputToken, db); }
private readonly Window window;
[Obsolete("Designer")]
public TrackingPageModel() : this(null!) {}
public TrackingPageModel(Window window) {
this.window = window;
}
public async Task Initialize() {
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
}
else {
IsToggleAppDevToolsButtonEnabled = false;
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
}
}
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"));
var clipboard = window.Clipboard;
if (clipboard == null) {
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
return false;
}
try {
await clipboard.SetTextAsync(script);
return true; return true;
} catch {
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
return false;
} }
}
private void StopServer() { public async void OnClickToggleAppDevTools() {
IsToggleButtonEnabled = false; const string DialogTitle = "Discord App Settings File";
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
ServerLauncher.Stop();
}
public async Task<bool> OnClickToggleButton() { bool oldState = AreDevToolsEnabled;
if (ServerLauncher.IsRunning) { bool newState = !oldState;
StopServer();
return true;
}
else {
return await StartServer();
}
}
public async Task OnClickCopyTrackingScript() { switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
string bootstrap = await ResourceLoader.ReadTextAsync("Tracker/bootstrap.js"); case SettingsJsonResult.Success:
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerPort + ";") AreDevToolsEnabled = newState;
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerToken)) await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
.Replace("/*[IMPORTS]*/", await ResourceLoader.ReadJoinedAsync("Tracker/scripts/", '\n')) break;
.Replace("/*[CSS-CONTROLLER]*/", await ResourceLoader.ReadTextAsync("Tracker/styles/controller.css"))
.Replace("/*[CSS-SETTINGS]*/", await ResourceLoader.ReadTextAsync("Tracker/styles/settings.css"));
await Application.Current.Clipboard.SetTextAsync(script); case SettingsJsonResult.AlreadySet:
} await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
AreDevToolsEnabled = newState;
break;
public void OnClickRandomizeToken() { case SettingsJsonResult.FileNotFound:
InputToken = ServerUtils.GenerateRandomToken(20); await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
} break;
public async void OnClickApplyChanges() { case SettingsJsonResult.ReadError:
if (await StartServer()) { await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
ServerPort = InputPort; break;
ServerToken = InputToken;
OnPropertyChanged(nameof(HasMadeChanges));
}
}
public void OnClickCancelChanges() { case SettingsJsonResult.InvalidJson:
InputPort = ServerPort; await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
InputToken = ServerToken; break;
}
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) { case SettingsJsonResult.WriteError:
ServerStatusChanged?.Invoke(this, ServerLauncher.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped); await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
OnPropertyChanged(nameof(ToggleButtonText)); break;
IsToggleButtonEnabled = true;
}
private void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) { default:
Log.Error(ex); throw new ArgumentOutOfRangeException();
string message = ex.Message;
Dispatcher.UIThread.Post(async () => { await Dialog.ShowOk(window, "Server Error", message); });
} }
} }
} }

View File

@@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" 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" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.ViewerPage"> x:Class="DHT.Desktop.Main.Pages.ViewerPage">
@@ -11,16 +12,8 @@
</Design.DataContext> </Design.DataContext>
<UserControl.Styles> <UserControl.Styles>
<Style Selector="Grid > Label"> <Style Selector="Expander">
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="Margin" Value="0 5 0 0" />
</Style>
<Style Selector="Grid > CalendarDatePicker">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="IsTodayHighlighted" Value="True" />
<Setter Property="SelectedDateFormat" Value="Short" />
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
@@ -29,16 +22,16 @@
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button> <Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
<Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button> <Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button>
</StackPanel> </StackPanel>
<TextBlock Text="{Binding ExportedMessageText}" /> <controls:MessageFilterPanel DataContext="{Binding FilterModel}" />
<StackPanel> <Expander Header="Database Tools">
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox> <StackPanel Orientation="Vertical" Spacing="10">
<Grid ColumnDefinitions="Auto, 4, 140" RowDefinitions="Auto, 4, Auto" Margin="4"> <StackPanel Orientation="Vertical" Spacing="4">
<Label Grid.Row="0" Grid.Column="0">From:</Label> <RadioButton GroupName="DatabaseToolFilterMode" IsEnabled="{Binding HasFilters}" IsChecked="{Binding DatabaseToolFilterModeKeep}">Keep Only Messages Matching Filters</RadioButton>
<CalendarDatePicker Grid.Row="0" Grid.Column="2" x:Name="StartDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" /> <RadioButton GroupName="DatabaseToolFilterMode" IsEnabled="{Binding HasFilters}" IsChecked="{Binding DatabaseToolFilterModeRemove}">Remove Messages Matching Filters</RadioButton>
<Label Grid.Row="2" Grid.Column="0">To:</Label> </StackPanel>
<CalendarDatePicker Grid.Row="2" Grid.Column="2" x:Name="EndDatePicker" IsEnabled="{Binding FilterByDate}" SelectedDateChanged="CalendarDatePicker_OnSelectedDateChanged" /> <Button IsEnabled="{Binding HasFilters}" Command="{Binding OnClickApplyFiltersToDatabase}">Apply Filters to Database</Button>
</Grid> </StackPanel>
</StackPanel> </Expander>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View File

@@ -1,21 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
public class ViewerPage : UserControl {
public ViewerPage() {
InitializeComponent();
}
private void InitializeComponent() { [SuppressMessage("ReSharper", "MemberCanBeInternal")]
AvaloniaXamlLoader.Load(this); public sealed partial class ViewerPage : UserControl {
} public ViewerPage() {
InitializeComponent();
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
if (DataContext is ViewerPageModel model) {
model.StartDate = this.FindControl<CalendarDatePicker>("StartDatePicker").SelectedDate;
model.EndDate = this.FindControl<CalendarDatePicker>("EndDatePicker").SelectedDate;
}
}
} }
} }

View File

@@ -1,127 +1,148 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Concurrent;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Models; using Avalonia.Platform.Storage;
using DHT.Desktop.Resources; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Export; using DHT.Server.Database.Export;
using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Models;
using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
public class ViewerPageModel : BaseModel {
public string ExportedMessageText { get; private set; } = "";
private bool filterByDate = false; sealed class ViewerPageModel : BaseModel, IDisposable {
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
public bool FilterByDate { public bool DatabaseToolFilterModeKeep { get; set; } = true;
get => filterByDate; public bool DatabaseToolFilterModeRemove { get; set; } = false;
set => Change(ref filterByDate, value);
}
private DateTime? startDate = null; private bool hasFilters = false;
public DateTime? StartDate { public bool HasFilters {
get => startDate; get => hasFilters;
set => Change(ref startDate, value); set => Change(ref hasFilters, value);
} }
private DateTime? endDate = null; private MessageFilterPanelModel FilterModel { get; }
public DateTime? EndDate { private readonly Window window;
get => endDate; private readonly IDatabaseFile db;
set => Change(ref endDate, value);
}
private readonly Window window; [Obsolete("Designer")]
private readonly IDatabaseFile db; public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
[Obsolete("Designer")] public ViewerPageModel(Window window, IDatabaseFile db) {
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {} this.window = window;
this.db = db;
public ViewerPageModel(Window window, IDatabaseFile db) { FilterModel = new MessageFilterPanelModel(window, db, "Will export");
this.window = window; FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
this.db = db; }
this.PropertyChanged += OnPropertyChanged; public void Dispose() {
this.db.Statistics.PropertyChanged += OnDbStatisticsChanged; FilterModel.Dispose();
UpdateStatistics(); }
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName is nameof(FilterByDate) or nameof(StartDate) or nameof(EndDate)) { HasFilters = FilterModel.HasAnyFilters;
UpdateStatistics(); }
}
}
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) { const string ArchiveTag = "/*[ARCHIVE]*/";
UpdateStatistics();
}
}
private MessageFilter CreateFilter() { string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
MessageFilter filter = new(); string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
if (FilterByDate) { int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
filter.StartDate = StartDate; int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
filter.EndDate = EndDate;
}
return filter; string jsonTempFile = path + ".tmp";
}
private void UpdateStatistics() { await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
ExportedMessageText = "Will export " + db.CountMessages(CreateFilter()) + " out of " + db.Statistics.TotalMessages + " message(s)."; await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
OnPropertyChanged(nameof(ExportedMessageText));
}
private async Task<string> GenerateViewerContents() { char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
string json = ViewerJsonExport.Generate(db, CreateFilter()); jsonStream.Position = 0;
string index = await ResourceLoader.ReadTextAsync("Viewer/index.html"); await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
string viewer = index.Replace("/*[JS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/scripts/", '\n')) await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
.Replace("/*[CSS]*/", await ResourceLoader.ReadJoinedAsync("Viewer/styles/", '\n')) await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
.Replace("/*[ARCHIVE]*/", HttpUtility.JavaScriptStringEncode(json));
return viewer;
}
public async void OnClickOpenViewer() { using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker"); int readBytes;
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd"); while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
string fullPath = Path.Combine(rootPath, filenameBase + ".html"); string jsonChunk = new string(jsonBuffer, 0, readBytes);
int counter = 0; await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
while (File.Exists(fullPath)) {
fullPath = Path.Combine(rootPath, filenameBase + "-" + (++counter) + ".html");
}
Directory.CreateDirectory(rootPath);
await File.WriteAllTextAsync(fullPath, await GenerateViewerContents());
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
}
public async void OnClickSaveViewer() {
var dialog = new SaveFileDialog {
Title = "Save Viewer",
InitialFileName = "archive.html",
Directory = Path.GetDirectoryName(db.Path),
Filters = new List<FileDialogFilter> {
new() {
Name = "Discord History Viewer",
Extensions = { "html" }
} }
} }
}.ShowAsync(window);
string path = await dialog; await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
if (!string.IsNullOrEmpty(path)) { }
await File.WriteAllTextAsync(path, await GenerateViewerContents()); }
File.Delete(jsonTempFile);
}
public async void OnClickOpenViewer() {
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
int counter = 0;
while (File.Exists(fullPath)) {
++counter;
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
}
TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath);
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
}
private static readonly FilePickerFileType[] ViewerFileTypes = {
FileDialogs.CreateFilter("Discord History Viewer", new string[] { "html" }),
};
public async void OnClickSaveViewer() {
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
Title = "Save Viewer",
FileTypeChoices = ViewerFileTypes,
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)),
});
if (path != null) {
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
}
}
public async void OnClickApplyFiltersToDatabase() {
var filter = FilterModel.CreateFilter();
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);
}
}
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);
} }
} }
} }

View File

@@ -0,0 +1,119 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.MainContentScreen">
<Design.DataContext>
<screens:MainContentScreenModel />
</Design.DataContext>
<UserControl.Styles>
<Style Selector="Border.statusBar">
<Setter Property="Background" Value="#3C4F79" />
</Style>
<Style Selector="TextBlock.invisibleTabItem">
<Setter Property="FontSize" Value="20" />
<Setter Property="FontWeight" Value="SemiLight" />
<Setter Property="Margin" Value="17 0" />
<Setter Property="Opacity" Value="0" />
</Style>
<Style Selector="TabControl ItemsPresenter > Grid">
<Setter Property="Background" Value="#546A9F" />
</Style>
<Style Selector="TabItem">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="#E9E9E9" />
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="TabItem[TabStripPlacement=Left] /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Margin" Value="5 0" />
</Style>
<Style Selector="TabItem:pointerover">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="#455785" />
</Style>
<Style Selector="TabItem:pointerover /template/ Border">
<Setter Property="Background" Value="#455785" />
</Style>
<Style Selector="TabItem:pointerover > TextBlock">
<Setter Property="Foreground" Value="#E9E9E9" />
</Style>
<Style Selector="TabItem:selected:pointerover /template/ Border">
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected:pointerover > TextBlock">
<Setter Property="Foreground" Value="#1A2234" />
</Style>
<Style Selector="TabItem:selected">
<Setter Property="Foreground" Value="#1A2234" />
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="TabItem:disabled > TextBlock">
<Setter Property="Foreground" Value="#B2B2B2" />
<Setter Property="TextDecorations" Value="Strikethrough" />
</Style>
<Style Selector="TabItem.first">
<Setter Property="Margin" Value="0 13 0 0" />
</Style>
<Style Selector="TabControl">
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="ContentPresenter.page">
<Setter Property="Margin" Value="15 21 20 21" />
</Style>
</UserControl.Styles>
<DockPanel>
<Border Classes="statusBar" DockPanel.Dock="Bottom">
<DockPanel>
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Attachments</TextBlock>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
</DockPanel>
</Border>
<TabControl x:Name="TabControl" TabStripPlacement="Left" DockPanel.Dock="Top">
<TabControl.ItemsPanel>
<ItemsPanelTemplate>
<Grid ColumnDefinitions="Auto" RowDefinitions="Auto,Auto,Auto,Auto,*,Auto,Auto" />
</ItemsPanelTemplate>
</TabControl.ItemsPanel>
<TabItem x:Name="TabDatabase" Header="Database" Classes="first" Grid.Row="0">
<ScrollViewer>
<ContentPresenter Content="{Binding DatabasePage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabTracking" Header="Tracking" Grid.Row="1">
<ScrollViewer>
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabAttachments" Header="Attachments" Grid.Row="2">
<ScrollViewer>
<ContentPresenter Content="{Binding AttachmentsPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
<ScrollViewer>
<ContentPresenter Content="{Binding ViewerPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabAdvanced" Header="Advanced" Grid.Row="5">
<ScrollViewer>
<ContentPresenter Content="{Binding AdvancedPage}" Classes="page" />
</ScrollViewer>
</TabItem>
<TabItem x:Name="TabDebug" Header="Debug" Grid.Row="6" IsVisible="{Binding HasDebugPage}">
<ScrollViewer>
<ContentPresenter Content="{Binding DebugPage}" Classes="page" />
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel>
</UserControl>

View File

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

View File

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

View File

@@ -2,12 +2,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:main="clr-namespace:DHT.Desktop.Main" xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.WelcomeScreen"> x:Class="DHT.Desktop.Main.Screens.WelcomeScreen">
<Design.DataContext> <Design.DataContext>
<main:WelcomeScreenModel /> <screens:WelcomeScreenModel />
</Design.DataContext> </Design.DataContext>
<UserControl.Background> <UserControl.Background>
@@ -28,7 +28,7 @@
<Panel Name="RootPanel"> <Panel Name="RootPanel">
<StackPanel Margin="42"> <StackPanel Margin="42">
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v.{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" /> <TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button> <Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>

View File

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

View File

@@ -0,0 +1,70 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message;
using DHT.Server.Database;
using DHT.Utils.Models;
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;
private readonly Window window;
private string? dbFilePath;
[Obsolete("Designer")]
public WelcomeScreenModel() : this(null!) {}
public WelcomeScreenModel(Window window) {
this.window = window;
}
public async void OpenOrCreateDatabase() {
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
if (path != null) {
await OpenOrCreateDatabaseFromPath(path);
}
}
public async Task OpenOrCreateDatabaseFromPath(string path) {
if (Db != null) {
Db = null;
}
dbFilePath = path;
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
private async Task<bool> CheckCanUpgradeDatabase() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
}
public void CloseDatabase() {
Dispose();
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
public async void ShowAboutDialog() {
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
}
public void Exit() {
window.Close();
}
public void Dispose() {
Db?.Dispose();
Db = null;
}
}

View File

@@ -1,14 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main {
public class WelcomeScreen : UserControl {
public WelcomeScreen() {
InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
}
}
}

View File

@@ -1,91 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs;
using DHT.Desktop.Models;
using DHT.Server.Database;
using DHT.Server.Database.Exceptions;
using DHT.Server.Database.Sqlite;
using DHT.Server.Logging;
namespace DHT.Desktop.Main {
public class WelcomeScreenModel : BaseModel {
public string Version => Program.Version;
public IDatabaseFile? Db { get; private set; }
public bool HasDatabase => Db != null;
private readonly Window window;
private string? dbFilePath;
[Obsolete("Designer")]
public WelcomeScreenModel() : this(null!) {}
public WelcomeScreenModel(Window window) {
this.window = window;
}
public async void OpenOrCreateDatabase() {
var dialog = new SaveFileDialog {
Title = "Open or Create Database File",
InitialFileName = "archive.dht",
Directory = Path.GetDirectoryName(dbFilePath),
Filters = new List<FileDialogFilter> {
new() {
Name = "Discord History Tracker Database",
Extensions = { "dht" }
}
}
}.ShowAsync(window);
string path = await dialog;
if (!string.IsNullOrWhiteSpace(path)) {
await OpenOrCreateDatabaseFromPath(path);
}
}
public async Task OpenOrCreateDatabaseFromPath(string path) {
if (Db != null) {
Db = null;
}
dbFilePath = path;
try {
Db = await SqliteDatabaseFile.OpenOrCreate(path, CheckCanUpgradeDatabase);
} catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "This database appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) {
await Dialog.ShowOk(window, "Database Error", "This database was opened in a newer version of DHT (database version " + ex.DatabaseVersion + ", app version " + ex.CurrentVersion + ").");
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Database Error", ex.Message);
}
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
private async Task<bool> CheckCanUpgradeDatabase() {
return DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT. Do you want to proceed?");
}
public void CloseDatabase() {
Db = null;
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
public async void ShowAboutDialog() {
await new AboutWindow() { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
}
public void Exit() {
window.Close();
}
}
}

View File

@@ -1,22 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
namespace DHT.Desktop.Models {
public abstract class BaseModel : INotifyPropertyChanged {
public event PropertyChangedEventHandler? PropertyChanged;
[NotifyPropertyChangedInvocator]
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected void Change<T>(ref T field, T newValue, [CallerMemberName] string? propertyName = null) {
if (!EqualityComparer<T>.Default.Equals(field, newValue)) {
field = newValue;
OnPropertyChanged(propertyName);
}
}
}
}

View File

@@ -1,31 +1,40 @@
using System.Globalization; using System.Globalization;
using System.Reflection; using System.Reflection;
using Avalonia; using Avalonia;
using DHT.Utils.Resources;
namespace DHT.Desktop { namespace DHT.Desktop;
internal static class Program {
public static string Version { get; }
static Program() { static class Program {
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? ""; public static string Version { get; }
while (Version.EndsWith(".0")) { public static CultureInfo Culture { get; }
Version = Version[..^2]; public static ResourceLoader Resources { get; }
}
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; static Program() {
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; var assembly = Assembly.GetExecutingAssembly();
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; Version = assembly.GetName().Version?.ToString() ?? "";
while (Version.EndsWith(".0")) {
Version = Version[..^2];
} }
public static void Main(string[] args) { Culture = CultureInfo.CurrentCulture;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
} CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
private static AppBuilder BuildAvaloniaApp() { Resources = new ResourceLoader(assembly);
return AppBuilder.Configure<App>() }
.UsePlatformDetect()
.LogToTrace(); public static void Main(string[] args) {
} BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
private static AppBuilder BuildAvaloniaApp() {
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
} }
} }

View File

@@ -1,44 +0,0 @@
using System;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace DHT.Desktop.Resources {
public static class ResourceLoader {
private static Stream GetEmbeddedStream(string filename) {
Stream? stream = null;
Assembly assembly = Assembly.GetExecutingAssembly();
foreach (var embeddedName in assembly.GetManifestResourceNames()) {
if (embeddedName.Replace('\\', '/') == filename) {
stream = assembly.GetManifestResourceStream(embeddedName);
break;
}
}
return stream ?? throw new ArgumentException("Missing embedded resource: " + filename);
}
private static async Task<string> ReadTextAsync(Stream stream) {
using var reader = new StreamReader(stream, Encoding.UTF8);
return await reader.ReadToEndAsync();
}
public static async Task<string> ReadTextAsync(string filename) {
return await ReadTextAsync(GetEmbeddedStream(filename));
}
public static async Task<string> ReadJoinedAsync(string path, char separator) {
StringBuilder joined = new();
Assembly assembly = Assembly.GetExecutingAssembly();
foreach (var embeddedName in assembly.GetManifestResourceNames()) {
if (embeddedName.Replace('\\', '/').StartsWith(path)) {
joined.Append(await ReadTextAsync(assembly.GetManifestResourceStream(embeddedName)!)).Append(separator);
}
}
return joined.ToString(0, Math.Max(0, joined.Length - 1));
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
using DHT.Server.Database;
using DHT.Server.Service;
namespace DHT.Desktop.Server;
sealed class ServerManager : IDisposable {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
private static ServerManager? instance;
public bool IsRunning => ServerLauncher.IsRunning;
private readonly IDatabaseFile db;
public ServerManager(IDatabaseFile db) {
if (db != DummyDatabaseFile.Instance) {
if (instance != null) {
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
}
instance = this;
}
this.db = db;
}
public void Launch() {
ServerLauncher.Relaunch(Port, Token, db);
}
public void Relaunch(ushort port, string token) {
Port = port;
Token = token;
Launch();
}
public void Stop() {
ServerLauncher.Stop();
}
public void Dispose() {
Stop();
if (instance == this) {
instance = null;
}
}
}

View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>11</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Desktop", "Desktop\Desktop.
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{7F94B470-B06F-43C0-9655-6592A9AE2D92}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "Server\Server.csproj", "{7F94B470-B06F-43C0-9655-6592A9AE2D92}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Utils", "Utils\Utils.csproj", "{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -18,5 +20,9 @@ Global
{7F94B470-B06F-43C0-9655-6592A9AE2D92}.Debug|Any CPU.Build.0 = Debug|Any CPU {7F94B470-B06F-43C0-9655-6592A9AE2D92}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7F94B470-B06F-43C0-9655-6592A9AE2D92}.Release|Any CPU.ActiveCfg = Release|Any CPU {7F94B470-B06F-43C0-9655-6592A9AE2D92}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7F94B470-B06F-43C0-9655-6592A9AE2D92}.Release|Any CPU.Build.0 = Release|Any CPU {7F94B470-B06F-43C0-9655-6592A9AE2D92}.Release|Any CPU.Build.0 = Release|Any CPU
{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EEC1C05-1D21-4B39-B1D6-CF51F3795629}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -42,6 +42,10 @@
stopTrackingDelayed(() => isSending = false); stopTrackingDelayed(() => isSending = false);
}; };
const isNoAction = function(action) {
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
};
const onTrackingContinued = function(anyNewMessages) { const onTrackingContinued = function(anyNewMessages) {
if (!STATE.isTracking()) { if (!STATE.isTracking()) {
return; return;
@@ -59,14 +63,14 @@
if (SETTINGS.autoscroll) { if (SETTINGS.autoscroll) {
let action = null; let action = null;
if (!anyNewMessages) { if (!DISCORD.hasMoreMessages()) {
action = SETTINGS.afterSavedMsg;
}
else if (!DISCORD.hasMoreMessages()) {
action = SETTINGS.afterFirstMsg; action = SETTINGS.afterFirstMsg;
} }
if (isNoAction(action) && !anyNewMessages) {
action = SETTINGS.afterSavedMsg;
}
if (action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING) { if (isNoAction(action)) {
DISCORD.loadOlderMessages(); DISCORD.loadOlderMessages();
} }
else if (action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE || (action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel())) { else if (action === CONSTANTS.AUTOSCROLL_ACTION_PAUSE || (action === CONSTANTS.AUTOSCROLL_ACTION_SWITCH && !DISCORD.selectNextTextChannel())) {
@@ -113,8 +117,8 @@
try { try {
if (!messages.length) { if (!messages.length) {
DISCORD.loadOlderMessages();
isSending = false; isSending = false;
onTrackingContinued(false);
} }
else { else {
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages); const anyNewMessages = await STATE.addDiscordMessages(info.id, messages);
@@ -125,8 +129,7 @@
} }
}; };
const callbackTimer = DISCORD.setupMessageCallback(onMessagesUpdated); DISCORD.setupMessageCallback(onMessagesUpdated);
window.DHT_ON_UNLOAD.push(() => window.clearTimeout(callbackTimer));
STATE.onTrackingStateChanged(enabled => { STATE.onTrackingStateChanged(enabled => {
if (enabled) { if (enabled) {

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
class DOM{static id(id,parent){return(parent||document).getElementById(id)}static queryReactClass(cls,parent){return(parent||document).querySelector(`[class*="${cls}-"]`)}static createElement(tag,parent,id,html){const ele=document.createElement(tag);ele.id=id||"";ele.innerHTML=html||"";parent.appendChild(ele);return ele}static removeElement(ele){return ele.parentNode.removeChild(ele)}static createStyle(styles){return this.createElement("style",document.head,"",styles)}static saveToCookie(name,obj,expiresInSeconds){const expires=new Date(Date.now()+1e3*expiresInSeconds).toUTCString();document.cookie=name+"="+encodeURIComponent(JSON.stringify(obj))+";path=/;expires="+expires}static loadFromCookie(name){const value=document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)"+name+"\\s*\\=\\s*([^;]*).*$)|^.*$"),"$1");return value.length?JSON.parse(decodeURIComponent(value)):null}static getReactProps(ele){const keys=Object.keys(ele||{});let key=keys.find(key=>key.startsWith("__reactInternalInstance"));if(key){return ele[key].memoizedProps}key=keys.find(key=>key.startsWith("__reactProps$"));return key?ele[key]:null}}

View File

@@ -1,17 +0,0 @@
const GUI=function(){let controller=null;let settings=null;const stateChangedEvent=()=>{if(settings){settings.ui.cbAutoscroll.checked=SETTINGS.autoscroll;settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked=true;settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked=true;const autoscrollDisabled=!SETTINGS.autoscroll;Object.values(settings.ui.optsAfterFirstMsg).forEach(ele=>ele.disabled=autoscrollDisabled);Object.values(settings.ui.optsAfterSavedMsg).forEach(ele=>ele.disabled=autoscrollDisabled)}};return{showController(){if(controller){return}const html=`
<button id='dht-ctrl-close'>X</button>
<button id='dht-ctrl-settings'>Settings</button>
<button id='dht-ctrl-track'></button>
<p id='dht-ctrl-status'>Waiting</p>`;controller={styles:DOM.createStyle(`/*[CSS-CONTROLLER]*/`),ele:DOM.createElement("div",document.body,"dht-ctrl",html)};controller.ui={btnSettings:DOM.id("dht-ctrl-settings"),btnTrack:DOM.id("dht-ctrl-track"),btnClose:DOM.id("dht-ctrl-close"),textStatus:DOM.id("dht-ctrl-status")};controller.ui.btnSettings.addEventListener("click",()=>{this.showSettings()});controller.ui.btnTrack.addEventListener("click",()=>{const isTracking=!STATE.isTracking();STATE.setIsTracking(isTracking);if(!isTracking){controller.ui.textStatus.innerText="Stopped"}});controller.ui.btnClose.addEventListener("click",()=>{this.hideController();window.DHT_ON_UNLOAD.forEach(f=>f());window.DHT_LOADED=false});STATE.onTrackingStateChanged(isTracking=>{controller.ui.btnTrack.innerText=isTracking?"Pause Tracking":"Start Tracking";controller.ui.btnSettings.disabled=isTracking});SETTINGS.onSettingsChanged(stateChangedEvent);stateChangedEvent()},hideController(){if(controller){DOM.removeElement(controller.ele);DOM.removeElement(controller.styles);controller=null}},showSettings(){if(settings){return}const radio=(type,id,label)=>"<label><input id='dht-cfg-"+type+"-"+id+"' name='dht-"+type+"' type='radio'> "+label+"</label><br>";const html=`
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<br>
<label>After reaching the first message in channel...</label><br>
${radio("afm","nothing","Do Nothing")}
${radio("afm","pause","Pause Tracking")}
${radio("afm","switch","Switch to Next Channel")}
<br>
<label>After reaching a previously saved message...</label><br>
${radio("asm","nothing","Do Nothing")}
${radio("asm","pause","Pause Tracking")}
${radio("asm","switch","Switch to Next Channel")}
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;settings={styles:DOM.createStyle(`/*[CSS-SETTINGS]*/`),overlay:DOM.createElement("div",document.body,"dht-cfg-overlay"),ele:DOM.createElement("div",document.body,"dht-cfg",html)};settings.overlay.addEventListener("click",()=>{this.hideSettings()});settings.ui={cbAutoscroll:DOM.id("dht-cfg-autoscroll"),optsAfterFirstMsg:{},optsAfterSavedMsg:{}};settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING]=DOM.id("dht-cfg-afm-nothing");settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE]=DOM.id("dht-cfg-afm-pause");settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH]=DOM.id("dht-cfg-afm-switch");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING]=DOM.id("dht-cfg-asm-nothing");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_PAUSE]=DOM.id("dht-cfg-asm-pause");settings.ui.optsAfterSavedMsg[CONSTANTS.AUTOSCROLL_ACTION_SWITCH]=DOM.id("dht-cfg-asm-switch");settings.ui.cbAutoscroll.addEventListener("change",()=>{SETTINGS.autoscroll=settings.ui.cbAutoscroll.checked});Object.keys(settings.ui.optsAfterFirstMsg).forEach(key=>{settings.ui.optsAfterFirstMsg[key].addEventListener("click",()=>{SETTINGS.afterFirstMsg=key})});Object.keys(settings.ui.optsAfterSavedMsg).forEach(key=>{settings.ui.optsAfterSavedMsg[key].addEventListener("click",()=>{SETTINGS.afterSavedMsg=key})});stateChangedEvent()},hideSettings(){if(settings){DOM.removeElement(settings.overlay);DOM.removeElement(settings.ele);DOM.removeElement(settings.styles);settings=null}},setStatus(state){if(controller){controller.ui.textStatus.innerText=state}}}}();

View File

@@ -1 +0,0 @@
const CONSTANTS={AUTOSCROLL_ACTION_NOTHING:"optNothing",AUTOSCROLL_ACTION_PAUSE:"optPause",AUTOSCROLL_ACTION_SWITCH:"optSwitch"};let IS_FIRST_RUN=false;const SETTINGS=function(){const settingsChangedEvents=[];const saveSettings=function(){DOM.saveToCookie("DHT_SETTINGS",root,60*60*24*365*5)};const triggerSettingsChanged=function(property){for(const callback of settingsChangedEvents){callback(property)}saveSettings()};const defineTriggeringProperty=function(obj,property,value){const name="_"+property;Object.defineProperty(obj,property,{get:()=>obj[name],set:value=>{obj[name]=value;triggerSettingsChanged(property)}});obj[name]=value};let loaded=DOM.loadFromCookie("DHT_SETTINGS");if(!loaded){loaded={_autoscroll:true,_afterFirstMsg:CONSTANTS.AUTOSCROLL_ACTION_PAUSE,_afterSavedMsg:CONSTANTS.AUTOSCROLL_ACTION_PAUSE};IS_FIRST_RUN=true}const root={onSettingsChanged(callback){settingsChangedEvents.push(callback)}};defineTriggeringProperty(root,"autoscroll",loaded._autoscroll);defineTriggeringProperty(root,"afterFirstMsg",loaded._afterFirstMsg);defineTriggeringProperty(root,"afterSavedMsg",loaded._afterSavedMsg);if(IS_FIRST_RUN){saveSettings()}return root}();

View File

@@ -1 +0,0 @@
const STATE=function(){let serverPort=-1;let serverToken="";const post=function(endpoint,json){const aborter=new AbortController;const timeout=window.setTimeout(()=>aborter.abort(),5e3);return new Promise(async(resolve,reject)=>{let r;try{r=await fetch("http://127.0.0.1:"+serverPort+endpoint,{method:"POST",headers:{"Content-Type":"application/json","X-DHT-Token":serverToken},credentials:"omit",redirect:"error",body:JSON.stringify(json),signal:aborter.signal})}catch(e){if(e.name==="AbortError"){reject({status:"DISCONNECTED"});return}else{reject({status:"ERROR",message:e.message});return}}finally{window.clearTimeout(timeout)}if(r.status===200){resolve(r);return}try{const message=await r.text();reject({status:"ERROR",message:message})}catch(e){reject({status:"ERROR",message:e.message})}})};const trackingStateChangedListeners=[];let isTracking=false;const addedChannels=new Set;const addedUsers=new Set;return{setup(port,token){serverPort=port;serverToken=token},onTrackingStateChanged(callback){trackingStateChangedListeners.push(callback);callback(isTracking)},isTracking(){return isTracking},setIsTracking(state){if(isTracking!==state){isTracking=state;if(isTracking){addedChannels.clear();addedUsers.clear()}for(const callback of trackingStateChangedListeners){callback(isTracking)}}},async addDiscordChannel(serverInfo,channelInfo){if(addedChannels.has(channelInfo.id)){return}const server={id:serverInfo.id,name:serverInfo.name,type:serverInfo.type};const channel={id:channelInfo.id,name:channelInfo.name};if("extra"in channelInfo){const extra=channelInfo.extra;if("parent"in extra){channel.parent=extra.parent}channel.position=extra.position;channel.topic=extra.topic;channel.nsfw=extra.nsfw}await post("/track-channel",{server:server,channel:channel});addedChannels.add(channelInfo.id)},async addDiscordMessages(channelId,discordMessageArray){discordMessageArray=discordMessageArray.filter(msg=>(msg.type===0||msg.type===19||msg.type===21)&&msg.state==="SENT");if(discordMessageArray.length===0){return false}const userInfo={};let hasNewUsers=false;for(const msg of discordMessageArray){const user=msg.author;if(!addedUsers.has(user.id)){const obj={id:user.id,name:user.username};if(user.avatar){obj.avatar=user.avatar}if(!user.bot){obj.discriminator=user.discriminator}userInfo[user.id]=obj;hasNewUsers=true}}if(hasNewUsers){await post("/track-users",Object.values(userInfo));for(const id of Object.keys(userInfo)){addedUsers.add(id)}}const response=await post("/track-messages",discordMessageArray.map(msg=>{const obj={id:msg.id,sender:msg.author.id,channel:msg.channel_id,text:msg.content,timestamp:msg.timestamp.toDate().getTime()};if(msg.editedTimestamp!==null){obj.editTimestamp=msg.editedTimestamp.toDate().getTime()}if(msg.messageReference!==null){obj.repliedToId=msg.messageReference.message_id}if(msg.attachments.length>0){obj.attachments=msg.attachments.map(attachment=>{const mapped={id:attachment.id,name:attachment.filename,size:attachment.size,url:attachment.url};if(attachment.content_type){mapped.type=attachment.content_type}return mapped})}if(msg.embeds.length>0){obj.embeds=msg.embeds.map(embed=>{const mapped={};for(const key of Object.keys(embed)){if(key==="id"){continue}if(key==="rawTitle"){mapped["title"]=embed[key]}else if(key==="rawDescription"){mapped["description"]=embed[key]}else{mapped[key]=embed[key]}}return JSON.stringify(mapped)})}if(msg.reactions.length>0){obj.reactions=msg.reactions.map(reaction=>{const emoji=reaction.emoji;const mapped={count:reaction.count};if(emoji.id){mapped.id=emoji.id}if(emoji.name){mapped.name=emoji.name}if(emoji.animated){mapped.isAnimated=emoji.animated}return mapped})}return obj}));const anyNewMessages=await response.text();return anyNewMessages==="1"}}}();

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