1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-08-18 04:31:41 +02:00

68 Commits
v38 ... v41.2

Author SHA1 Message Date
d4da64a5ed Release v41.2 2024-04-17 14:14:46 +02:00
8de309a6c4 Fix some Rider inspections and a typo 2024-04-17 14:13:33 +02:00
de8d6a1e11 Stream downloaded files during database merges 2024-04-17 13:31:25 +02:00
d79e6f53b4 Stream downloaded files from database directly into HTTP server responses 2024-04-17 13:31:25 +02:00
70c04fc986 Stream downloaded files directly into database 2024-04-17 13:31:24 +02:00
c8d8d95daa Fix not rolling back database transactions after unhandled exceptions 2024-04-17 12:30:13 +02:00
daafdbbfaf Prevent active downloads from timing out
Closes #256
2024-04-17 08:51:37 +02:00
07615de87a Fix download timeouts not marking the downloaded file as failed
References #256
2024-04-16 11:00:38 +02:00
7fdc19880e Add -concurrentdownloads program argument to configure number of concurrent download tasks
References #256
2024-04-16 10:50:50 +02:00
67b9c12843 Release v41.1 2024-02-15 13:07:36 +01:00
9030a2f010 Update message timestamp processing for latest Discord update
Closes #249
2024-02-15 13:06:06 +01:00
a6dad6b4c7 Release v41.0 2024-01-11 04:24:34 +01:00
72b8fb7c14 Update viewer to reference downloaded embeds, avatars, and emoji 2024-01-07 19:15:12 +01:00
7173dc6cfc Refactor last change to CDN URL normalization 2024-01-07 06:17:17 +01:00
2c1e5a7603 Rework download storage and start collecting download URLs from embeds, avatars, and reactions
Resolves #200
2024-01-07 05:50:46 +01:00
4929a19397 Fix button to retry failed downloads & show error if downloads fail to start 2024-01-01 16:25:32 +01:00
c5f77872fe Fix some database calls not being asynchronous 2024-01-01 14:37:05 +01:00
c9e50e1a80 Refactor database schema upgrades 2024-01-01 09:29:32 +01:00
ef3e34066a Release v40.0 2023-12-31 20:18:24 +01:00
37374eeb18 Migrate ConfigureAwait to Task.Run 2023-12-31 20:18:08 +01:00
23ddb45a0d Make opening/saving viewer asynchronous 2023-12-31 20:18:08 +01:00
9904a711f7 Make database connection pool asynchronous 2023-12-31 19:47:28 +01:00
d5720c8758 Code cleanup 2023-12-31 19:44:44 +01:00
89161e14b1 Increase default window size slightly 2023-12-31 16:54:14 +01:00
9d208b026c Synchronize publishing downloaded items to UI 2023-12-31 16:51:33 +01:00
119649ef9b Optimize rendering of download statistics table 2023-12-31 15:33:00 +01:00
0bc6232da7 Convert database events to reactive 2023-12-31 14:42:03 +01:00
de266473c5 Migrate models to a custom version of MVVM Community Toolkit 2023-12-31 14:42:03 +01:00
e0f359c15b Fix wrong attachment download progress information after re-enqueueing items 2023-12-30 12:27:44 +01:00
935f11d736 Make database schema upgrades asynchronous 2023-12-30 12:27:44 +01:00
f64141e768 Rewrite database interface to be asynchronous and improve UI 2023-12-30 12:27:44 +01:00
edea3470df Set C# language version to 12 2023-12-28 20:34:45 +01:00
031d521402 Convert attachment download events to reactive 2023-12-28 17:33:30 +01:00
0131f8cb50 Refactor integrated server management 2023-12-28 17:33:30 +01:00
3bf5acfa65 Fix trailing spaces Rider generates in code 2023-12-27 08:22:23 +01:00
f603c861c5 Encapsulate server-side state and ensure graceful shutdown when closing main window 2023-12-27 08:22:23 +01:00
d2934f4d6a Update user agent of downloader 2023-12-25 09:36:14 +01:00
567253d147 Use multiple threads to download attachments 2023-12-25 09:36:14 +01:00
aa6555990c Fix throwing exceptions in UI binding converters 2023-12-25 07:04:31 +01:00
3d9d6a454a Remove unnecessary ASP.NET features 2023-12-23 13:47:33 +01:00
ee39780928 Rewrite token authorization checks in integrated server 2023-12-23 13:47:31 +01:00
7b58f973a0 Disable ASP.NET logging and use custom logging for request duration 2023-12-23 11:19:54 +01:00
93fe018343 Add -console argument to show a console on Windows 2023-12-23 08:46:43 +01:00
4f5e27f651 Release v39.1 2023-12-22 16:31:11 +01:00
cbf81ec95a Fix missing JSON source generator when parsing integrated server requests 2023-12-22 16:31:11 +01:00
8a80cb8c20 Show progress dialog when upgrading database schema 2023-12-22 16:18:03 +01:00
865deb356a Fix progress dialog not propagating exceptions from its task 2023-12-22 14:47:55 +01:00
069ab97196 Disable reflection-based JSON serialization 2023-12-22 05:54:24 +01:00
caab038eaa Use source generators for JSON serialization everywhere 2023-12-22 05:24:28 +01:00
fb837374fc Enable single file compression and disable unnecessary .NET features 2023-12-22 02:30:24 +01:00
65d935cca1 Use compiled bindings in Avalonia XAML 2023-12-21 08:55:56 +01:00
6e64c86d7a Optimize viewer JSON export using source generators 2023-12-21 08:29:07 +01:00
8aeb590bb3 Release v39.0 2023-12-21 05:18:01 +01:00
8dc1adc9f0 Fix viewer converting underscores to italics even when not at the end of a word 2023-12-21 05:09:55 +01:00
ddf70b02e7 Fix not storing channel topic and position if the channel is in a category
Closes #232
2023-12-21 04:49:19 +01:00
ef59fd992e Reword "Do Nothing" option in autoscroll settings to improve clarity 2023-12-21 04:27:24 +01:00
d044627fac Reduce delay in tracker for responding to new messages 2023-12-21 04:27:24 +01:00
a624745602 Force light theme in Avalonia 2023-12-21 04:24:12 +01:00
6da3c185e5 Update Avalonia to 11.0.6 2023-12-21 04:24:01 +01:00
d4d14cab97 Update Rider project configuration files 2023-12-21 00:55:19 +01:00
095c9a061a Update attachment downloads for time-limited Discord CDN URLs
Closes #223
2023-12-21 00:27:08 +01:00
d01f9ed218 Rewrite detection of newly loaded messages 2023-12-20 13:19:11 +01:00
dd6f121059 Fix not seeing messages after a Discord update
Closes #240
2023-12-20 10:51:14 +01:00
8bba33d815 Update SQLite provider to 8.0.0 (SQLite version 3.41.2) 2023-12-20 10:42:17 +01:00
9eab8ac92a Update README section on distribution 2023-11-24 13:47:53 +01:00
fe588686fc Clean up project configuration and build scripts & fix CI builds 2023-11-23 21:45:54 +01:00
7392987165 Remove unused argument in tracker script
Closes #235
2023-11-23 05:22:33 +01:00
492dddb35d Load full tracking script from the integrated server to remove need for minification 2023-11-21 07:08:26 +01:00
213 changed files with 4933 additions and 45197 deletions

View File

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

View File

@@ -9,15 +9,15 @@
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/AttachmentFilterPanel.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Controls/DownloadItemFilterPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/AttachmentsPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DebugPage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/DebugPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DownloadsPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" /> <entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />

View File

@@ -29,9 +29,6 @@
<H2CodeStyleSettings version="6"> <H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings> </H2CodeStyleSettings>
<H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings>
<HSQLCodeStyleSettings version="6"> <HSQLCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</HSQLCodeStyleSettings> </HSQLCodeStyleSettings>

View File

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

View File

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

View File

@@ -2,7 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="clr-namespace:DHT.Desktop.Common" xmlns:common="clr-namespace:DHT.Desktop.Common"
xmlns:system="clr-namespace:System;assembly=System.Runtime" xmlns:system="clr-namespace:System;assembly=System.Runtime"
x:Class="DHT.Desktop.App"> x:Class="DHT.Desktop.App"
RequestedThemeVariant="Light">
<Application.Styles> <Application.Styles>

View File

