mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-18 13:31:42 +02:00
Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
50d5bdba97
|
|||
295c32c7b4
|
|||
5e4bb5b44d
|
|||
96c19afa66
|
|||
a45f60b528
|
|||
62b97807b8
|
|||
0fff3e8eaf
|
|||
02b773db8f
|
|||
261a583b50
|
|||
2ecb658e42
|
|||
7a6dd23912
|
|||
60761d80ab
|
|||
e11db62015
|
|||
bfac9b91d9
|
|||
4215dc76f4
|
|||
86adda610b
|
|||
f1c4cd040e
|
|||
737ab43ea4
|
|||
0229c00259
|
|||
95361d2e01
|
|||
5b7312109b
|
|||
9f1db9a662
|
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,3 +1 @@
|
|||||||
github: chylex
|
|
||||||
patreon: chylex
|
|
||||||
ko_fi: chylex
|
ko_fi: chylex
|
||||||
|
18
README.md
18
README.md
@@ -18,7 +18,7 @@ Folder organization:
|
|||||||
* `app/` contains a Visual Studio solution for the desktop app
|
* `app/` contains a Visual Studio solution for the desktop app
|
||||||
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
|
||||||
|
|
||||||
To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
To start editing source code for the desktop app, install the [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
@@ -28,18 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
|
|||||||
|
|
||||||
#### Release – Windows (64-bit)
|
#### Release – Windows (64-bit)
|
||||||
|
|
||||||
1. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
|
1. Install Debian in WSL and open a terminal in the project folder.
|
||||||
|
2. Run the `app/build.wsl.sh` script.
|
||||||
|
3. Read the [Distribution](#distribution) section below.
|
||||||
|
|
||||||
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
|
Note: The build script expects `dotnet.exe` to be installed in `C:\Program Files\dotnet`.
|
||||||
|
|
||||||
#### Release – Other Operating Systems
|
#### Release – Other Operating Systems
|
||||||
|
|
||||||
1. Install the `zip` package from your repository
|
1. Install the `zip` package from your repository.
|
||||||
|
2. Run the `app/build.sh` script.
|
||||||
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
|
3. Read the [Distribution](#distribution) section below.
|
||||||
|
|
||||||
#### Distribution
|
#### Distribution
|
||||||
|
|
||||||
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 8 to be installed.
|
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires the ASP.NET Core Runtime to be installed.
|
||||||
|
|
||||||
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking the executable. Since .NET 8 fixed several issues with publishing Windows executables on Linux, I recommend using Linux to build the app for all operating systems.
|
|
||||||
|
@@ -53,7 +53,7 @@
|
|||||||
<Setter Property="MinHeight" Value="40" />
|
<Setter Property="MinHeight" Value="40" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
|
||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
|
||||||
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
|
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
|
||||||
|
|
||||||
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
|
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
|
||||||
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
|
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
|
||||||
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />
|
||||||
|
@@ -9,12 +9,12 @@ sealed class App : Application {
|
|||||||
public override void Initialize() {
|
public override void Initialize() {
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted() {
|
public override void OnFrameworkInitializationCompleted() {
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
|
||||||
desktop.MainWindow = new MainWindow(Program.Arguments);
|
desktop.MainWindow = new MainWindow(Program.Arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
@@ -6,33 +5,33 @@ namespace DHT.Desktop;
|
|||||||
|
|
||||||
sealed class Arguments {
|
sealed class Arguments {
|
||||||
private static readonly Log Log = Log.ForType<Arguments>();
|
private static readonly Log Log = Log.ForType<Arguments>();
|
||||||
|
|
||||||
private const int FirstArgument = 1;
|
private const int FirstArgument = 1;
|
||||||
|
|
||||||
public static Arguments Empty => new (Array.Empty<string>());
|
public static Arguments Empty => new ([]);
|
||||||
|
|
||||||
public bool Console { get; }
|
public bool Console { get; }
|
||||||
public string? DatabaseFile { get; }
|
public string? DatabaseFile { get; }
|
||||||
public ushort? ServerPort { get; }
|
public ushort? ServerPort { get; }
|
||||||
public string? ServerToken { get; }
|
public string? ServerToken { get; }
|
||||||
public byte? ConcurrentDownloads { get; }
|
public byte? ConcurrentDownloads { get; }
|
||||||
|
|
||||||
public Arguments(IReadOnlyList<string> args) {
|
public Arguments(IReadOnlyList<string> args) {
|
||||||
for (int i = FirstArgument; i < args.Count; 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":
|
case "-console":
|
||||||
Console = true;
|
Console = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string value;
|
string value;
|
||||||
|
|
||||||
if (i == FirstArgument && !key.StartsWith('-')) {
|
if (i == FirstArgument && !key.StartsWith('-')) {
|
||||||
value = key;
|
value = key;
|
||||||
key = "-db";
|
key = "-db";
|
||||||
@@ -44,29 +43,29 @@ sealed class Arguments {
|
|||||||
else {
|
else {
|
||||||
value = args[++i];
|
value = args[++i];
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "-db":
|
case "-db":
|
||||||
DatabaseFile = value;
|
DatabaseFile = value;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
case "-port": {
|
case "-port": {
|
||||||
if (!ushort.TryParse(value, out var port)) {
|
if (!ushort.TryParse(value, out ushort port)) {
|
||||||
Log.Warn("Invalid port number: " + value);
|
Log.Warn("Invalid port number: " + value);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ServerPort = port;
|
ServerPort = port;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "-token":
|
case "-token":
|
||||||
ServerToken = value;
|
ServerToken = value;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
case "-concurrentdownloads":
|
case "-concurrentdownloads":
|
||||||
if (!ulong.TryParse(value, out var concurrentDownloads) || concurrentDownloads == 0) {
|
if (!ulong.TryParse(value, out ulong concurrentDownloads) || concurrentDownloads == 0) {
|
||||||
Log.Warn("Invalid concurrent downloads count: " + value);
|
Log.Warn("Invalid concurrent downloads count: " + value);
|
||||||
}
|
}
|
||||||
else if (concurrentDownloads > 10) {
|
else if (concurrentDownloads > 10) {
|
||||||
@@ -78,7 +77,7 @@ sealed class Arguments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
Log.Warn("Unknown command line argument: " + key);
|
Log.Warn("Unknown command line argument: " + key);
|
||||||
break;
|
break;
|
||||||
|
@@ -8,17 +8,17 @@ sealed class BytesValueConverter : IValueConverter {
|
|||||||
private sealed class Unit {
|
private sealed class Unit {
|
||||||
private readonly string label;
|
private readonly string label;
|
||||||
private readonly string numberFormat;
|
private readonly string numberFormat;
|
||||||
|
|
||||||
public Unit(string label, int decimalPlaces) {
|
public Unit(string label, int decimalPlaces) {
|
||||||
this.label = label;
|
this.label = label;
|
||||||
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
this.numberFormat = "{0:n" + decimalPlaces + "}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Format(double size) {
|
public string Format(double size) {
|
||||||
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
return string.Format(Program.Culture, numberFormat, size) + " " + label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly Unit[] Units = [
|
private static readonly Unit[] Units = [
|
||||||
new Unit("B", decimalPlaces: 0),
|
new Unit("B", decimalPlaces: 0),
|
||||||
new Unit("kB", decimalPlaces: 0),
|
new Unit("kB", decimalPlaces: 0),
|
||||||
@@ -26,15 +26,15 @@ sealed class BytesValueConverter : IValueConverter {
|
|||||||
new Unit("GB", decimalPlaces: 1),
|
new Unit("GB", decimalPlaces: 1),
|
||||||
new Unit("TB", decimalPlaces: 1)
|
new Unit("TB", decimalPlaces: 1)
|
||||||
];
|
];
|
||||||
|
|
||||||
private const int Scale = 1000;
|
private const int Scale = 1000;
|
||||||
|
|
||||||
public static string Convert(ulong size) {
|
public static string Convert(ulong size) {
|
||||||
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
||||||
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
||||||
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||||
}
|
}
|
||||||
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
if (value is long size and >= 0L) {
|
if (value is long size and >= 0L) {
|
||||||
return Convert((ulong) size);
|
return Convert((ulong) size);
|
||||||
@@ -46,7 +46,7 @@ sealed class BytesValueConverter : IValueConverter {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
@@ -16,35 +16,35 @@ namespace DHT.Desktop.Common;
|
|||||||
|
|
||||||
static class DatabaseGui {
|
static class DatabaseGui {
|
||||||
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
|
||||||
|
|
||||||
private const string DatabaseFileInitialName = "archive.dht";
|
private const string DatabaseFileInitialName = "archive.dht";
|
||||||
|
|
||||||
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
|
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
|
||||||
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"])
|
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
||||||
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
Title = "Open Database File",
|
Title = "Open Database File",
|
||||||
FileTypeFilter = DatabaseFileDialogFilter,
|
FileTypeFilter = DatabaseFileDialogFilter,
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
AllowMultiple = true
|
AllowMultiple = true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
public static async Task<string?> NewOpenOrCreateDatabaseFileDialog(Window window, string? suggestedDirectory) {
|
||||||
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
return await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||||
Title = "Open or Create Database File",
|
Title = "Open or Create Database File",
|
||||||
FileTypeChoices = DatabaseFileDialogFilter,
|
FileTypeChoices = DatabaseFileDialogFilter,
|
||||||
SuggestedFileName = DatabaseFileInitialName,
|
SuggestedFileName = DatabaseFileInitialName,
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
|
||||||
ShowOverwritePrompt = false
|
ShowOverwritePrompt = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||||
IDatabaseFile? file = null;
|
IDatabaseFile? file = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
|
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
|
||||||
} catch (InvalidDatabaseVersionException ex) {
|
} catch (InvalidDatabaseVersionException ex) {
|
||||||
@@ -55,14 +55,14 @@ static class DatabaseGui {
|
|||||||
Log.Error(ex);
|
Log.Error(ex);
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
|
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' could not be opened:" + ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
public static async Task<DialogResult.YesNo> ShowCanUpgradeDatabaseDialog(Window window) {
|
||||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
return await Dialog.ShowYesNo(window, "Database Upgrade", "This database was created with an older version of DHT. If you proceed, the database will be upgraded and will no longer open in previous versions of DHT.\n\nPlease ensure you have a backup of the database. Do you want to proceed with the upgrade?");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
public static async Task<DialogResult.YesNo> ShowCanUpgradeMultipleDatabaseDialog(Window window) {
|
||||||
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
|
return await Dialog.ShowYesNo(window, "Database Upgrade", "One or more databases were created with an older version of DHT. If you proceed, these databases will be upgraded and will no longer open in previous versions of DHT. Otherwise, these databases will be skipped.\n\nPlease ensure you have a backup of the databases. Do you want to proceed with the upgrade?");
|
||||||
}
|
}
|
||||||
|
@@ -8,7 +8,7 @@ sealed class NumberValueConverter : IValueConverter {
|
|||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
9
app/Desktop/Common/SystemUtils.cs
Normal file
9
app/Desktop/Common/SystemUtils.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Common;
|
||||||
|
|
||||||
|
static class SystemUtils {
|
||||||
|
public static void OpenUrl(string url) {
|
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
}
|
@@ -4,15 +4,15 @@ static class TextFormat {
|
|||||||
public static string Format(this int number) {
|
public static string Format(this int number) {
|
||||||
return number.ToString("N0", Program.Culture);
|
return number.ToString("N0", Program.Culture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Format(this long number) {
|
public static string Format(this long number) {
|
||||||
return number.ToString("N0", Program.Culture);
|
return number.ToString("N0", Program.Culture);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Pluralize(this int number, string singular) {
|
public static string Pluralize(this int number, string singular) {
|
||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Pluralize(this long number, string singular) {
|
public static string Pluralize(this long number, string singular) {
|
||||||
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
return number.Format() + "\u00A0" + (number == 1 ? singular : singular + "s");
|
||||||
}
|
}
|
||||||
|
@@ -15,14 +15,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.0.11" />
|
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.11" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.11" />
|
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.11" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.11" Condition=" '$(Configuration)' == 'Debug' " />
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.3" Condition=" '$(Configuration)' == 'Debug' " />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.11" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.11" />
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.11" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -40,5 +40,5 @@
|
|||||||
<LogicalName>tracker-loader.js</LogicalName>
|
<LogicalName>tracker-loader.js</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@@ -10,11 +10,11 @@ public sealed partial class CheckBoxDialog : Window {
|
|||||||
public CheckBoxDialog() {
|
public CheckBoxDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Ok);
|
Close(DialogResult.OkCancel.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Cancel);
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -8,49 +7,49 @@ namespace DHT.Desktop.Dialogs.CheckBox;
|
|||||||
|
|
||||||
class CheckBoxDialogModel : ObservableObject {
|
class CheckBoxDialogModel : ObservableObject {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
private IReadOnlyList<CheckBoxItem> items = [];
|
||||||
|
|
||||||
public IReadOnlyList<CheckBoxItem> Items {
|
public IReadOnlyList<CheckBoxItem> Items {
|
||||||
get => items;
|
get => items;
|
||||||
|
|
||||||
protected set {
|
protected set {
|
||||||
foreach (var item in items) {
|
foreach (CheckBoxItem item in items) {
|
||||||
item.PropertyChanged -= OnItemPropertyChanged;
|
item.PropertyChanged -= OnItemPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
items = value;
|
items = value;
|
||||||
|
|
||||||
foreach (var item in items) {
|
foreach (CheckBoxItem item in items) {
|
||||||
item.PropertyChanged += OnItemPropertyChanged;
|
item.PropertyChanged += OnItemPropertyChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool pauseCheckEvents = false;
|
private bool pauseCheckEvents = false;
|
||||||
|
|
||||||
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
||||||
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
||||||
|
|
||||||
public void SelectAll() => SetAllChecked(true);
|
public void SelectAll() => SetAllChecked(true);
|
||||||
public void SelectNone() => SetAllChecked(false);
|
public void SelectNone() => SetAllChecked(false);
|
||||||
|
|
||||||
private void SetAllChecked(bool isChecked) {
|
private void SetAllChecked(bool isChecked) {
|
||||||
pauseCheckEvents = true;
|
pauseCheckEvents = true;
|
||||||
|
|
||||||
foreach (var item in Items) {
|
foreach (CheckBoxItem item in Items) {
|
||||||
item.IsChecked = isChecked;
|
item.IsChecked = isChecked;
|
||||||
}
|
}
|
||||||
|
|
||||||
pauseCheckEvents = false;
|
pauseCheckEvents = false;
|
||||||
UpdateBulkButtons();
|
UpdateBulkButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateBulkButtons() {
|
private void UpdateBulkButtons() {
|
||||||
OnPropertyChanged(nameof(AreAllSelected));
|
OnPropertyChanged(nameof(AreAllSelected));
|
||||||
OnPropertyChanged(nameof(AreNoneSelected));
|
OnPropertyChanged(nameof(AreNoneSelected));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
||||||
UpdateBulkButtons();
|
UpdateBulkButtons();
|
||||||
@@ -60,11 +59,11 @@ class CheckBoxDialogModel : ObservableObject {
|
|||||||
|
|
||||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||||
private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
|
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
|
||||||
|
|
||||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||||
this.Items = new List<CheckBoxItem<T>>(items);
|
this.Items = new List<CheckBoxItem<T>>(items);
|
||||||
base.Items = this.Items;
|
base.Items = Items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,14 +5,14 @@ namespace DHT.Desktop.Dialogs.CheckBox;
|
|||||||
partial class CheckBoxItem : ObservableObject {
|
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]
|
[ObservableProperty]
|
||||||
private bool isChecked = false;
|
private bool isChecked = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||||
public new T Item { get; }
|
public new T Item { get; }
|
||||||
|
|
||||||
public CheckBoxItem(T item) {
|
public CheckBoxItem(T item) {
|
||||||
this.Item = item;
|
this.Item = item;
|
||||||
base.Item = item;
|
base.Item = item;
|
||||||
|
@@ -11,25 +11,25 @@ static class FileDialogs {
|
|||||||
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
||||||
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
||||||
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
||||||
return new FilePickerFileType(name) {
|
return new FilePickerFileType(name) {
|
||||||
Patterns = extensions.Select(static ext => "*." + ext).ToArray()
|
Patterns = extensions.Select(static ext => "*." + ext).ToArray(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
||||||
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ToLocalPath(this IStorageFile file) {
|
private static string ToLocalPath(this IStorageFile file) {
|
||||||
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
|
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
|
||||||
return files.Select(ToLocalPath).ToArray();
|
return files.Select(ToLocalPath).ToArray();
|
||||||
}
|
}
|
||||||
|
@@ -10,65 +10,65 @@ static class Dialog {
|
|||||||
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await new MessageDialog {
|
await new MessageDialog {
|
||||||
DataContext = new MessageDialogModel {
|
DataContext = new MessageDialogModel {
|
||||||
Title = title,
|
Title = title,
|
||||||
Message = message,
|
Message = message,
|
||||||
IsOkVisible = true
|
IsOkVisible = true,
|
||||||
}
|
},
|
||||||
}.ShowDialog<DialogResult.All>(owner);
|
}.ShowDialog<DialogResult.All>(owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
public static async Task<DialogResult.OkCancel> ShowOkCancel(Window owner, string title, string message) {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowOkCancel(owner, title, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
var result = await new MessageDialog {
|
||||||
DataContext = new MessageDialogModel {
|
DataContext = new MessageDialogModel {
|
||||||
Title = title,
|
Title = title,
|
||||||
Message = message,
|
Message = message,
|
||||||
IsOkVisible = true,
|
IsOkVisible = true,
|
||||||
IsCancelVisible = true
|
IsCancelVisible = true,
|
||||||
}
|
},
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
return result.ToOkCancel();
|
return result.ToOkCancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
public static async Task<DialogResult.YesNo> ShowYesNo(Window owner, string title, string message) {
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNo(owner, title, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
|
||||||
DataContext = new MessageDialogModel {
|
|
||||||
Title = title,
|
|
||||||
Message = message,
|
|
||||||
IsYesVisible = true,
|
|
||||||
IsNoVisible = true
|
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
|
||||||
|
|
||||||
return result.ToYesNo();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
|
||||||
if (!Dispatcher.UIThread.CheckAccess()) {
|
|
||||||
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await new MessageDialog {
|
var result = await new MessageDialog {
|
||||||
DataContext = new MessageDialogModel {
|
DataContext = new MessageDialogModel {
|
||||||
Title = title,
|
Title = title,
|
||||||
Message = message,
|
Message = message,
|
||||||
IsYesVisible = true,
|
IsYesVisible = true,
|
||||||
IsNoVisible = true,
|
IsNoVisible = true,
|
||||||
IsCancelVisible = true
|
},
|
||||||
}
|
|
||||||
}.ShowDialog<DialogResult.All?>(owner);
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
|
return result.ToYesNo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
|
||||||
|
if (!Dispatcher.UIThread.CheckAccess()) {
|
||||||
|
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await new MessageDialog {
|
||||||
|
DataContext = new MessageDialogModel {
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
IsYesVisible = true,
|
||||||
|
IsNoVisible = true,
|
||||||
|
IsCancelVisible = true,
|
||||||
|
},
|
||||||
|
}.ShowDialog<DialogResult.All?>(owner);
|
||||||
|
|
||||||
return result.ToYesNoCancel();
|
return result.ToYesNoCancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,53 +7,53 @@ static class DialogResult {
|
|||||||
Ok,
|
Ok,
|
||||||
Yes,
|
Yes,
|
||||||
No,
|
No,
|
||||||
Cancel
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum OkCancel {
|
public enum OkCancel {
|
||||||
Closed,
|
Closed,
|
||||||
Ok,
|
Ok,
|
||||||
Cancel
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum YesNo {
|
public enum YesNo {
|
||||||
Closed,
|
Closed,
|
||||||
Yes,
|
Yes,
|
||||||
No
|
No,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum YesNoCancel {
|
public enum YesNoCancel {
|
||||||
Closed,
|
Closed,
|
||||||
Yes,
|
Yes,
|
||||||
No,
|
No,
|
||||||
Cancel
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static OkCancel ToOkCancel(this All? result) {
|
public static OkCancel ToOkCancel(this All? result) {
|
||||||
return result switch {
|
return result switch {
|
||||||
null => OkCancel.Closed,
|
null => OkCancel.Closed,
|
||||||
All.Ok => OkCancel.Ok,
|
All.Ok => OkCancel.Ok,
|
||||||
All.Cancel => OkCancel.Cancel,
|
All.Cancel => OkCancel.Cancel,
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel.")
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to ok/cancel."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static YesNo ToYesNo(this All? result) {
|
public static YesNo ToYesNo(this All? result) {
|
||||||
return result switch {
|
return result switch {
|
||||||
null => YesNo.Closed,
|
null => YesNo.Closed,
|
||||||
All.Yes => YesNo.Yes,
|
All.Yes => YesNo.Yes,
|
||||||
All.No => YesNo.No,
|
All.No => YesNo.No,
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no.")
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static YesNoCancel ToYesNoCancel(this All? result) {
|
public static YesNoCancel ToYesNoCancel(this All? result) {
|
||||||
return result switch {
|
return result switch {
|
||||||
null => YesNoCancel.Closed,
|
null => YesNoCancel.Closed,
|
||||||
All.Yes => YesNoCancel.Yes,
|
All.Yes => YesNoCancel.Yes,
|
||||||
All.No => YesNoCancel.No,
|
All.No => YesNoCancel.No,
|
||||||
All.Cancel => YesNoCancel.Cancel,
|
All.Cancel => YesNoCancel.Cancel,
|
||||||
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
|
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,19 +9,19 @@ public sealed partial class MessageDialog : Window {
|
|||||||
public MessageDialog() {
|
public MessageDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Ok);
|
Close(DialogResult.All.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickYes(object? sender, RoutedEventArgs e) {
|
public void ClickYes(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Yes);
|
Close(DialogResult.All.Yes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickNo(object? sender, RoutedEventArgs e) {
|
public void ClickNo(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.No);
|
Close(DialogResult.All.No);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.All.Cancel);
|
Close(DialogResult.All.Cancel);
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,7 @@ namespace DHT.Desktop.Dialogs.Message;
|
|||||||
sealed class MessageDialogModel {
|
sealed class MessageDialogModel {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public string Message { get; init; } = "";
|
public string Message { get; init; } = "";
|
||||||
|
|
||||||
public bool IsOkVisible { get; init; } = false;
|
public bool IsOkVisible { get; init; } = false;
|
||||||
public bool IsYesVisible { get; init; } = false;
|
public bool IsYesVisible { get; init; } = false;
|
||||||
public bool IsNoVisible { get; init; } = false;
|
public bool IsNoVisible { get; init; } = false;
|
||||||
|
@@ -2,11 +2,15 @@ using System;
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress;
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class ProgressDialog : Window {
|
public sealed partial class ProgressDialog : Window {
|
||||||
|
private static readonly Log Log = Log.ForType<ProgressDialog>();
|
||||||
|
|
||||||
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
|
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
|
||||||
var taskCompletionSource = new TaskCompletionSource();
|
var taskCompletionSource = new TaskCompletionSource();
|
||||||
var dialog = new ProgressDialog();
|
var dialog = new ProgressDialog();
|
||||||
@@ -45,7 +49,7 @@ public sealed partial class ProgressDialog : Window {
|
|||||||
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
||||||
var taskCompletionSource = new TaskCompletionSource<T>();
|
var taskCompletionSource = new TaskCompletionSource<T>();
|
||||||
var dialog = new ProgressDialog();
|
var dialog = new ProgressDialog();
|
||||||
|
|
||||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||||
await callbacks[0].UpdateIndeterminate(message);
|
await callbacks[0].UpdateIndeterminate(message);
|
||||||
try {
|
try {
|
||||||
@@ -58,32 +62,37 @@ public sealed partial class ProgressDialog : Window {
|
|||||||
await dialog.ShowProgressDialog(owner);
|
await dialog.ShowProgressDialog(owner);
|
||||||
return await taskCompletionSource.Task;
|
return await taskCompletionSource.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool isFinished = false;
|
private bool isFinished = false;
|
||||||
private Task progressTask = Task.CompletedTask;
|
private Task progressTask = Task.CompletedTask;
|
||||||
|
|
||||||
public ProgressDialog() {
|
public ProgressDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnOpened(object? sender, EventArgs e) {
|
public void OnOpened(object? sender, EventArgs e) {
|
||||||
if (DataContext is ProgressDialogModel model) {
|
if (DataContext is ProgressDialogModel model) {
|
||||||
progressTask = Task.Run(model.StartTask);
|
progressTask = Task.Run(model.StartTask);
|
||||||
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClosing(object? sender, WindowClosingEventArgs e) {
|
public void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
e.Cancel = !isFinished;
|
e.Cancel = !isFinished;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnFinished(Task task) {
|
private void OnFinished(Task task) {
|
||||||
isFinished = true;
|
isFinished = true;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ShowProgressDialog(Window owner) {
|
public async Task ShowProgressDialog(Window owner) {
|
||||||
await ShowDialog(owner);
|
await ShowDialog(owner);
|
||||||
await progressTask;
|
try {
|
||||||
|
await progressTask;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(owner, "Unexpected Error", e.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,35 +9,35 @@ namespace DHT.Desktop.Dialogs.Progress;
|
|||||||
|
|
||||||
sealed class ProgressDialogModel {
|
sealed class ProgressDialogModel {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
public IReadOnlyList<ProgressItem> Items { get; } = [];
|
||||||
|
|
||||||
private readonly TaskRunner? task;
|
private readonly TaskRunner? task;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ProgressDialogModel() {}
|
public ProgressDialogModel() {}
|
||||||
|
|
||||||
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
|
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
|
||||||
this.Title = title;
|
this.Title = title;
|
||||||
this.task = task;
|
this.task = task;
|
||||||
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
this.Items = Enumerable.Range(start: 0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task StartTask() {
|
internal async Task StartTask() {
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
await task(Items.Select(static item => new Callback(item)).ToArray());
|
await task(Items.Select(static item => new Callback(item)).ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
|
||||||
|
|
||||||
private sealed class Callback : IProgressCallback {
|
private sealed class Callback : IProgressCallback {
|
||||||
private readonly ProgressItem item;
|
private readonly ProgressItem item;
|
||||||
|
|
||||||
public Callback(ProgressItem item) {
|
public Callback(ProgressItem item) {
|
||||||
this.item = item;
|
this.item = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Update(string message, int finishedItems, int totalItems) {
|
public async Task Update(string message, int finishedItems, int totalItems) {
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||||
item.Message = message;
|
item.Message = message;
|
||||||
@@ -46,7 +46,7 @@ sealed class ProgressDialogModel {
|
|||||||
item.IsIndeterminate = false;
|
item.IsIndeterminate = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateIndeterminate(string message) {
|
public async Task UpdateIndeterminate(string message) {
|
||||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||||
item.Message = message;
|
item.Message = message;
|
||||||
@@ -55,9 +55,9 @@ sealed class ProgressDialogModel {
|
|||||||
item.IsIndeterminate = true;
|
item.IsIndeterminate = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Hide() {
|
public Task Hide() {
|
||||||
return Update(string.Empty, 0, 0);
|
return Update(string.Empty, finishedItems: 0, totalItems: 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,11 +6,11 @@ sealed partial class ProgressItem : ObservableObject {
|
|||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
[NotifyPropertyChangedFor(nameof(Opacity))]
|
[NotifyPropertyChangedFor(nameof(Opacity))]
|
||||||
private bool isVisible = false;
|
private bool isVisible = false;
|
||||||
|
|
||||||
public double Opacity => IsVisible ? 1.0 : 0.0;
|
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||||
|
|
||||||
private string message = "";
|
private string message = "";
|
||||||
|
|
||||||
public string Message {
|
public string Message {
|
||||||
get => message;
|
get => message;
|
||||||
set {
|
set {
|
||||||
@@ -18,13 +18,13 @@ sealed partial class ProgressItem : ObservableObject {
|
|||||||
IsVisible = !string.IsNullOrEmpty(value);
|
IsVisible = !string.IsNullOrEmpty(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string items = "";
|
private string items = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int progress = 0;
|
private int progress = 0;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isIndeterminate;
|
private bool isIndeterminate;
|
||||||
}
|
}
|
||||||
|
@@ -10,11 +10,11 @@ public sealed partial class TextBoxDialog : Window {
|
|||||||
public TextBoxDialog() {
|
public TextBoxDialog() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickOk(object? sender, RoutedEventArgs e) {
|
public void ClickOk(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Ok);
|
Close(DialogResult.OkCancel.Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
public void ClickCancel(object? sender, RoutedEventArgs e) {
|
||||||
Close(DialogResult.OkCancel.Cancel);
|
Close(DialogResult.OkCancel.Cancel);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -9,27 +8,27 @@ namespace DHT.Desktop.Dialogs.TextBox;
|
|||||||
class TextBoxDialogModel : ObservableObject {
|
class TextBoxDialogModel : ObservableObject {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public string Description { get; init; } = "";
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
|
private IReadOnlyList<TextBoxItem> items = [];
|
||||||
|
|
||||||
public IReadOnlyList<TextBoxItem> Items {
|
public IReadOnlyList<TextBoxItem> Items {
|
||||||
get => items;
|
get => items;
|
||||||
|
|
||||||
protected set {
|
protected set {
|
||||||
foreach (var item in items) {
|
foreach (TextBoxItem item in items) {
|
||||||
item.ErrorsChanged -= OnItemErrorsChanged;
|
item.ErrorsChanged -= OnItemErrorsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
items = value;
|
items = value;
|
||||||
|
|
||||||
foreach (var item in items) {
|
foreach (TextBoxItem item in items) {
|
||||||
item.ErrorsChanged += OnItemErrorsChanged;
|
item.ErrorsChanged += OnItemErrorsChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
||||||
|
|
||||||
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||||
OnPropertyChanged(nameof(HasErrors));
|
OnPropertyChanged(nameof(HasErrors));
|
||||||
}
|
}
|
||||||
@@ -37,9 +36,9 @@ class TextBoxDialogModel : ObservableObject {
|
|||||||
|
|
||||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||||
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||||
|
|
||||||
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
public TextBoxDialogModel(IEnumerable<TextBoxItem<T>> items) {
|
||||||
this.Items = new List<TextBoxItem<T>>(items);
|
this.Items = new List<TextBoxItem<T>>(items);
|
||||||
base.Items = this.Items;
|
base.Items = this.Items;
|
||||||
|
@@ -8,33 +8,33 @@ namespace DHT.Desktop.Dialogs.TextBox;
|
|||||||
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public object? Item { get; init; } = null;
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
||||||
public bool IsValid => ValidityCheck(Value);
|
public bool IsValid => ValidityCheck(Value);
|
||||||
|
|
||||||
private string value = string.Empty;
|
private string value = string.Empty;
|
||||||
|
|
||||||
public string Value {
|
public string Value {
|
||||||
get => this.value;
|
get => value;
|
||||||
set {
|
set {
|
||||||
SetProperty(ref this.value, value);
|
SetProperty(ref this.value, value);
|
||||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable GetErrors(string? propertyName) {
|
public IEnumerable GetErrors(string? propertyName) {
|
||||||
if (propertyName == nameof(Value) && !IsValid) {
|
if (propertyName == nameof(Value) && !IsValid) {
|
||||||
yield return string.Empty;
|
yield return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool HasErrors => !IsValid;
|
public bool HasErrors => !IsValid;
|
||||||
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class TextBoxItem<T> : TextBoxItem {
|
sealed class TextBoxItem<T> : TextBoxItem {
|
||||||
public new T Item { get; }
|
public new T Item { get; }
|
||||||
|
|
||||||
public TextBoxItem(T item) {
|
public TextBoxItem(T item) {
|
||||||
this.Item = item;
|
this.Item = item;
|
||||||
base.Item = item;
|
base.Item = item;
|
||||||
|
@@ -13,16 +13,16 @@ namespace DHT.Desktop.Discord;
|
|||||||
|
|
||||||
static class DiscordAppSettings {
|
static class DiscordAppSettings {
|
||||||
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
|
||||||
|
|
||||||
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
|
||||||
|
|
||||||
public static string JsonFilePath { get; }
|
public static string JsonFilePath { get; }
|
||||||
private static string JsonBackupFilePath { get; }
|
private static string JsonBackupFilePath { get; }
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
|
||||||
static DiscordAppSettings() {
|
static DiscordAppSettings() {
|
||||||
string rootFolder;
|
string rootFolder;
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
|
||||||
}
|
}
|
||||||
@@ -32,14 +32,14 @@ static class DiscordAppSettings {
|
|||||||
else {
|
else {
|
||||||
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "discord");
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
JsonFilePath = Path.Combine(rootFolder, "settings.json");
|
||||||
JsonBackupFilePath = JsonFilePath + ".bak";
|
JsonBackupFilePath = JsonFilePath + ".bak";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<bool?> AreDevToolsEnabled() {
|
public static async Task<bool?> AreDevToolsEnabled() {
|
||||||
try {
|
try {
|
||||||
var settingsJson = await ReadSettingsJson();
|
JsonObject settingsJson = await ReadSettingsJson();
|
||||||
return AreDevToolsEnabled(settingsJson);
|
return AreDevToolsEnabled(settingsJson);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.Error("Cannot read settings file.");
|
Log.Error("Cannot read settings file.");
|
||||||
@@ -47,14 +47,14 @@ static class DiscordAppSettings {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool AreDevToolsEnabled(JsonObject json) {
|
private static bool AreDevToolsEnabled(JsonObject json) {
|
||||||
return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
|
return json.TryGetPropertyValue(JsonKeyDevTools, out JsonNode? node) && node?.GetValueKind() == JsonValueKind.True;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
|
||||||
JsonObject json;
|
JsonObject json;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json = await ReadSettingsJson();
|
json = await ReadSettingsJson();
|
||||||
} catch (FileNotFoundException) {
|
} catch (FileNotFoundException) {
|
||||||
@@ -65,56 +65,56 @@ static class DiscordAppSettings {
|
|||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
return SettingsJsonResult.ReadError;
|
return SettingsJsonResult.ReadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enable == AreDevToolsEnabled(json)) {
|
if (enable == AreDevToolsEnabled(json)) {
|
||||||
return SettingsJsonResult.AlreadySet;
|
return SettingsJsonResult.AlreadySet;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enable) {
|
if (enable) {
|
||||||
json[JsonKeyDevTools] = true;
|
json[JsonKeyDevTools] = true;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
json.Remove(JsonKeyDevTools);
|
json.Remove(JsonKeyDevTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!File.Exists(JsonBackupFilePath)) {
|
if (!File.Exists(JsonBackupFilePath)) {
|
||||||
File.Copy(JsonFilePath, JsonBackupFilePath);
|
File.Copy(JsonFilePath, JsonBackupFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
await WriteSettingsJson(json);
|
await WriteSettingsJson(json);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.Error("An error occurred when writing settings file.");
|
Log.Error("An error occurred when writing settings file.");
|
||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
|
|
||||||
if (File.Exists(JsonBackupFilePath)) {
|
if (File.Exists(JsonBackupFilePath)) {
|
||||||
try {
|
try {
|
||||||
File.Move(JsonBackupFilePath, JsonFilePath, true);
|
File.Move(JsonBackupFilePath, JsonFilePath, overwrite: true);
|
||||||
Log.Info("Restored settings file from backup.");
|
Log.Info("Restored settings file from backup.");
|
||||||
} catch (Exception e2) {
|
} catch (Exception e2) {
|
||||||
Log.Error("Cannot restore settings file from backup.");
|
Log.Error("Cannot restore settings file from backup.");
|
||||||
Log.Error(e2);
|
Log.Error(e2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return SettingsJsonResult.WriteError;
|
return SettingsJsonResult.WriteError;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
File.Delete(JsonBackupFilePath);
|
File.Delete(JsonBackupFilePath);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.Error("Cannot delete backup file.");
|
Log.Error("Cannot delete backup file.");
|
||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SettingsJsonResult.Success;
|
return SettingsJsonResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<JsonObject> ReadSettingsJson() {
|
private static async Task<JsonObject> ReadSettingsJson() {
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WriteSettingsJson(JsonObject json) {
|
private static async Task WriteSettingsJson(JsonObject json) {
|
||||||
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
|
||||||
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
|
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
|
||||||
|
@@ -6,5 +6,5 @@ enum SettingsJsonResult {
|
|||||||
FileNotFound,
|
FileNotFound,
|
||||||
ReadError,
|
ReadError,
|
||||||
InvalidJson,
|
InvalidJson,
|
||||||
WriteError
|
WriteError,
|
||||||
}
|
}
|
||||||
|
@@ -59,7 +59,7 @@
|
|||||||
<TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</TextBlock>
|
<TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</TextBlock>
|
||||||
<TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock>
|
<TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock>
|
||||||
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryCommunityToolkit}">GitHub</Button>
|
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryCommunityToolkit}">GitHub</Button>
|
||||||
|
|
||||||
<TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock>
|
<TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock>
|
||||||
<TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock>
|
<TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock>
|
||||||
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
|
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<TextBlock Grid.Row="6" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
|
<TextBlock Grid.Row="6" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
|
||||||
<TextBlock Grid.Row="6" Grid.Column="1">Apache-2.0</TextBlock>
|
<TextBlock Grid.Row="6" Grid.Column="1">Apache-2.0</TextBlock>
|
||||||
<Button Grid.Row="6" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button>
|
<Button Grid.Row="6" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button>
|
||||||
|
|
||||||
<TextBlock Grid.Row="7" Grid.Column="0">Rx.NET</TextBlock>
|
<TextBlock Grid.Row="7" Grid.Column="0">Rx.NET</TextBlock>
|
||||||
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
|
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
|
||||||
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button>
|
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button>
|
||||||
|
@@ -1,45 +1,41 @@
|
|||||||
using System.Diagnostics;
|
using DHT.Desktop.Common;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main;
|
namespace DHT.Desktop.Main;
|
||||||
|
|
||||||
sealed class AboutWindowModel {
|
sealed class AboutWindowModel {
|
||||||
public void ShowOfficialWebsite() {
|
public void ShowOfficialWebsite() {
|
||||||
OpenUrl("https://dht.chylex.com");
|
SystemUtils.OpenUrl(Program.Website);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowIssueTracker() {
|
public void ShowIssueTracker() {
|
||||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowSourceCode() {
|
public void ShowSourceCode() {
|
||||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibraryNetCore() {
|
public void ShowLibraryNetCore() {
|
||||||
OpenUrl("https://github.com/dotnet/core");
|
SystemUtils.OpenUrl("https://github.com/dotnet/core");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibraryAvalonia() {
|
public void ShowLibraryAvalonia() {
|
||||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
SystemUtils.OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibraryCommunityToolkit() {
|
public void ShowLibraryCommunityToolkit() {
|
||||||
OpenUrl("https://github.com/CommunityToolkit/dotnet");
|
SystemUtils.OpenUrl("https://github.com/CommunityToolkit/dotnet");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibrarySqlite() {
|
public void ShowLibrarySqlite() {
|
||||||
OpenUrl("https://www.sqlite.org");
|
SystemUtils.OpenUrl("https://www.sqlite.org");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibrarySqliteAdoNet() {
|
public void ShowLibrarySqliteAdoNet() {
|
||||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowLibraryRxNet() {
|
public void ShowLibraryRxNet() {
|
||||||
OpenUrl("https://github.com/dotnet/reactive");
|
SystemUtils.OpenUrl("https://github.com/dotnet/reactive");
|
||||||
}
|
|
||||||
|
|
||||||
private static void OpenUrl(string url) {
|
|
||||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -21,19 +21,19 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
|||||||
public sealed record Unit(string Name, uint Scale);
|
public sealed record Unit(string Name, uint Scale);
|
||||||
|
|
||||||
private static readonly Unit[] AllUnits = [
|
private static readonly Unit[] AllUnits = [
|
||||||
new Unit("B", 1),
|
new Unit("B", Scale: 1),
|
||||||
new Unit("kB", 1024),
|
new Unit("kB", Scale: 1024),
|
||||||
new Unit("MB", 1024 * 1024)
|
new Unit("MB", Scale: 1024 * 1024)
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly HashSet<string> FilterProperties = [
|
private static readonly HashSet<string> FilterProperties = [
|
||||||
nameof(LimitSize),
|
nameof(LimitSize),
|
||||||
nameof(MaximumSize),
|
nameof(MaximumSize),
|
||||||
nameof(MaximumSizeUnit)
|
nameof(MaximumSizeUnit),
|
||||||
];
|
];
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
public string FilterStatisticsText { get; private set; } = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool limitSize = false;
|
private bool limitSize = false;
|
||||||
|
|
||||||
@@ -42,51 +42,51 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private Unit maximumSizeUnit = AllUnits[0];
|
private Unit maximumSizeUnit = AllUnits[0];
|
||||||
|
|
||||||
public IEnumerable<Unit> Units => AllUnits;
|
public IEnumerable<Unit> Units => AllUnits;
|
||||||
|
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
private readonly string verb;
|
private readonly string verb;
|
||||||
|
|
||||||
private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask;
|
private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask;
|
||||||
private bool isLoadingFilterSettings;
|
private bool isLoadingFilterSettings;
|
||||||
|
|
||||||
private readonly RestartableTask<long> downloadItemCountTask;
|
private readonly RestartableTask<long> downloadItemCountTask;
|
||||||
private long? matchingItemCount;
|
private long? matchingItemCount;
|
||||||
|
|
||||||
private readonly IDisposable downloadItemCountSubscription;
|
private readonly IDisposable downloadItemCountSubscription;
|
||||||
private long? totalItemCount;
|
private long? totalItemCount;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public DownloadItemFilterPanelModel() : this(State.Dummy) {}
|
public DownloadItemFilterPanelModel() : this(State.Dummy) {}
|
||||||
|
|
||||||
public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
|
public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.verb = verb;
|
this.verb = verb;
|
||||||
|
|
||||||
this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings);
|
this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings);
|
||||||
|
|
||||||
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
|
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
|
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
|
||||||
|
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
PropertyChanged += OnPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Initialize() {
|
public async Task Initialize() {
|
||||||
isLoadingFilterSettings = true;
|
isLoadingFilterSettings = true;
|
||||||
|
|
||||||
LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize);
|
LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize);
|
||||||
MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize);
|
MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize);
|
||||||
|
|
||||||
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
|
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, defaultValue: null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
|
||||||
MaximumSizeUnit = unitValue;
|
MaximumSizeUnit = unitValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingFilterSettings = false;
|
isLoadingFilterSettings = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
public async ValueTask DisposeAsync() {
|
||||||
saveFilterSettingsTask.Dispose();
|
saveFilterSettingsTask.Dispose();
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
|||||||
Log.Error(e);
|
Log.Error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
if (!isLoadingFilterSettings) {
|
if (!isLoadingFilterSettings) {
|
||||||
@@ -128,9 +128,9 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
|||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
private void UpdateFilterStatistics() {
|
||||||
var filter = CreateFilter();
|
DownloadItemFilter filter = CreateFilter();
|
||||||
if (filter.IsEmpty) {
|
if (filter.IsEmpty) {
|
||||||
downloadItemCountTask.Cancel();
|
downloadItemCountTask.Cancel();
|
||||||
matchingItemCount = totalItemCount;
|
matchingItemCount = totalItemCount;
|
||||||
@@ -142,23 +142,23 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
|||||||
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
|
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetMatchingCount(long matchingAttachmentCount) {
|
private void SetMatchingCount(long matchingAttachmentCount) {
|
||||||
this.matchingItemCount = matchingAttachmentCount;
|
this.matchingItemCount = matchingAttachmentCount;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateFilterStatisticsText() {
|
private void UpdateFilterStatisticsText() {
|
||||||
var matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
|
string matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
|
||||||
var totalItemCountStr = totalItemCount?.Format() ?? "(...)";
|
string totalItemCountStr = totalItemCount?.Format() ?? "(...)";
|
||||||
|
|
||||||
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
|
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
|
||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||||
}
|
}
|
||||||
|
|
||||||
public DownloadItemFilter CreateFilter() {
|
public DownloadItemFilter CreateFilter() {
|
||||||
DownloadItemFilter filter = new ();
|
DownloadItemFilter filter = new ();
|
||||||
|
|
||||||
if (LimitSize) {
|
if (LimitSize) {
|
||||||
try {
|
try {
|
||||||
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||||
@@ -166,7 +166,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
|||||||
// set no size limit, because the overflown size is larger than any file could possibly be
|
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
@@ -7,16 +8,16 @@ namespace DHT.Desktop.Main.Controls;
|
|||||||
public sealed partial class MessageFilterPanel : UserControl {
|
public sealed partial class MessageFilterPanel : UserControl {
|
||||||
public MessageFilterPanel() {
|
public MessageFilterPanel() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
var culture = Program.Culture;
|
CultureInfo culture = Program.Culture;
|
||||||
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
foreach (CalendarDatePicker picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
|
||||||
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
|
picker.FirstDayOfWeek = culture.DateTimeFormat.FirstDayOfWeek;
|
||||||
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
|
picker.SelectedDateFormat = CalendarDatePickerFormat.Custom;
|
||||||
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
|
picker.CustomDateFormatString = culture.DateTimeFormat.ShortDatePattern;
|
||||||
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
|
picker.Watermark = culture.DateTimeFormat.ShortDatePattern;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
public void CalendarDatePicker_OnSelectedDateChanged(object? sender, SelectionChangedEventArgs e) {
|
||||||
if (DataContext is MessageFilterPanelModel model) {
|
if (DataContext is MessageFilterPanelModel model) {
|
||||||
model.StartDate = StartDatePicker.SelectedDate;
|
model.StartDate = StartDatePicker.SelectedDate;
|
||||||
|
@@ -27,46 +27,46 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
nameof(FilterByChannel),
|
nameof(FilterByChannel),
|
||||||
nameof(IncludedChannels),
|
nameof(IncludedChannels),
|
||||||
nameof(FilterByUser),
|
nameof(FilterByUser),
|
||||||
nameof(IncludedUsers)
|
nameof(IncludedUsers),
|
||||||
];
|
];
|
||||||
|
|
||||||
public string FilterStatisticsText { get; private set; } = "";
|
public string FilterStatisticsText { get; private set; } = "";
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
public event PropertyChangedEventHandler? FilterPropertyChanged;
|
||||||
|
|
||||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool filterByDate = false;
|
private bool filterByDate = false;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private DateTime? startDate = null;
|
private DateTime? startDate = null;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private DateTime? endDate = null;
|
private DateTime? endDate = null;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool filterByChannel = false;
|
private bool filterByChannel = false;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private HashSet<ulong>? includedChannels = null;
|
private HashSet<ulong>? includedChannels = null;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool filterByUser = false;
|
private bool filterByUser = false;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private HashSet<ulong>? includedUsers = null;
|
private HashSet<ulong>? includedUsers = null;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string channelFilterLabel = "";
|
private string channelFilterLabel = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string userFilterLabel = "";
|
private string userFilterLabel = "";
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
private readonly string verb;
|
private readonly string verb;
|
||||||
|
|
||||||
private readonly RestartableTask<long> exportedMessageCountTask;
|
private readonly RestartableTask<long> exportedMessageCountTask;
|
||||||
private long? exportedMessageCount;
|
private long? exportedMessageCount;
|
||||||
|
|
||||||
@@ -78,28 +78,28 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
private readonly IDisposable userCountSubscription;
|
private readonly IDisposable userCountSubscription;
|
||||||
private long? totalUserCount;
|
private long? totalUserCount;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
|
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.verb = verb;
|
this.verb = verb;
|
||||||
|
|
||||||
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
|
||||||
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
|
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
|
||||||
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
|
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
|
||||||
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
|
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
|
||||||
|
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
PropertyChanged += OnPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
exportedMessageCountTask.Cancel();
|
exportedMessageCountTask.Cancel();
|
||||||
|
|
||||||
@@ -107,13 +107,13 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
channelCountSubscription.Dispose();
|
channelCountSubscription.Dispose();
|
||||||
userCountSubscription.Dispose();
|
userCountSubscription.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
FilterPropertyChanged?.Invoke(sender, e);
|
FilterPropertyChanged?.Invoke(sender, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
if (e.PropertyName is nameof(FilterByChannel) or nameof(IncludedChannels)) {
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
}
|
}
|
||||||
@@ -121,12 +121,12 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnMessageCountChanged(long newMessageCount) {
|
private void OnMessageCountChanged(long newMessageCount) {
|
||||||
totalMessageCount = newMessageCount;
|
totalMessageCount = newMessageCount;
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnChannelCountChanged(long newChannelCount) {
|
private void OnChannelCountChanged(long newChannelCount) {
|
||||||
totalChannelCount = newChannelCount;
|
totalChannelCount = newChannelCount;
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
@@ -136,7 +136,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
totalUserCount = newUserCount;
|
totalUserCount = newUserCount;
|
||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateChannelFilterLabel() {
|
private void UpdateChannelFilterLabel() {
|
||||||
if (totalChannelCount.HasValue) {
|
if (totalChannelCount.HasValue) {
|
||||||
long total = totalChannelCount.Value;
|
long total = totalChannelCount.Value;
|
||||||
@@ -147,7 +147,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
ChannelFilterLabel = "Loading...";
|
ChannelFilterLabel = "Loading...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateUserFilterLabel() {
|
private void UpdateUserFilterLabel() {
|
||||||
if (totalUserCount.HasValue) {
|
if (totalUserCount.HasValue) {
|
||||||
long total = totalUserCount.Value;
|
long total = totalUserCount.Value;
|
||||||
@@ -158,9 +158,9 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
UserFilterLabel = "Loading...";
|
UserFilterLabel = "Loading...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
private void UpdateFilterStatistics() {
|
||||||
var filter = CreateFilter();
|
MessageFilter filter = CreateFilter();
|
||||||
if (filter.IsEmpty) {
|
if (filter.IsEmpty) {
|
||||||
exportedMessageCountTask.Cancel();
|
exportedMessageCountTask.Cancel();
|
||||||
exportedMessageCount = totalMessageCount;
|
exportedMessageCount = totalMessageCount;
|
||||||
@@ -172,38 +172,38 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
|
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetExportedMessageCount(long exportedMessageCount) {
|
private void SetExportedMessageCount(long exportedMessageCount) {
|
||||||
this.exportedMessageCount = exportedMessageCount;
|
this.exportedMessageCount = exportedMessageCount;
|
||||||
UpdateFilterStatisticsText();
|
UpdateFilterStatisticsText();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateFilterStatisticsText() {
|
private void UpdateFilterStatisticsText() {
|
||||||
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
string exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
|
||||||
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
string totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
|
||||||
|
|
||||||
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
|
FilterStatisticsText = verb + " " + exportedMessageCountStr + " out of " + totalMessageCountStr + " message" + (totalMessageCount is null or 1 ? "." : "s.");
|
||||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenChannelFilterDialog() {
|
public async Task OpenChannelFilterDialog() {
|
||||||
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
|
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
|
||||||
var items = new List<CheckBoxItem<ulong>>();
|
var items = new List<CheckBoxItem<ulong>>();
|
||||||
var servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
Dictionary<ulong, DHT.Server.Data.Server> servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
||||||
|
|
||||||
await foreach (var channel in state.Db.Channels.Get()) {
|
await foreach (Channel channel in state.Db.Channels.Get()) {
|
||||||
var channelId = channel.Id;
|
ulong channelId = channel.Id;
|
||||||
var channelName = channel.Name;
|
string channelName = channel.Name;
|
||||||
|
|
||||||
string title;
|
string title;
|
||||||
if (servers.TryGetValue(channel.Server, out var server)) {
|
if (servers.TryGetValue(channel.Server, out var server)) {
|
||||||
var titleBuilder = new StringBuilder();
|
var titleBuilder = new StringBuilder();
|
||||||
var serverType = server.Type;
|
ServerType? serverType = server.Type;
|
||||||
|
|
||||||
titleBuilder.Append('[')
|
titleBuilder.Append('[')
|
||||||
.Append(ServerTypes.ToString(serverType))
|
.Append(ServerTypes.ToString(serverType))
|
||||||
.Append("] ");
|
.Append("] ");
|
||||||
|
|
||||||
if (serverType == ServerType.DirectMessage) {
|
if (serverType == ServerType.DirectMessage) {
|
||||||
titleBuilder.Append(channelName);
|
titleBuilder.Append(channelName);
|
||||||
}
|
}
|
||||||
@@ -212,24 +212,24 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
.Append(" - ")
|
.Append(" - ")
|
||||||
.Append(channelName);
|
.Append(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
title = titleBuilder.ToString();
|
title = titleBuilder.ToString();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
title = channelName;
|
title = channelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||||
Title = title,
|
Title = title,
|
||||||
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId)
|
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
const string Title = "Included Channels";
|
const string Title = "Included Channels";
|
||||||
|
|
||||||
List<CheckBoxItem<ulong>> items;
|
List<CheckBoxItem<ulong>> items;
|
||||||
try {
|
try {
|
||||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
|
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
|
||||||
@@ -237,29 +237,29 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
|
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await OpenIdFilterDialog(Title, items);
|
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
IncludedChannels = result;
|
IncludedChannels = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenUserFilterDialog() {
|
public async Task OpenUserFilterDialog() {
|
||||||
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
|
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
|
||||||
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
||||||
|
|
||||||
await foreach (var user in state.Db.Users.Get()) {
|
await foreach (User user in state.Db.Users.Get()) {
|
||||||
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||||
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
|
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
|
||||||
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkBoxItems;
|
return checkBoxItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
const string Title = "Included Users";
|
const string Title = "Included Users";
|
||||||
|
|
||||||
List<CheckBoxItem<ulong>> items;
|
List<CheckBoxItem<ulong>> items;
|
||||||
try {
|
try {
|
||||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
||||||
@@ -267,42 +267,42 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
|||||||
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
|
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await OpenIdFilterDialog(Title, items);
|
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
IncludedUsers = result;
|
IncludedUsers = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) {
|
private async Task<HashSet<ulong>?> OpenIdFilterDialog(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) {
|
||||||
Title = title
|
Title = title,
|
||||||
};
|
};
|
||||||
|
|
||||||
var dialog = new CheckBoxDialog { DataContext = model };
|
var dialog = new CheckBoxDialog { DataContext = model };
|
||||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||||
|
|
||||||
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() {
|
public MessageFilter CreateFilter() {
|
||||||
MessageFilter filter = new ();
|
MessageFilter filter = new ();
|
||||||
|
|
||||||
if (FilterByDate) {
|
if (FilterByDate) {
|
||||||
filter.StartDate = StartDate;
|
filter.StartDate = StartDate;
|
||||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FilterByChannel && IncludedChannels != null) {
|
if (FilterByChannel && IncludedChannels != null) {
|
||||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FilterByUser && IncludedUsers != null) {
|
if (FilterByUser && IncludedUsers != null) {
|
||||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -16,6 +16,10 @@
|
|||||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||||
<Setter Property="FontSize" Value="15" />
|
<Setter Property="FontSize" Value="15" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Label">
|
||||||
|
<Setter Property="Margin" Value="0 5" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
</Style>
|
||||||
<Style Selector="WrapPanel > StackPanel">
|
<Style Selector="WrapPanel > StackPanel">
|
||||||
<Setter Property="Orientation" Value="Vertical" />
|
<Setter Property="Orientation" Value="Vertical" />
|
||||||
<Setter Property="Margin" Value="0 0 10 10" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
|
@@ -17,24 +17,24 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||||
private string inputPort;
|
private string inputPort;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||||
private string inputToken;
|
private string inputToken;
|
||||||
|
|
||||||
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private bool isToggleServerButtonEnabled = true;
|
private bool isToggleServerButtonEnabled = true;
|
||||||
|
|
||||||
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly ServerManager server;
|
private readonly ServerManager server;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
|
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public ServerConfigurationPanelModel(Window window, State state) {
|
public ServerConfigurationPanelModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.server = state.Server;
|
this.server = state.Server;
|
||||||
@@ -43,19 +43,19 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
|
|||||||
|
|
||||||
server.StatusChanged += OnServerStatusChanged;
|
server.StatusChanged += OnServerStatusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
server.StatusChanged -= OnServerStatusChanged;
|
server.StatusChanged -= OnServerStatusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||||
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
|
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateServerStatus() {
|
private void UpdateServerStatus() {
|
||||||
OnPropertyChanged(nameof(ToggleServerButtonText));
|
OnPropertyChanged(nameof(ToggleServerButtonText));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StartServer() {
|
private async Task StartServer() {
|
||||||
IsToggleServerButtonEnabled = false;
|
IsToggleServerButtonEnabled = false;
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
|
|||||||
UpdateServerStatus();
|
UpdateServerStatus();
|
||||||
IsToggleServerButtonEnabled = true;
|
IsToggleServerButtonEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StopServer() {
|
private async Task StopServer() {
|
||||||
IsToggleServerButtonEnabled = false;
|
IsToggleServerButtonEnabled = false;
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
|
|||||||
UpdateServerStatus();
|
UpdateServerStatus();
|
||||||
IsToggleServerButtonEnabled = true;
|
IsToggleServerButtonEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickToggleServerButton() {
|
public async Task OnClickToggleServerButton() {
|
||||||
if (server.IsRunning) {
|
if (server.IsRunning) {
|
||||||
await StopServer();
|
await StopServer();
|
||||||
@@ -92,17 +92,17 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
|
|||||||
await StartServer();
|
await StartServer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClickRandomizeToken() {
|
public void OnClickRandomizeToken() {
|
||||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
ServerConfiguration.Port = port;
|
ServerConfiguration.Port = port;
|
||||||
ServerConfiguration.Token = inputToken;
|
ServerConfiguration.Token = inputToken;
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
|
|||||||
|
|
||||||
await StartServer();
|
await StartServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnClickCancelChanges() {
|
public void OnClickCancelChanges() {
|
||||||
InputPort = ServerConfiguration.Port.ToString();
|
InputPort = ServerConfiguration.Port.ToString();
|
||||||
InputToken = ServerConfiguration.Token;
|
InputToken = ServerConfiguration.Token;
|
||||||
|
@@ -17,38 +17,38 @@ sealed partial class StatusBarModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private long? messageCount;
|
private long? messageCount;
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
||||||
private ServerManager.Status serverStatus;
|
private ServerManager.Status serverStatus;
|
||||||
|
|
||||||
public string ServerStatusText => serverStatus switch {
|
public string ServerStatusText => serverStatus switch {
|
||||||
ServerManager.Status.Starting => "STARTING",
|
ServerManager.Status.Starting => "STARTING",
|
||||||
ServerManager.Status.Started => "READY",
|
ServerManager.Status.Started => "READY",
|
||||||
ServerManager.Status.Stopping => "STOPPING",
|
ServerManager.Status.Stopping => "STOPPING",
|
||||||
ServerManager.Status.Stopped => "STOPPED",
|
ServerManager.Status.Stopped => "STOPPED",
|
||||||
_ => ""
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
private readonly IDisposable serverCountSubscription;
|
private readonly IDisposable serverCountSubscription;
|
||||||
private readonly IDisposable channelCountSubscription;
|
private readonly IDisposable channelCountSubscription;
|
||||||
private readonly IDisposable messageCountSubscription;
|
private readonly IDisposable messageCountSubscription;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public StatusBarModel() : this(State.Dummy) {}
|
public StatusBarModel() : this(State.Dummy) {}
|
||||||
|
|
||||||
public StatusBarModel(State state) {
|
public StatusBarModel(State state) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
|
||||||
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
|
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
|
||||||
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
|
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
|
||||||
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
|
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
|
||||||
|
|
||||||
state.Server.StatusChanged += OnServerStatusChanged;
|
state.Server.StatusChanged += OnServerStatusChanged;
|
||||||
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
serverCountSubscription.Dispose();
|
serverCountSubscription.Dispose();
|
||||||
channelCountSubscription.Dispose();
|
channelCountSubscription.Dispose();
|
||||||
@@ -56,7 +56,7 @@ sealed partial class StatusBarModel : ObservableObject, IDisposable {
|
|||||||
|
|
||||||
state.Server.StatusChanged -= OnServerStatusChanged;
|
state.Server.StatusChanged -= OnServerStatusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||||
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
|
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
|
||||||
}
|
}
|
||||||
|
@@ -16,23 +16,23 @@ public sealed partial class MainWindow : Window {
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = new MainWindowModel(this, Arguments.Empty);
|
DataContext = new MainWindowModel(this, Arguments.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal MainWindow(Arguments args) {
|
internal MainWindow(Arguments args) {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = new MainWindowModel(this, args);
|
DataContext = new MainWindowModel(this, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
|
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||||
e.Cancel = true;
|
e.Cancel = true;
|
||||||
Closing -= OnClosing;
|
Closing -= OnClosing;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Dispose();
|
await Dispose();
|
||||||
} finally {
|
} finally {
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Dispose() {
|
private async Task Dispose() {
|
||||||
if (DataContext is MainWindowModel model) {
|
if (DataContext is MainWindowModel model) {
|
||||||
try {
|
try {
|
||||||
|
@@ -15,47 +15,47 @@ namespace DHT.Desktop.Main;
|
|||||||
|
|
||||||
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||||
private const string DefaultTitle = "Discord History Tracker";
|
private const string DefaultTitle = "Discord History Tracker";
|
||||||
|
|
||||||
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private string title = DefaultTitle;
|
private string title = DefaultTitle;
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private UserControl currentScreen;
|
private UserControl currentScreen;
|
||||||
|
|
||||||
private readonly WelcomeScreen welcomeScreen;
|
private readonly WelcomeScreen welcomeScreen;
|
||||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||||
|
|
||||||
private MainContentScreenModel? mainContentScreenModel;
|
private MainContentScreenModel? mainContentScreenModel;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly int? concurrentDownloads;
|
private readonly int? concurrentDownloads;
|
||||||
|
|
||||||
private State? state;
|
private State? state;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||||
|
|
||||||
public MainWindowModel(Window window, Arguments args) {
|
public MainWindowModel(Window window, Arguments args) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
|
|
||||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
|
|
||||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||||
currentScreen = welcomeScreen;
|
currentScreen = welcomeScreen;
|
||||||
|
|
||||||
var dbFile = args.DatabaseFile;
|
string? dbFile = args.DatabaseFile;
|
||||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||||
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
async void OnWindowOpened(object? o, EventArgs eventArgs) {
|
||||||
window.Opened -= OnWindowOpened;
|
window.Opened -= OnWindowOpened;
|
||||||
|
|
||||||
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
// https://github.com/AvaloniaUI/Avalonia/issues/3071
|
||||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File.Exists(dbFile)) {
|
if (File.Exists(dbFile)) {
|
||||||
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
|
||||||
}
|
}
|
||||||
@@ -63,35 +63,35 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
|||||||
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Opened += OnWindowOpened;
|
window.Opened += OnWindowOpened;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.ServerPort != null) {
|
if (args.ServerPort != null) {
|
||||||
ServerConfiguration.Port = args.ServerPort.Value;
|
ServerConfiguration.Port = args.ServerPort.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.ServerToken != null) {
|
if (args.ServerToken != null) {
|
||||||
ServerConfiguration.Token = args.ServerToken;
|
ServerConfiguration.Token = args.ServerToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
concurrentDownloads = args.ConcurrentDownloads;
|
concurrentDownloads = args.ConcurrentDownloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
||||||
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
|
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
|
||||||
|
|
||||||
await DisposeState();
|
await DisposeState();
|
||||||
|
|
||||||
state = new State(db, concurrentDownloads);
|
state = new State(db, concurrentDownloads);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Log.Error(ex);
|
Log.Error(ex);
|
||||||
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainContentScreenModel = new MainContentScreenModel(window, state);
|
mainContentScreenModel = new MainContentScreenModel(window, state);
|
||||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||||
|
|
||||||
@@ -99,32 +99,32 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
|||||||
|
|
||||||
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
||||||
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||||
|
|
||||||
window.Focus();
|
window.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||||
if (mainContentScreenModel != null) {
|
if (mainContentScreenModel != null) {
|
||||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||||
await mainContentScreenModel.DisposeAsync();
|
await mainContentScreenModel.DisposeAsync();
|
||||||
mainContentScreenModel = null;
|
mainContentScreenModel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await DisposeState();
|
await DisposeState();
|
||||||
|
|
||||||
Title = DefaultTitle;
|
Title = DefaultTitle;
|
||||||
CurrentScreen = welcomeScreen;
|
CurrentScreen = welcomeScreen;
|
||||||
|
|
||||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DisposeState() {
|
private async Task DisposeState() {
|
||||||
if (state != null) {
|
if (state != null) {
|
||||||
await state.DisposeAsync();
|
await state.DisposeAsync();
|
||||||
state = null;
|
state = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
public async ValueTask DisposeAsync() {
|
||||||
if (mainContentScreenModel != null) {
|
if (mainContentScreenModel != null) {
|
||||||
await mainContentScreenModel.DisposeAsync();
|
await mainContentScreenModel.DisposeAsync();
|
||||||
|
@@ -13,21 +13,21 @@ sealed class AdvancedPageModel : IDisposable {
|
|||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public AdvancedPageModel() : this(null!, State.Dummy) {}
|
public AdvancedPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public AdvancedPageModel(Window window, State state) {
|
public AdvancedPageModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
|
||||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
ServerConfigurationModel.Dispose();
|
ServerConfigurationModel.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task VacuumDatabase() {
|
public async Task VacuumDatabase() {
|
||||||
const string Title = "Vacuum Database";
|
const string Title = "Vacuum Database";
|
||||||
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
|
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
|
||||||
|
@@ -25,69 +25,69 @@ namespace DHT.Desktop.Main.Pages;
|
|||||||
|
|
||||||
sealed class DatabasePageModel {
|
sealed class DatabasePageModel {
|
||||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||||
|
|
||||||
public IDatabaseFile Db { get; }
|
public IDatabaseFile Db { get; }
|
||||||
|
|
||||||
public event EventHandler? DatabaseClosed;
|
public event EventHandler? DatabaseClosed;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public DatabasePageModel() : this(null!, State.Dummy) {}
|
public DatabasePageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public DatabasePageModel(Window window, State state) {
|
public DatabasePageModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.Db = state.Db;
|
this.Db = state.Db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenDatabaseFolder() {
|
public async Task OpenDatabaseFolder() {
|
||||||
string file = Db.Path;
|
string file = Db.Path;
|
||||||
string? folder = Path.GetDirectoryName(file);
|
string? folder = Path.GetDirectoryName(file);
|
||||||
|
|
||||||
if (folder == null) {
|
if (folder == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (Environment.OSVersion.Platform) {
|
switch (Environment.OSVersion.Platform) {
|
||||||
case PlatformID.Win32NT:
|
case PlatformID.Win32NT:
|
||||||
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlatformID.Unix:
|
case PlatformID.Unix:
|
||||||
Process.Start("xdg-open", new string[] { folder });
|
Process.Start("xdg-open", new string[] { folder });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PlatformID.MacOSX:
|
case PlatformID.MacOSX:
|
||||||
Process.Start("open", new string[] { folder });
|
Process.Start("open", new string[] { folder });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CloseDatabase() {
|
public void CloseDatabase() {
|
||||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MergeWithDatabase() {
|
public async Task MergeWithDatabase() {
|
||||||
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
string[] paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||||
if (paths.Length > 0) {
|
if (paths.Length > 0) {
|
||||||
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
||||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||||
|
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await target.AddFrom(db);
|
await target.AddFrom(db);
|
||||||
return true;
|
return true;
|
||||||
@@ -96,60 +96,60 @@ sealed class DatabasePageModel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||||
private readonly ProgressDialog dialog;
|
private readonly ProgressDialog dialog;
|
||||||
private readonly int total;
|
private readonly int total;
|
||||||
private bool? decision;
|
private bool? decision;
|
||||||
|
|
||||||
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||||
this.total = total;
|
this.total = total;
|
||||||
this.dialog = dialog;
|
this.dialog = dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> CanUpgrade() {
|
public async Task<bool> CanUpgrade() {
|
||||||
return decision ??= (total > 1
|
return decision ??= (total > 1
|
||||||
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
|
||||||
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
return doUpgrade(new NullReporter());
|
return doUpgrade(new NullReporter());
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
public Task NextVersion() {
|
public Task NextVersion() {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task MainWork(string message, int finishedItems, int totalItems) {
|
public Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SubWork(string message, int finishedItems, int totalItems) {
|
public Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ImportLegacyArchive() {
|
public async Task ImportLegacyArchive() {
|
||||||
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||||
Title = "Open Legacy DHT Archive",
|
Title = "Open Legacy DHT Archive",
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
|
||||||
AllowMultiple = true
|
AllowMultiple = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (paths.Length > 0) {
|
if (paths.Length > 0) {
|
||||||
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
var fakeSnowflake = new FakeSnowflake();
|
var fakeSnowflake = new FakeSnowflake();
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
||||||
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||||
@@ -159,54 +159,54 @@ sealed class DatabasePageModel {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
|
||||||
static bool IsValidSnowflake(string value) {
|
static bool IsValidSnowflake(string value) {
|
||||||
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||||
|
|
||||||
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
foreach (DHT.Server.Data.Server server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||||
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
|
||||||
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
Title = server.Name + " (" + ServerTypes.ToNiceString(server.Type) + ")",
|
||||||
ValidityCheck = IsValidSnowflake
|
ValidityCheck = IsValidSnowflake,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
|
||||||
Title = "Imported Server IDs",
|
Title = "Imported Server IDs",
|
||||||
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
|
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID.",
|
||||||
};
|
};
|
||||||
|
|
||||||
var dialog = new TextBoxDialog { DataContext = model };
|
var dialog = new TextBoxDialog { DataContext = model };
|
||||||
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
|
||||||
|
|
||||||
if (result != DialogResult.OkCancel.Ok) {
|
if (result != DialogResult.OkCancel.Ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return model.ValidItems
|
return model.ValidItems
|
||||||
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
.Where(static item => !string.IsNullOrEmpty(item.Value))
|
||||||
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
.ToDictionary(static item => item.Item, static item => ulong.Parse(item.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
||||||
int total = paths.Length;
|
int total = paths.Length;
|
||||||
var oldStatistics = await DatabaseStatistics.Take(target);
|
DatabaseStatistics oldStatistics = await DatabaseStatistics.Take(target);
|
||||||
|
|
||||||
int successful = 0;
|
int successful = 0;
|
||||||
int finished = 0;
|
int finished = 0;
|
||||||
|
|
||||||
foreach (string path in paths) {
|
foreach (string path in paths) {
|
||||||
await callback.Update(Path.GetFileName(path), finished, total);
|
await callback.Update(Path.GetFileName(path), finished, total);
|
||||||
++finished;
|
++finished;
|
||||||
|
|
||||||
if (!File.Exists(path)) {
|
if (!File.Exists(path)) {
|
||||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (await performImport(path)) {
|
if (await performImport(path)) {
|
||||||
++successful;
|
++successful;
|
||||||
@@ -216,18 +216,18 @@ sealed class DatabasePageModel {
|
|||||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await callback.Update("Done", finished, total);
|
await callback.Update("Done", finished, total);
|
||||||
|
|
||||||
if (successful == 0) {
|
if (successful == 0) {
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newStatistics = await DatabaseStatistics.Take(target);
|
DatabaseStatistics newStatistics = await DatabaseStatistics.Take(target);
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
|
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
|
||||||
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
|
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
|
||||||
return new DatabaseStatistics(
|
return new DatabaseStatistics(
|
||||||
@@ -238,29 +238,29 @@ sealed class DatabasePageModel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
|
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
|
||||||
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
|
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
|
||||||
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
|
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
|
||||||
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
|
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
|
||||||
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
|
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
|
||||||
|
|
||||||
StringBuilder message = new StringBuilder();
|
var message = new StringBuilder();
|
||||||
message.Append("Processed ");
|
message.Append("Processed ");
|
||||||
|
|
||||||
if (successfulItems == totalItems) {
|
if (successfulItems == totalItems) {
|
||||||
message.Append(successfulItems.Pluralize(itemName));
|
message.Append(successfulItems.Pluralize(itemName));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
||||||
}
|
}
|
||||||
|
|
||||||
message.Append(" and added:\n\n \u2022 ");
|
message.Append(" and added:\n\n \u2022 ");
|
||||||
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
||||||
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
||||||
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
||||||
message.Append(newMessages.Pluralize("message"));
|
message.Append(newMessages.Pluralize("message"));
|
||||||
|
|
||||||
return message.ToString();
|
return message.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.DebugPage"
|
x:Class="DHT.Desktop.Main.Pages.DebugPage"
|
||||||
x:DataType="pages:DebugPageModel">
|
x:DataType="pages:DebugPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:DebugPageModel />
|
<pages:DebugPageModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||||
<Setter Property="FontSize" Value="15" />
|
<Setter Property="FontSize" Value="15" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Label">
|
||||||
|
<Setter Property="Margin" Value="0 5" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
</Style>
|
||||||
<Style Selector="WrapPanel > StackPanel">
|
<Style Selector="WrapPanel > StackPanel">
|
||||||
<Setter Property="Orientation" Value="Vertical" />
|
<Setter Property="Orientation" Value="Vertical" />
|
||||||
<Setter Property="Margin" Value="0 0 10 10" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
|
@@ -16,90 +16,90 @@ sealed class DebugPageModel {
|
|||||||
public string GenerateChannels { get; set; } = "0";
|
public string GenerateChannels { get; set; } = "0";
|
||||||
public string GenerateUsers { get; set; } = "0";
|
public string GenerateUsers { get; set; } = "0";
|
||||||
public string GenerateMessages { get; set; } = "0";
|
public string GenerateMessages { get; set; } = "0";
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public DebugPageModel() : this(null!, State.Dummy) {}
|
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public DebugPageModel(Window window, State state) {
|
public DebugPageModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickAddRandomDataToDatabase() {
|
public async void OnClickAddRandomDataToDatabase() {
|
||||||
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels 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) {
|
if (!int.TryParse(GenerateUsers, out int users) || users < 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 users must be at least 1!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int BatchSize = 500;
|
private const int BatchSize = 500;
|
||||||
|
|
||||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||||
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
await callback.Update("Adding messages in batches of " + BatchSize, finishedItems: 0, batchCount);
|
||||||
|
|
||||||
var rand = new Random();
|
var rand = new Random();
|
||||||
var server = new DHT.Server.Data.Server {
|
var server = new DHT.Server.Data.Server {
|
||||||
Id = RandomId(rand),
|
Id = RandomId(rand),
|
||||||
Name = RandomName("s"),
|
Name = RandomName("s"),
|
||||||
Type = ServerType.Server,
|
Type = ServerType.Server,
|
||||||
};
|
};
|
||||||
|
|
||||||
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
Channel[] channels = Enumerable.Range(start: 0, channelCount).Select(i => new Channel {
|
||||||
Id = RandomId(rand),
|
Id = RandomId(rand),
|
||||||
Server = server.Id,
|
Server = server.Id,
|
||||||
Name = RandomName("c"),
|
Name = RandomName("c"),
|
||||||
ParentId = null,
|
ParentId = null,
|
||||||
Position = i,
|
Position = i,
|
||||||
Topic = RandomText(rand, 10),
|
Topic = RandomText(rand, maxWords: 10),
|
||||||
Nsfw = rand.Next(4) == 0,
|
Nsfw = rand.Next(4) == 0,
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
User[] users = Enumerable.Range(start: 0, userCount).Select(_ => new User {
|
||||||
Id = RandomId(rand),
|
Id = RandomId(rand),
|
||||||
Name = RandomName("u"),
|
Name = RandomName("u"),
|
||||||
DisplayName = RandomName("u"),
|
DisplayName = RandomName("u"),
|
||||||
AvatarUrl = null,
|
AvatarUrl = null,
|
||||||
Discriminator = rand.Next(0, 9999).ToString(),
|
Discriminator = rand.Next(minValue: 0, maxValue: 9999).ToString(),
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
await state.Db.Users.Add(users);
|
await state.Db.Users.Add(users);
|
||||||
await state.Db.Servers.Add([server]);
|
await state.Db.Servers.Add([server]);
|
||||||
await state.Db.Channels.Add(channels);
|
await state.Db.Channels.Add(channels);
|
||||||
|
|
||||||
var now = DateTimeOffset.Now;
|
DateTimeOffset now = DateTimeOffset.Now;
|
||||||
int batchIndex = 0;
|
int batchIndex = 0;
|
||||||
|
|
||||||
while (messageCount > 0) {
|
while (messageCount > 0) {
|
||||||
int hourOffset = batchIndex;
|
int hourOffset = batchIndex;
|
||||||
|
|
||||||
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
|
Message[] messages = Enumerable.Range(start: 0, Math.Min(messageCount, BatchSize)).Select(i => {
|
||||||
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
|
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
|
||||||
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(minValue: 1, maxValue: 60)) : null;
|
||||||
|
|
||||||
var timeMillis = time.ToUnixTimeMilliseconds();
|
long timeMillis = time.ToUnixTimeMilliseconds();
|
||||||
var editMillis = edit?.ToUnixTimeMilliseconds();
|
long? editMillis = edit?.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
return new Message {
|
return new Message {
|
||||||
Id = (ulong) timeMillis,
|
Id = (ulong) timeMillis,
|
||||||
Sender = RandomBiasedIndex(rand, users).Id,
|
Sender = RandomBiasedIndex(rand, users).Id,
|
||||||
Channel = RandomBiasedIndex(rand, channels).Id,
|
Channel = RandomBiasedIndex(rand, channels).Id,
|
||||||
Text = RandomText(rand, 100),
|
Text = RandomText(rand, maxWords: 100),
|
||||||
Timestamp = timeMillis,
|
Timestamp = timeMillis,
|
||||||
EditTimestamp = editMillis,
|
EditTimestamp = editMillis,
|
||||||
RepliedToId = null,
|
RepliedToId = null,
|
||||||
@@ -108,28 +108,28 @@ sealed class DebugPageModel {
|
|||||||
Reactions = ImmutableList<Reaction>.Empty,
|
Reactions = ImmutableList<Reaction>.Empty,
|
||||||
};
|
};
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
await state.Db.Messages.Add(messages);
|
await state.Db.Messages.Add(messages);
|
||||||
|
|
||||||
messageCount -= BatchSize;
|
messageCount -= BatchSize;
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ulong RandomId(Random rand) {
|
private static ulong RandomId(Random rand) {
|
||||||
ulong h = unchecked((ulong) rand.Next());
|
ulong h = unchecked((ulong) rand.Next());
|
||||||
ulong l = unchecked((ulong) rand.Next());
|
ulong l = unchecked((ulong) rand.Next());
|
||||||
return (h << 32) | l;
|
return (h << 32) | l;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RandomName(string prefix) {
|
private static string RandomName(string prefix) {
|
||||||
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
||||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly string[] RandomWords = [
|
private static readonly string[] RandomWords = [
|
||||||
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||||
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
||||||
@@ -153,12 +153,12 @@ sealed class DebugPageModel {
|
|||||||
"vanilla",
|
"vanilla",
|
||||||
"watercress", "watermelon",
|
"watercress", "watermelon",
|
||||||
"yam",
|
"yam",
|
||||||
"zucchini"
|
"zucchini",
|
||||||
];
|
];
|
||||||
|
|
||||||
private static string RandomText(Random rand, int maxWords) {
|
private static string RandomText(Random rand, int maxWords) {
|
||||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), y: 3));
|
||||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
return string.Join(separator: ' ', Enumerable.Range(start: 0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
|
@@ -13,8 +13,8 @@
|
|||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<Style Selector="Expander">
|
<Style Selector="WrapPanel > Button">
|
||||||
<Setter Property="Margin" Value="0 5 0 0" />
|
<Setter Property="Margin" Value="0 0 10 10" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="DataGridColumnHeader">
|
<Style Selector="DataGridColumnHeader">
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
@@ -30,16 +30,17 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<StackPanel Orientation="Vertical" Spacing="20">
|
<StackPanel Orientation="Vertical">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<WrapPanel Orientation="Horizontal">
|
||||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
|
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
|
||||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
||||||
</StackPanel>
|
<Button Command="{Binding OnClickDeleteOrphanedDownloads}">Delete Orphaned Downloads</Button>
|
||||||
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
</WrapPanel>
|
||||||
<TextBlock TextWrapping="Wrap">
|
<StackPanel Orientation="Vertical" Spacing="20" Margin="0 10 0 0">
|
||||||
Downloading state and filter settings are remembered per-database.
|
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
||||||
</TextBlock>
|
<TextBlock TextWrapping="Wrap">
|
||||||
<StackPanel Orientation="Vertical" Spacing="12">
|
Downloading state and filter settings are remembered per-database.
|
||||||
|
</TextBlock>
|
||||||
<Expander Header="Download Status" IsExpanded="True">
|
<Expander Header="Download Status" IsExpanded="True">
|
||||||
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
|
@@ -1,12 +1,17 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Reactive.Linq;
|
using System.Reactive.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.ReactiveUI;
|
using Avalonia.ReactiveUI;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
|
using DHT.Desktop.Dialogs.Message;
|
||||||
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Aggregations;
|
using DHT.Server.Data.Aggregations;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Data.Settings;
|
using DHT.Server.Data.Settings;
|
||||||
@@ -18,132 +23,134 @@ namespace DHT.Desktop.Main.Pages;
|
|||||||
|
|
||||||
sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
||||||
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
|
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private bool isToggleDownloadButtonEnabled = true;
|
private bool isToggleDownloadButtonEnabled = true;
|
||||||
|
|
||||||
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||||
private bool isRetryingFailedDownloads = false;
|
private bool isRetryingFailedDownloads = false;
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||||
private bool hasFailedDownloads;
|
private bool hasFailedDownloads;
|
||||||
|
|
||||||
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private string downloadMessage = "";
|
private string downloadMessage = "";
|
||||||
|
|
||||||
public DownloadItemFilterPanelModel FilterModel { get; }
|
public DownloadItemFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
private readonly StatisticsRow statisticsPending = new ("Pending");
|
private readonly StatisticsRow statisticsPending = new ("Pending");
|
||||||
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
||||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||||
|
|
||||||
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
|
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
|
||||||
|
|
||||||
public bool IsDownloading => state.Downloader.IsDownloading;
|
public bool IsDownloading => state.Downloader.IsDownloading;
|
||||||
|
|
||||||
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
||||||
private readonly IDisposable downloadItemCountSubscription;
|
private readonly IDisposable downloadItemCountSubscription;
|
||||||
|
|
||||||
private IDisposable? finishedItemsSubscription;
|
private IDisposable? finishedItemsSubscription;
|
||||||
private DownloadItemFilter? currentDownloadFilter;
|
private DownloadItemFilter? currentDownloadFilter;
|
||||||
|
|
||||||
public DownloadsPageModel() : this(State.Dummy) {}
|
public DownloadsPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public DownloadsPageModel(State state) {
|
public DownloadsPageModel(Window window, State state) {
|
||||||
|
this.window = window;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
|
||||||
FilterModel = new DownloadItemFilterPanelModel(state);
|
FilterModel = new DownloadItemFilterPanelModel(state);
|
||||||
|
|
||||||
StatisticsRows = [
|
StatisticsRows = [
|
||||||
statisticsPending,
|
statisticsPending,
|
||||||
statisticsDownloaded,
|
statisticsDownloaded,
|
||||||
statisticsFailed,
|
statisticsFailed,
|
||||||
statisticsSkipped
|
statisticsSkipped,
|
||||||
];
|
];
|
||||||
|
|
||||||
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
|
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
|
||||||
|
|
||||||
RecomputeDownloadStatistics();
|
RecomputeDownloadStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Initialize() {
|
public async Task Initialize() {
|
||||||
await FilterModel.Initialize();
|
await FilterModel.Initialize();
|
||||||
|
|
||||||
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, false)) {
|
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, defaultValue: false)) {
|
||||||
await StartDownload();
|
await StartDownload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
public async ValueTask DisposeAsync() {
|
||||||
finishedItemsSubscription?.Dispose();
|
finishedItemsSubscription?.Dispose();
|
||||||
|
|
||||||
downloadItemCountSubscription.Dispose();
|
downloadItemCountSubscription.Dispose();
|
||||||
downloadStatisticsTask.Dispose();
|
downloadStatisticsTask.Dispose();
|
||||||
|
|
||||||
await FilterModel.DisposeAsync();
|
await FilterModel.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDownloadCountChanged(long newDownloadCount) {
|
private void OnDownloadCountChanged(long newDownloadCount) {
|
||||||
RecomputeDownloadStatistics();
|
RecomputeDownloadStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickToggleDownload() {
|
public async Task OnClickToggleDownload() {
|
||||||
IsToggleDownloadButtonEnabled = false;
|
IsToggleDownloadButtonEnabled = false;
|
||||||
|
|
||||||
if (IsDownloading) {
|
if (IsDownloading) {
|
||||||
await StopDownload();
|
await StopDownload();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await StartDownload();
|
await StartDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
|
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
|
||||||
IsToggleDownloadButtonEnabled = true;
|
IsToggleDownloadButtonEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StartDownload() {
|
private async Task StartDownload() {
|
||||||
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||||
|
|
||||||
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
||||||
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
|
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
|
||||||
|
|
||||||
OnDownloadStateChanged();
|
OnDownloadStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StopDownload() {
|
private async Task StopDownload() {
|
||||||
await state.Downloader.Stop();
|
await state.Downloader.Stop();
|
||||||
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||||
|
|
||||||
finishedItemsSubscription?.Dispose();
|
finishedItemsSubscription?.Dispose();
|
||||||
finishedItemsSubscription = null;
|
finishedItemsSubscription = null;
|
||||||
|
|
||||||
currentDownloadFilter = null;
|
currentDownloadFilter = null;
|
||||||
OnDownloadStateChanged();
|
OnDownloadStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDownloadStateChanged() {
|
private void OnDownloadStateChanged() {
|
||||||
RecomputeDownloadStatistics();
|
RecomputeDownloadStatistics();
|
||||||
|
|
||||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
||||||
OnPropertyChanged(nameof(IsDownloading));
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnItemFinished(DownloadItem item) {
|
private void OnItemFinished(DownloadItem item) {
|
||||||
RecomputeDownloadStatistics();
|
RecomputeDownloadStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickRetryFailedDownloads() {
|
public async Task OnClickRetryFailedDownloads() {
|
||||||
IsRetryingFailedDownloads = true;
|
IsRetryingFailedDownloads = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await state.Db.Downloads.RetryFailed();
|
await state.Db.Downloads.RetryFailed();
|
||||||
RecomputeDownloadStatistics();
|
RecomputeDownloadStatistics();
|
||||||
@@ -153,20 +160,64 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
|||||||
IsRetryingFailedDownloads = false;
|
IsRetryingFailedDownloads = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RecomputeDownloadStatistics() {
|
private void RecomputeDownloadStatistics() {
|
||||||
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
|
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string DeleteOrphanedDownloadsTitle = "Delete Orphaned Downloads";
|
||||||
|
|
||||||
|
public async Task OnClickDeleteOrphanedDownloads() {
|
||||||
|
await ProgressDialog.Show(window, DeleteOrphanedDownloadsTitle, DeleteOrphanedDownloads);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteOrphanedDownloads(ProgressDialog dialog, IProgressCallback callback) {
|
||||||
|
await callback.UpdateIndeterminate("Searching for orphaned downloads...");
|
||||||
|
|
||||||
|
HashSet<string> reachableNormalizedUrls = [];
|
||||||
|
HashSet<string> orphanedNormalizedUrls = [];
|
||||||
|
|
||||||
|
await foreach (Download download in state.Db.Downloads.FindAllDownloadableUrls()) {
|
||||||
|
reachableNormalizedUrls.Add(download.NormalizedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await foreach (Download download in state.Db.Downloads.Get()) {
|
||||||
|
string normalizedUrl = download.NormalizedUrl;
|
||||||
|
if (!reachableNormalizedUrls.Contains(normalizedUrl)) {
|
||||||
|
orphanedNormalizedUrls.Add(normalizedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanedNormalizedUrls.Count == 0) {
|
||||||
|
await Dialog.ShowOk(window, DeleteOrphanedDownloadsTitle, "No orphaned downloads found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, DeleteOrphanedDownloadsTitle, orphanedNormalizedUrls.Count + " orphaned download(s) will be removed from this database. This action cannot be undone. Proceed?") != DialogResult.YesNo.Yes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Deleting orphaned downloads...");
|
||||||
|
await state.Db.Downloads.Remove(orphanedNormalizedUrls);
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, DeleteOrphanedDownloadsTitle, "Orphaned downloads deleted. Vacuum database now to reclaim space?") != DialogResult.YesNo.Yes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Vacuuming database...");
|
||||||
|
await state.Db.Vacuum();
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||||
statisticsPending.Items = statusStatistics.PendingCount;
|
statisticsPending.Items = statusStatistics.PendingCount;
|
||||||
statisticsPending.Size = statusStatistics.PendingTotalSize;
|
statisticsPending.Size = statusStatistics.PendingTotalSize;
|
||||||
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
|
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
||||||
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
|
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
|
||||||
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
|
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
statisticsFailed.Items = statusStatistics.FailedCount;
|
statisticsFailed.Items = statusStatistics.FailedCount;
|
||||||
statisticsFailed.Size = statusStatistics.FailedTotalSize;
|
statisticsFailed.Size = statusStatistics.FailedTotalSize;
|
||||||
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
|
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
|
||||||
@@ -174,17 +225,17 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
|||||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||||
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
|
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
|
||||||
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
|
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
|
||||||
|
|
||||||
HasFailedDownloads = statusStatistics.FailedCount > 0;
|
HasFailedDownloads = statusStatistics.FailedCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[ObservableObject]
|
[ObservableObject]
|
||||||
public sealed partial class StatisticsRow(string state) {
|
public sealed partial class StatisticsRow(string state) {
|
||||||
public string State { get; } = state;
|
public string State { get; } = state;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int items;
|
private int items;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(SizeText))]
|
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||||
private ulong? size;
|
private ulong? size;
|
||||||
@@ -192,7 +243,7 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(SizeText))]
|
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||||
private bool hasFilesWithUnknownSize;
|
private bool hasFilesWithUnknownSize;
|
||||||
|
|
||||||
public string SizeText {
|
public string SizeText {
|
||||||
get {
|
get {
|
||||||
if (size == null) {
|
if (size == null) {
|
||||||
|
@@ -13,13 +13,21 @@
|
|||||||
|
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
<TextBlock TextWrapping="Wrap">
|
<TextBlock TextWrapping="Wrap">
|
||||||
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing {0}.">
|
||||||
|
<Binding Path="OpenDevToolsShortcutText" />
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
||||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
|
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
||||||
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the {0} shortcut is enabled.">
|
||||||
|
<Binding Path="OpenDevToolsShortcutText" />
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
@@ -9,20 +9,20 @@ namespace DHT.Desktop.Main.Pages;
|
|||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class TrackingPage : UserControl {
|
public sealed partial class TrackingPage : UserControl {
|
||||||
private bool isCopyingScript;
|
private bool isCopyingScript;
|
||||||
|
|
||||||
public TrackingPage() {
|
public TrackingPage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||||
if (DataContext is TrackingPageModel model) {
|
if (DataContext is TrackingPageModel model) {
|
||||||
var originalText = CopyTrackingScript.Content;
|
object? originalText = CopyTrackingScript.Content;
|
||||||
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
||||||
|
|
||||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||||
isCopyingScript = true;
|
isCopyingScript = true;
|
||||||
CopyTrackingScript.Content = "Script Copied!";
|
CopyTrackingScript.Content = "Script Copied!";
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||||
CopyTrackingScript.Content = originalText;
|
CopyTrackingScript.Content = originalText;
|
||||||
isCopyingScript = false;
|
isCopyingScript = false;
|
||||||
|
@@ -3,6 +3,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Discord;
|
using DHT.Desktop.Discord;
|
||||||
@@ -14,60 +15,62 @@ namespace DHT.Desktop.Main.Pages;
|
|||||||
sealed partial class TrackingPageModel : ObservableObject {
|
sealed partial class TrackingPageModel : ObservableObject {
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private bool isCopyTrackingScriptButtonEnabled = true;
|
private bool isCopyTrackingScriptButtonEnabled = true;
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||||
private bool? areDevToolsEnabled = null;
|
private bool? areDevToolsEnabled = null;
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||||
private bool isToggleAppDevToolsButtonEnabled = false;
|
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||||
|
|
||||||
|
public string OpenDevToolsShortcutText { get; } = OperatingSystem.IsMacOS() ? "Cmd+Shift+I" : "Ctrl+Shift+I";
|
||||||
|
|
||||||
public string ToggleAppDevToolsButtonText {
|
public string ToggleAppDevToolsButtonText {
|
||||||
get {
|
get {
|
||||||
if (!AreDevToolsEnabled.HasValue) {
|
if (!AreDevToolsEnabled.HasValue) {
|
||||||
return "Loading...";
|
return "Loading...";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||||
return "Unavailable";
|
return "Unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
return (AreDevToolsEnabled.Value ? "Disable" : "Enable") + " " + OpenDevToolsShortcutText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public TrackingPageModel() : this(null!) {}
|
public TrackingPageModel() : this(null!) {}
|
||||||
|
|
||||||
public TrackingPageModel(Window window) {
|
public TrackingPageModel(Window window) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
|
|
||||||
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> OnClickCopyTrackingScript() {
|
public async Task<bool> OnClickCopyTrackingScript() {
|
||||||
IsCopyTrackingScriptButtonEnabled = false;
|
IsCopyTrackingScriptButtonEnabled = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await CopyTrackingScript();
|
return await CopyTrackingScript();
|
||||||
} finally {
|
} finally {
|
||||||
IsCopyTrackingScriptButtonEnabled = true;
|
IsCopyTrackingScriptButtonEnabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CopyTrackingScript() {
|
private async Task<bool> CopyTrackingScript() {
|
||||||
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
|
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
|
||||||
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
||||||
|
|
||||||
var clipboard = window.Clipboard;
|
IClipboard? clipboard = window.Clipboard;
|
||||||
if (clipboard == null) {
|
if (clipboard == null) {
|
||||||
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await clipboard.SetTextAsync(script);
|
await clipboard.SetTextAsync(script);
|
||||||
return true;
|
return true;
|
||||||
@@ -76,10 +79,10 @@ sealed partial class TrackingPageModel : ObservableObject {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InitializeDevToolsToggle() {
|
private async Task InitializeDevToolsToggle() {
|
||||||
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
|
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
|
||||||
|
|
||||||
if (devToolsEnabled.HasValue) {
|
if (devToolsEnabled.HasValue) {
|
||||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||||
IsToggleAppDevToolsButtonEnabled = true;
|
IsToggleAppDevToolsButtonEnabled = true;
|
||||||
@@ -88,44 +91,44 @@ sealed partial class TrackingPageModel : ObservableObject {
|
|||||||
IsToggleAppDevToolsButtonEnabled = false;
|
IsToggleAppDevToolsButtonEnabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickToggleAppDevTools() {
|
public async Task OnClickToggleAppDevTools() {
|
||||||
const string DialogTitle = "Discord App Settings File";
|
const string DialogTitle = "Discord App Settings File";
|
||||||
|
|
||||||
if (!AreDevToolsEnabled.HasValue) {
|
if (!AreDevToolsEnabled.HasValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool oldState = AreDevToolsEnabled.Value;
|
bool oldState = AreDevToolsEnabled.Value;
|
||||||
bool newState = !oldState;
|
bool newState = !oldState;
|
||||||
|
|
||||||
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||||
case SettingsJsonResult.Success:
|
case SettingsJsonResult.Success:
|
||||||
AreDevToolsEnabled = newState;
|
AreDevToolsEnabled = newState;
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.AlreadySet:
|
case SettingsJsonResult.AlreadySet:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
|
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " is already " + (newState ? "enabled." : "disabled."));
|
||||||
AreDevToolsEnabled = newState;
|
AreDevToolsEnabled = newState;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.FileNotFound:
|
case SettingsJsonResult.FileNotFound:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.ReadError:
|
case SettingsJsonResult.ReadError:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.InvalidJson:
|
case SettingsJsonResult.InvalidJson:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SettingsJsonResult.WriteError:
|
case SettingsJsonResult.WriteError:
|
||||||
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
@@ -13,58 +12,58 @@ using DHT.Desktop.Server;
|
|||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Service.Viewer;
|
using DHT.Server.Service.Viewer;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||||
|
private static readonly Log Log = Log.ForType<ViewerPageModel>();
|
||||||
|
|
||||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool hasFilters = false;
|
private bool hasFilters = false;
|
||||||
|
|
||||||
public MessageFilterPanelModel FilterModel { get; }
|
public MessageFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public ViewerPageModel() : this(null!, State.Dummy) {}
|
public ViewerPageModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public ViewerPageModel(Window window, State state) {
|
public ViewerPageModel(Window window, State state) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
|
||||||
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
|
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
|
||||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
FilterModel.Dispose();
|
FilterModel.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
HasFilters = FilterModel.HasAnyFilters;
|
HasFilters = FilterModel.HasAnyFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void OnClickOpenViewer() {
|
public async void OnClickOpenViewer() {
|
||||||
try {
|
try {
|
||||||
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
|
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
|
||||||
string serverToken = ServerConfiguration.Token;
|
string serverToken = ServerConfiguration.Token;
|
||||||
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
|
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
|
||||||
|
SystemUtils.OpenUrl(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
|
||||||
Process.Start(new ProcessStartInfo(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId)) {
|
|
||||||
UseShellExecute = true
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
|
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickApplyFiltersToDatabase() {
|
public async Task OnClickApplyFiltersToDatabase() {
|
||||||
var filter = FilterModel.CreateFilter();
|
MessageFilter filter = FilterModel.CreateFilter();
|
||||||
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
long messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
||||||
|
|
||||||
if (DatabaseToolFilterModeKeep) {
|
if (DatabaseToolFilterModeKeep) {
|
||||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.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?")) {
|
||||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
|
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
|
||||||
@@ -76,8 +75,23 @@ sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
|
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
|
||||||
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
|
await ProgressDialog.Show(window, "Apply Filters", async (_, callback) => {
|
||||||
|
await callback.UpdateIndeterminate("Removing messages...");
|
||||||
|
Log.Info("Removed messages: " + await state.Db.Messages.Remove(filter, removalMode));
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up attachments...");
|
||||||
|
Log.Info("Removed orphaned attachments: " + await state.Db.Messages.RemoveUnreachableAttachments());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up users...");
|
||||||
|
Log.Info("Removed orphaned users: " + await state.Db.Users.RemoveUnreachable());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up channels...");
|
||||||
|
Log.Info("Removed orphaned channels: " + await state.Db.Channels.RemoveUnreachable());
|
||||||
|
|
||||||
|
await callback.UpdateIndeterminate("Cleaning up servers...");
|
||||||
|
Log.Info("Removed orphaned servers: " + await state.Db.Servers.RemoveUnreachable());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -40,13 +40,13 @@
|
|||||||
<Style Selector="TabItem:pointerover /template/ Border">
|
<Style Selector="TabItem:pointerover /template/ Border">
|
||||||
<Setter Property="Background" Value="#455785" />
|
<Setter Property="Background" Value="#455785" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:pointerover > TextBlock">
|
<Style Selector="TabItem:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#E9E9E9" />
|
<Setter Property="Foreground" Value="#E9E9E9" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected:pointerover /template/ Border">
|
<Style Selector="TabItem:selected:pointerover /template/ Border">
|
||||||
<Setter Property="Background" Value="#FFFFFF" />
|
<Setter Property="Background" Value="#FFFFFF" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected:pointerover > TextBlock">
|
<Style Selector="TabItem:selected:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#1A2234" />
|
<Setter Property="Foreground" Value="#1A2234" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:selected">
|
<Style Selector="TabItem:selected">
|
||||||
@@ -56,9 +56,11 @@
|
|||||||
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
|
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
|
||||||
<Setter Property="IsVisible" Value="False" />
|
<Setter Property="IsVisible" Value="False" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem:disabled > TextBlock">
|
<Style Selector="TabItem:disabled /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="#B2B2B2" />
|
<Setter Property="Foreground" Value="#B2B2B2" />
|
||||||
<Setter Property="TextDecorations" Value="Strikethrough" />
|
</Style>
|
||||||
|
<Style Selector="TabItem:disabled /template/ ContentPresenter">
|
||||||
|
<Setter Property="AccessText.TextDecorations" Value="Strikethrough" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem.first">
|
<Style Selector="TabItem.first">
|
||||||
<Setter Property="Margin" Value="0 13 0 0" />
|
<Setter Property="Margin" Value="0 13 0 0" />
|
||||||
|
@@ -10,72 +10,68 @@ namespace DHT.Desktop.Main.Screens;
|
|||||||
sealed class MainContentScreenModel : IAsyncDisposable {
|
sealed class MainContentScreenModel : IAsyncDisposable {
|
||||||
public DatabasePage DatabasePage { get; }
|
public DatabasePage DatabasePage { get; }
|
||||||
private DatabasePageModel DatabasePageModel { get; }
|
private DatabasePageModel DatabasePageModel { get; }
|
||||||
|
|
||||||
public TrackingPage TrackingPage { get; }
|
public TrackingPage TrackingPage { get; }
|
||||||
private TrackingPageModel TrackingPageModel { get; }
|
private TrackingPageModel TrackingPageModel { get; }
|
||||||
|
|
||||||
public DownloadsPage DownloadsPage { get; }
|
public DownloadsPage DownloadsPage { get; }
|
||||||
private DownloadsPageModel DownloadsPageModel { get; }
|
private DownloadsPageModel DownloadsPageModel { get; }
|
||||||
|
|
||||||
public ViewerPage ViewerPage { get; }
|
public ViewerPage ViewerPage { get; }
|
||||||
private ViewerPageModel ViewerPageModel { get; }
|
private ViewerPageModel ViewerPageModel { get; }
|
||||||
|
|
||||||
public AdvancedPage AdvancedPage { get; }
|
public AdvancedPage AdvancedPage { get; }
|
||||||
private AdvancedPageModel AdvancedPageModel { get; }
|
private AdvancedPageModel AdvancedPageModel { get; }
|
||||||
|
|
||||||
public DebugPage? DebugPage { get; }
|
public DebugPage? DebugPage { get; }
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
public bool HasDebugPage => true;
|
public bool HasDebugPage => true;
|
||||||
private DebugPageModel DebugPageModel { get; }
|
private DebugPageModel DebugPageModel { get; }
|
||||||
#else
|
#else
|
||||||
public bool HasDebugPage => false;
|
public bool HasDebugPage => false;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public StatusBarModel StatusBarModel { get; }
|
public StatusBarModel StatusBarModel { get; }
|
||||||
|
|
||||||
public event EventHandler? DatabaseClosed {
|
public event EventHandler? DatabaseClosed {
|
||||||
add {
|
add => DatabasePageModel.DatabaseClosed += value;
|
||||||
DatabasePageModel.DatabaseClosed += value;
|
remove => DatabasePageModel.DatabaseClosed -= value;
|
||||||
}
|
|
||||||
remove {
|
|
||||||
DatabasePageModel.DatabaseClosed -= value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
||||||
|
|
||||||
public MainContentScreenModel(Window window, State state) {
|
public MainContentScreenModel(Window window, State state) {
|
||||||
DatabasePageModel = new DatabasePageModel(window, state);
|
DatabasePageModel = new DatabasePageModel(window, state);
|
||||||
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 };
|
||||||
|
|
||||||
DownloadsPageModel = new DownloadsPageModel(state);
|
DownloadsPageModel = new DownloadsPageModel(window, state);
|
||||||
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
|
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
|
||||||
|
|
||||||
ViewerPageModel = new ViewerPageModel(window, state);
|
ViewerPageModel = new ViewerPageModel(window, state);
|
||||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||||
|
|
||||||
AdvancedPageModel = new AdvancedPageModel(window, state);
|
AdvancedPageModel = new AdvancedPageModel(window, state);
|
||||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
DebugPageModel = new DebugPageModel(window, state);
|
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(state);
|
StatusBarModel = new StatusBarModel(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Initialize() {
|
public async Task Initialize() {
|
||||||
await DownloadsPageModel.Initialize();
|
await DownloadsPageModel.Initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
public async ValueTask DisposeAsync() {
|
||||||
await DownloadsPageModel.DisposeAsync();
|
await DownloadsPageModel.DisposeAsync();
|
||||||
ViewerPageModel.Dispose();
|
ViewerPageModel.Dispose();
|
||||||
|
@@ -22,20 +22,21 @@
|
|||||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button">
|
<Style Selector="Grid#ButtonPanel > Button">
|
||||||
<Setter Property="Margin" Value="5 0" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Panel Name="RootPanel">
|
<Panel Name="RootPanel">
|
||||||
<StackPanel Margin="42">
|
<StackPanel Margin="42 30">
|
||||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
|
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
<Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
|
||||||
<Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
<Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>
|
||||||
<Button Command="{Binding Exit}">Exit</Button>
|
<Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button>
|
||||||
</StackPanel>
|
<Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button>
|
||||||
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -10,32 +12,35 @@ using DHT.Desktop.Dialogs.Progress;
|
|||||||
using DHT.Server.Data.Settings;
|
using DHT.Server.Data.Settings;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Sqlite.Schema;
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens;
|
namespace DHT.Desktop.Main.Screens;
|
||||||
|
|
||||||
sealed partial class WelcomeScreenModel : ObservableObject {
|
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||||
|
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
|
||||||
|
|
||||||
public string Version => Program.Version;
|
public string Version => Program.Version;
|
||||||
|
|
||||||
[ObservableProperty(Setter = Access.Private)]
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||||
|
|
||||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
|
||||||
private string? dbFilePath;
|
private string? dbFilePath;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public WelcomeScreenModel() : this(null!) {}
|
public WelcomeScreenModel() : this(null!) {}
|
||||||
|
|
||||||
public WelcomeScreenModel(Window window) {
|
public WelcomeScreenModel(Window window) {
|
||||||
this.window = window;
|
this.window = window;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenOrCreateDatabase() {
|
public async Task OpenOrCreateDatabase() {
|
||||||
IsOpenOrCreateDatabaseButtonEnabled = false;
|
IsOpenOrCreateDatabaseButtonEnabled = false;
|
||||||
try {
|
try {
|
||||||
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
string? path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
await OpenOrCreateDatabaseFromPath(path);
|
await OpenOrCreateDatabaseFromPath(path);
|
||||||
}
|
}
|
||||||
@@ -43,35 +48,29 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
|||||||
IsOpenOrCreateDatabaseButtonEnabled = true;
|
IsOpenOrCreateDatabaseButtonEnabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||||
dbFilePath = path;
|
dbFilePath = path;
|
||||||
|
|
||||||
bool isNew = !File.Exists(path);
|
bool isNew = !File.Exists(path);
|
||||||
|
|
||||||
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
|
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
|
||||||
await db.Settings.Set(SettingsKey.DownloadsAutoStart, true);
|
await db.Settings.Set(SettingsKey.DownloadsAutoStart, value: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
DatabaseSelected?.Invoke(this, db);
|
DatabaseSelected?.Invoke(this, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
|
||||||
private readonly Window window;
|
|
||||||
|
|
||||||
public SchemaUpgradeCallbacks(Window window) {
|
|
||||||
this.window = window;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> CanUpgrade() {
|
public async Task<bool> CanUpgrade() {
|
||||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||||
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
||||||
var reporter = new ProgressReporter(versionSteps, callbacks);
|
var reporter = new ProgressReporter(versionSteps, callbacks);
|
||||||
@@ -83,33 +82,25 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
|||||||
|
|
||||||
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
|
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||||
private readonly IReadOnlyList<IProgressCallback> callbacks;
|
|
||||||
|
|
||||||
private readonly int versionSteps;
|
|
||||||
private int versionProgress = 0;
|
private int versionProgress = 0;
|
||||||
|
|
||||||
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
|
|
||||||
this.callbacks = callbacks;
|
|
||||||
this.versionSteps = versionSteps;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task NextVersion() {
|
public async Task NextVersion() {
|
||||||
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||||
await HideChildren(0);
|
await HideChildren(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
||||||
await callbacks[1].Update(message, finishedItems, totalItems);
|
await callbacks[1].Update(message, finishedItems, totalItems);
|
||||||
await HideChildren(1);
|
await HideChildren(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
||||||
await callbacks[2].Update(message, finishedItems, totalItems);
|
await callbacks[2].Update(message, finishedItems, totalItems);
|
||||||
await HideChildren(2);
|
await HideChildren(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HideChildren(int parentIndex) {
|
private async Task HideChildren(int parentIndex) {
|
||||||
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
||||||
await callbacks[i].Hide();
|
await callbacks[i].Hide();
|
||||||
@@ -117,11 +108,58 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CheckUpdates() {
|
||||||
|
var latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => {
|
||||||
|
var client = new HttpClient(new SocketsHttpHandler {
|
||||||
|
AutomaticDecompression = DecompressionMethods.None,
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
UseCookies = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.MaxResponseContentBufferSize = 1024;
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
|
||||||
|
|
||||||
|
string response;
|
||||||
|
try {
|
||||||
|
response = await client.GetStringAsync(Program.Website + "/version");
|
||||||
|
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.Error(e);
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.Version.TryParse(response, out Version? latestVersion)) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestVersion;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latestVersion == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Program.AssemblyVersion >= latestVersion) {
|
||||||
|
await Dialog.ShowOk(window, "Check Updates", "You are using the latest version.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Dialog.ShowYesNo(window, "Check Updates", "A newer version is available: v" + Program.VersionToString(latestVersion) + "\nVisit the official website and close the app?") == DialogResult.YesNo.Yes) {
|
||||||
|
SystemUtils.OpenUrl(Program.Website);
|
||||||
|
Exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ShowAboutDialog() {
|
public async Task ShowAboutDialog() {
|
||||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Exit() {
|
public void Exit() {
|
||||||
window.Close();
|
window.Close();
|
||||||
}
|
}
|
||||||
|
@@ -9,33 +9,44 @@ namespace DHT.Desktop;
|
|||||||
|
|
||||||
static class Program {
|
static class Program {
|
||||||
public static string Version { get; }
|
public static string Version { get; }
|
||||||
|
public static Version AssemblyVersion { get; }
|
||||||
public static CultureInfo Culture { get; }
|
public static CultureInfo Culture { get; }
|
||||||
public static ResourceLoader Resources { get; }
|
public static ResourceLoader Resources { get; }
|
||||||
public static Arguments Arguments { get; }
|
public static Arguments Arguments { get; }
|
||||||
|
|
||||||
|
public const string Website = "https://dht.chylex.com";
|
||||||
|
|
||||||
static Program() {
|
static Program() {
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
|
||||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
AssemblyVersion = assembly.GetName().Version ?? new Version(major: 0, minor: 0, build: 0, revision: 0);
|
||||||
while (Version.EndsWith(".0")) {
|
Version = VersionToString(AssemblyVersion);
|
||||||
Version = Version[..^2];
|
|
||||||
}
|
|
||||||
|
|
||||||
Culture = CultureInfo.CurrentCulture;
|
Culture = CultureInfo.CurrentCulture;
|
||||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
||||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
Resources = new ResourceLoader(assembly);
|
Resources = new ResourceLoader(assembly);
|
||||||
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string VersionToString(Version version) {
|
||||||
|
string versionStr = version.ToString();
|
||||||
|
|
||||||
|
while (versionStr.EndsWith(".0")) {
|
||||||
|
versionStr = versionStr[..^2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionStr;
|
||||||
|
}
|
||||||
|
|
||||||
public static void Main(string[] args) {
|
public static void Main(string[] args) {
|
||||||
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||||
WindowsConsole.AllocConsole();
|
WindowsConsole.AllocConsole();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -44,7 +55,7 @@ static class Program {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppBuilder BuildAvaloniaApp() {
|
private static AppBuilder BuildAvaloniaApp() {
|
||||||
return AppBuilder.Configure<App>()
|
return AppBuilder.Configure<App>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
|
@@ -3,6 +3,6 @@ using DHT.Server.Service;
|
|||||||
namespace DHT.Desktop.Server;
|
namespace DHT.Desktop.Server;
|
||||||
|
|
||||||
static class ServerConfiguration {
|
static class ServerConfiguration {
|
||||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(min: 50000, max: 60000);
|
||||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<LangVersion>12</LangVersion>
|
<LangVersion>13</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -39,11 +39,12 @@
|
|||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||||
<DebugSymbols>false</DebugSymbols>
|
<DebugSymbols>false</DebugSymbols>
|
||||||
<DebugType>none</DebugType>
|
<DebugType>none</DebugType>
|
||||||
|
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
|
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
49
app/Resources/Tracker/bootstrap.js
vendored
49
app/Resources/Tracker/bootstrap.js
vendored
@@ -10,11 +10,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*[IMPORTS]*/
|
||||||
|
|
||||||
|
if (!DISCORD.isCompatible()) {
|
||||||
|
alert("Discord History Tracker is not compatible with this version of Discord.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.DHT_LOADED = true;
|
window.DHT_LOADED = true;
|
||||||
window.DHT_ON_UNLOAD = [];
|
window.DHT_ON_UNLOAD = [];
|
||||||
|
|
||||||
/*[IMPORTS]*/
|
|
||||||
|
|
||||||
const port = 0; /*[PORT]*/
|
const port = 0; /*[PORT]*/
|
||||||
const token = "/*[TOKEN]*/";
|
const token = "/*[TOKEN]*/";
|
||||||
STATE.setup(port, token);
|
STATE.setup(port, token);
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTrackingContinued = function(anyNewMessages) {
|
const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
|
||||||
if (!STATE.isTracking()) {
|
if (!STATE.isTracking()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,7 +68,7 @@
|
|||||||
if (SETTINGS.autoscroll) {
|
if (SETTINGS.autoscroll) {
|
||||||
let action = null;
|
let action = null;
|
||||||
|
|
||||||
if (!DISCORD.hasMoreMessages()) {
|
if (!hasMoreBefore) {
|
||||||
console.debug("[DHT] Reached first message.");
|
console.debug("[DHT] Reached first message.");
|
||||||
action = SETTINGS.afterFirstMsg;
|
action = SETTINGS.afterFirstMsg;
|
||||||
}
|
}
|
||||||
@@ -84,7 +89,7 @@
|
|||||||
|
|
||||||
let waitUntilSendingFinishedTimer = null;
|
let waitUntilSendingFinishedTimer = null;
|
||||||
|
|
||||||
const onMessagesUpdated = async messages => {
|
const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
|
||||||
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,24 +99,16 @@
|
|||||||
|
|
||||||
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
||||||
waitUntilSendingFinishedTimer = null;
|
waitUntilSendingFinishedTimer = null;
|
||||||
onMessagesUpdated(messages);
|
onMessagesUpdated(server, channel, messages, hasMoreBefore);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = DISCORD.getSelectedChannel();
|
|
||||||
|
|
||||||
if (!info) {
|
|
||||||
GUI.setStatus("Error (Unknown Channel)");
|
|
||||||
stopTrackingDelayed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSending = true;
|
isSending = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await STATE.addDiscordChannel(info.server, info.channel);
|
await STATE.addDiscordChannel(server, channel);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
return;
|
return;
|
||||||
@@ -120,35 +117,33 @@
|
|||||||
try {
|
try {
|
||||||
if (!messages.length) {
|
if (!messages.length) {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
onTrackingContinued(false);
|
onTrackingContinued(false, hasMoreBefore);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||||
onTrackingContinued(anyNewMessages);
|
onTrackingContinued(anyNewMessages, hasMoreBefore);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
DISCORD.setupMessageCallback(onMessagesUpdated);
|
const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||||
|
|
||||||
STATE.onTrackingStateChanged(enabled => {
|
STATE.onTrackingStateChanged(enabled => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
const messages = DISCORD.getMessages();
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GUI.setStatus("Starting");
|
GUI.setStatus("Starting");
|
||||||
|
GUI.createTrackingStyles();
|
||||||
hasJustStarted = true;
|
hasJustStarted = true;
|
||||||
// noinspection JSIgnoredPromiseFromCall
|
|
||||||
onMessagesUpdated(messages);
|
if (!starter()) {
|
||||||
|
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
||||||
|
hasJustStarted = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
|
GUI.deleteTrackingStyles();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -4,11 +4,26 @@ class DISCORD {
|
|||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
static CHANNEL_TYPE = {
|
static CHANNEL_TYPE = {
|
||||||
|
GUILD_TEXT: 0,
|
||||||
DM: 1,
|
DM: 1,
|
||||||
GROUP_DM: 3,
|
GROUP_DM: 3,
|
||||||
|
GUILD_ANNOUNCEMENT: 5,
|
||||||
ANNOUNCEMENT_THREAD: 10,
|
ANNOUNCEMENT_THREAD: 10,
|
||||||
PUBLIC_THREAD: 11,
|
PUBLIC_THREAD: 11,
|
||||||
PRIVATE_THREAD: 12
|
PRIVATE_THREAD: 12,
|
||||||
|
|
||||||
|
isPrivate(type) {
|
||||||
|
return type === this.DM
|
||||||
|
|| type === this.GROUP_DM;
|
||||||
|
},
|
||||||
|
|
||||||
|
isNavigableGuildChannel(type) {
|
||||||
|
return type === this.GUILD_TEXT
|
||||||
|
|| type === this.GUILD_ANNOUNCEMENT
|
||||||
|
|| type === this.ANNOUNCEMENT_THREAD
|
||||||
|
|| type === this.PUBLIC_THREAD
|
||||||
|
|| type === this.PRIVATE_THREAD;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
@@ -18,6 +33,74 @@ class DISCORD {
|
|||||||
THREAD_STARTER: 21
|
THREAD_STARTER: 21
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
|
||||||
|
static PERMISSION = {
|
||||||
|
VIEW_CHANNEL: 1n << 10n
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): ?DiscordGuild} getGuild
|
||||||
|
*/
|
||||||
|
static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): Boolean} isOptInEnabled
|
||||||
|
* @property {function(String): Set<String>} getOptedInChannels
|
||||||
|
*/
|
||||||
|
static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {function(String): ?DiscordChannel} getChannel
|
||||||
|
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
|
||||||
|
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
|
||||||
|
*/
|
||||||
|
static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(BigInt, Object): Boolean}
|
||||||
|
*/
|
||||||
|
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): MessageData}
|
||||||
|
*/
|
||||||
|
static #getMessages = WEBPACK.findFunction("getMessages");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): void}
|
||||||
|
*/
|
||||||
|
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(): String}
|
||||||
|
*/
|
||||||
|
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String): void}
|
||||||
|
*/
|
||||||
|
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function(String, Object, String=null): void}
|
||||||
|
*/
|
||||||
|
static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync");
|
||||||
|
|
||||||
|
static isCompatible() {
|
||||||
|
return !!this.#guildStore
|
||||||
|
&& !!this.#guildSettings
|
||||||
|
&& !!this.#channelStore
|
||||||
|
&& !!this.#hasPermission
|
||||||
|
&& !!this.#getMessages
|
||||||
|
&& !!this.#jumpToMessage
|
||||||
|
&& !!this.#getCurrentlySelectedChannelId
|
||||||
|
&& !!this.#selectPrivateChannel
|
||||||
|
&& !!this.#transitionToGuildSync;
|
||||||
|
}
|
||||||
|
|
||||||
static getMessageOuterElement() {
|
static getMessageOuterElement() {
|
||||||
return DOM.queryReactClass("messagesWrapper");
|
return DOM.queryReactClass("messagesWrapper");
|
||||||
}
|
}
|
||||||
@@ -26,14 +109,6 @@ class DISCORD {
|
|||||||
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
||||||
}
|
}
|
||||||
|
|
||||||
static getMessageElements() {
|
|
||||||
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
|
|
||||||
}
|
|
||||||
|
|
||||||
static hasMoreMessages() {
|
|
||||||
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static loadOlderMessages() {
|
static loadOlderMessages() {
|
||||||
const view = this.getMessageScrollerElement();
|
const view = this.getMessageScrollerElement();
|
||||||
|
|
||||||
@@ -42,26 +117,43 @@ class DISCORD {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getMessagesFromSelectedChannel() {
|
||||||
|
const channelId = this.#getCurrentlySelectedChannelId();
|
||||||
|
return channelId ? this.#getMessages(channelId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||||
|
* @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)}
|
||||||
*/
|
*/
|
||||||
static setupMessageCallback(callback) {
|
static setupMessageCallback(callback) {
|
||||||
const previousMessages = new Set();
|
const previousMessages = new Set();
|
||||||
|
|
||||||
const onMessageElementsChanged = function() {
|
const onMessageElementsChanged = force => {
|
||||||
const messages = DISCORD.getMessages();
|
const messages = this.getMessagesFromSelectedChannel();
|
||||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
if (!messages || !messages.ready || messages.loadingMore) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = this.#channelStore.getChannel(messages.channelId);
|
||||||
|
if (!channel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
|
||||||
if (!hasChanged) {
|
if (!hasChanged) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
previousMessages.clear();
|
previousMessages.clear();
|
||||||
for (const message of messages) {
|
for (const message of messages._array) {
|
||||||
previousMessages.add(message.id);
|
previousMessages.add(message.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(messages);
|
const server = this.#guildStore.getGuild(channel.guild_id);
|
||||||
|
|
||||||
|
callback(server, channel, messages._array, messages.hasMoreBefore);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
let debounceTimer;
|
let debounceTimer;
|
||||||
@@ -74,7 +166,7 @@ class DISCORD {
|
|||||||
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new MutationObserver(function () {
|
const observer = new MutationObserver(function() {
|
||||||
onMessageElementsChangedLater();
|
onMessageElementsChangedLater();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,216 +204,54 @@ class DISCORD {
|
|||||||
observedElement = null;
|
observedElement = null;
|
||||||
window.clearInterval(observerTimer);
|
window.clearInterval(observerTimer);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the message from a message element.
|
|
||||||
* @returns { null | DiscordMessage } }
|
|
||||||
*/
|
|
||||||
static getMessageFromElement(ele) {
|
|
||||||
const props = DOM.getReactProps(ele);
|
|
||||||
|
|
||||||
if (props && Array.isArray(props.children)) {
|
return () => onMessageElementsChanged(true);
|
||||||
for (const child of props.children) {
|
|
||||||
if (!(child instanceof Object)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childProps = child.props;
|
|
||||||
if (childProps instanceof Object && "message" in childProps) {
|
|
||||||
return childProps.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an array containing currently loaded messages.
|
|
||||||
*/
|
|
||||||
static getMessages() {
|
|
||||||
try {
|
|
||||||
const messages = [];
|
|
||||||
|
|
||||||
for (const ele of this.getMessageElements()) {
|
|
||||||
try {
|
|
||||||
const message = this.getMessageFromElement(ele);
|
|
||||||
|
|
||||||
if (message != null) {
|
|
||||||
messages.push(message);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error retrieving messages.", e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an object containing the selected server and channel information.
|
|
||||||
* For types DM and GROUP, the server and channel ids and names are identical.
|
|
||||||
* @returns { {} | null }
|
|
||||||
*/
|
|
||||||
static getSelectedChannel() {
|
|
||||||
try {
|
|
||||||
let obj = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
|
||||||
if (child && child.props && child.props.channel) {
|
|
||||||
obj = child.props.channel;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!obj || typeof obj.id !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dms = DOM.queryReactClass("privateChannels");
|
|
||||||
|
|
||||||
if (dms) {
|
|
||||||
let name;
|
|
||||||
|
|
||||||
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
|
|
||||||
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
|
|
||||||
|
|
||||||
if (node) {
|
|
||||||
name = node.nodeValue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let type;
|
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
|
||||||
switch (obj.type) {
|
|
||||||
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
|
|
||||||
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = obj.id;
|
|
||||||
const server = { id, name, type };
|
|
||||||
const channel = { id, name };
|
|
||||||
|
|
||||||
return { server, channel };
|
|
||||||
}
|
|
||||||
else if (obj.guild_id) {
|
|
||||||
let guild;
|
|
||||||
|
|
||||||
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
|
|
||||||
if (child && child.props && child.props.guild) {
|
|
||||||
guild = child.props.guild;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = {
|
|
||||||
"id": guild.id,
|
|
||||||
"name": guild.name,
|
|
||||||
"type": "SERVER"
|
|
||||||
};
|
|
||||||
|
|
||||||
const channel = {
|
|
||||||
"id": obj.id,
|
|
||||||
"name": obj.name,
|
|
||||||
"extra": {
|
|
||||||
"nsfw": obj.nsfw
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
|
||||||
channel["extra"]["parent"] = obj.parent_id;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
channel["extra"]["position"] = obj.position;
|
|
||||||
channel["extra"]["topic"] = obj.topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { server, channel };
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[DHT] Error retrieving selected channel.", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
||||||
*/
|
*/
|
||||||
static selectNextTextChannel() {
|
static selectNextTextChannel() {
|
||||||
const dms = DOM.queryReactClass("privateChannels");
|
const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
|
||||||
|
if (!currentChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (dms) {
|
if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
|
||||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
const privateChannel = this.#channelStore.getSortedPrivateChannels();
|
||||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
|
const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
|
||||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
|
||||||
|
|
||||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
|
||||||
if (!nextChannelLink) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextChannelLink.click();
|
|
||||||
nextChannelLink.scrollIntoView(true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const channelListEle = document.getElementById("channels");
|
const guildId = currentChannel.guild_id;
|
||||||
if (!channelListEle) {
|
|
||||||
|
let isChannelOptedIn;
|
||||||
|
if (this.#guildSettings.isOptInEnabled(guildId)) {
|
||||||
|
const optedInChannels = this.#guildSettings.getOptedInChannels(guildId);
|
||||||
|
isChannelOptedIn = channel => optedInChannels.has(channel.id);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
isChannelOptedIn = _ => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId);
|
||||||
|
const guildChannels = Object.values(guildChannelMap)
|
||||||
|
.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel))
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || currentIndex === guildChannels.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLinkElement(channel) {
|
this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id);
|
||||||
return channel.querySelector("a[href^='/channels/'][role='link']");
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
|
|
||||||
let nextChannel = null;
|
|
||||||
|
|
||||||
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
|
||||||
if (allTextChannels[index].className.includes("selected_")) {
|
|
||||||
nextChannel = allTextChannels[index + 1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextChannel === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextChannelLink = getLinkElement(nextChannel);
|
|
||||||
if (!nextChannelLink) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextChannelLink.click();
|
|
||||||
nextChannel.scrollIntoView(true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
class DOM {
|
class DOM {
|
||||||
/**
|
/**
|
||||||
* Returns a child element by its ID. Parent defaults to the entire document.
|
* Returns a child element by its ID. Parent defaults to the entire document.
|
||||||
|
* @param {string} id
|
||||||
|
* @param {HTMLElement?} [parent]
|
||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
static id(id, parent) {
|
static id(id, parent) {
|
||||||
@@ -9,6 +11,9 @@ class DOM {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
||||||
|
* @param {string} cls
|
||||||
|
* @param {HTMLElement?} [parent]
|
||||||
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
static queryReactClass(cls, parent) {
|
static queryReactClass(cls, parent) {
|
||||||
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
||||||
@@ -55,31 +60,4 @@ class DOM {
|
|||||||
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
||||||
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
|
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns internal React state object of an element.
|
|
||||||
*/
|
|
||||||
static getReactProps(ele) {
|
|
||||||
const keys = Object.keys(ele || {});
|
|
||||||
let key = keys.find(key => key.startsWith("__reactInternalInstance"));
|
|
||||||
|
|
||||||
if (key) {
|
|
||||||
// noinspection JSUnresolvedVariable
|
|
||||||
return ele[key].memoizedProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
key = keys.find(key => key.startsWith("__reactProps$"));
|
|
||||||
return key ? ele[key] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns internal React state object of an element, or null if the retrieval throws.
|
|
||||||
*/
|
|
||||||
static tryGetReactProps(ele) {
|
|
||||||
try {
|
|
||||||
return this.getReactProps(ele);
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -2,14 +2,17 @@
|
|||||||
const GUI = (function() {
|
const GUI = (function() {
|
||||||
let controller = null;
|
let controller = null;
|
||||||
let settings = null;
|
let settings = null;
|
||||||
|
let trackingStyles = null;
|
||||||
|
|
||||||
const stateChangedEvent = () => {
|
const stateChangedEvent = () => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
|
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
|
||||||
|
settings.ui.cbHidePreviewsWhileAutoscrolling.checked = SETTINGS.hidePreviewsWhileAutoscrolling;
|
||||||
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
|
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
|
||||||
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
|
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
|
||||||
|
|
||||||
const autoscrollDisabled = !SETTINGS.autoscroll;
|
const autoscrollDisabled = !SETTINGS.autoscroll;
|
||||||
|
settings.ui.cbHidePreviewsWhileAutoscrolling.disabled = autoscrollDisabled;
|
||||||
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
||||||
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
||||||
}
|
}
|
||||||
@@ -54,6 +57,7 @@ const GUI = (function() {
|
|||||||
|
|
||||||
controller.ui.btnClose.addEventListener("click", () => {
|
controller.ui.btnClose.addEventListener("click", () => {
|
||||||
this.hideController();
|
this.hideController();
|
||||||
|
this.deleteTrackingStyles();
|
||||||
window.DHT_ON_UNLOAD.forEach(f => f());
|
window.DHT_ON_UNLOAD.forEach(f => f());
|
||||||
delete window.DHT_ON_UNLOAD;
|
delete window.DHT_ON_UNLOAD;
|
||||||
delete window.DHT_LOADED;
|
delete window.DHT_LOADED;
|
||||||
@@ -84,6 +88,7 @@ const GUI = (function() {
|
|||||||
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
|
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
|
||||||
const html = `
|
const html = `
|
||||||
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
||||||
|
<label><input id='dht-cfg-hide-previews-while-autoscrolling' type='checkbox'> Hide previews to improve browser performance</label><br>
|
||||||
<br>
|
<br>
|
||||||
<label>After reaching the first message in channel...</label><br>
|
<label>After reaching the first message in channel...</label><br>
|
||||||
${radio("afm", "nothing", "Continue Tracking")}
|
${radio("afm", "nothing", "Continue Tracking")}
|
||||||
@@ -93,8 +98,7 @@ ${radio("afm", "switch", "Switch to Next Channel")}
|
|||||||
<label>After reaching a previously saved message...</label><br>
|
<label>After reaching a previously saved message...</label><br>
|
||||||
${radio("asm", "nothing", "Continue Tracking")}
|
${radio("asm", "nothing", "Continue Tracking")}
|
||||||
${radio("asm", "pause", "Pause Tracking")}
|
${radio("asm", "pause", "Pause Tracking")}
|
||||||
${radio("asm", "switch", "Switch to Next Channel")}
|
${radio("asm", "switch", "Switch to Next Channel")}`;
|
||||||
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;
|
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
|
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
|
||||||
@@ -107,9 +111,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
|||||||
});
|
});
|
||||||
|
|
||||||
settings.ui = {
|
settings.ui = {
|
||||||
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
/** @type {HTMLInputElement} */ cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
||||||
optsAfterFirstMsg: {},
|
/** @type {HTMLInputElement} */ cbHidePreviewsWhileAutoscrolling: DOM.id("dht-cfg-hide-previews-while-autoscrolling"),
|
||||||
optsAfterSavedMsg: {}
|
/** @type {Object.<number, HTMLInputElement>} */ optsAfterFirstMsg: {},
|
||||||
|
/** @type {Object.<number, HTMLInputElement>} */ optsAfterSavedMsg: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
|
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
|
||||||
@@ -124,6 +129,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
|||||||
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
|
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
settings.ui.cbHidePreviewsWhileAutoscrolling.addEventListener("change", () => {
|
||||||
|
SETTINGS.hidePreviewsWhileAutoscrolling = settings.ui.cbHidePreviewsWhileAutoscrolling.checked;
|
||||||
|
});
|
||||||
|
|
||||||
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
|
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
|
||||||
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
|
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
|
||||||
SETTINGS.afterFirstMsg = key;
|
SETTINGS.afterFirstMsg = key;
|
||||||
@@ -152,6 +161,29 @@ ${radio("asm", "switch", "Switch to Next Channel")}
|
|||||||
if (controller) {
|
if (controller) {
|
||||||
controller.ui.textStatus.innerText = state;
|
controller.ui.textStatus.innerText = state;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createTrackingStyles() {
|
||||||
|
if (trackingStyles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = "";
|
||||||
|
|
||||||
|
if (SETTINGS.autoscroll && SETTINGS.hidePreviewsWhileAutoscrolling) {
|
||||||
|
style += `div[id^="message-accessories-"] { display: none; }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.length > 0) {
|
||||||
|
trackingStyles = DOM.createStyle(style);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTrackingStyles() {
|
||||||
|
if (trackingStyles) {
|
||||||
|
DOM.removeElement(trackingStyles);
|
||||||
|
trackingStyles = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
@@ -35,17 +35,28 @@ const SETTINGS = (function() {
|
|||||||
obj[name] = value;
|
obj[name] = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
"_autoscroll": true,
|
||||||
|
"_hidePreviewsWhileAutoscrolling": true,
|
||||||
|
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||||
|
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||||
|
};
|
||||||
|
|
||||||
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
|
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
|
||||||
|
let hasChanged = false;
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
loaded = {
|
loaded = defaults;
|
||||||
"_autoscroll": true,
|
|
||||||
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
|
||||||
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
|
|
||||||
};
|
|
||||||
|
|
||||||
IS_FIRST_RUN = true;
|
IS_FIRST_RUN = true;
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
for (const property in defaults) {
|
||||||
|
if (!(property in loaded)) {
|
||||||
|
loaded[property] = defaults[property];
|
||||||
|
hasChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const root = {
|
const root = {
|
||||||
onSettingsChanged(callback) {
|
onSettingsChanged(callback) {
|
||||||
@@ -54,10 +65,11 @@ const SETTINGS = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
|
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
|
||||||
|
defineTriggeringProperty(root, "hidePreviewsWhileAutoscrolling", loaded._hidePreviewsWhileAutoscrolling);
|
||||||
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
|
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
|
||||||
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
|
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
|
||||||
|
|
||||||
if (IS_FIRST_RUN) {
|
if (IS_FIRST_RUN || hasChanged) {
|
||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -58,65 +58,39 @@ const STATE = (function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {DiscordChannel} channel
|
||||||
|
*/
|
||||||
|
const getPrivateChannelName = function(channel) {
|
||||||
|
if (channel.name === "") {
|
||||||
|
return channel.rawRecipients.map(user => user.username).join(", ");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return channel.name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Number} type
|
||||||
|
*/
|
||||||
|
const getChannelTypeName = function(type) {
|
||||||
|
if (type === DISCORD.CHANNEL_TYPE.DM) {
|
||||||
|
return "DM";
|
||||||
|
}
|
||||||
|
else if (type === DISCORD.CHANNEL_TYPE.GROUP_DM) {
|
||||||
|
return "GROUP";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "SERVER";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const trackingStateChangedListeners = [];
|
const trackingStateChangedListeners = [];
|
||||||
let isTracking = false;
|
let isTracking = false;
|
||||||
|
|
||||||
const addedChannels = new Set();
|
const addedChannels = new Set();
|
||||||
const addedUsers = new Set();
|
const addedUsers = new Set();
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordUser
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} username
|
|
||||||
* @property {String} discriminator
|
|
||||||
* @property {String} [globalName]
|
|
||||||
* @property {String} [avatar]
|
|
||||||
* @property {Boolean} [bot]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessage
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} channel_id
|
|
||||||
* @property {DiscordUser} author
|
|
||||||
* @property {String} content
|
|
||||||
* @property {Date} timestamp
|
|
||||||
* @property {Date|null} editedTimestamp
|
|
||||||
* @property {DiscordAttachment[]} attachments
|
|
||||||
* @property {Object[]} embeds
|
|
||||||
* @property {DiscordMessageReaction[]} [reactions]
|
|
||||||
* @property {DiscordMessageReference} [messageReference]
|
|
||||||
* @property {Number} type
|
|
||||||
* @property {String} state
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordAttachment
|
|
||||||
* @property {String} id
|
|
||||||
* @property {String} filename
|
|
||||||
* @property {String} [content_type]
|
|
||||||
* @property {String} size
|
|
||||||
* @property {String} url
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessageReaction
|
|
||||||
* @property {DiscordEmoji} emoji
|
|
||||||
* @property {Number} count
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordMessageReference
|
|
||||||
* @property {String} [message_id]
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name DiscordEmoji
|
|
||||||
* @property {String|null} id
|
|
||||||
* @property {String|null} name
|
|
||||||
* @property {Boolean} animated
|
|
||||||
*/
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setup(port, token) {
|
setup(port, token) {
|
||||||
serverPort = port;
|
serverPort = port;
|
||||||
@@ -147,32 +121,51 @@ const STATE = (function() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {?DiscordGuild} serverInfo
|
||||||
|
* @param {DiscordChannel} channelInfo
|
||||||
|
*/
|
||||||
async addDiscordChannel(serverInfo, channelInfo) {
|
async addDiscordChannel(serverInfo, channelInfo) {
|
||||||
if (addedChannels.has(channelInfo.id)) {
|
if (addedChannels.has(channelInfo.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = {
|
const server = {
|
||||||
id: serverInfo.id,
|
type: getChannelTypeName(channelInfo.type)
|
||||||
name: serverInfo.name,
|
|
||||||
type: serverInfo.type
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const channel = {
|
const channel = {
|
||||||
id: channelInfo.id,
|
id: channelInfo.id,
|
||||||
name: channelInfo.name
|
extra: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("extra" in channelInfo) {
|
if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
|
||||||
const extra = channelInfo.extra;
|
server.id = channelInfo.id;
|
||||||
|
server.name = channel.name = getPrivateChannelName(channelInfo);
|
||||||
if ("parent" in extra) {
|
}
|
||||||
channel.parent = extra.parent;
|
else if (serverInfo) {
|
||||||
}
|
server.id = serverInfo.id;
|
||||||
|
server.name = serverInfo.name;
|
||||||
channel.position = extra.position;
|
channel.name = channelInfo.name;
|
||||||
channel.topic = extra.topic;
|
}
|
||||||
channel.nsfw = extra.nsfw;
|
else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("nsfw" in channelInfo) {
|
||||||
|
channel.extra.nsfw = channelInfo.nsfw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("topic" in channelInfo) {
|
||||||
|
channel.extra.topic = channelInfo.topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("position" in channelInfo) {
|
||||||
|
channel.extra.position = channelInfo.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelInfo.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||||
|
channel.extra.parent = channelInfo.parent_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await post("/track-channel", { server, channel });
|
await post("/track-channel", { server, channel });
|
||||||
@@ -293,7 +286,7 @@ const STATE = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.reactions.length > 0) {
|
if (msg.reactions.length > 0) {
|
||||||
obj.reactions = msg.reactions.map(reaction => {
|
obj.reactions = msg.reactions.filter(reaction => reaction.count > 0).map(reaction => {
|
||||||
const emoji = reaction.emoji;
|
const emoji = reaction.emoji;
|
||||||
|
|
||||||
const mapped = {
|
const mapped = {
|
||||||
|
82
app/Resources/Tracker/scripts/types.js
Normal file
82
app/Resources/Tracker/scripts/types.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @name DiscordGuild
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} name
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordChannel
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} name
|
||||||
|
* @property {Number} type
|
||||||
|
* @property {String} [guild_id]
|
||||||
|
* @property {String} [parent_id]
|
||||||
|
* @property {Number} [position]
|
||||||
|
* @property {String} [topic]
|
||||||
|
* @property {Boolean} [nsfw]
|
||||||
|
* @property {DiscordUser[]} [rawRecipients]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordUser
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} username
|
||||||
|
* @property {String} discriminator
|
||||||
|
* @property {String} [globalName]
|
||||||
|
* @property {String} [avatar]
|
||||||
|
* @property {Boolean} [bot]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessage
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} channel_id
|
||||||
|
* @property {DiscordUser} author
|
||||||
|
* @property {String} content
|
||||||
|
* @property {Date} timestamp
|
||||||
|
* @property {Date|null} editedTimestamp
|
||||||
|
* @property {DiscordAttachment[]} attachments
|
||||||
|
* @property {Object[]} embeds
|
||||||
|
* @property {DiscordMessageReaction[]} [reactions]
|
||||||
|
* @property {DiscordMessageReference} [messageReference]
|
||||||
|
* @property {Number} type
|
||||||
|
* @property {String} state
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordAttachment
|
||||||
|
* @property {String} id
|
||||||
|
* @property {String} filename
|
||||||
|
* @property {String} [content_type]
|
||||||
|
* @property {String} size
|
||||||
|
* @property {String} url
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessageReaction
|
||||||
|
* @property {DiscordEmoji} emoji
|
||||||
|
* @property {Number} count
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordMessageReference
|
||||||
|
* @property {String} [message_id]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name DiscordEmoji
|
||||||
|
* @property {String|null} id
|
||||||
|
* @property {String|null} name
|
||||||
|
* @property {Boolean} animated
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name MessageData
|
||||||
|
* @type {Object}
|
||||||
|
* @property {String} channelId
|
||||||
|
* @property {Boolean} ready
|
||||||
|
* @property {Boolean} loadingMore
|
||||||
|
* @property {Boolean} hasMoreAfter
|
||||||
|
* @property {Boolean} hasMoreBefore
|
||||||
|
* @property {Array<DiscordMessage>} _array
|
||||||
|
*/
|
98
app/Resources/Tracker/scripts/webpack.js
Normal file
98
app/Resources/Tracker/scripts/webpack.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Parts copied from Better Discord, licensed under Apache License 2.0.
|
||||||
|
*
|
||||||
|
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/renderer/src/modules/webpackmodules.js
|
||||||
|
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/LICENSE
|
||||||
|
*/
|
||||||
|
class WEBPACK {
|
||||||
|
static get require() {
|
||||||
|
if (this._require) {
|
||||||
|
return this._require;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property {Object} m
|
||||||
|
* @property {Object} c
|
||||||
|
*/
|
||||||
|
let hookedRequire;
|
||||||
|
|
||||||
|
const id = "dht-webpackmodules-" + new Date().getTime();
|
||||||
|
if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") {
|
||||||
|
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => hookedRequire = internalRequire ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete hookedRequire.m[id];
|
||||||
|
delete hookedRequire.c[id];
|
||||||
|
return this._require = hookedRequire;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getAllModules() {
|
||||||
|
return this.require.c;
|
||||||
|
}
|
||||||
|
|
||||||
|
static filterByProps(...props) {
|
||||||
|
return module => props.every(prop => prop in module);
|
||||||
|
}
|
||||||
|
|
||||||
|
static filterByPropsWithPredicate(predicate, ...props) {
|
||||||
|
return module => props.every(prop => prop in module && predicate(module[prop]));
|
||||||
|
}
|
||||||
|
|
||||||
|
static findModules(filter) {
|
||||||
|
const defaultExport = true;
|
||||||
|
const moduleFilter = module => (typeof module === "object" || typeof module === "function") && filter(module);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const module of Object.values(this.getAllModules())) {
|
||||||
|
/**
|
||||||
|
* @type {Object}
|
||||||
|
* @property [Z]
|
||||||
|
* @property [ZP]
|
||||||
|
* @property [__esModule]
|
||||||
|
* @property [default]
|
||||||
|
*/
|
||||||
|
const exports = module.exports;
|
||||||
|
if (!exports || exports === window || exports === document.documentElement || exports[Symbol.toStringTag] === "DOMTokenList") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let foundModule = null;
|
||||||
|
if (exports.Z && moduleFilter(exports.Z)) {
|
||||||
|
foundModule = defaultExport ? exports.Z : exports;
|
||||||
|
}
|
||||||
|
if (exports.ZP && moduleFilter(exports.ZP)) {
|
||||||
|
foundModule = defaultExport ? exports.ZP : exports;
|
||||||
|
}
|
||||||
|
if (exports.__esModule && exports.default && moduleFilter(exports.default)) {
|
||||||
|
foundModule = defaultExport ? exports.default : exports;
|
||||||
|
}
|
||||||
|
if (moduleFilter(exports)) {
|
||||||
|
foundModule = exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundModule) {
|
||||||
|
results.push(foundModule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
static findModule(name, filter) {
|
||||||
|
const modules = this.findModules(filter);
|
||||||
|
if (modules.length === 1) {
|
||||||
|
return modules[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[DHT] Cannot find module " + name + ", results found:", modules.length);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static findFunction(name, additionalRequiredProps) {
|
||||||
|
const searchedProps = additionalRequiredProps ? [ name, ...additionalRequiredProps ] : [ name ];
|
||||||
|
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof (prop) === "function", ...searchedProps));
|
||||||
|
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
|
||||||
|
}
|
||||||
|
}
|
@@ -18,11 +18,13 @@
|
|||||||
height: 262px;
|
height: 262px;
|
||||||
margin-left: -400px;
|
margin-left: -400px;
|
||||||
margin-top: -131px;
|
margin-top: -131px;
|
||||||
|
line-height: 120%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
z-index: 1000002;
|
z-index: 1000002;
|
||||||
}
|
}
|
||||||
|
|
||||||
#dht-cfg-note {
|
#dht-cfg label {
|
||||||
margin-top: 22px;
|
display: inline-block;
|
||||||
|
margin: 1px 0;
|
||||||
}
|
}
|
||||||
|
@@ -298,13 +298,13 @@ export default (function() {
|
|||||||
|
|
||||||
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
|
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
|
||||||
}
|
}
|
||||||
else if (property === "reactions"){
|
else if (property === "reactions") {
|
||||||
if (!value){
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "<div class='reactions'>" + value.map(reaction => {
|
return "<div class='reactions'>" + value.map(reaction => {
|
||||||
if ("id" in reaction){
|
if ("id" in reaction) {
|
||||||
const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp";
|
const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp";
|
||||||
const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
|
const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
|
||||||
// noinspection JSUnusedGlobalSymbols
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
@@ -171,7 +171,7 @@ export default (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getMessageChannel = function(id) {
|
const getMessageChannel = function(id) {
|
||||||
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
|
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
|
||||||
if (id in messages) {
|
if (id in messages) {
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
#messages {
|
#messages {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background-color: #36393E;
|
background-color: #36393e;
|
||||||
}
|
}
|
||||||
|
|
||||||
#messages > div {
|
#messages > div {
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#messages .username {
|
#messages .username {
|
||||||
color: #FFF;
|
color: #fff;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
@@ -75,12 +75,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message .link, .reply-message .link {
|
.message .link, .reply-message .link {
|
||||||
color: #7289DA;
|
color: #7289da;
|
||||||
background-color: rgba(115, 139, 215, 0.1);
|
background-color: rgba(115, 139, 215, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message a, .reply-message a {
|
.message a, .reply-message a {
|
||||||
color: #0096CF;
|
color: #0096cf;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message code {
|
.message code {
|
||||||
background-color: #2E3136;
|
background-color: #2e3136;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-family: Menlo, Consolas, Monaco, monospace;
|
font-family: Menlo, Consolas, Monaco, monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
.message code.block {
|
.message code.block {
|
||||||
display: block;
|
display: block;
|
||||||
border: 2px solid #282B30;
|
border: 2px solid #282b30;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reply-username {
|
.reply-username {
|
||||||
color: #FFF;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reply-contents code {
|
.reply-contents code {
|
||||||
background-color: #2E3136;
|
background-color: #2e3136;
|
||||||
font-family: Menlo, Consolas, Monaco, monospace;
|
font-family: Menlo, Consolas, Monaco, monospace;
|
||||||
padding: 1px 2px;
|
padding: 1px 2px;
|
||||||
}
|
}
|
||||||
|
@@ -4,11 +4,11 @@ public sealed class DownloadStatusStatistics {
|
|||||||
public int PendingCount { get; internal init; }
|
public int PendingCount { get; internal init; }
|
||||||
public ulong PendingTotalSize { get; internal init; }
|
public ulong PendingTotalSize { get; internal init; }
|
||||||
public int PendingWithUnknownSizeCount { get; internal init; }
|
public int PendingWithUnknownSizeCount { get; internal init; }
|
||||||
|
|
||||||
public int SuccessfulCount { get; internal init; }
|
public int SuccessfulCount { get; internal init; }
|
||||||
public ulong SuccessfulTotalSize { get; internal init; }
|
public ulong SuccessfulTotalSize { get; internal init; }
|
||||||
public int SuccessfulWithUnknownSizeCount { get; internal init; }
|
public int SuccessfulWithUnknownSizeCount { get; internal init; }
|
||||||
|
|
||||||
public int FailedCount { get; internal init; }
|
public int FailedCount { get; internal init; }
|
||||||
public ulong FailedTotalSize { get; internal init; }
|
public ulong FailedTotalSize { get; internal init; }
|
||||||
public int FailedWithUnknownSizeCount { get; internal init; }
|
public int FailedWithUnknownSizeCount { get; internal init; }
|
||||||
|
@@ -6,7 +6,7 @@ public sealed class Download {
|
|||||||
public DownloadStatus Status { get; }
|
public DownloadStatus Status { get; }
|
||||||
public string? Type { get; }
|
public string? Type { get; }
|
||||||
public ulong? Size { get; }
|
public ulong? Size { get; }
|
||||||
|
|
||||||
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) {
|
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) {
|
||||||
NormalizedUrl = normalizedUrl;
|
NormalizedUrl = normalizedUrl;
|
||||||
DownloadUrl = downloadUrl;
|
DownloadUrl = downloadUrl;
|
||||||
|
@@ -10,5 +10,5 @@ public enum DownloadStatus {
|
|||||||
GenericError = 1,
|
GenericError = 1,
|
||||||
Downloading = 2,
|
Downloading = 2,
|
||||||
LastCustomCode = 99,
|
LastCustomCode = 99,
|
||||||
Success = HttpStatusCode.OK
|
Success = HttpStatusCode.OK,
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,7 @@ sealed class DiscordEmbedJson {
|
|||||||
public JsonImage? Image { get; set; }
|
public JsonImage? Image { get; set; }
|
||||||
public JsonImage? Thumbnail { get; set; }
|
public JsonImage? Thumbnail { get; set; }
|
||||||
public JsonImage? Video { get; set; }
|
public JsonImage? Video { get; set; }
|
||||||
|
|
||||||
public sealed class JsonImage {
|
public sealed class JsonImage {
|
||||||
public string? Url { get; set; }
|
public string? Url { get; set; }
|
||||||
public string? ProxyUrl { get; set; }
|
public string? ProxyUrl { get; set; }
|
||||||
|
@@ -5,5 +5,5 @@ namespace DHT.Server.Data;
|
|||||||
[Flags]
|
[Flags]
|
||||||
public enum EmojiFlags : ushort {
|
public enum EmojiFlags : ushort {
|
||||||
None = 0,
|
None = 0,
|
||||||
Animated = 0b1
|
Animated = 0b1,
|
||||||
}
|
}
|
||||||
|
@@ -7,6 +7,6 @@ public sealed class DownloadItemFilter {
|
|||||||
public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
|
public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
|
||||||
|
|
||||||
public ulong? MaxBytes { get; set; } = null;
|
public ulong? MaxBytes { get; set; } = null;
|
||||||
|
|
||||||
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null && MaxBytes == null;
|
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null && MaxBytes == null;
|
||||||
}
|
}
|
||||||
|
@@ -2,5 +2,5 @@ namespace DHT.Server.Data.Filters;
|
|||||||
|
|
||||||
public enum FilterRemovalMode {
|
public enum FilterRemovalMode {
|
||||||
KeepMatching,
|
KeepMatching,
|
||||||
RemoveMatching
|
RemoveMatching,
|
||||||
}
|
}
|
||||||
|
@@ -6,11 +6,11 @@ namespace DHT.Server.Data.Filters;
|
|||||||
public sealed class MessageFilter {
|
public sealed class MessageFilter {
|
||||||
public DateTime? StartDate { get; set; } = null;
|
public DateTime? StartDate { get; set; } = null;
|
||||||
public DateTime? EndDate { get; set; } = null;
|
public DateTime? EndDate { get; set; } = null;
|
||||||
|
|
||||||
public HashSet<ulong>? ChannelIds { get; set; } = null;
|
public HashSet<ulong>? ChannelIds { get; set; } = null;
|
||||||
public HashSet<ulong>? UserIds { get; set; } = null;
|
public HashSet<ulong>? UserIds { get; set; } = null;
|
||||||
public HashSet<ulong>? MessageIds { get; set; } = null;
|
public HashSet<ulong>? MessageIds { get; set; } = null;
|
||||||
|
|
||||||
public bool IsEmpty => StartDate == null &&
|
public bool IsEmpty => StartDate == null &&
|
||||||
EndDate == null &&
|
EndDate == null &&
|
||||||
ChannelIds == null &&
|
ChannelIds == null &&
|
||||||
|
@@ -3,7 +3,7 @@ namespace DHT.Server.Data;
|
|||||||
public enum ServerType {
|
public enum ServerType {
|
||||||
Server,
|
Server,
|
||||||
Group,
|
Group,
|
||||||
DirectMessage
|
DirectMessage,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ServerTypes {
|
public static class ServerTypes {
|
||||||
@@ -12,34 +12,34 @@ public static class ServerTypes {
|
|||||||
"SERVER" => ServerType.Server,
|
"SERVER" => ServerType.Server,
|
||||||
"GROUP" => ServerType.Group,
|
"GROUP" => ServerType.Group,
|
||||||
"DM" => ServerType.DirectMessage,
|
"DM" => ServerType.DirectMessage,
|
||||||
_ => null
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string ToString(ServerType? type) {
|
public static string ToString(ServerType? type) {
|
||||||
return type switch {
|
return type switch {
|
||||||
ServerType.Server => "SERVER",
|
ServerType.Server => "SERVER",
|
||||||
ServerType.Group => "GROUP",
|
ServerType.Group => "GROUP",
|
||||||
ServerType.DirectMessage => "DM",
|
ServerType.DirectMessage => "DM",
|
||||||
_ => "UNKNOWN"
|
_ => "UNKNOWN",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string ToNiceString(ServerType? type) {
|
public static string ToNiceString(ServerType? type) {
|
||||||
return type switch {
|
return type switch {
|
||||||
ServerType.Server => "Server",
|
ServerType.Server => "Server",
|
||||||
ServerType.Group => "Group",
|
ServerType.Group => "Group",
|
||||||
ServerType.DirectMessage => "DM",
|
ServerType.DirectMessage => "DM",
|
||||||
_ => "Unknown"
|
_ => "Unknown",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string ToJsonViewerString(ServerType? type) {
|
internal static string ToJsonViewerString(ServerType? type) {
|
||||||
return type switch {
|
return type switch {
|
||||||
ServerType.Server => "server",
|
ServerType.Server => "server",
|
||||||
ServerType.Group => "group",
|
ServerType.Group => "group",
|
||||||
ServerType.DirectMessage => "user",
|
ServerType.DirectMessage => "user",
|
||||||
_ => "unknown"
|
_ => "unknown",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,13 +7,13 @@ public static class SettingsKey {
|
|||||||
public static Bool DownloadsLimitSize { get; } = new ("downloads_limit_size");
|
public static Bool DownloadsLimitSize { get; } = new ("downloads_limit_size");
|
||||||
public static UnsignedLong DownloadsMaximumSize { get; } = new ("downloads_maximum_size");
|
public static UnsignedLong DownloadsMaximumSize { get; } = new ("downloads_maximum_size");
|
||||||
public static String DownloadsMaximumSizeUnit { get; } = new ("downloads_maximum_size_unit");
|
public static String DownloadsMaximumSizeUnit { get; } = new ("downloads_maximum_size_unit");
|
||||||
|
|
||||||
public sealed class String(string key) : SettingsKey<string>(key) {
|
public sealed class String(string key) : SettingsKey<string>(key) {
|
||||||
internal override bool FromString(string value, out string result) {
|
internal override bool FromString(string value, out string result) {
|
||||||
result = value;
|
result = value;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal override string ToString(string value) {
|
internal override string ToString(string value) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -25,17 +25,17 @@ public static class SettingsKey {
|
|||||||
case "1":
|
case "1":
|
||||||
result = true;
|
result = true;
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "0":
|
case "0":
|
||||||
result = false;
|
result = false;
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
result = false;
|
result = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal override string ToString(bool value) {
|
internal override string ToString(bool value) {
|
||||||
return value ? "1" : "0";
|
return value ? "1" : "0";
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ public static class SettingsKey {
|
|||||||
internal override bool FromString(string value, out ulong result) {
|
internal override bool FromString(string value, out ulong result) {
|
||||||
return ulong.TryParse(value, out result);
|
return ulong.TryParse(value, out result);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal override string ToString(ulong value) {
|
internal override string ToString(ulong value) {
|
||||||
return value.ToString();
|
return value.ToString();
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ public static class SettingsKey {
|
|||||||
|
|
||||||
public abstract class SettingsKey<T>(string key) {
|
public abstract class SettingsKey<T>(string key) {
|
||||||
internal string Key => key;
|
internal string Key => key;
|
||||||
|
|
||||||
internal abstract bool FromString(string value, [NotNullWhen(true)] out T result);
|
internal abstract bool FromString(string value, [NotNullWhen(true)] out T result);
|
||||||
internal abstract string ToString(T value);
|
internal abstract string ToString(T value);
|
||||||
}
|
}
|
||||||
|
@@ -10,13 +10,13 @@ public static class DatabaseExtensions {
|
|||||||
await target.Users.Add(await source.Users.Get().ToListAsync());
|
await target.Users.Add(await source.Users.Get().ToListAsync());
|
||||||
await target.Servers.Add(await source.Servers.Get().ToListAsync());
|
await target.Servers.Add(await source.Servers.Get().ToListAsync());
|
||||||
await target.Channels.Add(await source.Channels.Get().ToListAsync());
|
await target.Channels.Add(await source.Channels.Get().ToListAsync());
|
||||||
|
|
||||||
const int MessageBatchSize = 100;
|
const int MessageBatchSize = 100;
|
||||||
List<Message> batchedMessages = new (MessageBatchSize);
|
List<Message> batchedMessages = new (MessageBatchSize);
|
||||||
|
|
||||||
await foreach (var message in source.Messages.Get()) {
|
await foreach (Message message in source.Messages.Get()) {
|
||||||
batchedMessages.Add(message);
|
batchedMessages.Add(message);
|
||||||
|
|
||||||
if (batchedMessages.Count >= MessageBatchSize) {
|
if (batchedMessages.Count >= MessageBatchSize) {
|
||||||
await target.Messages.Add(batchedMessages);
|
await target.Messages.Add(batchedMessages);
|
||||||
batchedMessages.Clear();
|
batchedMessages.Clear();
|
||||||
@@ -24,8 +24,8 @@ public static class DatabaseExtensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await target.Messages.Add(batchedMessages);
|
await target.Messages.Add(batchedMessages);
|
||||||
|
|
||||||
await foreach (var download in source.Downloads.Get()) {
|
await foreach (Data.Download download in source.Downloads.Get()) {
|
||||||
if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) {
|
if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) {
|
||||||
await target.Downloads.AddDownload(download, stream: null);
|
await target.Downloads.AddDownload(download, stream: null);
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,7 @@ namespace DHT.Server.Database;
|
|||||||
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
|
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
|
||||||
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 ISettingsRepository Settings { get; } = new ISettingsRepository.Dummy();
|
public ISettingsRepository Settings { get; } = new ISettingsRepository.Dummy();
|
||||||
@@ -18,11 +18,11 @@ sealed class DummyDatabaseFile : IDatabaseFile {
|
|||||||
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
|
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
|
||||||
|
|
||||||
private DummyDatabaseFile() {}
|
private DummyDatabaseFile() {}
|
||||||
|
|
||||||
public Task Vacuum() {
|
public Task Vacuum() {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync() {
|
public ValueTask DisposeAsync() {
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
@@ -6,7 +6,7 @@ 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 => SqliteSchema.Version;
|
public int CurrentVersion => SqliteSchema.Version;
|
||||||
|
|
||||||
internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + SqliteSchema.Version) {
|
internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + SqliteSchema.Version) {
|
||||||
this.DatabaseVersion = databaseVersion;
|
this.DatabaseVersion = databaseVersion;
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,7 @@ namespace DHT.Server.Database.Exceptions;
|
|||||||
|
|
||||||
public sealed class InvalidDatabaseVersionException : Exception {
|
public sealed class InvalidDatabaseVersionException : Exception {
|
||||||
public string Version { get; }
|
public string Version { get; }
|
||||||
|
|
||||||
internal InvalidDatabaseVersionException(string version) : base("Invalid database version: " + version) {
|
internal InvalidDatabaseVersionException(string version) : base("Invalid database version: " + version) {
|
||||||
this.Version = version;
|
this.Version = version;
|
||||||
}
|
}
|
||||||
|
@@ -8,15 +8,15 @@ sealed class SnowflakeJsonSerializer : JsonConverter<Snowflake> {
|
|||||||
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
||||||
return new Snowflake(ulong.Parse(reader.GetString()!));
|
return new Snowflake(ulong.Parse(reader.GetString()!));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
|
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
|
||||||
writer.WriteStringValue(value.Id.ToString());
|
writer.WriteStringValue(value.Id.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Snowflake ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
public override Snowflake ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
||||||
return new Snowflake(ulong.Parse(reader.GetString()!));
|
return new Snowflake(ulong.Parse(reader.GetString()!));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void WriteAsPropertyName(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
|
public override void WriteAsPropertyName(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
|
||||||
writer.WritePropertyName(value.Id.ToString());
|
writer.WritePropertyName(value.Id.ToString());
|
||||||
}
|
}
|
||||||
|
@@ -19,12 +19,12 @@ static class ViewerJson {
|
|||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Avatar { get; init; }
|
public string? Avatar { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonServer {
|
public sealed class JsonServer {
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
public required string Type { get; init; }
|
public required string Type { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonChannel {
|
public sealed class JsonChannel {
|
||||||
public required Snowflake Server { get; init; }
|
public required Snowflake Server { get; init; }
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
@@ -41,22 +41,22 @@ static class ViewerJson {
|
|||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public bool? Nsfw { get; init; }
|
public bool? Nsfw { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonMessage {
|
public sealed class JsonMessage {
|
||||||
public required Snowflake Id { get; init; }
|
public required Snowflake Id { get; init; }
|
||||||
public required Snowflake C { get; init; }
|
public required Snowflake C { get; init; }
|
||||||
public required Snowflake U { get; init; }
|
public required Snowflake U { get; init; }
|
||||||
public required long T { get; init; }
|
public required long T { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? M { get; init; }
|
public string? M { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public long? Te { get; init; }
|
public long? Te { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? R { get; init; }
|
public string? R { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public JsonMessageAttachment[]? A { get; init; }
|
public JsonMessageAttachment[]? A { get; init; }
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ static class ViewerJson {
|
|||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public JsonMessageReaction[]? Re { get; init; }
|
public JsonMessageReaction[]? Re { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonMessageAttachment {
|
public sealed class JsonMessageAttachment {
|
||||||
public required string Url { get; init; }
|
public required string Url { get; init; }
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
@@ -77,14 +77,14 @@ static class ViewerJson {
|
|||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public int? Height { get; set; }
|
public int? Height { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonMessageReaction {
|
public sealed class JsonMessageReaction {
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Id { get; init; }
|
public string? Id { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? N { get; init; }
|
public string? N { get; init; }
|
||||||
|
|
||||||
public required bool A { get; init; }
|
public required bool A { get; init; }
|
||||||
public required int C { get; init; }
|
public required int C { get; init; }
|
||||||
}
|
}
|
||||||
|
@@ -14,104 +14,104 @@ namespace DHT.Server.Database.Export;
|
|||||||
|
|
||||||
static class ViewerJsonExport {
|
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 GetMetadata(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) {
|
public static async Task GetMetadata(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) {
|
||||||
var perf = Log.Start();
|
Perf perf = Log.Start();
|
||||||
|
|
||||||
var includedChannels = new List<Channel>();
|
var includedChannels = new List<Channel>();
|
||||||
var includedServerIds = new HashSet<ulong>();
|
var includedServerIds = new HashSet<ulong>();
|
||||||
|
|
||||||
var channelIdFilter = filter?.ChannelIds;
|
HashSet<ulong>? channelIdFilter = filter?.ChannelIds;
|
||||||
|
|
||||||
await foreach (var channel in db.Channels.Get(cancellationToken)) {
|
await foreach (Channel channel in db.Channels.Get(cancellationToken)) {
|
||||||
if (channelIdFilter == null || channelIdFilter.Contains(channel.Id)) {
|
if (channelIdFilter == null || channelIdFilter.Contains(channel.Id)) {
|
||||||
includedChannels.Add(channel);
|
includedChannels.Add(channel);
|
||||||
includedServerIds.Add(channel.Server);
|
includedServerIds.Add(channel.Server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var users = await GenerateUserList(db, cancellationToken);
|
Dictionary<Snowflake, ViewerJson.JsonUser> users = await GenerateUserList(db, cancellationToken);
|
||||||
var servers = await GenerateServerList(db, includedServerIds, cancellationToken);
|
Dictionary<Snowflake, ViewerJson.JsonServer> servers = await GenerateServerList(db, includedServerIds, cancellationToken);
|
||||||
var channels = GenerateChannelList(includedChannels);
|
Dictionary<Snowflake, ViewerJson.JsonChannel> channels = GenerateChannelList(includedChannels);
|
||||||
|
|
||||||
var meta = new ViewerJson.JsonMeta {
|
var meta = new ViewerJson.JsonMeta {
|
||||||
Users = users,
|
Users = users,
|
||||||
Servers = servers,
|
Servers = servers,
|
||||||
Channels = channels
|
Channels = channels,
|
||||||
};
|
};
|
||||||
|
|
||||||
perf.Step("Collect database data");
|
perf.Step("Collect database data");
|
||||||
|
|
||||||
await JsonSerializer.SerializeAsync(stream, meta, ViewerJsonMetadataContext.Default.JsonMeta, cancellationToken);
|
await JsonSerializer.SerializeAsync(stream, meta, ViewerJsonMetadataContext.Default.JsonMeta, cancellationToken);
|
||||||
|
|
||||||
perf.Step("Serialize to JSON");
|
perf.Step("Serialize to JSON");
|
||||||
perf.End();
|
perf.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task GetMessages(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) {
|
public static async Task GetMessages(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) {
|
||||||
var perf = Log.Start();
|
Perf perf = Log.Start();
|
||||||
|
|
||||||
ReadOnlyMemory<byte> newLine = "\n"u8.ToArray();
|
ReadOnlyMemory<byte> newLine = "\n"u8.ToArray();
|
||||||
|
|
||||||
await foreach(var message in GenerateMessageList(db, filter, cancellationToken)) {
|
await foreach (ViewerJson.JsonMessage message in GenerateMessageList(db, filter, cancellationToken)) {
|
||||||
await JsonSerializer.SerializeAsync(stream, message, ViewerJsonMessageContext.Default.JsonMessage, cancellationToken);
|
await JsonSerializer.SerializeAsync(stream, message, ViewerJsonMessageContext.Default.JsonMessage, cancellationToken);
|
||||||
await stream.WriteAsync(newLine, cancellationToken);
|
await stream.WriteAsync(newLine, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
perf.Step("Generate and serialize messages to JSON");
|
perf.Step("Generate and serialize messages to JSON");
|
||||||
perf.End();
|
perf.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Dictionary<Snowflake, ViewerJson.JsonUser>> GenerateUserList(IDatabaseFile db, CancellationToken cancellationToken) {
|
private static async Task<Dictionary<Snowflake, ViewerJson.JsonUser>> GenerateUserList(IDatabaseFile db, CancellationToken cancellationToken) {
|
||||||
var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
|
var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
|
||||||
|
|
||||||
await foreach (var user in db.Users.Get(cancellationToken)) {
|
await foreach (User user in db.Users.Get(cancellationToken)) {
|
||||||
users[user.Id] = new ViewerJson.JsonUser {
|
users[user.Id] = new ViewerJson.JsonUser {
|
||||||
Name = user.Name,
|
Name = user.Name,
|
||||||
DisplayName = user.DisplayName,
|
DisplayName = user.DisplayName,
|
||||||
Avatar = user.AvatarUrl,
|
Avatar = user.AvatarUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Dictionary<Snowflake, ViewerJson.JsonServer>> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, CancellationToken cancellationToken) {
|
private static async Task<Dictionary<Snowflake, ViewerJson.JsonServer>> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, CancellationToken cancellationToken) {
|
||||||
var servers = new Dictionary<Snowflake, ViewerJson.JsonServer>();
|
var servers = new Dictionary<Snowflake, ViewerJson.JsonServer>();
|
||||||
|
|
||||||
await foreach (var server in db.Servers.Get(cancellationToken)) {
|
await foreach (Data.Server server in db.Servers.Get(cancellationToken)) {
|
||||||
if (!serverIds.Contains(server.Id)) {
|
if (!serverIds.Contains(server.Id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
servers[server.Id] = new ViewerJson.JsonServer {
|
servers[server.Id] = new ViewerJson.JsonServer {
|
||||||
Name = server.Name,
|
Name = server.Name,
|
||||||
Type = ServerTypes.ToJsonViewerString(server.Type)
|
Type = ServerTypes.ToJsonViewerString(server.Type),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return servers;
|
return servers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels) {
|
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels) {
|
||||||
var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
|
var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
|
||||||
|
|
||||||
foreach (var channel in includedChannels) {
|
foreach (Channel channel in includedChannels) {
|
||||||
channels[channel.Id] = new ViewerJson.JsonChannel {
|
channels[channel.Id] = new ViewerJson.JsonChannel {
|
||||||
Server = channel.Server,
|
Server = channel.Server,
|
||||||
Name = channel.Name,
|
Name = channel.Name,
|
||||||
Parent = channel.ParentId?.ToString(),
|
Parent = channel.ParentId?.ToString(),
|
||||||
Position = channel.Position,
|
Position = channel.Position,
|
||||||
Topic = channel.Topic,
|
Topic = channel.Topic,
|
||||||
Nsfw = channel.Nsfw
|
Nsfw = channel.Nsfw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async IAsyncEnumerable<ViewerJson.JsonMessage> GenerateMessageList(IDatabaseFile db, MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
private static async IAsyncEnumerable<ViewerJson.JsonMessage> GenerateMessageList(IDatabaseFile db, MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
await foreach (var message in db.Messages.Get(filter, cancellationToken)) {
|
await foreach (Message message in db.Messages.Get(filter, cancellationToken)) {
|
||||||
yield return new ViewerJson.JsonMessage {
|
yield return new ViewerJson.JsonMessage {
|
||||||
Id = message.Id,
|
Id = message.Id,
|
||||||
C = message.Channel,
|
C = message.Channel,
|
||||||
@@ -120,29 +120,29 @@ static class ViewerJsonExport {
|
|||||||
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
|
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
|
||||||
Te = message.EditTimestamp,
|
Te = message.EditTimestamp,
|
||||||
R = message.RepliedToId?.ToString(),
|
R = message.RepliedToId?.ToString(),
|
||||||
|
|
||||||
A = message.Attachments.IsEmpty ? null : message.Attachments.Select(static attachment => {
|
A = message.Attachments.IsEmpty ? null : message.Attachments.Select(static attachment => {
|
||||||
var a = new ViewerJson.JsonMessageAttachment {
|
var a = new ViewerJson.JsonMessageAttachment {
|
||||||
Url = attachment.DownloadUrl,
|
Url = attachment.DownloadUrl,
|
||||||
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl
|
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out Uri? uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
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(),
|
||||||
|
|
||||||
E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
|
E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
|
||||||
|
|
||||||
Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
|
Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
|
||||||
Id = reaction.EmojiId?.ToString(),
|
Id = reaction.EmojiId?.ToString(),
|
||||||
N = reaction.EmojiName,
|
N = reaction.EmojiName,
|
||||||
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
|
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
|
||||||
C = reaction.Count
|
C = reaction.Count,
|
||||||
}).ToArray()
|
}).ToArray(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,13 +6,13 @@ namespace DHT.Server.Database;
|
|||||||
|
|
||||||
public interface IDatabaseFile : IAsyncDisposable {
|
public interface IDatabaseFile : IAsyncDisposable {
|
||||||
string Path { get; }
|
string Path { get; }
|
||||||
|
|
||||||
ISettingsRepository Settings { get; }
|
ISettingsRepository Settings { get; }
|
||||||
IUserRepository Users { get; }
|
IUserRepository Users { get; }
|
||||||
IServerRepository Servers { get; }
|
IServerRepository Servers { get; }
|
||||||
IChannelRepository Channels { get; }
|
IChannelRepository Channels { get; }
|
||||||
IMessageRepository Messages { get; }
|
IMessageRepository Messages { get; }
|
||||||
IDownloadRepository Downloads { get; }
|
IDownloadRepository Downloads { get; }
|
||||||
|
|
||||||
Task Vacuum();
|
Task Vacuum();
|
||||||
}
|
}
|
||||||
|
@@ -7,16 +7,16 @@ sealed class DiscordEmbedLegacyJson {
|
|||||||
public required string Type { get; init; }
|
public required string Type { get; init; }
|
||||||
|
|
||||||
public bool DhtLegacy { get; } = true;
|
public bool DhtLegacy { get; } = true;
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Title { get; init; }
|
public string? Title { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Description { get; init; }
|
public string? Description { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public ImageJson? Image { get; init; }
|
public ImageJson? Image { get; init; }
|
||||||
|
|
||||||
public sealed class ImageJson {
|
public sealed class ImageJson {
|
||||||
public required string Url { get; init; }
|
public required string Url { get; init; }
|
||||||
}
|
}
|
||||||
|
@@ -7,14 +7,14 @@ namespace DHT.Server.Database.Import;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FakeSnowflake {
|
public sealed class FakeSnowflake {
|
||||||
private const ulong DiscordEpoch = 1420070400000UL;
|
private const ulong DiscordEpoch = 1420070400000UL;
|
||||||
|
|
||||||
private ulong id;
|
private ulong id;
|
||||||
|
|
||||||
public FakeSnowflake() {
|
public FakeSnowflake() {
|
||||||
var unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
|
ulong unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
|
||||||
this.id = (unixMillis - DiscordEpoch) << 22;
|
this.id = (unixMillis - DiscordEpoch) << 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal ulong Next() {
|
internal ulong Next() {
|
||||||
return id++;
|
return id++;
|
||||||
}
|
}
|
||||||
|
@@ -16,93 +16,93 @@ namespace DHT.Server.Database.Import;
|
|||||||
|
|
||||||
public static class LegacyArchiveImport {
|
public static class LegacyArchiveImport {
|
||||||
private static readonly Log Log = Log.ForType(typeof(LegacyArchiveImport));
|
private static readonly Log Log = Log.ForType(typeof(LegacyArchiveImport));
|
||||||
|
|
||||||
private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new ();
|
private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new ();
|
||||||
|
|
||||||
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();
|
Perf perf = Log.Start();
|
||||||
var root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
|
JsonElement root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var meta = root.RequireObject("meta");
|
JsonElement meta = root.RequireObject("meta");
|
||||||
var data = root.RequireObject("data");
|
JsonElement data = root.RequireObject("data");
|
||||||
|
|
||||||
perf.Step("Deserialize JSON");
|
perf.Step("Deserialize JSON");
|
||||||
|
|
||||||
var users = ReadUserList(meta);
|
User[] users = ReadUserList(meta);
|
||||||
var servers = ReadServerList(meta, fakeSnowflake);
|
Data.Server[] servers = ReadServerList(meta, fakeSnowflake);
|
||||||
|
|
||||||
var newServersOnly = new HashSet<Data.Server>(servers);
|
var newServersOnly = new HashSet<Data.Server>(servers);
|
||||||
var oldServersById = await db.Servers.Get().ToDictionaryAsync(static server => server.Id, static server => server);
|
Dictionary<ulong, Data.Server> 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);
|
Dictionary<ulong, Channel> oldChannelsById = await db.Channels.Get().ToDictionaryAsync(static channel => channel.Id, static channel => channel);
|
||||||
|
|
||||||
foreach (var (channelId, serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) {
|
foreach ((ulong channelId, int 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 Channel oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out Data.Server oldServer) && newServersOnly.Remove(servers[serverIndex])) {
|
||||||
servers[serverIndex] = oldServer;
|
servers[serverIndex] = oldServer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
perf.Step("Read server and user list");
|
perf.Step("Read server and user list");
|
||||||
|
|
||||||
if (newServersOnly.Count > 0) {
|
if (newServersOnly.Count > 0) {
|
||||||
var askedServerIds = await askForServerIds(newServersOnly.ToArray());
|
Dictionary<Data.Server, ulong>? askedServerIds = await askForServerIds(newServersOnly.ToArray());
|
||||||
if (askedServerIds == null) {
|
if (askedServerIds == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
perf.Step("Ask for server IDs");
|
perf.Step("Ask for server IDs");
|
||||||
|
|
||||||
for (var i = 0; i < servers.Length; i++) {
|
for (int i = 0; i < servers.Length; i++) {
|
||||||
var server = servers[i];
|
Data.Server server = servers[i];
|
||||||
if (askedServerIds.TryGetValue(server, out var serverId)) {
|
if (askedServerIds.TryGetValue(server, out ulong serverId)) {
|
||||||
servers[i] = server with { Id = serverId };
|
servers[i] = server with { Id = serverId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var channels = ReadChannelList(meta, servers);
|
Channel[] channels = ReadChannelList(meta, servers);
|
||||||
|
|
||||||
perf.Step("Read channel list");
|
perf.Step("Read channel list");
|
||||||
|
|
||||||
var oldMessageIds = await db.Messages.GetIds().ToHashSetAsync();
|
HashSet<ulong> oldMessageIds = await db.Messages.GetIds().ToHashSetAsync();
|
||||||
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
|
Message[] 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");
|
||||||
|
|
||||||
await db.Users.Add(users);
|
await db.Users.Add(users);
|
||||||
await db.Servers.Add(servers);
|
await db.Servers.Add(servers);
|
||||||
await db.Channels.Add(channels);
|
await db.Channels.Add(channels);
|
||||||
await db.Messages.Add(newMessages);
|
await db.Messages.Add(newMessages);
|
||||||
|
|
||||||
perf.Step("Import into database");
|
perf.Step("Import into database");
|
||||||
} catch (HttpException e) {
|
} catch (HttpException e) {
|
||||||
throw new JsonException(e.Message);
|
throw new JsonException(e.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
perf.End();
|
perf.End();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static User[] ReadUserList(JsonElement meta) {
|
private static User[] ReadUserList(JsonElement meta) {
|
||||||
const string UsersPath = "meta.users[]";
|
const string UsersPath = "meta.users[]";
|
||||||
|
|
||||||
static ulong ParseUserIndex(JsonElement element, int index) {
|
static ulong ParseUserIndex(JsonElement element, int index) {
|
||||||
return ulong.Parse(element.GetString() ?? throw new JsonException("Expected key 'meta.userindex[" + index + "]' to be a string."));
|
return ulong.Parse(element.GetString() ?? throw new JsonException("Expected key 'meta.userindex[" + index + "]' to be a string."));
|
||||||
}
|
}
|
||||||
|
|
||||||
var userindex = meta.RequireArray("userindex", "meta")
|
Dictionary<ulong, int> userindex = meta.RequireArray("userindex", "meta")
|
||||||
.Select(static (item, index) => (ParseUserIndex(item, index), index))
|
.Select(static (item, index) => (ParseUserIndex(item, index), index))
|
||||||
.ToDictionary();
|
.ToDictionary();
|
||||||
|
|
||||||
var users = new User[userindex.Count];
|
var users = new User[userindex.Count];
|
||||||
|
|
||||||
foreach (var item in meta.RequireObject("users", "meta").EnumerateObject()) {
|
foreach (JsonProperty item in meta.RequireObject("users", "meta").EnumerateObject()) {
|
||||||
var path = UsersPath + "." + item.Name;
|
string path = UsersPath + "." + item.Name;
|
||||||
var userId = ulong.Parse(item.Name);
|
ulong userId = ulong.Parse(item.Name);
|
||||||
var userObj = item.Value;
|
JsonElement userObj = item.Value;
|
||||||
|
|
||||||
users[userindex[userId]] = new User {
|
users[userindex[userId]] = new User {
|
||||||
Id = userId,
|
Id = userId,
|
||||||
Name = userObj.RequireString("name", path),
|
Name = userObj.RequireString("name", path),
|
||||||
@@ -110,41 +110,41 @@ public static class LegacyArchiveImport {
|
|||||||
Discriminator = userObj.HasKey("tag") ? userObj.RequireString("tag", path) : null,
|
Discriminator = userObj.HasKey("tag") ? userObj.RequireString("tag", path) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Data.Server[] ReadServerList(JsonElement meta, FakeSnowflake fakeSnowflake) {
|
private static Data.Server[] ReadServerList(JsonElement meta, FakeSnowflake fakeSnowflake) {
|
||||||
const string ServersPath = "meta.servers[]";
|
const string ServersPath = "meta.servers[]";
|
||||||
|
|
||||||
return meta.RequireArray("servers", "meta").Select(serverObj => new Data.Server {
|
return meta.RequireArray("servers", "meta").Select(serverObj => new Data.Server {
|
||||||
Id = fakeSnowflake.Next(),
|
Id = fakeSnowflake.Next(),
|
||||||
Name = serverObj.RequireString("name", ServersPath),
|
Name = serverObj.RequireString("name", ServersPath),
|
||||||
Type = ServerTypes.FromString(serverObj.RequireString("type", ServersPath)),
|
Type = ServerTypes.FromString(serverObj.RequireString("type", ServersPath)),
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string ChannelsPath = "meta.channels";
|
private const string ChannelsPath = "meta.channels";
|
||||||
|
|
||||||
private static Dictionary<ulong, int> ReadChannelToServerIndexMapping(JsonElement meta, Data.Server[] servers) {
|
private static Dictionary<ulong, int> ReadChannelToServerIndexMapping(JsonElement meta, Data.Server[] servers) {
|
||||||
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
|
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
|
||||||
var path = ChannelsPath + "." + item.Name;
|
string path = ChannelsPath + "." + item.Name;
|
||||||
var channelId = ulong.Parse(item.Name);
|
ulong channelId = ulong.Parse(item.Name);
|
||||||
var channelObj = item.Value;
|
JsonElement channelObj = item.Value;
|
||||||
|
|
||||||
return (channelId, channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1));
|
return (channelId, channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1));
|
||||||
}).ToDictionary();
|
}).ToDictionary();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Channel[] ReadChannelList(JsonElement meta, Data.Server[] servers) {
|
private static Channel[] ReadChannelList(JsonElement meta, Data.Server[] servers) {
|
||||||
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
|
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
|
||||||
var path = ChannelsPath + "." + item.Name;
|
string path = ChannelsPath + "." + item.Name;
|
||||||
var channelId = ulong.Parse(item.Name);
|
ulong channelId = ulong.Parse(item.Name);
|
||||||
var channelObj = item.Value;
|
JsonElement channelObj = item.Value;
|
||||||
|
|
||||||
return new Channel {
|
return new Channel {
|
||||||
Id = channelId,
|
Id = channelId,
|
||||||
Server = servers[channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1)].Id,
|
Server = servers[channelObj.RequireInt("server", path, min: 0, servers.Length - 1)].Id,
|
||||||
Name = channelObj.RequireString("name", path),
|
Name = channelObj.RequireString("name", path),
|
||||||
Position = channelObj.HasKey("position") ? channelObj.RequireInt("position", path, min: 0) : null,
|
Position = channelObj.HasKey("position") ? channelObj.RequireInt("position", path, min: 0) : null,
|
||||||
Topic = channelObj.HasKey("topic") ? channelObj.RequireString("topic", path) : null,
|
Topic = channelObj.HasKey("topic") ? channelObj.RequireString("topic", path) : null,
|
||||||
@@ -152,23 +152,23 @@ public static class LegacyArchiveImport {
|
|||||||
};
|
};
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Message[] ReadMessages(JsonElement data, Channel channel, User[] users, FakeSnowflake fakeSnowflake) {
|
private static Message[] ReadMessages(JsonElement data, Channel channel, User[] users, FakeSnowflake fakeSnowflake) {
|
||||||
const string DataPath = "data";
|
const string DataPath = "data";
|
||||||
|
|
||||||
var channelId = channel.Id;
|
ulong channelId = channel.Id;
|
||||||
var channelIdStr = channelId.ToString();
|
string channelIdStr = channelId.ToString();
|
||||||
|
|
||||||
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
|
JsonElement? messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : null;
|
||||||
if (messagesObj == null) {
|
if (messagesObj == null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return messagesObj.Value.EnumerateObject().Select(item => {
|
return messagesObj.Value.EnumerateObject().Select(item => {
|
||||||
var path = DataPath + "." + item.Name;
|
string path = DataPath + "." + item.Name;
|
||||||
var messageId = ulong.Parse(item.Name);
|
ulong messageId = ulong.Parse(item.Name);
|
||||||
var messageObj = item.Value;
|
JsonElement messageObj = item.Value;
|
||||||
|
|
||||||
return new Message {
|
return new Message {
|
||||||
Id = messageId,
|
Id = messageId,
|
||||||
Sender = users[messageObj.RequireInt("u", path, min: 0, max: users.Length - 1)].Id,
|
Sender = users[messageObj.RequireInt("u", path, min: 0, max: users.Length - 1)].Id,
|
||||||
@@ -183,14 +183,14 @@ public static class LegacyArchiveImport {
|
|||||||
};
|
};
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ConvertToLambdaExpression")]
|
[SuppressMessage("ReSharper", "ConvertToLambdaExpression")]
|
||||||
private static IEnumerable<Attachment> ReadMessageAttachments(JsonElement.ArrayEnumerator attachmentsArray, FakeSnowflake fakeSnowflake, string path) {
|
private static IEnumerable<Attachment> ReadMessageAttachments(JsonElement.ArrayEnumerator attachmentsArray, FakeSnowflake fakeSnowflake, string path) {
|
||||||
return attachmentsArray.Select(attachmentObj => {
|
return attachmentsArray.Select(attachmentObj => {
|
||||||
string url = attachmentObj.RequireString("url", path);
|
string url = attachmentObj.RequireString("url", path);
|
||||||
string name = url[(url.LastIndexOf('/') + 1)..];
|
string name = url[(url.LastIndexOf('/') + 1)..];
|
||||||
string? type = ContentTypeProvider.TryGetContentType(name, out var contentType) ? contentType : null;
|
string? type = ContentTypeProvider.TryGetContentType(name, out string? contentType) ? contentType : null;
|
||||||
|
|
||||||
return new Attachment {
|
return new Attachment {
|
||||||
Id = fakeSnowflake.Next(),
|
Id = fakeSnowflake.Next(),
|
||||||
Name = name,
|
Name = name,
|
||||||
@@ -204,7 +204,7 @@ public static class LegacyArchiveImport {
|
|||||||
return attachment.Id;
|
return attachment.Id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<Embed> ReadMessageEmbeds(JsonElement.ArrayEnumerator embedsArray, string path) {
|
private static IEnumerable<Embed> ReadMessageEmbeds(JsonElement.ArrayEnumerator embedsArray, string path) {
|
||||||
// Some rich embeds are missing URLs which causes a missing 'url' key.
|
// Some rich embeds are missing URLs which causes a missing 'url' key.
|
||||||
return embedsArray.Where(static embedObj => embedObj.HasKey("url")).Select(embedObj => {
|
return embedsArray.Where(static embedObj => embedObj.HasKey("url")).Select(embedObj => {
|
||||||
@@ -216,24 +216,24 @@ public static class LegacyArchiveImport {
|
|||||||
Type = type,
|
Type = type,
|
||||||
Title = type == "rich" && embedObj.HasKey("t") ? embedObj.RequireString("t", path) : null,
|
Title = type == "rich" && embedObj.HasKey("t") ? embedObj.RequireString("t", path) : null,
|
||||||
Description = type == "rich" && embedObj.HasKey("d") ? embedObj.RequireString("d", path) : null,
|
Description = type == "rich" && embedObj.HasKey("d") ? embedObj.RequireString("d", path) : null,
|
||||||
Image = type == "image" ? new DiscordEmbedLegacyJson.ImageJson { Url = url } : null
|
Image = type == "image" ? new DiscordEmbedLegacyJson.ImageJson { Url = url } : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Embed {
|
return new Embed {
|
||||||
Json = JsonSerializer.Serialize(embed, DiscordEmbedLegacyJsonContext.Default.DiscordEmbedLegacyJson)
|
Json = JsonSerializer.Serialize(embed, DiscordEmbedLegacyJsonContext.Default.DiscordEmbedLegacyJson),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<Reaction> ReadMessageReactions(JsonElement.ArrayEnumerator reactionsArray, string path) {
|
private static IEnumerable<Reaction> ReadMessageReactions(JsonElement.ArrayEnumerator reactionsArray, string path) {
|
||||||
return reactionsArray.Select(reactionObj => {
|
return reactionsArray.Select(reactionObj => {
|
||||||
var id = reactionObj.HasKey("id") ? reactionObj.RequireSnowflake("id", path) : (ulong?) null;
|
ulong? id = reactionObj.HasKey("id") ? reactionObj.RequireSnowflake("id", path) : (ulong?) null;
|
||||||
var name = reactionObj.HasKey("n") ? reactionObj.RequireString("n", path) : null;
|
string? name = reactionObj.HasKey("n") ? reactionObj.RequireString("n", path) : null;
|
||||||
|
|
||||||
if (id == null && name == null) {
|
if (id == null && name == null) {
|
||||||
throw new JsonException("Expected key '" + path + ".id' and/or '" + path + ".n' to be present.");
|
throw new JsonException("Expected key '" + path + ".id' and/or '" + path + ".n' to be present.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Reaction {
|
return new Reaction {
|
||||||
EmojiId = id,
|
EmojiId = id,
|
||||||
EmojiName = name,
|
EmojiName = name,
|
||||||
|
@@ -16,20 +16,26 @@ public interface IChannelRepository {
|
|||||||
Task<long> Count(CancellationToken cancellationToken = default);
|
Task<long> Count(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken = default);
|
IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<int> RemoveUnreachable();
|
||||||
|
|
||||||
internal sealed class Dummy : IChannelRepository {
|
internal sealed class Dummy : IChannelRepository {
|
||||||
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task Add(IReadOnlyList<Channel> channels) {
|
public Task Add(IReadOnlyList<Channel> channels) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> Count(CancellationToken cancellationToken) {
|
public Task<long> Count(CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(0L);
|
return Task.FromResult(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken) {
|
public IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<Channel>();
|
return AsyncEnumerable.Empty<Channel>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> RemoveUnreachable() {
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,62 +13,74 @@ namespace DHT.Server.Database.Repositories;
|
|||||||
|
|
||||||
public interface IDownloadRepository {
|
public interface IDownloadRepository {
|
||||||
IObservable<long> TotalCount { get; }
|
IObservable<long> TotalCount { get; }
|
||||||
|
|
||||||
Task AddDownload(Data.Download item, Stream? stream);
|
Task AddDownload(Data.Download item, Stream? stream);
|
||||||
|
|
||||||
Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken = default);
|
Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken = default);
|
Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<Data.Download> Get();
|
IAsyncEnumerable<Data.Download> Get();
|
||||||
|
|
||||||
Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor);
|
Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor);
|
||||||
|
|
||||||
Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken = default);
|
Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken = default);
|
IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken = default);
|
Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<int> RetryFailed(CancellationToken cancellationToken = default);
|
Task<int> RetryFailed(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task Remove(ICollection<string> normalizedUrls);
|
||||||
|
|
||||||
|
IAsyncEnumerable<Data.Download> FindAllDownloadableUrls(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
internal sealed class Dummy : IDownloadRepository {
|
internal sealed class Dummy : IDownloadRepository {
|
||||||
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task AddDownload(Data.Download item, Stream? stream) {
|
public Task AddDownload(Data.Download item, Stream? stream) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken) {
|
public Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(0L);
|
return Task.FromResult(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken) {
|
public Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(new DownloadStatusStatistics());
|
return Task.FromResult(new DownloadStatusStatistics());
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<Data.Download> Get() {
|
public IAsyncEnumerable<Data.Download> Get() {
|
||||||
return AsyncEnumerable.Empty<Data.Download>();
|
return AsyncEnumerable.Empty<Data.Download>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor) {
|
public Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor) {
|
||||||
return Task.FromResult(false);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) {
|
public Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(false);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken) {
|
public IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<DownloadItem>();
|
return AsyncEnumerable.Empty<DownloadItem>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken) {
|
public Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<int> RetryFailed(CancellationToken cancellationToken) {
|
public Task<int> RetryFailed(CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task Remove(ICollection<string> normalizedUrls) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Data.Download> FindAllDownloadableUrls(CancellationToken cancellationToken) {
|
||||||
|
return AsyncEnumerable.Empty<Data.Download>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -20,29 +20,35 @@ public interface IMessageRepository {
|
|||||||
|
|
||||||
IAsyncEnumerable<ulong> GetIds(MessageFilter? filter = null);
|
IAsyncEnumerable<ulong> GetIds(MessageFilter? filter = null);
|
||||||
|
|
||||||
Task Remove(MessageFilter filter, FilterRemovalMode mode);
|
Task<int> Remove(MessageFilter filter, FilterRemovalMode mode);
|
||||||
|
|
||||||
|
Task<int> RemoveUnreachableAttachments();
|
||||||
|
|
||||||
internal sealed class Dummy : IMessageRepository {
|
internal sealed class Dummy : IMessageRepository {
|
||||||
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task Add(IReadOnlyList<Message> messages) {
|
public Task Add(IReadOnlyList<Message> messages) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
|
public Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(0L);
|
return Task.FromResult(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<Message> Get(MessageFilter? filter, CancellationToken cancellationToken) {
|
public IAsyncEnumerable<Message> Get(MessageFilter? filter, CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<Message>();
|
return AsyncEnumerable.Empty<Message>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
|
public IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
|
||||||
return AsyncEnumerable.Empty<ulong>();
|
return AsyncEnumerable.Empty<ulong>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Remove(MessageFilter filter, FilterRemovalMode mode) {
|
public Task<int> Remove(MessageFilter filter, FilterRemovalMode mode) {
|
||||||
return Task.CompletedTask;
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> RemoveUnreachableAttachments() {
|
||||||
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -15,20 +15,26 @@ public interface IServerRepository {
|
|||||||
Task<long> Count(CancellationToken cancellationToken = default);
|
Task<long> Count(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken = default);
|
IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<int> RemoveUnreachable();
|
||||||
|
|
||||||
internal sealed class Dummy : IServerRepository {
|
internal sealed class Dummy : IServerRepository {
|
||||||
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task Add(IReadOnlyList<Data.Server> servers) {
|
public Task Add(IReadOnlyList<Data.Server> servers) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> Count(CancellationToken cancellationToken) {
|
public Task<long> Count(CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(0L);
|
return Task.FromResult(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken) {
|
public IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<Data.Server>();
|
return AsyncEnumerable.Empty<Data.Server>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> RemoveUnreachable() {
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -19,11 +19,11 @@ public interface ISettingsRepository {
|
|||||||
public Task Set<T>(SettingsKey<T> key, T value) {
|
public Task Set<T>(SettingsKey<T> key, T value) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Set(Func<ISetter, Task> setter) {
|
public Task Set(Func<ISetter, Task> setter) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) {
|
public Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) {
|
||||||
return Task.FromResult(defaultValue);
|
return Task.FromResult(defaultValue);
|
||||||
}
|
}
|
||||||
|
@@ -16,20 +16,26 @@ public interface IUserRepository {
|
|||||||
Task<long> Count(CancellationToken cancellationToken = default);
|
Task<long> Count(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<User> Get(CancellationToken cancellationToken = default);
|
IAsyncEnumerable<User> Get(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<int> RemoveUnreachable();
|
||||||
|
|
||||||
internal sealed class Dummy : IUserRepository {
|
internal sealed class Dummy : IUserRepository {
|
||||||
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task Add(IReadOnlyList<User> users) {
|
public Task Add(IReadOnlyList<User> users) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> Count(CancellationToken cancellationToken) {
|
public Task<long> Count(CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(0L);
|
return Task.FromResult(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<User> Get(CancellationToken cancellationToken) {
|
public IAsyncEnumerable<User> Get(CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<User>();
|
return AsyncEnumerable.Empty<User>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> RemoveUnreachable() {
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,22 +9,22 @@ namespace DHT.Server.Database.Sqlite.Repositories;
|
|||||||
|
|
||||||
abstract class BaseSqliteRepository : IDisposable {
|
abstract class BaseSqliteRepository : IDisposable {
|
||||||
private readonly ObservableThrottledTask<long> totalCountTask;
|
private readonly ObservableThrottledTask<long> totalCountTask;
|
||||||
|
|
||||||
public IObservable<long> TotalCount { get; }
|
public IObservable<long> TotalCount { get; }
|
||||||
|
|
||||||
protected BaseSqliteRepository(Log log) {
|
protected BaseSqliteRepository(Log log) {
|
||||||
totalCountTask = new ObservableThrottledTask<long>(log, TaskScheduler.Default);
|
totalCountTask = new ObservableThrottledTask<long>(log, TaskScheduler.Default);
|
||||||
TotalCount = totalCountTask.DistinctUntilChanged();
|
TotalCount = totalCountTask.DistinctUntilChanged();
|
||||||
UpdateTotalCount();
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
totalCountTask.Dispose();
|
totalCountTask.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void UpdateTotalCount() {
|
protected void UpdateTotalCount() {
|
||||||
totalCountTask.Post(Count);
|
totalCountTask.Post(Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task<long> Count(CancellationToken cancellationToken);
|
public abstract Task<long> Count(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
@@ -14,11 +14,11 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository
|
|||||||
private static readonly Log Log = Log.ForType<SqliteChannelRepository>();
|
private static readonly Log Log = Log.ForType<SqliteChannelRepository>();
|
||||||
|
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
|
|
||||||
public SqliteChannelRepository(SqliteConnectionPool pool) : base(Log) {
|
public SqliteChannelRepository(SqliteConnectionPool pool) : base(Log) {
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Channel> channels) {
|
public async Task Add(IReadOnlyList<Channel> channels) {
|
||||||
await using (var conn = await pool.Take()) {
|
await using (var conn = await pool.Take()) {
|
||||||
await conn.BeginTransactionAsync();
|
await conn.BeginTransactionAsync();
|
||||||
@@ -30,10 +30,10 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository
|
|||||||
("parent_id", SqliteType.Integer),
|
("parent_id", SqliteType.Integer),
|
||||||
("position", SqliteType.Integer),
|
("position", SqliteType.Integer),
|
||||||
("topic", SqliteType.Text),
|
("topic", SqliteType.Text),
|
||||||
("nsfw", SqliteType.Integer)
|
("nsfw", SqliteType.Integer),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach (var channel in channels) {
|
foreach (Channel channel in channels) {
|
||||||
cmd.Set(":id", channel.Id);
|
cmd.Set(":id", channel.Id);
|
||||||
cmd.Set(":server", channel.Server);
|
cmd.Set(":server", channel.Server);
|
||||||
cmd.Set(":name", channel.Name);
|
cmd.Set(":name", channel.Name);
|
||||||
@@ -43,24 +43,24 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository
|
|||||||
cmd.Set(":nsfw", channel.Nsfw);
|
cmd.Set(":nsfw", channel.Nsfw);
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.CommitTransactionAsync();
|
await conn.CommitTransactionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateTotalCount();
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<long> Count(CancellationToken cancellationToken) {
|
public override async Task<long> Count(CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Channel> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
public async IAsyncEnumerable<Channel> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
|
await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken)) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
yield return new Channel {
|
yield return new Channel {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
@@ -73,4 +73,14 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> RemoveUnreachable() {
|
||||||
|
int removed;
|
||||||
|
await using (var conn = await pool.Take()) {
|
||||||
|
removed = await conn.ExecuteAsync("DELETE FROM channels WHERE id NOT IN (SELECT DISTINCT channel_id FROM messages)");
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateTotalCount();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -17,16 +17,16 @@ namespace DHT.Server.Database.Sqlite.Repositories;
|
|||||||
|
|
||||||
sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRepository(Log), IDownloadRepository {
|
sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRepository(Log), IDownloadRepository {
|
||||||
private static readonly Log Log = Log.ForType<SqliteDownloadRepository>();
|
private static readonly Log Log = Log.ForType<SqliteDownloadRepository>();
|
||||||
|
|
||||||
internal sealed class NewDownloadCollector : IAsyncDisposable {
|
internal sealed class NewDownloadCollector : IAsyncDisposable {
|
||||||
private readonly SqliteDownloadRepository repository;
|
private readonly SqliteDownloadRepository repository;
|
||||||
private bool hasChanged = false;
|
private bool hasChanged = false;
|
||||||
|
|
||||||
private readonly SqliteCommand metadataCmd;
|
private readonly SqliteCommand metadataCmd;
|
||||||
|
|
||||||
public NewDownloadCollector(SqliteDownloadRepository repository, ISqliteConnection conn) {
|
public NewDownloadCollector(SqliteDownloadRepository repository, ISqliteConnection conn) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
|
|
||||||
metadataCmd = conn.Command(
|
metadataCmd = conn.Command(
|
||||||
"""
|
"""
|
||||||
INSERT INTO download_metadata (normalized_url, download_url, status, type, size)
|
INSERT INTO download_metadata (normalized_url, download_url, status, type, size)
|
||||||
@@ -50,7 +50,7 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
metadataCmd.Add(":size", SqliteType.Integer);
|
metadataCmd.Add(":size", SqliteType.Integer);
|
||||||
metadataCmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
metadataCmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Add(Data.Download download) {
|
public async Task Add(Data.Download download) {
|
||||||
metadataCmd.Set(":normalized_url", download.NormalizedUrl);
|
metadataCmd.Set(":normalized_url", download.NormalizedUrl);
|
||||||
metadataCmd.Set(":download_url", download.DownloadUrl);
|
metadataCmd.Set(":download_url", download.DownloadUrl);
|
||||||
@@ -59,18 +59,18 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
metadataCmd.Set(":size", download.Size);
|
metadataCmd.Set(":size", download.Size);
|
||||||
hasChanged |= await metadataCmd.ExecuteNonQueryAsync() > 0;
|
hasChanged |= await metadataCmd.ExecuteNonQueryAsync() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnCommitted() {
|
public void OnCommitted() {
|
||||||
if (hasChanged) {
|
if (hasChanged) {
|
||||||
repository.UpdateTotalCount();
|
repository.UpdateTotalCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
public async ValueTask DisposeAsync() {
|
||||||
await metadataCmd.DisposeAsync();
|
await metadataCmd.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddDownload(Data.Download item, Stream? stream) {
|
public async Task AddDownload(Data.Download item, Stream? stream) {
|
||||||
await using (var conn = await pool.Take()) {
|
await using (var conn = await pool.Take()) {
|
||||||
await conn.BeginTransactionAsync();
|
await conn.BeginTransactionAsync();
|
||||||
@@ -82,14 +82,14 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
("type", SqliteType.Text),
|
("type", SqliteType.Text),
|
||||||
("size", SqliteType.Integer),
|
("size", SqliteType.Integer),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
metadataCmd.Set(":normalized_url", item.NormalizedUrl);
|
metadataCmd.Set(":normalized_url", item.NormalizedUrl);
|
||||||
metadataCmd.Set(":download_url", item.DownloadUrl);
|
metadataCmd.Set(":download_url", item.DownloadUrl);
|
||||||
metadataCmd.Set(":status", (int) item.Status);
|
metadataCmd.Set(":status", (int) item.Status);
|
||||||
metadataCmd.Set(":type", item.Type);
|
metadataCmd.Set(":type", item.Type);
|
||||||
metadataCmd.Set(":size", item.Size);
|
metadataCmd.Set(":size", item.Size);
|
||||||
await metadataCmd.ExecuteNonQueryAsync();
|
await metadataCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
await using var deleteBlobCmd = conn.Command("DELETE FROM download_blobs WHERE normalized_url = :normalized_url");
|
await using var deleteBlobCmd = conn.Command("DELETE FROM download_blobs WHERE normalized_url = :normalized_url");
|
||||||
deleteBlobCmd.AddAndSet(":normalized_url", SqliteType.Text, item.NormalizedUrl);
|
deleteBlobCmd.AddAndSet(":normalized_url", SqliteType.Text, item.NormalizedUrl);
|
||||||
@@ -109,33 +109,33 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
upsertBlobCmd.AddAndSet(":normalized_url", SqliteType.Text, item.NormalizedUrl);
|
upsertBlobCmd.AddAndSet(":normalized_url", SqliteType.Text, item.NormalizedUrl);
|
||||||
upsertBlobCmd.AddAndSet(":blob_length", SqliteType.Integer, item.Size);
|
upsertBlobCmd.AddAndSet(":blob_length", SqliteType.Integer, item.Size);
|
||||||
long rowid = await upsertBlobCmd.ExecuteLongScalarAsync();
|
long rowid = await upsertBlobCmd.ExecuteLongScalarAsync();
|
||||||
|
|
||||||
await using var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid);
|
await using var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid);
|
||||||
await stream.CopyToAsync(blob);
|
await stream.CopyToAsync(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.CommitTransactionAsync();
|
await conn.CommitTransactionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateTotalCount();
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<long> Count(CancellationToken cancellationToken) {
|
public override Task<long> Count(CancellationToken cancellationToken) {
|
||||||
return Count(filter: null, cancellationToken);
|
return Count(filter: null, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<long> Count(DownloadItemFilter? filter, CancellationToken cancellationToken) {
|
public async Task<long> Count(DownloadItemFilter? filter, CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM download_metadata" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM download_metadata" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken) {
|
public async Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken) {
|
||||||
nonSkippedFilter.IncludeStatuses = null;
|
nonSkippedFilter.IncludeStatuses = null;
|
||||||
nonSkippedFilter.ExcludeStatuses = null;
|
nonSkippedFilter.ExcludeStatuses = null;
|
||||||
string nonSkippedFilterConditions = nonSkippedFilter.GenerateConditions().Build();
|
string nonSkippedFilterConditions = nonSkippedFilter.GenerateConditions().Build();
|
||||||
|
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command(
|
await using var cmd = conn.Command(
|
||||||
$"""
|
$"""
|
||||||
SELECT
|
SELECT
|
||||||
@@ -154,17 +154,17 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
FROM download_metadata
|
FROM download_metadata
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
||||||
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
||||||
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
if (!await reader.ReadAsync(cancellationToken)) {
|
if (!await reader.ReadAsync(cancellationToken)) {
|
||||||
return new DownloadStatusStatistics();
|
return new DownloadStatusStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DownloadStatusStatistics {
|
return new DownloadStatusStatistics {
|
||||||
PendingCount = reader.GetInt32(0),
|
PendingCount = reader.GetInt32(0),
|
||||||
PendingTotalSize = reader.GetUint64(1),
|
PendingTotalSize = reader.GetUint64(1),
|
||||||
@@ -177,53 +177,53 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
FailedWithUnknownSizeCount = reader.GetInt32(8),
|
FailedWithUnknownSizeCount = reader.GetInt32(8),
|
||||||
SkippedCount = reader.GetInt32(9),
|
SkippedCount = reader.GetInt32(9),
|
||||||
SkippedTotalSize = reader.GetUint64(10),
|
SkippedTotalSize = reader.GetUint64(10),
|
||||||
SkippedWithUnknownSizeCount = reader.GetInt32(11)
|
SkippedWithUnknownSizeCount = reader.GetInt32(11),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Data.Download> Get() {
|
public async IAsyncEnumerable<Data.Download> Get() {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT normalized_url, download_url, status, type, size FROM download_metadata");
|
await using var cmd = conn.Command("SELECT normalized_url, download_url, status, type, size FROM download_metadata");
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
while (await reader.ReadAsync()) {
|
while (await reader.ReadAsync()) {
|
||||||
string normalizedUrl = reader.GetString(0);
|
string normalizedUrl = reader.GetString(0);
|
||||||
string downloadUrl = reader.GetString(1);
|
string downloadUrl = reader.GetString(1);
|
||||||
var status = (DownloadStatus) reader.GetInt32(2);
|
var status = (DownloadStatus) reader.GetInt32(2);
|
||||||
string? type = reader.IsDBNull(3) ? null : reader.GetString(3);
|
string? type = reader.IsDBNull(3) ? null : reader.GetString(3);
|
||||||
ulong? size = reader.IsDBNull(4) ? null : reader.GetUint64(4);
|
ulong? size = reader.IsDBNull(4) ? null : reader.GetUint64(4);
|
||||||
|
|
||||||
yield return new Data.Download(normalizedUrl, downloadUrl, status, type, size);
|
yield return new Data.Download(normalizedUrl, downloadUrl, status, type, size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor) {
|
public async Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT rowid FROM download_blobs WHERE normalized_url = :normalized_url");
|
await using var cmd = conn.Command("SELECT rowid FROM download_blobs WHERE normalized_url = :normalized_url");
|
||||||
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
||||||
|
|
||||||
long rowid;
|
long rowid;
|
||||||
|
|
||||||
await using (var reader = await cmd.ExecuteReaderAsync()) {
|
await using (SqliteDataReader reader = await cmd.ExecuteReaderAsync()) {
|
||||||
if (!await reader.ReadAsync()) {
|
if (!await reader.ReadAsync()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
rowid = reader.GetInt64(0);
|
rowid = reader.GetInt64(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) {
|
await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) {
|
||||||
await dataProcessor(blob);
|
await dataProcessor(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) {
|
public async Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command(
|
await using var cmd = conn.Command(
|
||||||
"""
|
"""
|
||||||
SELECT dm.download_url, dm.type, db.rowid FROM download_metadata dm
|
SELECT dm.download_url, dm.type, db.rowid FROM download_metadata dm
|
||||||
@@ -231,10 +231,10 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
WHERE dm.normalized_url = :normalized_url AND dm.status = :success IS NOT NULL
|
WHERE dm.normalized_url = :normalized_url AND dm.status = :success IS NOT NULL
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
||||||
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
|
|
||||||
string downloadUrl;
|
string downloadUrl;
|
||||||
string? type;
|
string? type;
|
||||||
long rowid;
|
long rowid;
|
||||||
@@ -243,7 +243,7 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
if (!await reader.ReadAsync(cancellationToken)) {
|
if (!await reader.ReadAsync(cancellationToken)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadUrl = reader.GetString(0);
|
downloadUrl = reader.GetString(0);
|
||||||
type = reader.IsDBNull(1) ? null : reader.GetString(1);
|
type = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||||
rowid = reader.GetInt64(2);
|
rowid = reader.GetInt64(2);
|
||||||
@@ -252,68 +252,68 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) {
|
await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) {
|
||||||
await dataProcessor(new Data.Download(normalizedUrl, downloadUrl, DownloadStatus.Success, type, (ulong) blob.Length), blob, cancellationToken);
|
await dataProcessor(new Data.Download(normalizedUrl, downloadUrl, DownloadStatus.Success, type, (ulong) blob.Length), blob, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
public async IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
filter.IncludeStatuses = [DownloadStatus.Pending];
|
filter.IncludeStatuses = [DownloadStatus.Pending];
|
||||||
filter.ExcludeStatuses = null;
|
filter.ExcludeStatuses = null;
|
||||||
|
|
||||||
var found = new List<DownloadItem>();
|
var found = new List<DownloadItem>();
|
||||||
|
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
var sql = $"""
|
string sql = $"""
|
||||||
SELECT normalized_url, download_url, type, size
|
SELECT normalized_url, download_url, type, size
|
||||||
FROM download_metadata
|
FROM download_metadata
|
||||||
{filter.GenerateConditions().BuildWhereClause()}
|
{filter.GenerateConditions().BuildWhereClause()}
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using (var cmd = conn.Command(sql)) {
|
await using (var cmd = conn.Command(sql)) {
|
||||||
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count));
|
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(val1: 0, count));
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken)) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
found.Add(new DownloadItem {
|
found.Add(new DownloadItem {
|
||||||
NormalizedUrl = reader.GetString(0),
|
NormalizedUrl = reader.GetString(0),
|
||||||
DownloadUrl = reader.GetString(1),
|
DownloadUrl = reader.GetString(1),
|
||||||
Type = reader.IsDBNull(2) ? null : reader.GetString(2),
|
Type = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
Size = reader.IsDBNull(3) ? null : reader.GetUint64(3)
|
Size = reader.IsDBNull(3) ? null : reader.GetUint64(3),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (found.Count != 0) {
|
if (found.Count != 0) {
|
||||||
await using var cmd = conn.Command("UPDATE download_metadata SET status = :downloading WHERE normalized_url = :normalized_url AND status = :pending");
|
await using var cmd = conn.Command("UPDATE download_metadata SET status = :downloading WHERE normalized_url = :normalized_url AND status = :pending");
|
||||||
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
||||||
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
||||||
cmd.Add(":normalized_url", SqliteType.Text);
|
cmd.Add(":normalized_url", SqliteType.Text);
|
||||||
|
|
||||||
foreach (var item in found) {
|
foreach (DownloadItem item in found) {
|
||||||
cmd.Set(":normalized_url", item.NormalizedUrl);
|
cmd.Set(":normalized_url", item.NormalizedUrl);
|
||||||
|
|
||||||
if (await cmd.ExecuteNonQueryAsync(cancellationToken) == 1) {
|
if (await cmd.ExecuteNonQueryAsync(cancellationToken) == 1) {
|
||||||
yield return item;
|
yield return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken) {
|
public async Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("UPDATE download_metadata SET status = :pending WHERE status = :downloading");
|
await using var cmd = conn.Command("UPDATE download_metadata SET status = :pending WHERE status = :downloading");
|
||||||
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
||||||
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
||||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> RetryFailed(CancellationToken cancellationToken) {
|
public async Task<int> RetryFailed(CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("UPDATE download_metadata SET status = :pending WHERE status = :generic_error OR (status > :last_custom_code AND status != :success)");
|
await using var cmd = conn.Command("UPDATE download_metadata SET status = :pending WHERE status = :generic_error OR (status > :last_custom_code AND status != :success)");
|
||||||
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
||||||
cmd.AddAndSet(":generic_error", SqliteType.Integer, (int) DownloadStatus.GenericError);
|
cmd.AddAndSet(":generic_error", SqliteType.Integer, (int) DownloadStatus.GenericError);
|
||||||
@@ -321,4 +321,62 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
|
|||||||
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
return await cmd.ExecuteNonQueryAsync(cancellationToken);
|
return await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task Remove(ICollection<string> normalizedUrls) {
|
||||||
|
await using (var conn = await pool.Take()) {
|
||||||
|
await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
|
await using (var cmd = conn.Command("DELETE FROM download_metadata WHERE normalized_url = :normalized_url")) {
|
||||||
|
cmd.Add(":normalized_url", SqliteType.Text);
|
||||||
|
|
||||||
|
foreach (string normalizedUrl in normalizedUrls) {
|
||||||
|
cmd.Set(":normalized_url", normalizedUrl);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.CommitTransactionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateTotalCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Data.Download> FindAllDownloadableUrls([EnumeratorCancellation] CancellationToken cancellationToken = default) {
|
||||||
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
|
await using (var cmd = conn.Command("SELECT normalized_url, download_url, type, size FROM attachments")) {
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
|
yield return DownloadLinkExtractor.FromAttachment(reader.GetString(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetString(2), reader.GetUint64(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var cmd = conn.Command("SELECT json FROM message_embeds")) {
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
|
var result = await DownloadLinkExtractor.TryFromEmbedJson(reader.GetStream(0));
|
||||||
|
if (result is not null) {
|
||||||
|
yield return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var cmd = conn.Command("SELECT id, avatar_url FROM users WHERE avatar_url IS NOT NULL")) {
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
|
yield return DownloadLinkExtractor.FromUserAvatar(reader.GetUint64(0), reader.GetString(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var cmd = conn.Command("SELECT DISTINCT emoji_id, emoji_flags FROM message_reactions WHERE emoji_id IS NOT NULL")) {
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
|
yield return DownloadLinkExtractor.FromEmoji(reader.GetUint64(0), (EmojiFlags) reader.GetInt16(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -16,32 +16,32 @@ namespace DHT.Server.Database.Sqlite.Repositories;
|
|||||||
|
|
||||||
sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : BaseSqliteRepository(Log), IMessageRepository {
|
sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : BaseSqliteRepository(Log), IMessageRepository {
|
||||||
private static readonly Log Log = Log.ForType<SqliteMessageRepository>();
|
private static readonly Log Log = Log.ForType<SqliteMessageRepository>();
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Message> messages) {
|
public async Task Add(IReadOnlyList<Message> messages) {
|
||||||
if (messages.Count == 0) {
|
if (messages.Count == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
static SqliteCommand DeleteByMessageId(ISqliteConnection conn, string tableName) {
|
static SqliteCommand DeleteByMessageId(ISqliteConnection conn, string tableName) {
|
||||||
return conn.Delete(tableName, ("message_id", SqliteType.Integer));
|
return conn.Delete(tableName, ("message_id", SqliteType.Integer));
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task ExecuteDeleteByMessageId(SqliteCommand cmd, object id) {
|
static async Task ExecuteDeleteByMessageId(SqliteCommand cmd, object id) {
|
||||||
cmd.Set(":message_id", id);
|
cmd.Set(":message_id", id);
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await using (var conn = await pool.Take()) {
|
await using (var conn = await pool.Take()) {
|
||||||
await conn.BeginTransactionAsync();
|
await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
await using var messageCmd = conn.Upsert("messages", [
|
await using var messageCmd = conn.Upsert("messages", [
|
||||||
("message_id", SqliteType.Integer),
|
("message_id", SqliteType.Integer),
|
||||||
("sender_id", SqliteType.Integer),
|
("sender_id", SqliteType.Integer),
|
||||||
("channel_id", SqliteType.Integer),
|
("channel_id", SqliteType.Integer),
|
||||||
("text", SqliteType.Text),
|
("text", SqliteType.Text),
|
||||||
("timestamp", SqliteType.Integer)
|
("timestamp", SqliteType.Integer),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var attachmentCmd = conn.Upsert("attachments", [
|
await using var attachmentCmd = conn.Upsert("attachments", [
|
||||||
("attachment_id", SqliteType.Integer),
|
("attachment_id", SqliteType.Integer),
|
||||||
("name", SqliteType.Text),
|
("name", SqliteType.Text),
|
||||||
@@ -50,79 +50,79 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
|
|||||||
("download_url", SqliteType.Text),
|
("download_url", SqliteType.Text),
|
||||||
("size", SqliteType.Integer),
|
("size", SqliteType.Integer),
|
||||||
("width", SqliteType.Integer),
|
("width", SqliteType.Integer),
|
||||||
("height", SqliteType.Integer)
|
("height", SqliteType.Integer),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var deleteMessageEditTimestampCmd = DeleteByMessageId(conn, "message_edit_timestamps");
|
await using var deleteMessageEditTimestampCmd = DeleteByMessageId(conn, "message_edit_timestamps");
|
||||||
await using var deleteMessageRepliedToCmd = DeleteByMessageId(conn, "message_replied_to");
|
await using var deleteMessageRepliedToCmd = DeleteByMessageId(conn, "message_replied_to");
|
||||||
|
|
||||||
await using var deleteMessageAttachmentsCmd = DeleteByMessageId(conn, "message_attachments");
|
await using var deleteMessageAttachmentsCmd = DeleteByMessageId(conn, "message_attachments");
|
||||||
await using var deleteMessageEmbedsCmd = DeleteByMessageId(conn, "message_embeds");
|
await using var deleteMessageEmbedsCmd = DeleteByMessageId(conn, "message_embeds");
|
||||||
await using var deleteMessageReactionsCmd = DeleteByMessageId(conn, "message_reactions");
|
await using var deleteMessageReactionsCmd = DeleteByMessageId(conn, "message_reactions");
|
||||||
|
|
||||||
await using var messageEditTimestampCmd = conn.Insert("message_edit_timestamps", [
|
await using var messageEditTimestampCmd = conn.Insert("message_edit_timestamps", [
|
||||||
("message_id", SqliteType.Integer),
|
("message_id", SqliteType.Integer),
|
||||||
("edit_timestamp", SqliteType.Integer)
|
("edit_timestamp", SqliteType.Integer),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var messageRepliedToCmd = conn.Insert("message_replied_to", [
|
await using var messageRepliedToCmd = conn.Insert("message_replied_to", [
|
||||||
("message_id", SqliteType.Integer),
|
("message_id", SqliteType.Integer),
|
||||||
("replied_to_id", SqliteType.Integer)
|
("replied_to_id", SqliteType.Integer),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var messageAttachmentCmd = conn.Insert("message_attachments", [
|
await using var messageAttachmentCmd = conn.Insert("message_attachments", [
|
||||||
("message_id", SqliteType.Integer),
|
("message_id", SqliteType.Integer),
|
||||||
("attachment_id", SqliteType.Integer)
|
("attachment_id", SqliteType.Integer),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var messageEmbedCmd = conn.Insert("message_embeds", [
|
await using var messageEmbedCmd = conn.Insert("message_embeds", [
|
||||||
("message_id", SqliteType.Integer),
|
("message_id", SqliteType.Integer),
|
||||||
("json", SqliteType.Text)
|
("json", SqliteType.Text),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var messageReactionCmd = conn.Insert("message_reactions", [
|
await using var messageReactionCmd = conn.Insert("message_reactions", [
|
||||||
("message_id", SqliteType.Integer),
|
("message_id", SqliteType.Integer),
|
||||||
("emoji_id", SqliteType.Integer),
|
("emoji_id", SqliteType.Integer),
|
||||||
("emoji_name", SqliteType.Text),
|
("emoji_name", SqliteType.Text),
|
||||||
("emoji_flags", SqliteType.Integer),
|
("emoji_flags", SqliteType.Integer),
|
||||||
("count", SqliteType.Integer)
|
("count", SqliteType.Integer),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn);
|
await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn);
|
||||||
|
|
||||||
foreach (var message in messages) {
|
foreach (Message message in messages) {
|
||||||
object messageId = message.Id;
|
object messageId = message.Id;
|
||||||
|
|
||||||
messageCmd.Set(":message_id", messageId);
|
messageCmd.Set(":message_id", messageId);
|
||||||
messageCmd.Set(":sender_id", message.Sender);
|
messageCmd.Set(":sender_id", message.Sender);
|
||||||
messageCmd.Set(":channel_id", message.Channel);
|
messageCmd.Set(":channel_id", message.Channel);
|
||||||
messageCmd.Set(":text", message.Text);
|
messageCmd.Set(":text", message.Text);
|
||||||
messageCmd.Set(":timestamp", message.Timestamp);
|
messageCmd.Set(":timestamp", message.Timestamp);
|
||||||
await messageCmd.ExecuteNonQueryAsync();
|
await messageCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
await ExecuteDeleteByMessageId(deleteMessageEditTimestampCmd, messageId);
|
await ExecuteDeleteByMessageId(deleteMessageEditTimestampCmd, messageId);
|
||||||
await ExecuteDeleteByMessageId(deleteMessageRepliedToCmd, messageId);
|
await ExecuteDeleteByMessageId(deleteMessageRepliedToCmd, messageId);
|
||||||
|
|
||||||
await ExecuteDeleteByMessageId(deleteMessageAttachmentsCmd, messageId);
|
await ExecuteDeleteByMessageId(deleteMessageAttachmentsCmd, messageId);
|
||||||
await ExecuteDeleteByMessageId(deleteMessageEmbedsCmd, messageId);
|
await ExecuteDeleteByMessageId(deleteMessageEmbedsCmd, messageId);
|
||||||
await ExecuteDeleteByMessageId(deleteMessageReactionsCmd, messageId);
|
await ExecuteDeleteByMessageId(deleteMessageReactionsCmd, messageId);
|
||||||
|
|
||||||
if (message.EditTimestamp is {} timestamp) {
|
if (message.EditTimestamp is {} timestamp) {
|
||||||
messageEditTimestampCmd.Set(":message_id", messageId);
|
messageEditTimestampCmd.Set(":message_id", messageId);
|
||||||
messageEditTimestampCmd.Set(":edit_timestamp", timestamp);
|
messageEditTimestampCmd.Set(":edit_timestamp", timestamp);
|
||||||
await messageEditTimestampCmd.ExecuteNonQueryAsync();
|
await messageEditTimestampCmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.RepliedToId is {} repliedToId) {
|
if (message.RepliedToId is {} repliedToId) {
|
||||||
messageRepliedToCmd.Set(":message_id", messageId);
|
messageRepliedToCmd.Set(":message_id", messageId);
|
||||||
messageRepliedToCmd.Set(":replied_to_id", repliedToId);
|
messageRepliedToCmd.Set(":replied_to_id", repliedToId);
|
||||||
await messageRepliedToCmd.ExecuteNonQueryAsync();
|
await messageRepliedToCmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message.Attachments.IsEmpty) {
|
if (!message.Attachments.IsEmpty) {
|
||||||
foreach (var attachment in message.Attachments) {
|
foreach (Attachment attachment in message.Attachments) {
|
||||||
object attachmentId = attachment.Id;
|
object attachmentId = attachment.Id;
|
||||||
|
|
||||||
attachmentCmd.Set(":attachment_id", attachmentId);
|
attachmentCmd.Set(":attachment_id", attachmentId);
|
||||||
attachmentCmd.Set(":name", attachment.Name);
|
attachmentCmd.Set(":name", attachment.Name);
|
||||||
attachmentCmd.Set(":type", attachment.Type);
|
attachmentCmd.Set(":type", attachment.Type);
|
||||||
@@ -132,92 +132,92 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
|
|||||||
attachmentCmd.Set(":width", attachment.Width);
|
attachmentCmd.Set(":width", attachment.Width);
|
||||||
attachmentCmd.Set(":height", attachment.Height);
|
attachmentCmd.Set(":height", attachment.Height);
|
||||||
await attachmentCmd.ExecuteNonQueryAsync();
|
await attachmentCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
messageAttachmentCmd.Set(":message_id", messageId);
|
messageAttachmentCmd.Set(":message_id", messageId);
|
||||||
messageAttachmentCmd.Set(":attachment_id", attachmentId);
|
messageAttachmentCmd.Set(":attachment_id", attachmentId);
|
||||||
await messageAttachmentCmd.ExecuteNonQueryAsync();
|
await messageAttachmentCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
await downloadCollector.Add(DownloadLinkExtractor.FromAttachment(attachment));
|
await downloadCollector.Add(DownloadLinkExtractor.FromAttachment(attachment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message.Embeds.IsEmpty) {
|
if (!message.Embeds.IsEmpty) {
|
||||||
foreach (var embed in message.Embeds) {
|
foreach (Embed embed in message.Embeds) {
|
||||||
messageEmbedCmd.Set(":message_id", messageId);
|
messageEmbedCmd.Set(":message_id", messageId);
|
||||||
messageEmbedCmd.Set(":json", embed.Json);
|
messageEmbedCmd.Set(":json", embed.Json);
|
||||||
await messageEmbedCmd.ExecuteNonQueryAsync();
|
await messageEmbedCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
if (DownloadLinkExtractor.TryFromEmbedJson(embed.Json) is {} download) {
|
if (DownloadLinkExtractor.TryFromEmbedJson(embed.Json) is {} download) {
|
||||||
await downloadCollector.Add(download);
|
await downloadCollector.Add(download);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message.Reactions.IsEmpty) {
|
if (!message.Reactions.IsEmpty) {
|
||||||
foreach (var reaction in message.Reactions) {
|
foreach (Reaction reaction in message.Reactions) {
|
||||||
messageReactionCmd.Set(":message_id", messageId);
|
messageReactionCmd.Set(":message_id", messageId);
|
||||||
messageReactionCmd.Set(":emoji_id", reaction.EmojiId);
|
messageReactionCmd.Set(":emoji_id", reaction.EmojiId);
|
||||||
messageReactionCmd.Set(":emoji_name", reaction.EmojiName);
|
messageReactionCmd.Set(":emoji_name", reaction.EmojiName);
|
||||||
messageReactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
|
messageReactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
|
||||||
messageReactionCmd.Set(":count", reaction.Count);
|
messageReactionCmd.Set(":count", reaction.Count);
|
||||||
await messageReactionCmd.ExecuteNonQueryAsync();
|
await messageReactionCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
if (reaction.EmojiId is {} emojiId) {
|
if (reaction.EmojiId is {} emojiId) {
|
||||||
await downloadCollector.Add(DownloadLinkExtractor.FromEmoji(emojiId, reaction.EmojiFlags));
|
await downloadCollector.Add(DownloadLinkExtractor.FromEmoji(emojiId, reaction.EmojiFlags));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.CommitTransactionAsync();
|
await conn.CommitTransactionAsync();
|
||||||
downloadCollector.OnCommitted();
|
downloadCollector.OnCommitted();
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateTotalCount();
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<long> Count(CancellationToken cancellationToken) {
|
public override Task<long> Count(CancellationToken cancellationToken) {
|
||||||
return Count(filter: null, cancellationToken);
|
return Count(filter: null, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
|
public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class MessageToManyCommand<T> : IAsyncDisposable {
|
private sealed class MessageToManyCommand<T> : IAsyncDisposable {
|
||||||
private readonly SqliteCommand cmd;
|
private readonly SqliteCommand cmd;
|
||||||
private readonly Func<SqliteDataReader, T> readItem;
|
private readonly Func<SqliteDataReader, T> readItem;
|
||||||
|
|
||||||
public MessageToManyCommand(ISqliteConnection conn, string sql, Func<SqliteDataReader, T> readItem) {
|
public MessageToManyCommand(ISqliteConnection conn, string sql, Func<SqliteDataReader, T> readItem) {
|
||||||
this.cmd = conn.Command(sql);
|
this.cmd = conn.Command(sql);
|
||||||
this.cmd.Add(":message_id", SqliteType.Integer);
|
this.cmd.Add(":message_id", SqliteType.Integer);
|
||||||
|
|
||||||
this.readItem = readItem;
|
this.readItem = readItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ImmutableList<T>> GetItems(ulong messageId) {
|
public async Task<ImmutableList<T>> GetItems(ulong messageId) {
|
||||||
cmd.Set(":message_id", messageId);
|
cmd.Set(":message_id", messageId);
|
||||||
|
|
||||||
var items = ImmutableList<T>.Empty;
|
var items = ImmutableList<T>.Empty;
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
while (await reader.ReadAsync()) {
|
while (await reader.ReadAsync()) {
|
||||||
items = items.Add(readItem(reader));
|
items = items.Add(readItem(reader));
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() {
|
public async ValueTask DisposeAsync() {
|
||||||
await cmd.DisposeAsync();
|
await cmd.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Message> Get(MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
public async IAsyncEnumerable<Message> Get(MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
const string AttachmentSql =
|
const string AttachmentSql =
|
||||||
"""
|
"""
|
||||||
SELECT attachment_id, name, type, normalized_url, download_url, size, width, height
|
SELECT attachment_id, name, type, normalized_url, download_url, size, width, height
|
||||||
@@ -225,7 +225,7 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
|
|||||||
JOIN message_attachments USING (attachment_id)
|
JOIN message_attachments USING (attachment_id)
|
||||||
WHERE message_attachments.message_id = :message_id
|
WHERE message_attachments.message_id = :message_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var attachmentCmd = new MessageToManyCommand<Attachment>(conn, AttachmentSql, static reader => new Attachment {
|
await using var attachmentCmd = new MessageToManyCommand<Attachment>(conn, AttachmentSql, static reader => new Attachment {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
Name = reader.GetString(1),
|
Name = reader.GetString(1),
|
||||||
@@ -236,32 +236,32 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
|
|||||||
Width = reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
Width = reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
||||||
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||||
});
|
});
|
||||||
|
|
||||||
const string EmbedSql =
|
const string EmbedSql =
|
||||||
"""
|
"""
|
||||||
SELECT json
|
SELECT json
|
||||||
FROM message_embeds
|
FROM message_embeds
|
||||||
WHERE message_id = :message_id
|
WHERE message_id = :message_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var embedCmd = new MessageToManyCommand<Embed>(conn, EmbedSql, static reader => new Embed {
|
await using var embedCmd = new MessageToManyCommand<Embed>(conn, EmbedSql, static reader => new Embed {
|
||||||
Json = reader.GetString(0)
|
Json = reader.GetString(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
const string ReactionSql =
|
const string ReactionSql =
|
||||||
"""
|
"""
|
||||||
SELECT emoji_id, emoji_name, emoji_flags, count
|
SELECT emoji_id, emoji_name, emoji_flags, count
|
||||||
FROM message_reactions
|
FROM message_reactions
|
||||||
WHERE message_id = :message_id
|
WHERE message_id = :message_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var reactionsCmd = new MessageToManyCommand<Reaction>(conn, ReactionSql, static reader => new Reaction {
|
await using var reactionsCmd = new MessageToManyCommand<Reaction>(conn, ReactionSql, static reader => new Reaction {
|
||||||
EmojiId = reader.IsDBNull(0) ? null : reader.GetUint64(0),
|
EmojiId = reader.IsDBNull(0) ? null : reader.GetUint64(0),
|
||||||
EmojiName = reader.IsDBNull(1) ? null : reader.GetString(1),
|
EmojiName = reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||||
EmojiFlags = (EmojiFlags) reader.GetInt16(2),
|
EmojiFlags = (EmojiFlags) reader.GetInt16(2),
|
||||||
Count = reader.GetInt32(3),
|
Count = reader.GetInt32(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
await using var messageCmd = conn.Command(
|
await using var messageCmd = conn.Command(
|
||||||
$"""
|
$"""
|
||||||
SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, met.edit_timestamp, mrt.replied_to_id
|
SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, met.edit_timestamp, mrt.replied_to_id
|
||||||
@@ -271,12 +271,12 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
|
|||||||
{filter.GenerateConditions("m").BuildWhereClause()}
|
{filter.GenerateConditions("m").BuildWhereClause()}
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
await using var reader = await messageCmd.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await messageCmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken)) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
ulong messageId = reader.GetUint64(0);
|
ulong messageId = reader.GetUint64(0);
|
||||||
|
|
||||||
yield return new Message {
|
yield return new Message {
|
||||||
Id = messageId,
|
Id = messageId,
|
||||||
Sender = reader.GetUint64(1),
|
Sender = reader.GetUint64(1),
|
||||||
@@ -287,33 +287,40 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
|
|||||||
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
|
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
|
||||||
Attachments = await attachmentCmd.GetItems(messageId),
|
Attachments = await attachmentCmd.GetItems(messageId),
|
||||||
Embeds = await embedCmd.GetItems(messageId),
|
Embeds = await embedCmd.GetItems(messageId),
|
||||||
Reactions = await reactionsCmd.GetItems(messageId)
|
Reactions = await reactionsCmd.GetItems(messageId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
|
public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateConditions().BuildWhereClause());
|
await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateConditions().BuildWhereClause());
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
while (await reader.ReadAsync()) {
|
while (await reader.ReadAsync()) {
|
||||||
yield return reader.GetUint64(0);
|
yield return reader.GetUint64(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Remove(MessageFilter filter, FilterRemovalMode mode) {
|
public async Task<int> Remove(MessageFilter filter, FilterRemovalMode mode) {
|
||||||
|
int removed;
|
||||||
await using (var conn = await pool.Take()) {
|
await using (var conn = await pool.Take()) {
|
||||||
await conn.ExecuteAsync(
|
removed = await conn.ExecuteAsync(
|
||||||
$"""
|
$"""
|
||||||
-- noinspection SqlWithoutWhere
|
-- noinspection SqlWithoutWhere
|
||||||
DELETE FROM messages
|
DELETE FROM messages
|
||||||
{filter.GenerateConditions(invert: mode == FilterRemovalMode.KeepMatching).BuildWhereClause()}
|
{filter.GenerateConditions(invert: mode == FilterRemovalMode.KeepMatching).BuildWhereClause()}
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateTotalCount();
|
UpdateTotalCount();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> RemoveUnreachableAttachments() {
|
||||||
|
await using var conn = await pool.Take();
|
||||||
|
return await conn.ExecuteAsync("DELETE FROM attachments WHERE attachment_id NOT IN (SELECT DISTINCT attachment_id FROM message_attachments)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,11 +14,11 @@ sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository {
|
|||||||
private static readonly Log Log = Log.ForType<SqliteServerRepository>();
|
private static readonly Log Log = Log.ForType<SqliteServerRepository>();
|
||||||
|
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
|
|
||||||
public SqliteServerRepository(SqliteConnectionPool pool) : base(Log) {
|
public SqliteServerRepository(SqliteConnectionPool pool) : base(Log) {
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Data.Server> servers) {
|
public async Task Add(IReadOnlyList<Data.Server> servers) {
|
||||||
await using (var conn = await pool.Take()) {
|
await using (var conn = await pool.Take()) {
|
||||||
await conn.BeginTransactionAsync();
|
await conn.BeginTransactionAsync();
|
||||||
@@ -26,33 +26,33 @@ sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository {
|
|||||||
await using var cmd = conn.Upsert("servers", [
|
await using var cmd = conn.Upsert("servers", [
|
||||||
("id", SqliteType.Integer),
|
("id", SqliteType.Integer),
|
||||||
("name", SqliteType.Text),
|
("name", SqliteType.Text),
|
||||||
("type", SqliteType.Text)
|
("type", SqliteType.Text),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach (var server in servers) {
|
foreach (Data.Server server in servers) {
|
||||||
cmd.Set(":id", server.Id);
|
cmd.Set(":id", server.Id);
|
||||||
cmd.Set(":name", server.Name);
|
cmd.Set(":name", server.Name);
|
||||||
cmd.Set(":type", ServerTypes.ToString(server.Type));
|
cmd.Set(":type", ServerTypes.ToString(server.Type));
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.CommitTransactionAsync();
|
await conn.CommitTransactionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateTotalCount();
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<long> Count(CancellationToken cancellationToken) {
|
public override async Task<long> Count(CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Data.Server> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
public async IAsyncEnumerable<Data.Server> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, name, type FROM servers");
|
await using var cmd = conn.Command("SELECT id, name, type FROM servers");
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken)) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
yield return new Data.Server {
|
yield return new Data.Server {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
@@ -61,4 +61,14 @@ sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> RemoveUnreachable() {
|
||||||
|
int removed;
|
||||||
|
await using (var conn = await pool.Take()) {
|
||||||
|
removed = await conn.ExecuteAsync("DELETE FROM servers WHERE id NOT IN (SELECT DISTINCT server FROM channels)");
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateTotalCount();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,7 @@ sealed class SqliteSettingsRepository(SqliteConnectionPool pool) : ISettingsRepo
|
|||||||
public Task Set<T>(SettingsKey<T> key, T value) {
|
public Task Set<T>(SettingsKey<T> key, T value) {
|
||||||
return Set(setter => setter.Set(key, value));
|
return Set(setter => setter.Set(key, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Set(Func<ISettingsRepository.ISetter, Task> setter) {
|
public async Task Set(Func<ISettingsRepository.ISetter, Task> setter) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
await conn.BeginTransactionAsync();
|
await conn.BeginTransactionAsync();
|
||||||
@@ -24,12 +24,12 @@ sealed class SqliteSettingsRepository(SqliteConnectionPool pool) : ISettingsRepo
|
|||||||
DO UPDATE SET value = excluded.value
|
DO UPDATE SET value = excluded.value
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
cmd.Add(":key", SqliteType.Text);
|
cmd.Add(":key", SqliteType.Text);
|
||||||
cmd.Add(":value", SqliteType.Text);
|
cmd.Add(":value", SqliteType.Text);
|
||||||
|
|
||||||
await setter(new Setter(cmd));
|
await setter(new Setter(cmd));
|
||||||
|
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
await conn.CommitTransactionAsync();
|
await conn.CommitTransactionAsync();
|
||||||
}
|
}
|
||||||
@@ -41,18 +41,18 @@ sealed class SqliteSettingsRepository(SqliteConnectionPool pool) : ISettingsRepo
|
|||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) {
|
public async Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) {
|
||||||
string? value;
|
string? value;
|
||||||
|
|
||||||
await using (var conn = await pool.Take()) {
|
await using (var conn = await pool.Take()) {
|
||||||
await using var cmd = conn.Command("SELECT value FROM metadata WHERE key = :key");
|
await using var cmd = conn.Command("SELECT value FROM metadata WHERE key = :key");
|
||||||
cmd.AddAndSet(":key", SqliteType.Text, key.Key);
|
cmd.AddAndSet(":key", SqliteType.Text, key.Key);
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
value = await reader.ReadAsync() ? reader.GetString(0) : null;
|
value = await reader.ReadAsync() ? reader.GetString(0) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value != null && key.FromString(value, out var convertedValue) ? convertedValue : defaultValue;
|
return value != null && key.FromString(value, out T convertedValue) ? convertedValue : defaultValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -16,57 +16,57 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
|
|||||||
|
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
private readonly SqliteDownloadRepository downloads;
|
private readonly SqliteDownloadRepository downloads;
|
||||||
|
|
||||||
public SqliteUserRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : base(Log) {
|
public SqliteUserRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : base(Log) {
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
this.downloads = downloads;
|
this.downloads = downloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<User> users) {
|
public async Task Add(IReadOnlyList<User> users) {
|
||||||
await using (var conn = await pool.Take()) {
|
await using (var conn = await pool.Take()) {
|
||||||
await conn.BeginTransactionAsync();
|
await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
await using var cmd = conn.Upsert("users", [
|
await using var cmd = conn.Upsert("users", [
|
||||||
("id", SqliteType.Integer),
|
("id", SqliteType.Integer),
|
||||||
("name", SqliteType.Text),
|
("name", SqliteType.Text),
|
||||||
("display_name", SqliteType.Text),
|
("display_name", SqliteType.Text),
|
||||||
("avatar_url", SqliteType.Text),
|
("avatar_url", SqliteType.Text),
|
||||||
("discriminator", SqliteType.Text)
|
("discriminator", SqliteType.Text),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn);
|
await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn);
|
||||||
|
|
||||||
foreach (var user in users) {
|
foreach (User user in users) {
|
||||||
cmd.Set(":id", user.Id);
|
cmd.Set(":id", user.Id);
|
||||||
cmd.Set(":name", user.Name);
|
cmd.Set(":name", user.Name);
|
||||||
cmd.Set(":display_name", user.DisplayName);
|
cmd.Set(":display_name", user.DisplayName);
|
||||||
cmd.Set(":avatar_url", user.AvatarUrl);
|
cmd.Set(":avatar_url", user.AvatarUrl);
|
||||||
cmd.Set(":discriminator", user.Discriminator);
|
cmd.Set(":discriminator", user.Discriminator);
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
if (user.AvatarUrl is {} avatarUrl) {
|
if (user.AvatarUrl is {} avatarUrl) {
|
||||||
await downloadCollector.Add(DownloadLinkExtractor.FromUserAvatar(user.Id, avatarUrl));
|
await downloadCollector.Add(DownloadLinkExtractor.FromUserAvatar(user.Id, avatarUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.CommitTransactionAsync();
|
await conn.CommitTransactionAsync();
|
||||||
downloadCollector.OnCommitted();
|
downloadCollector.OnCommitted();
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateTotalCount();
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<long> Count(CancellationToken cancellationToken) {
|
public override async Task<long> Count(CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM users", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM users", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<User> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
public async IAsyncEnumerable<User> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
await using var conn = await pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, name, display_name, avatar_url, discriminator FROM users");
|
await using var cmd = conn.Command("SELECT id, name, display_name, avatar_url, discriminator FROM users");
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (await reader.ReadAsync(cancellationToken)) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
yield return new User {
|
yield return new User {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
@@ -77,4 +77,14 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> RemoveUnreachable() {
|
||||||
|
int removed;
|
||||||
|
await using (var conn = await pool.Take()) {
|
||||||
|
removed = await conn.ExecuteAsync("DELETE FROM users WHERE id NOT IN (SELECT DISTINCT sender_id FROM messages)");
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateTotalCount();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user