@@ -1,4 +1,3 @@
using System;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
@@ -13,7 +12,7 @@ sealed class App : Application {
public override void OnFrameworkInitializationCompleted() { public override void OnFrameworkInitializationCompleted() {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args ?? Array.Empty<string>())); desktop.MainWindow = new MainWindow(Program.Arguments);
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop; namespace DHT.Desktop;
@@ -6,29 +7,37 @@ namespace DHT.Desktop;
sealed class Arguments { sealed class Arguments {
private static readonly Log Log = Log.ForType<Arguments>(); private static readonly Log Log = Log.ForType<Arguments>();
public static Arguments Empty => new(Array.Empty<string>()); private const int FirstArgument = 1;
public static Arguments Empty => new (Array.Empty<string>());
public bool Console { get; }
public string? DatabaseFile { get; } public string? DatabaseFile { get; }
public ushort? ServerPort { get; } public ushort? ServerPort { get; }
public string? ServerToken { get; } public string? ServerToken { get; }
public byte? ConcurrentDownloads { get; }
public Arguments(string[] args) { public Arguments(IReadOnlyList<string> args) {
for (int i = 0; i < args.Length; i++) { for (int i = FirstArgument; i < args.Count; i++) {
string key = args[i]; string key = args[i];
switch (key) { switch (key) {
case "-debug": case "-debug":
Log.IsDebugEnabled = true; Log.IsDebugEnabled = true;
continue; continue;
case "-console":
Console = true;
continue;
} }
string value; string value;
if (i == 0 && !key.StartsWith('-')) { if (i == FirstArgument && !key.StartsWith('-')) {
value = key; value = key;
key = "-db"; key = "-db";
} }
else if (i >= args.Length - 1) { else if (i >= args.Count - 1) {
Log.Warn("Missing value for command line argument: " + key); Log.Warn("Missing value for command line argument: " + key);
continue; continue;
} }
@@ -42,11 +51,11 @@ sealed class Arguments {
continue; continue;
case "-port": { case "-port": {
if (ushort.TryParse(value, out var port)) { if (!ushort.TryParse(value, out var port)) {
ServerPort = port; Log.Warn("Invalid port number: " + value);
} }
else { else {
Log.Warn("Invalid port number: " + value); ServerPort = port;
} }
continue; continue;
@@ -56,6 +65,20 @@ sealed class Arguments {
ServerToken = value; ServerToken = value;
continue; continue;
case "-concurrentdownloads":
if (!ulong.TryParse(value, out var concurrentDownloads) || concurrentDownloads == 0) {
Log.Warn("Invalid concurrent downloads count: " + value);
}
else if (concurrentDownloads > 10) {
Log.Warn("Limiting concurrent downloads to 10");
ConcurrentDownloads = 10;
}
else {
ConcurrentDownloads = (byte) concurrentDownloads;
}
continue;
default: default:
Log.Warn("Unknown command line argument: " + key); Log.Warn("Unknown command line argument: " + key);
break; break;

View File

@@ -19,17 +19,17 @@ sealed class BytesValueConverter : IValueConverter {
} }
} }
private static readonly Unit[] Units = { private static readonly Unit[] Units = [
new ("B", decimalPlaces: 0), new Unit("B", decimalPlaces: 0),
new ("kB", decimalPlaces: 0), new Unit("kB", decimalPlaces: 0),
new ("MB", decimalPlaces: 1), new Unit("MB", decimalPlaces: 1),
new ("GB", decimalPlaces: 1), new Unit("GB", decimalPlaces: 1),
new ("TB", decimalPlaces: 1) new Unit("TB", decimalPlaces: 1)
}; ];
private const int Scale = 1000; private const int Scale = 1000;
private static string Convert(ulong size) { public static string Convert(ulong size) {
int power = size == 0L ? 0 : (int) Math.Log(size, Scale); int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
int unit = power >= Units.Length ? Units.Length - 1 : power; int unit = power >= Units.Length ? Units.Length - 1 : power;
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit)); return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));

View File

@@ -9,6 +9,7 @@ using DHT.Desktop.Dialogs.Message;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Exceptions; using DHT.Server.Database.Exceptions;
using DHT.Server.Database.Sqlite; using DHT.Server.Database.Sqlite;
using DHT.Server.Database.Sqlite.Schema;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop.Common; namespace DHT.Desktop.Common;
@@ -18,9 +19,9 @@ static class DatabaseGui {
private const string DatabaseFileInitialName = "archive.dht"; private const string DatabaseFileInitialName = "archive.dht";
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = new List<FilePickerFileType> { private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
FileDialogs.CreateFilter("Discord History Tracker Database", new [] { "dht" }) FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"])
}; ];
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) { public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
@@ -41,11 +42,11 @@ static class DatabaseGui {
}); });
} }
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) { public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
IDatabaseFile? file = null; IDatabaseFile? file = null;
try { try {
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase); file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
} catch (InvalidDatabaseVersionException ex) { } catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ")."); await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) { } catch (DatabaseTooNewException ex) {

View File

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

View File

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

View File

@@ -2,11 +2,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using DHT.Utils.Models; using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.CheckBox; namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxDialogModel : BaseModel { class CheckBoxDialogModel : ObservableObject {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>(); private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
@@ -29,8 +29,8 @@ class CheckBoxDialogModel : BaseModel {
private bool pauseCheckEvents = false; private bool pauseCheckEvents = false;
public bool AreAllSelected => Items.All(static item => item.Checked); public bool AreAllSelected => Items.All(static item => item.IsChecked);
public bool AreNoneSelected => Items.All(static item => !item.Checked); public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
public void SelectAll() => SetAllChecked(true); public void SelectAll() => SetAllChecked(true);
public void SelectNone() => SetAllChecked(false); public void SelectNone() => SetAllChecked(false);
@@ -39,7 +39,7 @@ class CheckBoxDialogModel : BaseModel {
pauseCheckEvents = true; pauseCheckEvents = true;
foreach (var item in Items) { foreach (var item in Items) {
item.Checked = isChecked; item.IsChecked = isChecked;
} }
pauseCheckEvents = false; pauseCheckEvents = false;
@@ -52,16 +52,16 @@ class CheckBoxDialogModel : BaseModel {
} }
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) { if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
UpdateBulkButtons(); UpdateBulkButtons();
} }
} }
} }
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel { sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
public new IReadOnlyList<CheckBoxItem<T>> Items { get; } private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked); public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) { public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
this.Items = new List<CheckBoxItem<T>>(items); this.Items = new List<CheckBoxItem<T>>(items);

View File

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

View File

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

View File

@@ -4,4 +4,6 @@ namespace DHT.Desktop.Dialogs.Progress;
interface IProgressCallback { interface IProgressCallback {
Task Update(string message, int finishedItems, int totalItems); Task Update(string message, int finishedItems, int totalItems);
Task UpdateIndeterminate(string message);
Task Hide();
} }

View File

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

View File

@@ -7,7 +7,60 @@ namespace DHT.Desktop.Dialogs.Progress;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class ProgressDialog : Window { public sealed partial class ProgressDialog : Window {
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
var taskCompletionSource = new TaskCompletionSource();
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
try {
await action(dialog, callbacks[0]);
taskCompletionSource.SetResult();
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
});
await dialog.ShowProgressDialog(owner);
await taskCompletionSource.Task;
}
internal static async Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
var taskCompletionSource = new TaskCompletionSource();
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
await callbacks[0].UpdateIndeterminate(message);
try {
await action(dialog);
taskCompletionSource.SetResult();
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
});
await dialog.ShowProgressDialog(owner);
await taskCompletionSource.Task;
}
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
var taskCompletionSource = new TaskCompletionSource<T>();
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
await callbacks[0].UpdateIndeterminate(message);
try {
taskCompletionSource.SetResult(await action(dialog));
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
});
await dialog.ShowProgressDialog(owner);
return await taskCompletionSource.Task;
}
private bool isFinished = false; private bool isFinished = false;
private Task progressTask = Task.CompletedTask;
public ProgressDialog() { public ProgressDialog() {
InitializeComponent(); InitializeComponent();
@@ -15,7 +68,8 @@ public sealed partial class ProgressDialog : Window {
public void OnOpened(object? sender, EventArgs e) { public void OnOpened(object? sender, EventArgs e) {
if (DataContext is ProgressDialogModel model) { if (DataContext is ProgressDialogModel model) {
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext()); progressTask = Task.Run(model.StartTask);
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
} }
} }
@@ -27,4 +81,9 @@ public sealed partial class ProgressDialog : Window {
isFinished = true; isFinished = true;
Close(); Close();
} }
public async Task ShowProgressDialog(Window owner) {
await ShowDialog(owner);
await progressTask;
}
} }

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using DHT.Utils.Models; using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxDialogModel : BaseModel { class TextBoxDialogModel : ObservableObject {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public string Description { get; init; } = ""; public string Description { get; init; } = "";
@@ -36,7 +36,7 @@ class TextBoxDialogModel : BaseModel {
} }
sealed class TextBoxDialogModel<T> : TextBoxDialogModel { sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
public new IReadOnlyList<TextBoxItem<T>> Items { get; } private new IReadOnlyList<TextBoxItem<T>> Items { get; }
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid); public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);

View File

@@ -1,11 +1,11 @@
using System; using System;
using System.Collections; using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using DHT.Utils.Models; using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxItem : BaseModel, INotifyDataErrorInfo { class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public object? Item { get; init; } = null; public object? Item { get; init; } = null;
@@ -17,7 +17,7 @@ class TextBoxItem : BaseModel, INotifyDataErrorInfo {
public string Value { public string Value {
get => this.value; get => this.value;
set { set {
Change(ref this.value, value); SetProperty(ref this.value, value);
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
} }
} }

View File

@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using static System.Environment.SpecialFolder; using static System.Environment.SpecialFolder;
@@ -39,7 +39,8 @@ static class DiscordAppSettings {
public static async Task<bool?> AreDevToolsEnabled() { public static async Task<bool?> AreDevToolsEnabled() {
try { try {
return AreDevToolsEnabled(await ReadSettingsJson()); var settingsJson = await ReadSettingsJson();
return AreDevToolsEnabled(settingsJson);
} catch (Exception e) { } catch (Exception e) {
Log.Error("Cannot read settings file."); Log.Error("Cannot read settings file.");
Log.Error(e); Log.Error(e);
@@ -47,12 +48,12 @@ static class DiscordAppSettings {
} }
} }
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) { private static bool AreDevToolsEnabled(JsonObject json) {
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True }; return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
} }
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) { public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
Dictionary<string, object?> json; JsonObject json;
try { try {
json = await ReadSettingsJson(); json = await ReadSettingsJson();
@@ -109,13 +110,13 @@ static class DiscordAppSettings {
return SettingsJsonResult.Success; return SettingsJsonResult.Success;
} }
private static async Task<Dictionary<string, object?>> ReadSettingsJson() { private static async Task<JsonObject> ReadSettingsJson() {
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException(); return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
} }
private static async Task WriteSettingsJson(Dictionary<string, object?> json) { private static async Task WriteSettingsJson(JsonObject json) {
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None); await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true }); await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
} }
} }

View File

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

View File

@@ -5,6 +5,7 @@
xmlns:main="clr-namespace:DHT.Desktop.Main" xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295" mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
x:Class="DHT.Desktop.Main.AboutWindow" x:Class="DHT.Desktop.Main.AboutWindow"
x:DataType="main:AboutWindowModel"
Title="About Discord History Tracker" Title="About Discord History Tracker"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="480" Height="295" CanResize="False" Width="480" Height="295" CanResize="False"

View File

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

View File

@@ -4,10 +4,11 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"> x:Class="DHT.Desktop.Main.Controls.DownloadItemFilterPanel"
x:DataType="controls:DownloadItemFilterPanelModel">
<Design.DataContext> <Design.DataContext>
<controls:AttachmentFilterPanelModel /> <controls:DownloadItemFilterPanelModel />
</Design.DataContext> </Design.DataContext>
<UserControl.Styles> <UserControl.Styles>

View File

@@ -4,8 +4,8 @@ using Avalonia.Controls;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class AttachmentFilterPanel : UserControl { public sealed partial class DownloadItemFilterPanel : UserControl {
public AttachmentFilterPanel() { public DownloadItemFilterPanel() {
InitializeComponent(); InitializeComponent();
} }
} }

View File

@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Server;
using DHT.Server.Data.Filters;
using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Controls;
sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposable {
public sealed record Unit(string Name, uint Scale);
private static readonly Unit[] AllUnits = [
new Unit("B", 1),
new Unit("kB", 1024),
new Unit("MB", 1024 * 1024)
];
private static readonly HashSet<string> FilterProperties = [
nameof(LimitSize),
nameof(MaximumSize),
nameof(MaximumSizeUnit)
];
public string FilterStatisticsText { get; private set; } = "";
[ObservableProperty]
private bool limitSize = false;
[ObservableProperty]
private ulong maximumSize = 0L;
[ObservableProperty]
private Unit maximumSizeUnit = AllUnits[0];
public IEnumerable<Unit> Units => AllUnits;
private readonly State state;
private readonly string verb;
private readonly RestartableTask<long> downloadItemCountTask;
private long? matchingItemCount;
private readonly IDisposable downloadItemCountSubscription;
private long? totalItemCount;
[Obsolete("Designer")]
public DownloadItemFilterPanelModel() : this(State.Dummy) {}
public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
this.state = state;
this.verb = verb;
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
UpdateFilterStatistics();
PropertyChanged += OnPropertyChanged;
}
public void Dispose() {
downloadItemCountTask.Cancel();
downloadItemCountSubscription.Dispose();
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
UpdateFilterStatistics();
}
}
private void OnDownloadItemCountChanged(long newItemCount) {
totalItemCount = newItemCount;
UpdateFilterStatistics();
}
private void UpdateFilterStatistics() {
var filter = CreateFilter();
if (filter.IsEmpty) {
downloadItemCountTask.Cancel();
matchingItemCount = totalItemCount;
UpdateFilterStatisticsText();
}
else {
matchingItemCount = null;
UpdateFilterStatisticsText();
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
}
}
private void SetMatchingCount(long matchingAttachmentCount) {
this.matchingItemCount = matchingAttachmentCount;
UpdateFilterStatisticsText();
}
private void UpdateFilterStatisticsText() {
var matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
var totalItemCountStr = totalItemCount?.Format() ?? "(...)";
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
OnPropertyChanged(nameof(FilterStatisticsText));
}
public DownloadItemFilter CreateFilter() {
DownloadItemFilter filter = new ();
if (LimitSize) {
try {
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
} catch (ArithmeticException) {
// set no size limit, because the overflown size is larger than any file could possibly be
}
}
return filter;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.StatusBar"> x:Class="DHT.Desktop.Main.Controls.StatusBar"
x:DataType="controls:StatusBarModel">
<Design.DataContext> <Design.DataContext>
<controls:StatusBarModel /> <controls:StatusBarModel />
@@ -39,22 +40,22 @@
<StackPanel Orientation="Horizontal" Margin="6 3"> <StackPanel Orientation="Horizontal" Margin="6 3">
<StackPanel Orientation="Vertical" Width="65"> <StackPanel Orientation="Vertical" Width="65">
<TextBlock Classes="label">Status</TextBlock> <TextBlock Classes="label">Status</TextBlock>
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" /> <TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Servers</TextBlock> <TextBlock Classes="label">Servers</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding ServerCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Channels</TextBlock> <TextBlock Classes="label">Channels</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding ChannelCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Messages</TextBlock> <TextBlock Classes="label">Messages</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding MessageCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,15 +14,16 @@ using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress; using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Dialogs.TextBox; using DHT.Desktop.Dialogs.TextBox;
using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Import; using DHT.Server.Database.Import;
using DHT.Server.Database.Sqlite.Schema;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class DatabasePageModel : BaseModel { sealed class DatabasePageModel {
private static readonly Log Log = Log.ForType<DatabasePageModel>(); private static readonly Log Log = Log.ForType<DatabasePageModel>();
public IDatabaseFile Db { get; } public IDatabaseFile Db { get; }
@@ -32,14 +33,14 @@ sealed class DatabasePageModel : BaseModel {
private readonly Window window; private readonly Window window;
[Obsolete("Designer")] [Obsolete("Designer")]
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {} public DatabasePageModel() : this(null!, State.Dummy) {}
public DatabasePageModel(Window window, IDatabaseFile db) { public DatabasePageModel(Window window, State state) {
this.window = window; this.window = window;
this.Db = db; this.Db = state.Db;
} }
public async void OpenDatabaseFolder() { public async Task OpenDatabaseFolder() {
string file = Db.Path; string file = Db.Path;
string? folder = Path.GetDirectoryName(file); string? folder = Path.GetDirectoryName(file);
@@ -70,69 +71,77 @@ sealed class DatabasePageModel : BaseModel {
DatabaseClosed?.Invoke(this, EventArgs.Empty); DatabaseClosed?.Invoke(this, EventArgs.Empty);
} }
public async void MergeWithDatabase() { public async Task MergeWithDatabase() {
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path)); var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
if (paths.Length == 0) { if (paths.Length > 0) {
return; await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
} }
ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async 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) { private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
int total = paths.Length; var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, 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 => { await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current; IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
if (db == null) { if (db == null) {
return false; return false;
} }
try { try {
target.AddFrom(db); await target.AddFrom(db);
return true; return true;
} finally { } finally {
db.Dispose(); await db.DisposeAsync();
} }
}); });
} }
public async void ImportLegacyArchive() { private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly ProgressDialog dialog;
private readonly int total;
private bool? decision;
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
this.total = total;
this.dialog = dialog;
}
public async Task<bool> CanUpgrade() {
return decision ??= (total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
}
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
return doUpgrade(new NullReporter());
}
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
public Task NextVersion() {
return Task.CompletedTask;
}
public Task MainWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
public Task SubWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
}
}
public async Task ImportLegacyArchive() {
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Legacy DHT Archive", Title = "Open Legacy DHT Archive",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)), SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
AllowMultiple = true AllowMultiple = true
}); });
if (paths.Length == 0) { if (paths.Length > 0) {
return; await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
} }
ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async 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) { private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
@@ -184,7 +193,7 @@ sealed class DatabasePageModel : BaseModel {
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) { private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
int total = paths.Length; int total = paths.Length;
var oldStatistics = target.SnapshotStatistics(); var oldStatistics = await DatabaseStatistics.Take(target);
int successful = 0; int successful = 0;
int finished = 0; int finished = 0;
@@ -215,14 +224,26 @@ sealed class DatabasePageModel : BaseModel {
return; return;
} }
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName)); var newStatistics = await DatabaseStatistics.Take(target);
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
} }
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) { private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers; public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels; return new DatabaseStatistics(
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers; await db.Servers.Count(),
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages; await db.Channels.Count(),
await db.Users.Count(),
await db.Messages.Count()
);
}
}
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
StringBuilder message = new StringBuilder(); StringBuilder message = new StringBuilder();
message.Append("Processed "); message.Append("Processed ");

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ using Avalonia.Controls;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class AttachmentsPage : UserControl { public sealed partial class DownloadsPage : UserControl {
public AttachmentsPage() { public DownloadsPage() {
InitializeComponent(); InitializeComponent();
} }
} }

View File

@@ -0,0 +1,186 @@
using System;
using System.Collections.ObjectModel;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Main.Controls;
using DHT.Server;
using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters;
using DHT.Server.Download;
using DHT.Utils.Logging;
using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Pages;
sealed partial class DownloadsPageModel : ObservableObject, IDisposable {
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
[ObservableProperty(Setter = Access.Private)]
private bool isToggleDownloadButtonEnabled = true;
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
private bool isRetryingFailedDownloads = false;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
private bool hasFailedDownloads;
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
[ObservableProperty(Setter = Access.Private)]
private string downloadMessage = "";
public DownloadItemFilterPanelModel FilterModel { get; }
private readonly StatisticsRow statisticsPending = new ("Pending");
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
private readonly StatisticsRow statisticsFailed = new ("Failed");
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
public bool IsDownloading => state.Downloader.IsDownloading;
private readonly State state;
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
private readonly IDisposable downloadItemCountSubscription;
private IDisposable? finishedItemsSubscription;
private DownloadItemFilter? currentDownloadFilter;
public DownloadsPageModel() : this(State.Dummy) {}
public DownloadsPageModel(State state) {
this.state = state;
FilterModel = new DownloadItemFilterPanelModel(state);
StatisticsRows = [
statisticsPending,
statisticsDownloaded,
statisticsFailed,
statisticsSkipped
];
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
RecomputeDownloadStatistics();
}
public void Dispose() {
finishedItemsSubscription?.Dispose();
downloadItemCountSubscription.Dispose();
downloadStatisticsTask.Dispose();
FilterModel.Dispose();
}
private void OnDownloadCountChanged(long newDownloadCount) {
RecomputeDownloadStatistics();
}
public async Task OnClickToggleDownload() {
IsToggleDownloadButtonEnabled = false;
if (IsDownloading) {
await state.Downloader.Stop();
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null;
currentDownloadFilter = null;
}
else {
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
}
RecomputeDownloadStatistics();
OnPropertyChanged(nameof(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading));
IsToggleDownloadButtonEnabled = true;
}
private void OnItemFinished(DownloadItem item) {
RecomputeDownloadStatistics();
}
public async Task OnClickRetryFailedDownloads() {
IsRetryingFailedDownloads = true;
try {
await state.Db.Downloads.RetryFailed();
RecomputeDownloadStatistics();
} catch (Exception e) {
Log.Error(e);
} finally {
IsRetryingFailedDownloads = false;
}
}
private void RecomputeDownloadStatistics() {
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
}
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
statisticsPending.Items = statusStatistics.PendingCount;
statisticsPending.Size = statusStatistics.PendingTotalSize;
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
statisticsFailed.Items = statusStatistics.FailedCount;
statisticsFailed.Size = statusStatistics.FailedTotalSize;
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
statisticsSkipped.Items = statusStatistics.SkippedCount;
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
HasFailedDownloads = statusStatistics.FailedCount > 0;
}
[ObservableObject]
public sealed partial class StatisticsRow(string state) {
public string State { get; } = state;
[ObservableProperty]
private int items;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SizeText))]
private ulong? size;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SizeText))]
private bool hasFilesWithUnknownSize;
public string SizeText {
get {
if (size == null) {
return "-";
}
else if (hasFilesWithUnknownSize) {
return "\u2265 " + BytesValueConverter.Convert(size.Value);
}
else {
return BytesValueConverter.Convert(size.Value);
}
}
}
}
}

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.TrackingPage"> x:Class="DHT.Desktop.Main.Pages.TrackingPage"
x:DataType="pages:TrackingPageModel">
<Design.DataContext> <Design.DataContext>
<pages:TrackingPageModel /> <pages:TrackingPageModel />
@@ -15,7 +16,7 @@
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I. To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
</TextBlock> </TextBlock>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10"> <StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button> <Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
</StackPanel> </StackPanel>
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0"> <TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled. By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.

View File

@@ -1,35 +1,39 @@
using System; using System;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord; using DHT.Desktop.Discord;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Utils.Models;
using static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class TrackingPageModel : BaseModel { sealed partial class TrackingPageModel : ObservableObject {
private bool areDevToolsEnabled; [ObservableProperty(Setter = Access.Private)]
private bool isCopyTrackingScriptButtonEnabled = true;
private bool AreDevToolsEnabled { [ObservableProperty(Setter = Access.Private)]
get => areDevToolsEnabled; [NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
set { private bool? areDevToolsEnabled = null;
Change(ref areDevToolsEnabled, value);
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
}
}
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true; [ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool isToggleAppDevToolsButtonEnabled = false;
public string ToggleAppDevToolsButtonText { public string ToggleAppDevToolsButtonText {
get { get {
if (!AreDevToolsEnabled.HasValue) {
return "Loading...";
}
if (!IsToggleAppDevToolsButtonEnabled) { if (!IsToggleAppDevToolsButtonEnabled) {
return "Unavailable"; return "Unavailable";
} }
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I"; return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
} }
} }
@@ -40,26 +44,23 @@ sealed class TrackingPageModel : BaseModel {
public TrackingPageModel(Window window) { public TrackingPageModel(Window window) {
this.window = window; this.window = window;
}
public async Task Initialize() { Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
}
else {
IsToggleAppDevToolsButtonEnabled = false;
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
}
} }
public async Task<bool> OnClickCopyTrackingScript() { public async Task<bool> OnClickCopyTrackingScript() {
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js"); IsCopyTrackingScriptButtonEnabled = false;
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token)) try {
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n')) return await CopyTrackingScript();
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css")) } finally {
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css")); IsCopyTrackingScriptButtonEnabled = true;
}
}
private async Task<bool> CopyTrackingScript() {
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
var clipboard = window.Clipboard; var clipboard = window.Clipboard;
if (clipboard == null) { if (clipboard == null) {
@@ -76,10 +77,26 @@ sealed class TrackingPageModel : BaseModel {
} }
} }
public async void OnClickToggleAppDevTools() { private async Task InitializeDevToolsToggle() {
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
IsToggleAppDevToolsButtonEnabled = true;
}
else {
IsToggleAppDevToolsButtonEnabled = false;
}
}
public async Task OnClickToggleAppDevTools() {
const string DialogTitle = "Discord App Settings File"; const string DialogTitle = "Discord App Settings File";
bool oldState = AreDevToolsEnabled; if (!AreDevToolsEnabled.HasValue) {
return;
}
bool oldState = AreDevToolsEnabled.Value;
bool newState = !oldState; bool newState = !oldState;
switch (await DiscordAppSettings.ConfigureDevTools(newState)) { switch (await DiscordAppSettings.ConfigureDevTools(newState)) {

View File

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

View File

@@ -8,46 +8,46 @@ using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File; using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Server.Database.Export; using DHT.Server.Database.Export;
using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Models;
using static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class ViewerPageModel : BaseModel, IDisposable { sealed partial class ViewerPageModel : ObservableObject, IDisposable {
public static readonly ConcurrentBag<string> TemporaryFiles = new (); public static readonly ConcurrentBag<string> TemporaryFiles = [];
private static readonly FilePickerFileType[] ViewerFileTypes = [
FileDialogs.CreateFilter("Discord History Viewer", ["html"])
];
public bool DatabaseToolFilterModeKeep { get; set; } = true; public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false; public bool DatabaseToolFilterModeRemove { get; set; } = false;
[ObservableProperty]
private bool hasFilters = false; private bool hasFilters = false;
public bool HasFilters { public MessageFilterPanelModel FilterModel { get; }
get => hasFilters;
set => Change(ref hasFilters, value);
}
private MessageFilterPanelModel FilterModel { get; }
private readonly Window window; private readonly Window window;
private readonly IDatabaseFile db; private readonly State state;
[Obsolete("Designer")] [Obsolete("Designer")]
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {} public ViewerPageModel() : this(null!, State.Dummy) {}
public ViewerPageModel(Window window, IDatabaseFile db) { public ViewerPageModel(Window window, State state) {
this.window = window; this.window = window;
this.db = db; this.state = state;
FilterModel = new MessageFilterPanelModel(window, db, "Will export"); FilterModel = new MessageFilterPanelModel(window, state, "Will export");
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged; FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
} }
@@ -59,11 +59,71 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
HasFilters = FilterModel.HasAnyFilters; HasFilters = FilterModel.HasAnyFilters;
} }
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) { public async void OnClickOpenViewer() {
try {
var fullPath = await PrepareTemporaryViewerFile();
string jsConstants = $"""
window.DHT_SERVER_URL = "{HttpUtility.JavaScriptStringEncode("http://127.0.0.1:" + ServerConfiguration.Port)}";
window.DHT_SERVER_TOKEN = "{HttpUtility.JavaScriptStringEncode(ServerConfiguration.Token)}";
""";
await ProgressDialog.ShowIndeterminate(window, "Open Viewer", "Creating viewer...", _ => Task.Run(() => WriteViewerFile(fullPath, jsConstants)));
Process.Start(new ProcessStartInfo(fullPath) {
UseShellExecute = true
});
} catch (Exception e) {
await Dialog.ShowOk(window, "Open Viewer", "Could not create or save viewer: " + e.Message);
}
}
private async Task<string> PrepareTemporaryViewerFile() {
return await Task.Run(() => {
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
string filenameBase = Path.GetFileNameWithoutExtension(state.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);
return fullPath;
});
}
public async void OnClickSaveViewer() {
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
Title = "Save Viewer",
FileTypeChoices = ViewerFileTypes,
SuggestedFileName = Path.GetFileNameWithoutExtension(state.Db.Path) + ".html",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(state.Db.Path)),
});
if (path == null) {
return;
}
try {
await ProgressDialog.ShowIndeterminate(window, "Save Viewer", "Creating viewer...", _ => Task.Run(() => WriteViewerFile(path, string.Empty)));
} catch (Exception e) {
await Dialog.ShowOk(window, "Save Viewer", "Could not create or save viewer: " + e.Message);
}
}
private async Task WriteViewerFile(string path, string jsConstants) {
const string ArchiveTag = "/*[ARCHIVE]*/"; const string ArchiveTag = "/*[ARCHIVE]*/";
string indexFile = await Resources.ReadTextAsync("Viewer/index.html"); string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n')) string viewerTemplate = indexFile.Replace("/*[CONSTANTS]*/", jsConstants)
.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n')); .Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag); int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
@@ -72,7 +132,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
string jsonTempFile = path + ".tmp"; string jsonTempFile = path + ".tmp";
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) { await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter()); await ViewerJsonExport.Generate(jsonStream, state.Db, FilterModel.CreateFilter());
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)]; char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
jsonStream.Position = 0; jsonStream.Position = 0;
@@ -96,54 +156,23 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
File.Delete(jsonTempFile); File.Delete(jsonTempFile);
} }
public async void OnClickOpenViewer() { public async Task OnClickApplyFiltersToDatabase() {
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(); var filter = FilterModel.CreateFilter();
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
if (DatabaseToolFilterModeKeep) { if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) { if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching); await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
} }
} }
else if (DatabaseToolFilterModeRemove) { else if (DatabaseToolFilterModeRemove) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) { if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching); await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
} }
} }
} }
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
}
} }

View File

@@ -5,7 +5,8 @@
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens" xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.MainContentScreen"> x:Class="DHT.Desktop.Main.Screens.MainContentScreen"
x:DataType="screens:MainContentScreenModel">
<Design.DataContext> <Design.DataContext>
<screens:MainContentScreenModel /> <screens:MainContentScreenModel />
@@ -73,7 +74,7 @@
<DockPanel> <DockPanel>
<Border Classes="statusBar" DockPanel.Dock="Bottom"> <Border Classes="statusBar" DockPanel.Dock="Bottom">
<DockPanel> <DockPanel>
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Attachments</TextBlock> <TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Downloads</TextBlock>
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" /> <controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
</DockPanel> </DockPanel>
</Border> </Border>
@@ -93,9 +94,9 @@
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" /> <ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem x:Name="TabAttachments" Header="Attachments" Grid.Row="2"> <TabItem x:Name="TabDownloads" Header="Downloads" Grid.Row="2">
<ScrollViewer> <ScrollViewer>
<ContentPresenter Content="{Binding AttachmentsPage}" Classes="page" /> <ContentPresenter Content="{Binding DownloadsPage}" Classes="page" />
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3"> <TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">

View File

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

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens" xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"> x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
x:DataType="screens:WelcomeScreenModel">
<Design.DataContext> <Design.DataContext>
<screens:WelcomeScreenModel /> <screens:WelcomeScreenModel />
@@ -31,7 +32,7 @@
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" /> <TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button> <Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
<Button Command="{Binding ShowAboutDialog}">About</Button> <Button Command="{Binding ShowAboutDialog}">About</Button>
<Button Command="{Binding Exit}">Exit</Button> <Button Command="{Binding Exit}">Exit</Button>
</StackPanel> </StackPanel>

View File

@@ -1,19 +1,24 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Server.Database.Sqlite.Schema;
namespace DHT.Desktop.Main.Screens; namespace DHT.Desktop.Main.Screens;
sealed class WelcomeScreenModel : BaseModel, IDisposable { sealed partial class WelcomeScreenModel : ObservableObject {
public string Version => Program.Version; public string Version => Program.Version;
public IDatabaseFile? Db { get; private set; } [ObservableProperty(Setter = Access.Private)]
public bool HasDatabase => Db != null; private bool isOpenOrCreateDatabaseButtonEnabled = true;
public event EventHandler<IDatabaseFile>? DatabaseSelected;
private readonly Window window; private readonly Window window;
@@ -26,45 +31,89 @@ sealed class WelcomeScreenModel : BaseModel, IDisposable {
this.window = window; this.window = window;
} }
public async void OpenOrCreateDatabase() { public async Task OpenOrCreateDatabase() {
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath)); IsOpenOrCreateDatabaseButtonEnabled = false;
if (path != null) { try {
await OpenOrCreateDatabaseFromPath(path); var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
if (path != null) {
await OpenOrCreateDatabaseFromPath(path);
}
} finally {
IsOpenOrCreateDatabaseButtonEnabled = true;
} }
} }
public async Task OpenOrCreateDatabaseFromPath(string path) { public async Task OpenOrCreateDatabaseFromPath(string path) {
if (Db != null) { dbFilePath = path;
Db = null;
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
if (db != null) {
DatabaseSelected?.Invoke(this, db);
}
}
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly Window window;
public SchemaUpgradeCallbacks(Window window) {
this.window = window;
} }
dbFilePath = path; public async Task<bool> CanUpgrade() {
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase); return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
}
OnPropertyChanged(nameof(Db)); public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
OnPropertyChanged(nameof(HasDatabase)); async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
var reporter = new ProgressReporter(versionSteps, callbacks);
await reporter.NextVersion();
await Task.Delay(TimeSpan.FromMilliseconds(800));
await doUpgrade(reporter);
await Task.Delay(TimeSpan.FromMilliseconds(600));
}
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
}
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
private readonly IReadOnlyList<IProgressCallback> callbacks;
private readonly int versionSteps;
private int versionProgress = 0;
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
this.callbacks = callbacks;
this.versionSteps = versionSteps;
}
public async Task NextVersion() {
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
await HideChildren(0);
}
public async Task MainWork(string message, int finishedItems, int totalItems) {
await callbacks[1].Update(message, finishedItems, totalItems);
await HideChildren(1);
}
public async Task SubWork(string message, int finishedItems, int totalItems) {
await callbacks[2].Update(message, finishedItems, totalItems);
await HideChildren(2);
}
private async Task HideChildren(int parentIndex) {
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
await callbacks[i].Hide();
}
}
}
} }
private async Task<bool> CheckCanUpgradeDatabase() { public async Task ShowAboutDialog() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window); await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(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() { public void Exit() {
window.Close(); window.Close();
} }
public void Dispose() {
Db?.Dispose();
Db = null;
}
} }

View File

@@ -1,6 +1,8 @@
using System.Globalization; using System;
using System.Globalization;
using System.Reflection; using System.Reflection;
using Avalonia; using Avalonia;
using DHT.Utils.Logging;
using DHT.Utils.Resources; using DHT.Utils.Resources;
namespace DHT.Desktop; namespace DHT.Desktop;
@@ -9,6 +11,7 @@ static class Program {
public static string Version { get; } public static string Version { get; }
public static CultureInfo Culture { get; } public static CultureInfo Culture { get; }
public static ResourceLoader Resources { get; } public static ResourceLoader Resources { get; }
public static Arguments Arguments { get; }
static Program() { static Program() {
var assembly = Assembly.GetExecutingAssembly(); var assembly = Assembly.GetExecutingAssembly();
@@ -25,10 +28,21 @@ static class Program {
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
Resources = new ResourceLoader(assembly); Resources = new ResourceLoader(assembly);
Arguments = new Arguments(Environment.GetCommandLineArgs());
} }
public static void Main(string[] args) { public static void Main(string[] args) {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.AllocConsole();
}
try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} finally {
if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.FreeConsole();
}
}
} }
private static AppBuilder BuildAvaloniaApp() { private static AppBuilder BuildAvaloniaApp() {

View File

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

View File

@@ -0,0 +1,8 @@
using DHT.Server.Service;
namespace DHT.Desktop.Server;
static class ServerConfiguration {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
}

View File

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

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

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

View File

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

6
app/NuGet.Config Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="chylex's repository" value="https://nuget.chylex.com/feed/index.json" />
</packageSources>
</configuration>

View File

@@ -64,9 +64,11 @@
let action = null; let action = null;
if (!DISCORD.hasMoreMessages()) { if (!DISCORD.hasMoreMessages()) {
console.debug("[DHT] Reached first message.");
action = SETTINGS.afterFirstMsg; action = SETTINGS.afterFirstMsg;
} }
if (isNoAction(action) && !anyNewMessages) { if (isNoAction(action) && !anyNewMessages) {
console.debug("[DHT] No new messages.");
action = SETTINGS.afterSavedMsg; action = SETTINGS.afterSavedMsg;
} }
@@ -121,7 +123,7 @@
onTrackingContinued(false); onTrackingContinued(false);
} }
else { else {
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages); const anyNewMessages = await STATE.addDiscordMessages(messages);
onTrackingContinued(anyNewMessages); onTrackingContinued(anyNewMessages);
} }
} catch (e) { } catch (e) {
@@ -156,3 +158,4 @@
GUI.showSettings(); GUI.showSettings();
} }
})(); })();
/*[DEBUGGER]*/

View File

@@ -1,5 +1,23 @@
// noinspection JSUnresolvedVariable // noinspection JSUnresolvedVariable
// noinspection LocalVariableNamingConventionJS
class DISCORD { class DISCORD {
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
static CHANNEL_TYPE = {
DM: 1,
GROUP_DM: 3,
ANNOUNCEMENT_THREAD: 10,
PUBLIC_THREAD: 11,
PRIVATE_THREAD: 12
};
// https://discord.com/developers/docs/resources/channel#message-object-message-types
static MESSAGE_TYPE = {
DEFAULT: 0,
REPLY: 19,
THREAD_STARTER: 21
};
static getMessageOuterElement() { static getMessageOuterElement() {
return DOM.queryReactClass("messagesWrapper"); return DOM.queryReactClass("messagesWrapper");
} }
@@ -28,46 +46,11 @@ class DISCORD {
* Calls the provided function with a list of messages whenever the currently loaded messages change. * Calls the provided function with a list of messages whenever the currently loaded messages change.
*/ */
static setupMessageCallback(callback) { static setupMessageCallback(callback) {
let skipsLeft = 0;
let waitForCleanup = false;
const previousMessages = new Set(); const previousMessages = new Set();
const timer = window.setInterval(() => { const onMessageElementsChanged = function() {
if (skipsLeft > 0) { const messages = DISCORD.getMessages();
--skipsLeft; const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
return;
}
const view = this.getMessageOuterElement();
if (!view) {
skipsLeft = 2;
return;
}
const anyMessage = DOM.queryReactClass("message", this.getMessageOuterElement());
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
if (messageCount > 300) {
if (waitForCleanup) {
return;
}
skipsLeft = 3;
waitForCleanup = true;
window.setTimeout(() => {
const view = this.getMessageScrollerElement();
// noinspection JSUnusedGlobalSymbols
view.scrollTop = view.scrollHeight / 2;
}, 1);
}
else {
waitForCleanup = false;
}
const messages = this.getMessages();
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
if (!hasChanged) { if (!hasChanged) {
return; return;
@@ -79,24 +62,74 @@ class DISCORD {
} }
callback(messages); callback(messages);
}, 200); };
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer)); let debounceTimer;
/**
* Do not trigger the callback too often due to autoscrolling.
*/
const onMessageElementsChangedLater = function() {
window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
};
const observer = new MutationObserver(function () {
onMessageElementsChangedLater();
});
let skipsLeft = 0;
let observedElement = null;
const observerTimer = window.setInterval(() => {
if (skipsLeft > 0) {
--skipsLeft;
return;
}
const view = this.getMessageOuterElement();
if (!view) {
skipsLeft = 1;
return;
}
if (observedElement !== null && observedElement.isConnected) {
return;
}
observedElement = view.querySelector("[data-list-id='chat-messages']");
if (observedElement) {
console.debug("[DHT] Observed message container.");
observer.observe(observedElement, { childList: true });
onMessageElementsChangedLater();
}
}, 400);
window.DHT_ON_UNLOAD.push(() => {
observer.disconnect();
observedElement = null;
window.clearInterval(observerTimer);
});
} }
/** /**
* Returns the property object of a message element. * Returns the message from a message element.
* @returns { null | { message: DiscordMessage, channel: Object } } * @returns { null | DiscordMessage } }
*/ */
static getMessageElementProps(ele) { static getMessageFromElement(ele) {
const props = DOM.getReactProps(ele); const props = DOM.getReactProps(ele);
if (props.children && props.children.length) { if (props && Array.isArray(props.children)) {
for (let i = 3; i < props.children.length; i++) { for (const child of props.children) {
const childProps = props.children[i].props; if (!(child instanceof Object)) {
continue;
}
if (childProps && "message" in childProps && "channel" in childProps) { const childProps = child.props;
return childProps; if (childProps instanceof Object && "message" in childProps) {
return childProps.message;
} }
} }
} }
@@ -113,10 +146,10 @@ class DISCORD {
for (const ele of this.getMessageElements()) { for (const ele of this.getMessageElements()) {
try { try {
const props = this.getMessageElementProps(ele); const message = this.getMessageFromElement(ele);
if (props != null) { if (message != null) {
messages.push(props.message); messages.push(message);
} }
} catch (e) { } catch (e) {
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele)); console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
@@ -137,7 +170,7 @@ class DISCORD {
*/ */
static getSelectedChannel() { static getSelectedChannel() {
try { try {
let obj; let obj = null;
try { try {
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) { for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
@@ -148,15 +181,6 @@ class DISCORD {
} }
} catch (e) { } catch (e) {
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e); console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
for (const ele of this.getMessageElements()) {
const props = this.getMessageElementProps(ele);
if (props != null) {
obj = props.channel;
break;
}
}
} }
if (!obj || typeof obj.id !== "string") { if (!obj || typeof obj.id !== "string") {
@@ -185,8 +209,8 @@ class DISCORD {
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types // https://discord.com/developers/docs/resources/channel#channel-object-channel-types
switch (obj.type) { switch (obj.type) {
case 1: type = "DM"; break; case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
case 3: type = "GROUP"; break; case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
default: return null; default: return null;
} }
@@ -224,7 +248,7 @@ class DISCORD {
} }
}; };
if (obj.parent_id) { if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
channel["extra"]["parent"] = obj.parent_id; channel["extra"]["parent"] = obj.parent_id;
} }
else { else {

View File

@@ -86,12 +86,12 @@ const GUI = (function() {
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br> <label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<br> <br>
<label>After reaching the first message in channel...</label><br> <label>After reaching the first message in channel...</label><br>
${radio("afm", "nothing", "Do Nothing")} ${radio("afm", "nothing", "Continue Tracking")}
${radio("afm", "pause", "Pause Tracking")} ${radio("afm", "pause", "Pause Tracking")}
${radio("afm", "switch", "Switch to Next Channel")} ${radio("afm", "switch", "Switch to Next Channel")}
<br> <br>
<label>After reaching a previously saved message...</label><br> <label>After reaching a previously saved message...</label><br>
${radio("asm", "nothing", "Do Nothing")} ${radio("asm", "nothing", "Continue Tracking")}
${radio("asm", "pause", "Pause Tracking")} ${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "switch", "Switch to Next Channel")} ${radio("asm", "switch", "Switch to Next Channel")}
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`; <p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;

View File

@@ -48,6 +48,16 @@ const STATE = (function() {
}); });
}; };
const getDate = function(date) {
if (date instanceof Date) {
return date;
}
else {
// noinspection JSUnresolvedReference
return date.toDate();
}
};
const trackingStateChangedListeners = []; const trackingStateChangedListeners = [];
let isTracking = false; let isTracking = false;
@@ -69,8 +79,8 @@ const STATE = (function() {
* @property {String} channel_id * @property {String} channel_id
* @property {DiscordUser} author * @property {DiscordUser} author
* @property {String} content * @property {String} content
* @property {Timestamp} timestamp * @property {Date} timestamp
* @property {Timestamp|null} editedTimestamp * @property {Date|null} editedTimestamp
* @property {DiscordAttachment[]} attachments * @property {DiscordAttachment[]} attachments
* @property {Object[]} embeds * @property {Object[]} embeds
* @property {DiscordMessageReaction[]} [reactions] * @property {DiscordMessageReaction[]} [reactions]
@@ -106,11 +116,6 @@ const STATE = (function() {
* @property {Boolean} animated * @property {Boolean} animated
*/ */
/**
* @name Timestamp
* @property {Function} toDate
*/
return { return {
setup(port, token) { setup(port, token) {
serverPort = port; serverPort = port;
@@ -174,12 +179,10 @@ const STATE = (function() {
}, },
/** /**
* @param {String} channelId
* @param {DiscordMessage[]} discordMessageArray * @param {DiscordMessage[]} discordMessageArray
*/ */
async addDiscordMessages(channelId, discordMessageArray) { async addDiscordMessages(discordMessageArray) {
// https://discord.com/developers/docs/resources/channel#message-object-message-types discordMessageArray = discordMessageArray.filter(msg => (msg.type === DISCORD.MESSAGE_TYPE.DEFAULT || msg.type === DISCORD.MESSAGE_TYPE.REPLY || msg.type === DISCORD.MESSAGE_TYPE.THREAD_STARTER) && msg.state === "SENT");
discordMessageArray = discordMessageArray.filter(msg => (msg.type === 0 || msg.type === 19 || msg.type === 21) && msg.state === "SENT");
if (discordMessageArray.length === 0) { if (discordMessageArray.length === 0) {
return false; return false;
@@ -225,12 +228,12 @@ const STATE = (function() {
sender: msg.author.id, sender: msg.author.id,
channel: msg.channel_id, channel: msg.channel_id,
text: msg.content, text: msg.content,
timestamp: msg.timestamp.toDate().getTime() timestamp: getDate(msg.timestamp).getTime()
}; };
if (msg.editedTimestamp !== null) { if (msg.editedTimestamp !== null) {
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
obj.editTimestamp = msg.editedTimestamp.toDate().getTime(); obj.editTimestamp = getDate(msg.editedTimestamp).getTime();
} }
if (msg.messageReference !== null) { if (msg.messageReference !== null) {

View File

@@ -6,6 +6,7 @@
<script type="text/javascript"> <script type="text/javascript">
window.DHT_EMBEDDED = "/*[ARCHIVE]*/"; window.DHT_EMBEDDED = "/*[ARCHIVE]*/";
/*[CONSTANTS]*/
/*[JS]*/ /*[JS]*/
</script> </script>
<style> <style>

View File

@@ -1,7 +1,8 @@
const DISCORD = (function() { const DISCORD = (function() {
const regex = { const regex = {
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g, formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
formatItalic: /(.)?([_*])([\s\S]+?)\2(?!\2)/g, formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
formatItalic2: /_([\s\S]+?)_(?!_)\b/g,
formatUnderline: /__([\s\S]+?)__(?!_)/g, formatUnderline: /__([\s\S]+?)__(?!_)/g,
formatStrike: /~~([\s\S]+?)~~(?!~)/g, formatStrike: /~~([\s\S]+?)~~(?!~)/g,
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g, formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
@@ -34,6 +35,23 @@ const DISCORD = (function() {
let templateReaction; let templateReaction;
let templateReactionCustom; let templateReactionCustom;
const fileUrlProcessor = function(serverUrl, serverToken) {
if (typeof serverUrl === "string" && typeof serverToken === "string") {
return url => serverUrl + "/get-downloaded-file/" + encodeURIComponent(url) + "?token=" + encodeURIComponent(serverToken);
}
else {
return url => url;
}
}(
window["DHT_SERVER_URL"],
window["DHT_SERVER_TOKEN"]
);
const getEmoji = function(name, id, extension) {
const tag = ":" + name + ":";
return "<img src='" + fileUrlProcessor("https://cdn.discordapp.com/emojis/" + id + "." + extension) + "' alt='" + tag + "' title='" + tag + "' class='emoji'>";
};
const processMessageContents = function(contents) { const processMessageContents = function(contents) {
let processed = DOM.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1")); let processed = DOM.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1"));
@@ -48,33 +66,38 @@ const DISCORD = (function() {
.replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch)) .replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
.replace(regex.formatBold, "<b>$1</b>") .replace(regex.formatBold, "<b>$1</b>")
.replace(regex.formatUnderline, "<u>$1</u>") .replace(regex.formatUnderline, "<u>$1</u>")
.replace(regex.formatItalic, (full, pre, char, match) => pre === "\\" ? full : (pre || "") + "<i>" + match + "</i>") .replace(regex.formatItalic1, "<i>$1</i>")
.replace(regex.formatItalic2, "<i>$1</i>")
.replace(regex.formatStrike, "<s>$1</s>"); .replace(regex.formatStrike, "<s>$1</s>");
} }
const animatedEmojiExtension = SETTINGS.enableAnimatedEmoji ? "gif" : "png"; const animatedEmojiExtension = SETTINGS.enableAnimatedEmoji ? "gif" : "webp";
// noinspection HtmlUnknownTarget // noinspection HtmlUnknownTarget
processed = processed processed = processed
.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>") .replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + STATE.getChannelName(match) + "</span>") .replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + STATE.getChannelName(match) + "</span>")
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='#" + (STATE.getUserTag(match) || "????") + "'>@" + STATE.getUserName(match) + "</span>") .replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='#" + (STATE.getUserTag(match) || "????") + "'>@" + STATE.getUserName(match) + "</span>")
.replace(regex.customEmojiStatic, "<img src='https://cdn.discordapp.com/emojis/$2.png' alt=':$1:' title=':$1:' class='emoji'>") .replace(regex.customEmojiStatic, (full, m1, m2) => getEmoji(m1, m2, "webp"))
.replace(regex.customEmojiAnimated, "<img src='https://cdn.discordapp.com/emojis/$2." + animatedEmojiExtension + "' alt=':$1:' title=':$1:' class='emoji'>"); .replace(regex.customEmojiAnimated, (full, m1, m2) => getEmoji(m1, m2, animatedEmojiExtension));
return "<p>" + processed + "</p>"; return "<p>" + processed + "</p>";
}; };
const getAvatarUrlObject = function(avatar) {
return { url: fileUrlProcessor("https://cdn.discordapp.com/avatars/" + avatar.id + "/" + avatar.path + ".webp") };
};
const getImageEmbed = function(url, image) { const getImageEmbed = function(url, image) {
if (!SETTINGS.enableImagePreviews) { if (!SETTINGS.enableImagePreviews) {
return ""; return "";
} }
if (image.width && image.height) { if (image.width && image.height) {
return templateEmbedImageWithSize.apply({ url, src: image.url, width: image.width, height: image.height }); return templateEmbedImageWithSize.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url), width: image.width, height: image.height });
} }
else { else {
return templateEmbedImage.apply({ url, src: image.url }); return templateEmbedImage.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url) });
} }
}; };
@@ -123,8 +146,9 @@ const DISCORD = (function() {
"</div>" "</div>"
].join("")); ].join(""));
// noinspection HtmlUnknownTarget
templateUserAvatar = new TEMPLATE([ templateUserAvatar = new TEMPLATE([
"<img src='https://cdn.discordapp.com/avatars/{id}/{path}.webp?size=128' alt=''>" "<img src='{url}' alt=''>"
].join("")); ].join(""));
// noinspection HtmlUnknownTarget // noinspection HtmlUnknownTarget
@@ -165,8 +189,9 @@ const DISCORD = (function() {
"<span class='reaction-wrapper'><span class='reaction-emoji'>{n}</span><span class='count'>{c}</span></span>" "<span class='reaction-wrapper'><span class='reaction-emoji'>{n}</span><span class='count'>{c}</span></span>"
].join("")); ].join(""));
// noinspection HtmlUnknownTarget
templateReactionCustom = new TEMPLATE([ templateReactionCustom = new TEMPLATE([
"<span class='reaction-wrapper'><img src='https://cdn.discordapp.com/emojis/{id}.{ext}' alt=':{n}:' title=':{n}:' class='reaction-emoji-custom'><span class='count'>{c}</span></span>" "<span class='reaction-wrapper'><img src='{url}' alt=':{n}:' title=':{n}:' class='reaction-emoji-custom'><span class='count'>{c}</span></span>"
].join("")); ].join(""));
}, },
@@ -197,7 +222,7 @@ const DISCORD = (function() {
getMessageHTML(message) { // noinspection FunctionWithInconsistentReturnsJS getMessageHTML(message) { // noinspection FunctionWithInconsistentReturnsJS
return (SETTINGS.enableUserAvatars ? templateMessageWithAvatar : templateMessageNoAvatar).apply(message, (property, value) => { return (SETTINGS.enableUserAvatars ? templateMessageWithAvatar : templateMessageNoAvatar).apply(message, (property, value) => {
if (property === "avatar") { if (property === "avatar") {
return value ? templateUserAvatar.apply(value) : ""; return value ? templateUserAvatar.apply(getAvatarUrlObject(value)) : "";
} }
else if (property === "user.tag") { else if (property === "user.tag") {
return value ? value : "????"; return value ? value : "????";
@@ -218,10 +243,10 @@ const DISCORD = (function() {
return templateEmbedUnsupported.apply(embed); return templateEmbedUnsupported.apply(embed);
} }
else if ("image" in embed && embed.image.url) { else if ("image" in embed && embed.image.url) {
return getImageEmbed(embed.url, embed.image); return getImageEmbed(fileUrlProcessor(embed.url), embed.image);
} }
else if ("thumbnail" in embed && embed.thumbnail.url) { else if ("thumbnail" in embed && embed.thumbnail.url) {
return getImageEmbed(embed.url, embed.thumbnail); return getImageEmbed(fileUrlProcessor(embed.url), embed.thumbnail);
} }
else if ("title" in embed && "description" in embed) { else if ("title" in embed && "description" in embed) {
return templateEmbedRich.apply(embed); return templateEmbedRich.apply(embed);
@@ -240,14 +265,16 @@ const DISCORD = (function() {
} }
return value.map(attachment => { return value.map(attachment => {
const url = fileUrlProcessor(attachment.url);
if (!DISCORD.isImageAttachment(attachment) || !SETTINGS.enableImagePreviews) { if (!DISCORD.isImageAttachment(attachment) || !SETTINGS.enableImagePreviews) {
return templateAttachmentDownload.apply(attachment); return templateAttachmentDownload.apply({ url, name: attachment.name });
} }
else if ("width" in attachment && "height" in attachment) { else if ("width" in attachment && "height" in attachment) {
return templateEmbedImageWithSize.apply({ url: attachment.url, src: attachment.url, width: attachment.width, height: attachment.height }); return templateEmbedImageWithSize.apply({ url, src: url, width: attachment.width, height: attachment.height });
} }
else { else {
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url }); return templateEmbedImage.apply({ url, src: url });
} }
}).join(""); }).join("");
} }
@@ -263,7 +290,7 @@ const DISCORD = (function() {
} }
const user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>"; const user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>";
const avatar = SETTINGS.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(value.avatar) + "</span>" : ""; const avatar = SETTINGS.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(getAvatarUrlObject(value.avatar)) + "</span>" : "";
const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : ""; const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents; return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
@@ -275,9 +302,10 @@ const DISCORD = (function() {
return "<div class='reactions'>" + value.map(reaction => { return "<div class='reactions'>" + value.map(reaction => {
if ("id" in reaction){ if ("id" in reaction){
// noinspection JSUnusedGlobalSymbols, JSUnresolvedVariable const ext = reaction.a && SETTINGS.enableAnimatedEmoji ? "gif" : "webp";
reaction.ext = reaction.a && SETTINGS.enableAnimatedEmoji ? "gif" : "png"; const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
return templateReactionCustom.apply(reaction); // noinspection JSUnusedGlobalSymbols
return templateReactionCustom.apply({ url, n: reaction.n, c: reaction.c });
} }
else { else {
return templateReaction.apply(reaction); return templateReaction.apply(reaction);

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env python3
import glob
import os
import shutil
import sys
if os.name == "nt":
uglifyjs = os.path.abspath("../../lib/uglifyjs.cmd")
else:
uglifyjs = "uglifyjs"
if shutil.which(uglifyjs) is None:
print("Cannot find executable: {0}".format(uglifyjs))
sys.exit(1)
input_dir = os.path.abspath("./Tracker/scripts")
output_dir = os.path.abspath("../Desktop/bin/.res/scripts")
os.makedirs(output_dir, exist_ok=True)
for file in glob.glob(input_dir + "/*.js"):
name = os.path.basename(file)
print("Minifying {0}...".format(name))
os.system("{0} {1} -o {2}/{3}".format(uglifyjs, file, output_dir, name))
print("Done!")

View File

@@ -1,15 +1,19 @@
namespace DHT.Server.Data.Aggregations; namespace DHT.Server.Data.Aggregations;
public sealed class DownloadStatusStatistics { public sealed class DownloadStatusStatistics {
public int EnqueuedCount { get; internal set; } public int PendingCount { get; internal init; }
public ulong EnqueuedSize { get; internal set; } public ulong PendingTotalSize { get; internal init; }
public int PendingWithUnknownSizeCount { get; internal init; }
public int SuccessfulCount { get; internal set; } public int SuccessfulCount { get; internal init; }
public ulong SuccessfulSize { get; internal set; } public ulong SuccessfulTotalSize { get; internal init; }
public int SuccessfulWithUnknownSizeCount { get; internal init; }
public int FailedCount { get; internal set; } public int FailedCount { get; internal init; }
public ulong FailedSize { get; internal set; } public ulong FailedTotalSize { get; internal init; }
public int FailedWithUnknownSizeCount { get; internal init; }
public int SkippedCount { get; internal set; } public int SkippedCount { get; internal init; }
public ulong SkippedSize { get; internal set; } public ulong SkippedTotalSize { get; internal init; }
public int SkippedWithUnknownSizeCount { get; internal init; }
} }

View File

@@ -4,7 +4,8 @@ public readonly struct Attachment {
public ulong Id { get; internal init; } public ulong Id { get; internal init; }
public string Name { get; internal init; } public string Name { get; internal init; }
public string? Type { get; internal init; } public string? Type { get; internal init; }
public string Url { get; internal init; } public string NormalizedUrl { get; internal init; }
public string DownloadUrl { get; internal init; }
public ulong Size { get; internal init; } public ulong Size { get; internal init; }
public int? Width { get; internal init; } public int? Width { get; internal init; }
public int? Height { get; internal init; } public int? Height { get; internal init; }

View File

@@ -1,30 +1,17 @@
using System;
using System.Net;
namespace DHT.Server.Data; namespace DHT.Server.Data;
public readonly struct Download { public sealed class Download {
internal static Download NewSuccess(string url, byte[] data) { public string NormalizedUrl { get; }
return new Download(url, DownloadStatus.Success, (ulong) Math.Max(data.LongLength, 0), data); public string DownloadUrl { get; }
}
internal static Download NewFailure(string url, HttpStatusCode? statusCode, ulong size) {
return new Download(url, statusCode.HasValue ? (DownloadStatus) (int) statusCode : DownloadStatus.GenericError, size);
}
public string Url { get; }
public DownloadStatus Status { get; } public DownloadStatus Status { get; }
public ulong Size { get; } public string? Type { get; }
public byte[]? Data { get; } public ulong? Size { get; }
internal Download(string url, DownloadStatus status, ulong size, byte[]? data = null) { internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) {
Url = url; NormalizedUrl = normalizedUrl;
DownloadUrl = downloadUrl;
Status = status; Status = status;
Type = type;
Size = size; Size = size;
Data = data;
}
internal Download WithData(byte[] data) {
return new Download(Url, Status, Size, data);
} }
} }

View File

@@ -6,7 +6,9 @@ namespace DHT.Server.Data;
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99. /// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
/// </summary> /// </summary>
public enum DownloadStatus { public enum DownloadStatus {
Enqueued = 0, Pending = 0,
GenericError = 1, GenericError = 1,
Downloading = 2,
LastCustomCode = 99,
Success = HttpStatusCode.OK Success = HttpStatusCode.OK
} }

View File

@@ -1,6 +0,0 @@
namespace DHT.Server.Data;
public readonly struct DownloadedAttachment {
public string? Type { get; internal init; }
public byte[] Data { get; internal init; }
}

View File

@@ -0,0 +1,17 @@
namespace DHT.Server.Data.Embeds;
sealed class DiscordEmbedJson {
public string? Type { get; set; }
public string? Url { get; set; }
public JsonImage? Image { get; set; }
public JsonImage? Thumbnail { get; set; }
public JsonImage? Video { get; set; }
public sealed class JsonImage {
public string? Url { get; set; }
public string? ProxyUrl { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
}
}

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace DHT.Server.Data.Embeds;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(DiscordEmbedJson))]
sealed partial class DiscordEmbedJsonContext : JsonSerializerContext;

View File

@@ -1,15 +0,0 @@
namespace DHT.Server.Data.Filters;
public sealed class AttachmentFilter {
public ulong? MaxBytes { get; set; } = null;
public DownloadItemRules? DownloadItemRule { get; set; } = null;
public bool IsEmpty => MaxBytes == null &&
DownloadItemRule == null;
public enum DownloadItemRules {
OnlyNotPresent,
OnlyPresent
}
}

View File

@@ -3,8 +3,10 @@ using System.Collections.Generic;
namespace DHT.Server.Data.Filters; namespace DHT.Server.Data.Filters;
public sealed class DownloadItemFilter { public sealed class DownloadItemFilter {
public HashSet<DownloadStatus>? IncludeStatuses { get; init; } = null; public HashSet<DownloadStatus>? IncludeStatuses { get; set; } = null;
public HashSet<DownloadStatus>? ExcludeStatuses { get; init; } = null; public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null; public ulong? MaxBytes { get; set; } = null;
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null && MaxBytes == null;
} }

View File

@@ -10,7 +10,7 @@ public readonly struct Message {
public long Timestamp { get; init; } public long Timestamp { get; init; }
public long? EditTimestamp { get; init; } public long? EditTimestamp { get; init; }
public ulong? RepliedToId { get; init; } public ulong? RepliedToId { get; init; }
public ImmutableArray<Attachment> Attachments { get; init; } public ImmutableList<Attachment> Attachments { get; init; }
public ImmutableArray<Embed> Embeds { get; init; } public ImmutableList<Embed> Embeds { get; init; }
public ImmutableArray<Reaction> Reactions { get; init; } public ImmutableList<Reaction> Reactions { get; init; }
} }

View File

@@ -1,29 +1,34 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
namespace DHT.Server.Database; namespace DHT.Server.Database;
public static class DatabaseExtensions { public static class DatabaseExtensions {
public static void AddFrom(this IDatabaseFile target, IDatabaseFile source) { public static async Task AddFrom(this IDatabaseFile target, IDatabaseFile source) {
target.AddServers(source.GetAllServers()); await target.Users.Add(await source.Users.Get().ToListAsync());
target.AddChannels(source.GetAllChannels()); await target.Servers.Add(await source.Servers.Get().ToListAsync());
target.AddUsers(source.GetAllUsers().ToArray()); await target.Channels.Add(await source.Channels.Get().ToListAsync());
target.AddMessages(source.GetMessages().ToArray());
foreach (var download in source.GetDownloadsWithoutData()) { const int MessageBatchSize = 100;
target.AddDownload(download.Status == DownloadStatus.Success ? source.GetDownloadWithData(download) : download); List<Message> batchedMessages = new (MessageBatchSize);
await foreach (var message in source.Messages.Get()) {
batchedMessages.Add(message);
if (batchedMessages.Count >= MessageBatchSize) {
await target.Messages.Add(batchedMessages);
batchedMessages.Clear();
}
} }
}
internal static void AddServers(this IDatabaseFile target, IEnumerable<Data.Server> servers) { await target.Messages.Add(batchedMessages);
foreach (var server in servers) {
target.AddServer(server);
}
}
internal static void AddChannels(this IDatabaseFile target, IEnumerable<Channel> channels) { await foreach (var download in source.Downloads.Get()) {
foreach (var channel in channels) { if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) {
target.AddChannel(channel); await target.Downloads.AddDownload(download, stream: null);
}
} }
} }
} }

View File

@@ -1,46 +0,0 @@
using DHT.Utils.Models;
namespace DHT.Server.Database;
/// <summary>
/// A live view of database statistics.
/// Some of the totals are computed asynchronously and may not reflect the most recent version of the database, or may not be available at all until computed for the first time.
/// </summary>
public sealed class DatabaseStatistics : BaseModel {
private long totalServers;
private long totalChannels;
private long totalUsers;
private long? totalMessages;
private long? totalAttachments;
private long? totalDownloads;
public long TotalServers {
get => totalServers;
internal set => Change(ref totalServers, value);
}
public long TotalChannels {
get => totalChannels;
internal set => Change(ref totalChannels, value);
}
public long TotalUsers {
get => totalUsers;
internal set => Change(ref totalUsers, value);
}
public long? TotalMessages {
get => totalMessages;
internal set => Change(ref totalMessages, value);
}
public long? TotalAttachments {
get => totalAttachments;
internal set => Change(ref totalAttachments, value);
}
public long? TotalDownloads {
get => totalDownloads;
internal set => Change(ref totalDownloads, value);
}
}

View File

@@ -1,11 +0,0 @@
namespace DHT.Server.Database;
/// <summary>
/// A complete snapshot of database statistics at a particular point in time.
/// </summary>
public readonly struct DatabaseStatisticsSnapshot {
public long TotalServers { get; internal init; }
public long TotalChannels { get; internal init; }
public long TotalUsers { get; internal init; }
public long TotalMessages { get; internal init; }
}

View File

@@ -1,90 +1,28 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using DHT.Server.Data; using System.Threading.Tasks;
using DHT.Server.Data.Aggregations; using DHT.Server.Database.Repositories;
using DHT.Server.Data.Filters;
using DHT.Server.Download;
namespace DHT.Server.Database; namespace DHT.Server.Database;
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")] [SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
public sealed class DummyDatabaseFile : IDatabaseFile { sealed class DummyDatabaseFile : IDatabaseFile {
public static DummyDatabaseFile Instance { get; } = new(); public static DummyDatabaseFile Instance { get; } = new ();
public string Path => ""; public string Path => "";
public DatabaseStatistics Statistics { get; } = new();
public IUserRepository Users { get; } = new IUserRepository.Dummy();
public IServerRepository Servers { get; } = new IServerRepository.Dummy();
public IChannelRepository Channels { get; } = new IChannelRepository.Dummy();
public IMessageRepository Messages { get; } = new IMessageRepository.Dummy();
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
private DummyDatabaseFile() {} private DummyDatabaseFile() {}
public DatabaseStatisticsSnapshot SnapshotStatistics() { public Task Vacuum() {
return new(); return Task.CompletedTask;
} }
public void AddServer(Data.Server server) {} public ValueTask DisposeAsync() {
return ValueTask.CompletedTask;
public List<Data.Server> GetAllServers() {
return new();
} }
public void AddChannel(Channel channel) {}
public List<Channel> GetAllChannels() {
return new();
}
public void AddUsers(User[] users) {}
public List<User> GetAllUsers() {
return new();
}
public void AddMessages(Message[] messages) {}
public int CountMessages(MessageFilter? filter = null) {
return 0;
}
public List<Message> GetMessages(MessageFilter? filter = null) {
return new();
}
public HashSet<ulong> GetMessageIds(MessageFilter? filter = null) {
return new();
}
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {}
public int CountAttachments(AttachmentFilter? filter = null) {
return new();
}
public List<Data.Download> GetDownloadsWithoutData() {
return new();
}
public Data.Download GetDownloadWithData(Data.Download download) {
return download;
}
public DownloadedAttachment? GetDownloadedAttachment(string url) {
return null;
}
public void AddDownload(Data.Download download) {}
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {}
public List<DownloadItem> GetEnqueuedDownloadItems(int count) {
return new();
}
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {}
public DownloadStatusStatistics GetDownloadStatusStatistics() {
return new();
}
public void Vacuum() {}
public void Dispose() {}
} }

View File

@@ -5,9 +5,9 @@ namespace DHT.Server.Database.Exceptions;
public sealed class DatabaseTooNewException : Exception { public sealed class DatabaseTooNewException : Exception {
public int DatabaseVersion { get; } public int DatabaseVersion { get; }
public int CurrentVersion => Schema.Version; public int CurrentVersion => SqliteSchema.Version;
internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + Schema.Version) { internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + SqliteSchema.Version) {
this.DatabaseVersion = databaseVersion; this.DatabaseVersion = databaseVersion;
} }
} }

View File

@@ -0,0 +1,3 @@
namespace DHT.Server.Database.Export;
readonly record struct Snowflake(ulong Id);

View File

@@ -0,0 +1,23 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
sealed class SnowflakeJsonSerializer : JsonConverter<Snowflake> {
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return new Snowflake(ulong.Parse(reader.GetString()!));
}
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
writer.WriteStringValue(value.Id.ToString());
}
public override Snowflake ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return new Snowflake(ulong.Parse(reader.GetString()!));
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
writer.WritePropertyName(value.Id.ToString());
}
}

View File

@@ -1,7 +0,0 @@
using DHT.Server.Data;
namespace DHT.Server.Database.Export.Strategy;
public interface IViewerExportStrategy {
string GetAttachmentUrl(Attachment attachment);
}

View File

@@ -1,18 +0,0 @@
using System.Net;
using DHT.Server.Data;
namespace DHT.Server.Database.Export.Strategy;
public sealed class LiveViewerExportStrategy : IViewerExportStrategy {
private readonly string safePort;
private readonly string safeToken;
public LiveViewerExportStrategy(ushort port, string token) {
this.safePort = port.ToString();
this.safeToken = WebUtility.UrlEncode(token);
}
public string GetAttachmentUrl(Attachment attachment) {
return "http://127.0.0.1:" + safePort + "/get-attachment/" + WebUtility.UrlEncode(attachment.Url) + "?token=" + safeToken;
}
}

View File

@@ -1,13 +0,0 @@
using DHT.Server.Data;
namespace DHT.Server.Database.Export.Strategy;
public sealed class StandaloneViewerExportStrategy : IViewerExportStrategy {
public static StandaloneViewerExportStrategy Instance { get; } = new ();
private StandaloneViewerExportStrategy() {}
public string GetAttachmentUrl(Attachment attachment) {
return attachment.Url;
}
}

View File

@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
sealed class ViewerJson {
public required JsonMeta Meta { get; init; }
public required Dictionary<Snowflake, Dictionary<Snowflake, JsonMessage>> Data { get; init; }
public sealed class JsonMeta {
public required Dictionary<Snowflake, JsonUser> Users { get; init; }
public required List<Snowflake> Userindex { get; init; }
public required List<JsonServer> Servers { get; init; }
public required Dictionary<Snowflake, JsonChannel> Channels { get; init; }
}
public sealed class JsonUser {
public required string Name { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Avatar { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Tag { get; init; }
}
public sealed class JsonServer {
public required string Name { get; init; }
public required string Type { get; init; }
}
public sealed class JsonChannel {
public required int Server { get; init; }
public required string Name { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Parent { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Position { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Topic { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Nsfw { get; init; }
}
public sealed class JsonMessage {
public required int U { get; init; }
public required long T { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? M { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Te { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? R { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonMessageAttachment[]? A { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[]? E { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonMessageReaction[]? Re { get; init; }
}
public sealed class JsonMessageAttachment {
public required string Url { get; init; }
public required string Name { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Width { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Height { get; set; }
}
public sealed class JsonMessageReaction {
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? N { get; init; }
public required bool A { get; init; }
public required int C { get; init; }
}
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
[JsonSourceGenerationOptions(
Converters = [typeof(SnowflakeJsonSerializer)],
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
GenerationMode = JsonSourceGenerationMode.Default
)]
[JsonSerializable(typeof(ViewerJson))]
sealed partial class ViewerJsonContext : JsonSerializerContext;

View File

@@ -6,7 +6,6 @@ using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Server.Database.Export; namespace DHT.Server.Database.Export;
@@ -14,14 +13,14 @@ namespace DHT.Server.Database.Export;
public static class ViewerJsonExport { public static class ViewerJsonExport {
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport)); private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
public static async Task Generate(Stream stream, IViewerExportStrategy strategy, IDatabaseFile db, MessageFilter? filter = null) { public static async Task Generate(Stream stream, IDatabaseFile db, MessageFilter? filter = null) {
var perf = Log.Start(); var perf = Log.Start();
var includedUserIds = new HashSet<ulong>(); var includedUserIds = new HashSet<ulong>();
var includedChannelIds = new HashSet<ulong>(); var includedChannelIds = new HashSet<ulong>();
var includedServerIds = new HashSet<ulong>(); var includedServerIds = new HashSet<ulong>();
var includedMessages = db.GetMessages(filter); var includedMessages = await db.Messages.Get(filter).ToListAsync();
var includedChannels = new List<Channel>(); var includedChannels = new List<Channel>();
foreach (var message in includedMessages) { foreach (var message in includedMessages) {
@@ -29,185 +28,145 @@ public static class ViewerJsonExport {
includedChannelIds.Add(message.Channel); includedChannelIds.Add(message.Channel);
} }
foreach (var channel in db.GetAllChannels()) { await foreach (var channel in db.Channels.Get()) {
if (includedChannelIds.Contains(channel.Id)) { if (includedChannelIds.Contains(channel.Id)) {
includedChannels.Add(channel); includedChannels.Add(channel);
includedServerIds.Add(channel.Server); includedServerIds.Add(channel.Server);
} }
} }
var users = GenerateUserList(db, includedUserIds, out var userindex, out var userIndices); var (users, userIndex, userIndices) = await GenerateUserList(db, includedUserIds);
var servers = GenerateServerList(db, includedServerIds, out var serverindex); var (servers, serverIndices) = await GenerateServerList(db, includedServerIds);
var channels = GenerateChannelList(includedChannels, serverindex); var channels = GenerateChannelList(includedChannels, serverIndices);
perf.Step("Collect database data"); perf.Step("Collect database data");
var value = new { var value = new ViewerJson {
meta = new { users, userindex, servers, channels }, Meta = new ViewerJson.JsonMeta {
data = GenerateMessageList(includedMessages, userIndices, strategy), Users = users,
Userindex = userIndex,
Servers = servers,
Channels = channels
},
Data = GenerateMessageList(includedMessages, userIndices)
}; };
perf.Step("Generate value object"); perf.Step("Generate value object");
var opts = new JsonSerializerOptions(); await JsonSerializer.SerializeAsync(stream, value, ViewerJsonContext.Default.ViewerJson);
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
await JsonSerializer.SerializeAsync(stream, value, opts);
perf.Step("Serialize to JSON"); perf.Step("Serialize to JSON");
perf.End(); perf.End();
} }
private static Dictionary<string, object> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) { private static async Task<(Dictionary<Snowflake, ViewerJson.JsonUser> Users, List<Snowflake> UserIndex, Dictionary<ulong, int> UserIndices)> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds) {
var users = new Dictionary<string, object>(); var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
userindex = new List<string>(); var userIndex = new List<Snowflake>();
userIndices = new Dictionary<ulong, object>(); var userIndices = new Dictionary<ulong, int>();
foreach (var user in db.GetAllUsers()) { await foreach (var user in db.Users.Get()) {
var id = user.Id; var id = user.Id;
if (!userIds.Contains(id)) { if (!userIds.Contains(id)) {
continue; continue;
} }
var obj = new Dictionary<string, object> { var idSnowflake = new Snowflake(id);
["name"] = user.Name
};
if (user.AvatarUrl != null) {
obj["avatar"] = user.AvatarUrl;
}
if (user.Discriminator != null) {
obj["tag"] = user.Discriminator;
}
var idStr = id.ToString();
userIndices[id] = users.Count; userIndices[id] = users.Count;
userindex.Add(idStr); userIndex.Add(idSnowflake);
users[idStr] = obj;
users[idSnowflake] = new ViewerJson.JsonUser {
Name = user.Name,
Avatar = user.AvatarUrl,
Tag = user.Discriminator
};
} }
return users; return (users, userIndex, userIndices);
} }
private static List<object> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, object> serverIndices) { private static async Task<(List<ViewerJson.JsonServer> Servers, Dictionary<ulong, int> ServerIndices)> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds) {
var servers = new List<object>(); var servers = new List<ViewerJson.JsonServer>();
serverIndices = new Dictionary<ulong, object>(); var serverIndices = new Dictionary<ulong, int>();
foreach (var server in db.GetAllServers()) { await foreach (var server in db.Servers.Get()) {
var id = server.Id; var id = server.Id;
if (!serverIds.Contains(id)) { if (!serverIds.Contains(id)) {
continue; continue;
} }
serverIndices[id] = servers.Count; serverIndices[id] = servers.Count;
servers.Add(new Dictionary<string, object> {
["name"] = server.Name, servers.Add(new ViewerJson.JsonServer {
["type"] = ServerTypes.ToJsonViewerString(server.Type), Name = server.Name,
Type = ServerTypes.ToJsonViewerString(server.Type)
}); });
} }
return servers; return (servers, serverIndices);
} }
private static Dictionary<string, object> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, object> serverIndices) { private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
var channels = new Dictionary<string, object>(); var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
foreach (var channel in includedChannels) { foreach (var channel in includedChannels) {
var obj = new Dictionary<string, object> { var channelIdSnowflake = new Snowflake(channel.Id);
["server"] = serverIndices[channel.Server],
["name"] = channel.Name, channels[channelIdSnowflake] = new ViewerJson.JsonChannel {
Server = serverIndices[channel.Server],
Name = channel.Name,
Parent = channel.ParentId?.ToString(),
Position = channel.Position,
Topic = channel.Topic,
Nsfw = channel.Nsfw
}; };
if (channel.ParentId != null) {
obj["parent"] = channel.ParentId;
}
if (channel.Position != null) {
obj["position"] = channel.Position;
}
if (channel.Topic != null) {
obj["topic"] = channel.Topic;
}
if (channel.Nsfw != null) {
obj["nsfw"] = channel.Nsfw;
}
channels[channel.Id.ToString()] = obj;
} }
return channels; return channels;
} }
private static Dictionary<string, Dictionary<string, object>> GenerateMessageList( List<Message> includedMessages, Dictionary<ulong, object> userIndices, IViewerExportStrategy strategy) { private static Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>> GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices) {
var data = new Dictionary<string, Dictionary<string, object>>(); var data = new Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>>();
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) { foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
var channel = grouping.Key.ToString(); var channelIdSnowflake = new Snowflake(grouping.Key);
var channelData = new Dictionary<string, object>(); var channelData = new Dictionary<Snowflake, ViewerJson.JsonMessage>();
foreach (var message in grouping) { foreach (var message in grouping) {
var obj = new Dictionary<string, object> { var messageIdSnowflake = new Snowflake(message.Id);
["u"] = userIndices[message.Sender],
["t"] = message.Timestamp,
};
if (!string.IsNullOrEmpty(message.Text)) { channelData[messageIdSnowflake] = new ViewerJson.JsonMessage {
obj["m"] = message.Text; U = userIndices[message.Sender],
} T = message.Timestamp,
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
Te = message.EditTimestamp,
R = message.RepliedToId?.ToString(),
if (message.EditTimestamp != null) { A = message.Attachments.IsEmpty ? null : message.Attachments.Select(static attachment => {
obj["te"] = message.EditTimestamp; var a = new ViewerJson.JsonMessageAttachment {
} Url = attachment.DownloadUrl,
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl
if (message.RepliedToId != null) {
obj["r"] = message.RepliedToId.Value;
}
if (!message.Attachments.IsEmpty) {
obj["a"] = message.Attachments.Select(attachment => {
var a = new Dictionary<string, object> {
{ "url", strategy.GetAttachmentUrl(attachment) },
{ "name", Uri.TryCreate(attachment.Url, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.Url },
}; };
if (attachment is { Width: not null, Height: not null }) { if (attachment is { Width: not null, Height: not null }) {
a["width"] = attachment.Width; a.Width = attachment.Width;
a["height"] = attachment.Height; a.Height = attachment.Height;
} }
return a; return a;
}).ToArray(); }).ToArray(),
}
if (!message.Embeds.IsEmpty) { E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
obj["e"] = message.Embeds.Select(static embed => embed.Json).ToArray();
}
if (!message.Reactions.IsEmpty) { Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
obj["re"] = message.Reactions.Select(static reaction => { Id = reaction.EmojiId?.ToString(),
var r = new Dictionary<string, object>(); N = reaction.EmojiName,
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
if (reaction.EmojiId != null) { C = reaction.Count
r["id"] = reaction.EmojiId.Value; }).ToArray()
} };
if (reaction.EmojiName != null) {
r["n"] = reaction.EmojiName;
}
r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
r["c"] = reaction.Count;
return r;
}).ToArray();
}
channelData[message.Id.ToString()] = obj;
} }
data[channel] = channelData; data[channelIdSnowflake] = channelData;
} }
return data; return data;

View File

@@ -1,15 +0,0 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
sealed class ViewerJsonSnowflakeSerializer : JsonConverter<ulong> {
public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return ulong.Parse(reader.GetString()!);
}
public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) {
writer.WriteStringValue(value.ToString());
}
}

View File

@@ -1,43 +1,17 @@
using System; using System;
using System.Collections.Generic; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Database.Repositories;
using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters;
using DHT.Server.Download;
namespace DHT.Server.Database; namespace DHT.Server.Database;
public interface IDatabaseFile : IDisposable { public interface IDatabaseFile : IAsyncDisposable {
string Path { get; } string Path { get; }
DatabaseStatistics Statistics { get; }
DatabaseStatisticsSnapshot SnapshotStatistics();
void AddServer(Data.Server server); IUserRepository Users { get; }
List<Data.Server> GetAllServers(); IServerRepository Servers { get; }
IChannelRepository Channels { get; }
IMessageRepository Messages { get; }
IDownloadRepository Downloads { get; }
void AddChannel(Channel channel); Task Vacuum();
List<Channel> GetAllChannels();
void AddUsers(User[] users);
List<User> GetAllUsers();
void AddMessages(Message[] messages);
int CountMessages(MessageFilter? filter = null);
List<Message> GetMessages(MessageFilter? filter = null);
HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
int CountAttachments(AttachmentFilter? filter = null);
void AddDownload(Data.Download download);
List<Data.Download> GetDownloadsWithoutData();
Data.Download GetDownloadWithData(Data.Download download);
DownloadedAttachment? GetDownloadedAttachment(string url);
void EnqueueDownloadItems(AttachmentFilter? filter = null);
List<DownloadItem> GetEnqueuedDownloadItems(int count);
void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
DownloadStatusStatistics GetDownloadStatusStatistics();
void Vacuum();
} }

View File

@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Import;
sealed class DiscordEmbedLegacyJson {
public required string Url { get; init; }
public required string Type { get; init; }
public bool DhtLegacy { get; } = true;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Title { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImageJson? Image { get; init; }
public sealed class ImageJson {
public required string Url { get; init; }
}
}

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Import;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(DiscordEmbedLegacyJson))]
sealed partial class DiscordEmbedLegacyJsonContext : JsonSerializerContext;

View File

@@ -21,7 +21,7 @@ public static class LegacyArchiveImport {
public static async Task<bool> Read(Stream stream, IDatabaseFile db, FakeSnowflake fakeSnowflake, Func<Data.Server[], Task<Dictionary<Data.Server, ulong>?>> askForServerIds) { public static async Task<bool> Read(Stream stream, IDatabaseFile db, FakeSnowflake fakeSnowflake, Func<Data.Server[], Task<Dictionary<Data.Server, ulong>?>> askForServerIds) {
var perf = Log.Start(); var perf = Log.Start();
var root = await JsonSerializer.DeserializeAsync<JsonElement>(stream); var root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
try { try {
var meta = root.RequireObject("meta"); var meta = root.RequireObject("meta");
@@ -33,10 +33,8 @@ public static class LegacyArchiveImport {
var servers = ReadServerList(meta, fakeSnowflake); var servers = ReadServerList(meta, fakeSnowflake);
var newServersOnly = new HashSet<Data.Server>(servers); var newServersOnly = new HashSet<Data.Server>(servers);
var oldServersById = db.GetAllServers().ToDictionary(static server => server.Id, static server => server); var oldServersById = await db.Servers.Get().ToDictionaryAsync(static server => server.Id, static server => server);
var oldChannelsById = await db.Channels.Get().ToDictionaryAsync(static channel => channel.Id, static channel => channel);
var oldChannels = db.GetAllChannels();
var oldChannelsById = oldChannels.ToDictionary(static channel => channel.Id, static channel => channel);
foreach (var (channelId, serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) { foreach (var (channelId, serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) {
if (oldChannelsById.TryGetValue(channelId, out var oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out var oldServer) && newServersOnly.Remove(servers[serverIndex])) { if (oldChannelsById.TryGetValue(channelId, out var oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out var oldServer) && newServersOnly.Remove(servers[serverIndex])) {
@@ -66,17 +64,17 @@ public static class LegacyArchiveImport {
perf.Step("Read channel list"); perf.Step("Read channel list");
var oldMessageIds = db.GetMessageIds(); var oldMessageIds = await db.Messages.GetIds().ToHashSetAsync();
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake)) var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
.Where(message => !oldMessageIds.Contains(message.Id)) .Where(message => !oldMessageIds.Contains(message.Id))
.ToArray(); .ToArray();
perf.Step("Read messages"); perf.Step("Read messages");
db.AddUsers(users); await db.Users.Add(users);
db.AddServers(servers); await db.Servers.Add(servers);
db.AddChannels(channels); await db.Channels.Add(channels);
db.AddMessages(newMessages); await db.Messages.Add(newMessages);
perf.Step("Import into database"); perf.Step("Import into database");
} catch (HttpException e) { } catch (HttpException e) {
@@ -163,7 +161,7 @@ public static class LegacyArchiveImport {
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null; var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
if (messagesObj == null) { if (messagesObj == null) {
return Array.Empty<Message>(); return [];
} }
return messagesObj.Value.EnumerateObject().Select(item => { return messagesObj.Value.EnumerateObject().Select(item => {
@@ -179,9 +177,9 @@ public static class LegacyArchiveImport {
Timestamp = messageObj.RequireLong("t", path), Timestamp = messageObj.RequireLong("t", path),
EditTimestamp = messageObj.HasKey("te") ? messageObj.RequireLong("te", path) : null, EditTimestamp = messageObj.HasKey("te") ? messageObj.RequireLong("te", path) : null,
RepliedToId = messageObj.HasKey("r") ? messageObj.RequireSnowflake("r", path) : null, RepliedToId = messageObj.HasKey("r") ? messageObj.RequireSnowflake("r", path) : null,
Attachments = messageObj.HasKey("a") ? ReadMessageAttachments(messageObj.RequireArray("a", path), fakeSnowflake, path + ".a[]").ToImmutableArray() : ImmutableArray<Attachment>.Empty, Attachments = messageObj.HasKey("a") ? ReadMessageAttachments(messageObj.RequireArray("a", path), fakeSnowflake, path + ".a[]").ToImmutableList() : ImmutableList<Attachment>.Empty,
Embeds = messageObj.HasKey("e") ? ReadMessageEmbeds(messageObj.RequireArray("e", path), path + ".e[]").ToImmutableArray() : ImmutableArray<Embed>.Empty, Embeds = messageObj.HasKey("e") ? ReadMessageEmbeds(messageObj.RequireArray("e", path), path + ".e[]").ToImmutableList() : ImmutableList<Embed>.Empty,
Reactions = messageObj.HasKey("re") ? ReadMessageReactions(messageObj.RequireArray("re", path), path + ".re[]").ToImmutableArray() : ImmutableArray<Reaction>.Empty, Reactions = messageObj.HasKey("re") ? ReadMessageReactions(messageObj.RequireArray("re", path), path + ".re[]").ToImmutableList() : ImmutableList<Reaction>.Empty,
}; };
}).ToArray(); }).ToArray();
} }
@@ -197,7 +195,8 @@ public static class LegacyArchiveImport {
Id = fakeSnowflake.Next(), Id = fakeSnowflake.Next(),
Name = name, Name = name,
Type = type, Type = type,
Url = url, NormalizedUrl = url,
DownloadUrl = url,
Size = 0, // unknown size Size = 0, // unknown size
}; };
}).DistinctByKeyStable(static attachment => { }).DistinctByKeyStable(static attachment => {
@@ -212,29 +211,16 @@ public static class LegacyArchiveImport {
string url = embedObj.RequireString("url", path); string url = embedObj.RequireString("url", path);
string type = embedObj.RequireString("type", path); string type = embedObj.RequireString("type", path);
var embedJson = new Dictionary<string, object> { var embed = new DiscordEmbedLegacyJson {
{ "url", url }, Url = url,
{ "type", type }, Type = type,
{ "dht_legacy", true }, Title = type == "rich" && embedObj.HasKey("t") ? embedObj.RequireString("t", path) : null,
Description = type == "rich" && embedObj.HasKey("d") ? embedObj.RequireString("d", path) : null,
Image = type == "image" ? new DiscordEmbedLegacyJson.ImageJson { Url = url } : null
}; };
if (type == "image") {
embedJson["image"] = new Dictionary<string, string> {
{ "url", url }
};
}
else if (type == "rich") {
if (embedObj.HasKey("t")) {
embedJson["title"] = embedObj.RequireString("t", path);
}
if (embedObj.HasKey("d")) {
embedJson["description"] = embedObj.RequireString("d", path);
}
}
return new Embed { return new Embed {
Json = JsonSerializer.Serialize(embedJson) Json = JsonSerializer.Serialize(embed, DiscordEmbedLegacyJsonContext.Default.DiscordEmbedLegacyJson)
}; };
}); });
} }

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using DHT.Server.Data;
namespace DHT.Server.Database.Repositories;
public interface IChannelRepository {
IObservable<long> TotalCount { get; }
Task Add(IReadOnlyList<Channel> channels);
Task<long> Count(CancellationToken cancellationToken = default);
IAsyncEnumerable<Channel> Get();
internal sealed class Dummy : IChannelRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<Channel> channels) {
return Task.CompletedTask;
}
public Task<long> Count(CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public IAsyncEnumerable<Channel> Get() {
return AsyncEnumerable.Empty<Channel>();
}
}
}

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