1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2024-10-18 11:42:52 +02:00

Compare commits

..

No commits in common. "2424a8ac8db863bbcee55d384d6268ce00ed6016" and "22958536e7de0747080bcc33873adc7a03cd47c9" have entirely different histories.

130 changed files with 4957 additions and 4827 deletions

View File

@ -19,7 +19,7 @@ Folder organization:
* `lib/` contains utilities required to build the project * `lib/` contains utilities required to build the project
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website * `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/). To start editing source code for the desktop app, install the [.NET 5 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/5.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
### Building ### Building
@ -50,6 +50,6 @@ Run the `app/build.sh` script, and read the [Distribution](#distribution) sectio
#### 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 .NET 5 to be installed.
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking `DiscordHistoryTracker`. I tried using Python to re-create the archives with correct file permissions, but found that Linux `zip` tools could not see them. The only working solution is building the Windows + portable version on Windows, and Linux + Mac version on Linux. Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking `DiscordHistoryTracker`. I tried using Python to re-create the archives with correct file permissions, but found that Linux `zip` tools could not see them. The only working solution is building the Windows + portable version on Windows, and Linux + Mac version on Linux.

View File

@ -6,8 +6,8 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme Mode="Light" />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" /> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml" />
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader"> <Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="Cursor" Value="Hand" /> <Setter Property="Cursor" Value="Hand" />
@ -48,11 +48,70 @@
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" /> <Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
</Style> </Style>
<Style Selector="Expander"> <Style Selector="Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="MinHeight" Value="40" /> <Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="Template">
<ControlTemplate>
<Border x:Name="ToggleButtonBackground">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="35">
<Border x:Name="ExpandCollapseChevronBorder"
Grid.Column="0"
Width="35"
Height="35"
Margin="2,0"
RenderTransformOrigin="50%,50%">
<Path x:Name="ExpandCollapseChevron"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderTransformOrigin="50%,50%"
Stretch="None"
Stroke="{DynamicResource ExpanderChevronForeground}"
StrokeThickness="1" />
<Border.RenderTransform>
<RotateTransform />
</Border.RenderTransform>
</Border>
<ContentPresenter x:Name="PART_ContentPresenter"
Grid.Column="1"
Margin="0"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="Center"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
TextBlock.Foreground="{DynamicResource ExpanderForeground}" />
</Grid>
</Border>
</ControlTemplate>
</Setter>
</Style> </Style>
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
<Style.Animations>
<Animation FillMode="Both" Duration="0:0:0.0625">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
<Style.Animations>
<Animation FillMode="Both" Duration="0:0:0.0625">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</Application.Styles> </Application.Styles>
<Application.Resources> <Application.Resources>
@ -106,29 +165,15 @@
<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="ExpanderBorderBrush" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="#697DAB" /> <SolidColorBrush x:Key="ExpanderBackground" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPressed" Color="#697DAB" /> <SolidColorBrush x:Key="ExpanderForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushDisabled" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBackground" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPointerOver" Color="#536794" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPressed" Color="#47587F" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundDisabled" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundPointerOver" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundPressed" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundDisabled" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronBackground" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundPointerOver" Color="#536794" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundPressed" Color="#47587F" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" /> <SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForegroundPointerOver" Color="#FFFFFF" /> <SolidColorBrush x:Key="ExpanderDropDownBorderBrush" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="#FFFFFF" /> <SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="#FFFFFF" />
</Application.Resources> </Application.Resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyName>DiscordHistoryTracker</AssemblyName> <AssemblyName>DiscordHistoryTracker</AssemblyName>
<RootNamespace>DHT.Desktop</RootNamespace> <RootNamespace>DHT.Desktop</RootNamespace>
@ -20,13 +21,10 @@
<DebugType>none</DebugType> <DebugType>none</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.0" /> <PackageReference Include="Avalonia" Version="0.10.18" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.18" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0" /> <PackageReference Include="Avalonia.Desktop" Version="0.10.18" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" /> <PackageReference Include="Avalonia.Diagnostics" Version="0.10.18" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Server\Server.csproj" /> <ProjectReference Include="..\Server\Server.csproj" />

View File

@ -34,7 +34,7 @@
<StackPanel Margin="20"> <StackPanel Margin="20">
<ScrollViewer MaxHeight="400"> <ScrollViewer MaxHeight="400">
<ItemsRepeater ItemsSource="{Binding Items}"> <ItemsRepeater Items="{Binding Items}">
<ItemsRepeater.ItemTemplate> <ItemsRepeater.ItemTemplate>
<DataTemplate> <DataTemplate>
<CheckBox IsChecked="{Binding Checked}"> <CheckBox IsChecked="{Binding Checked}">

View File

@ -1,21 +1,31 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.CheckBox; namespace DHT.Desktop.Dialogs.CheckBox {
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class CheckBoxDialog : Window {
public CheckBoxDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
[SuppressMessage("ReSharper", "MemberCanBeInternal")] private void InitializeComponent() {
public sealed partial class CheckBoxDialog : Window { AvaloniaXamlLoader.Load(this);
public CheckBoxDialog() { }
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);
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,37 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Dialogs.Message; namespace DHT.Desktop.Dialogs.Message {
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MessageDialog : Window {
public MessageDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
[SuppressMessage("ReSharper", "MemberCanBeInternal")] private void InitializeComponent() {
public sealed partial class MessageDialog : Window { AvaloniaXamlLoader.Load(this);
public MessageDialog() { }
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);
}
} }
} }

View File

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

View File

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

View File

@ -7,7 +7,7 @@
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog" x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Opened="OnOpened" Opened="Loaded"
Closing="OnClosing" Closing="OnClosing"
Width="500" SizeToContent="Height" CanResize="False" Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">

View File

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

View File

@ -4,62 +4,62 @@ using Avalonia.Threading;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.Progress; namespace DHT.Desktop.Dialogs.Progress {
sealed class ProgressDialogModel : BaseModel {
public string Title { get; init; } = "";
sealed class ProgressDialogModel : BaseModel { private string message = "";
public string Title { get; init; } = "";
private string message = ""; public string Message {
get => message;
public string Message { private set => Change(ref message, value);
get => message;
private set => Change(ref message, value);
}
private string items = "";
public string Items {
get => items;
private set => Change(ref items, value);
}
private int progress = 0;
public int Progress {
get => progress;
private set => Change(ref progress, value);
}
private readonly TaskRunner? task;
[Obsolete("Designer")]
public ProgressDialogModel() {}
public ProgressDialogModel(TaskRunner task) {
this.task = task;
}
internal async Task StartTask() {
if (task != null) {
await task(new Callback(this));
}
}
public delegate Task TaskRunner(IProgressCallback callback);
private sealed class Callback : IProgressCallback {
private readonly ProgressDialogModel model;
public Callback(ProgressDialogModel model) {
this.model = model;
} }
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) { private string items = "";
await Dispatcher.UIThread.InvokeAsync(() => {
model.Message = message; public string Items {
model.Items = finishedItems.Format() + " / " + totalItems.Format(); get => items;
model.Progress = 100 * finishedItems / totalItems; private set => Change(ref items, value);
}); }
private int progress = 0;
public int Progress {
get => progress;
private set => Change(ref progress, value);
}
private readonly TaskRunner? task;
[Obsolete("Designer")]
public ProgressDialogModel() {}
public ProgressDialogModel(TaskRunner task) {
this.task = task;
}
internal async Task StartTask() {
if (task != null) {
await task(new Callback(this));
}
}
public delegate Task TaskRunner(IProgressCallback callback);
private sealed class Callback : IProgressCallback {
private readonly ProgressDialogModel model;
public Callback(ProgressDialogModel model) {
this.model = model;
}
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => {
model.Message = message;
model.Items = finishedItems.Format() + " / " + totalItems.Format();
model.Progress = 100 * finishedItems / totalItems;
});
}
} }
} }
} }

View File

@ -31,7 +31,7 @@
<ScrollViewer MaxHeight="400"> <ScrollViewer MaxHeight="400">
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" /> <TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
<ItemsRepeater ItemsSource="{Binding Items}"> <ItemsRepeater Items="{Binding Items}">
<ItemsRepeater.ItemTemplate> <ItemsRepeater.ItemTemplate>
<DataTemplate> <DataTemplate>
<DockPanel Margin="0 5 25 0"> <DockPanel Margin="0 5 25 0">

View File

@ -1,21 +1,31 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox {
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class TextBoxDialog : Window {
public TextBoxDialog() {
InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
[SuppressMessage("ReSharper", "MemberCanBeInternal")] private void InitializeComponent() {
public sealed partial class TextBoxDialog : Window { AvaloniaXamlLoader.Load(this);
public TextBoxDialog() { }
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);
}
} }
} }

View File

@ -4,44 +4,44 @@ using System.ComponentModel;
using System.Linq; using System.Linq;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox {
class TextBoxDialogModel : BaseModel {
public string Title { get; init; } = "";
public string Description { get; init; } = "";
class TextBoxDialogModel : BaseModel { private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
public string Title { get; init; } = "";
public string Description { get; init; } = "";
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>(); public IReadOnlyList<TextBoxItem> Items {
get => items;
public IReadOnlyList<TextBoxItem> Items { protected set {
get => items; foreach (var item in items) {
item.ErrorsChanged -= OnItemErrorsChanged;
}
protected set { items = value;
foreach (var item in items) {
item.ErrorsChanged -= OnItemErrorsChanged;
}
items = value; foreach (var item in items) {
item.ErrorsChanged += OnItemErrorsChanged;
foreach (var item in items) { }
item.ErrorsChanged += OnItemErrorsChanged;
} }
} }
}
public bool HasErrors => Items.Any(static item => !item.IsValid); public bool HasErrors => Items.Any(static item => !item.IsValid);
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) { private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
OnPropertyChanged(nameof(HasErrors)); OnPropertyChanged(nameof(HasErrors));
} }
} }
sealed class TextBoxDialogModel<T> : TextBoxDialogModel { sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
public new IReadOnlyList<TextBoxItem<T>> Items { get; } public 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;
}
} }
} }

View File

@ -3,40 +3,40 @@ using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.TextBox; namespace DHT.Desktop.Dialogs.TextBox {
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
class TextBoxItem : BaseModel, 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 => this.value;
set { set {
Change(ref this.value, value); Change(ref this.value, value);
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
}
}
public IEnumerable GetErrors(string? propertyName) {
if (propertyName == nameof(Value) && !IsValid) {
yield return string.Empty;
}
}
public bool HasErrors => !IsValid;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
}
sealed class TextBoxItem<T> : TextBoxItem {
public new T Item { get; }
public TextBoxItem(T item) {
this.Item = item;
base.Item = item;
} }
} }
public IEnumerable GetErrors(string? propertyName) {
if (propertyName == nameof(Value) && !IsValid) {
yield return string.Empty;
}
}
public bool HasErrors => !IsValid;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
}
sealed class TextBoxItem<T> : TextBoxItem {
public new T Item { get; }
public TextBoxItem(T item) {
this.Item = item;
base.Item = item;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox> <CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" /> <TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
<ComboBox IsEnabled="{Binding LimitSize}" ItemsSource="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}"> <ComboBox IsEnabled="{Binding LimitSize}" Items="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding Name}" /> <TextBlock Text="{Binding Name}" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" /> <controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" />
<StackPanel Orientation="Vertical" Spacing="12"> <StackPanel Orientation="Vertical" Spacing="12">
<Expander Header="Download Status" IsExpanded="True"> <Expander Header="Download Status" IsExpanded="True">
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True"> <DataGrid Items="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" /> <DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" /> <DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,9 +52,9 @@ namespace DHT.Desktop.Main.Pages {
await progressDialog.ShowDialog(window); await progressDialog.ShowDialog(window);
} }
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, 0, batchCount);
@ -63,7 +63,7 @@ namespace DHT.Desktop.Main.Pages {
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 { var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
@ -73,36 +73,36 @@ namespace DHT.Desktop.Main.Pages {
ParentId = null, ParentId = null,
Position = i, Position = i,
Topic = RandomText(rand, 10), Topic = RandomText(rand, 10),
Nsfw = rand.Next(4) == 0, Nsfw = rand.Next(4) == 0
}).ToArray(); }).ToArray();
var users = Enumerable.Range(0, userCount).Select(_ => new User { var users = Enumerable.Range(0, userCount).Select(_ => new User {
Id = RandomId(rand), Id = RandomId(rand),
Name = RandomName("u"), Name = RandomName("u"),
AvatarUrl = null, AvatarUrl = null,
Discriminator = rand.Next(0, 9999).ToString(), Discriminator = rand.Next(0, 9999).ToString()
}).ToArray(); }).ToArray();
db.AddServer(server); db.AddServer(server);
db.AddUsers(users); db.AddUsers(users);
foreach (var channel in channels) { foreach (var channel in channels) {
db.AddChannel(channel); db.AddChannel(channel);
} }
var now = DateTimeOffset.Now; var 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 => { var messages = Enumerable.Range(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(1, 60)) : null;
var timeMillis = time.ToUnixTimeMilliseconds(); var timeMillis = time.ToUnixTimeMilliseconds();
var editMillis = edit?.ToUnixTimeMilliseconds(); var 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,
@ -113,12 +113,12 @@ namespace DHT.Desktop.Main.Pages {
RepliedToId = null, RepliedToId = null,
Attachments = ImmutableArray<Attachment>.Empty, Attachments = ImmutableArray<Attachment>.Empty,
Embeds = ImmutableArray<Embed>.Empty, Embeds = ImmutableArray<Embed>.Empty,
Reactions = ImmutableArray<Reaction>.Empty, Reactions = ImmutableArray<Reaction>.Empty
}; };
}).ToArray(); }).ToArray();
db.AddMessages(messages); db.AddMessages(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);
} }
@ -161,9 +161,9 @@ namespace DHT.Desktop.Main.Pages {
"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(), 3));
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)])); return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));

View File

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

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord; using DHT.Desktop.Discord;
@ -8,109 +9,109 @@ using DHT.Desktop.Server;
using DHT.Utils.Models; using DHT.Utils.Models;
using static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages {
sealed class TrackingPageModel : BaseModel {
private bool areDevToolsEnabled;
sealed class TrackingPageModel : BaseModel { private bool AreDevToolsEnabled {
private bool areDevToolsEnabled; get => areDevToolsEnabled;
set {
private bool AreDevToolsEnabled { Change(ref areDevToolsEnabled, value);
get => areDevToolsEnabled; OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
set { }
Change(ref areDevToolsEnabled, value);
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
} }
}
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true; public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
public string ToggleAppDevToolsButtonText { public string ToggleAppDevToolsButtonText {
get { get {
if (!IsToggleAppDevToolsButtonEnabled) { if (!IsToggleAppDevToolsButtonEnabled) {
return "Unavailable"; return "Unavailable";
}
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
}
}
private readonly Window window;
[Obsolete("Designer")]
public TrackingPageModel() : this(null!) {}
public TrackingPageModel(Window window) {
this.window = window;
}
public async Task Initialize() {
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
}
else {
IsToggleAppDevToolsButtonEnabled = false;
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
}
}
public async Task<bool> OnClickCopyTrackingScript() {
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
var clipboard = Application.Current?.Clipboard;
if (clipboard == null) {
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
return false;
} }
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I"; try {
} await clipboard.SetTextAsync(script);
} return true;
} catch {
private readonly Window window; await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
return false;
[Obsolete("Designer")] }
public TrackingPageModel() : this(null!) {}
public TrackingPageModel(Window window) {
this.window = window;
}
public async Task Initialize() {
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
}
else {
IsToggleAppDevToolsButtonEnabled = false;
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
}
}
public async Task<bool> OnClickCopyTrackingScript() {
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";")
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
var clipboard = window.Clipboard;
if (clipboard == null) {
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
return false;
} }
try { public async void OnClickToggleAppDevTools() {
await clipboard.SetTextAsync(script); const string DialogTitle = "Discord App Settings File";
return true;
} catch {
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
return false;
}
}
public async void OnClickToggleAppDevTools() { bool oldState = AreDevToolsEnabled;
const string DialogTitle = "Discord App Settings File"; bool newState = !oldState;
bool oldState = AreDevToolsEnabled; switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
bool newState = !oldState; case SettingsJsonResult.Success:
AreDevToolsEnabled = newState;
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
break;
switch (await DiscordAppSettings.ConfigureDevTools(newState)) { case SettingsJsonResult.AlreadySet:
case SettingsJsonResult.Success: await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
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."); break;
break;
case SettingsJsonResult.AlreadySet: case SettingsJsonResult.FileNotFound:
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled.")); await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
AreDevToolsEnabled = newState; break;
break;
case SettingsJsonResult.FileNotFound: case SettingsJsonResult.ReadError:
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath); await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
break; break;
case SettingsJsonResult.ReadError: case SettingsJsonResult.InvalidJson:
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath); await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
break; break;
case SettingsJsonResult.InvalidJson: case SettingsJsonResult.WriteError:
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath); await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
break; break;
case SettingsJsonResult.WriteError: default:
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath); throw new ArgumentOutOfRangeException();
break; }
default:
throw new ArgumentOutOfRangeException();
} }
} }
} }

View File

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

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@ -7,9 +8,7 @@ using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server; using DHT.Desktop.Server;
@ -20,129 +19,131 @@ using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Models; using DHT.Utils.Models;
using static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages {
sealed class ViewerPageModel : BaseModel, IDisposable {
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false;
sealed class ViewerPageModel : BaseModel, IDisposable { private bool hasFilters = false;
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
public bool DatabaseToolFilterModeKeep { get; set; } = true; public bool HasFilters {
public bool DatabaseToolFilterModeRemove { get; set; } = false; get => hasFilters;
set => Change(ref hasFilters, value);
}
private bool hasFilters = false; private MessageFilterPanelModel FilterModel { get; }
public bool HasFilters { private readonly Window window;
get => hasFilters; private readonly IDatabaseFile db;
set => Change(ref hasFilters, value);
}
private MessageFilterPanelModel FilterModel { get; } [Obsolete("Designer")]
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
private readonly Window window; public ViewerPageModel(Window window, IDatabaseFile db) {
private readonly IDatabaseFile db; this.window = window;
this.db = db;
[Obsolete("Designer")] FilterModel = new MessageFilterPanelModel(window, db, "Will export");
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {} FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
}
public ViewerPageModel(Window window, IDatabaseFile db) { public void Dispose() {
this.window = window; FilterModel.Dispose();
this.db = db; }
FilterModel = new MessageFilterPanelModel(window, db, "Will export"); private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged; HasFilters = FilterModel.HasAnyFilters;
} }
public void Dispose() { private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
FilterModel.Dispose(); const string ArchiveTag = "/*[ARCHIVE]*/";
}
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) { string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
HasFilters = FilterModel.HasAnyFilters; string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
} .Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) { string jsonTempFile = path + ".tmp";
const string ArchiveTag = "/*[ARCHIVE]*/";
string indexFile = await Resources.ReadTextAsync("Viewer/index.html"); await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n')) await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag); char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length; jsonStream.Position = 0;
string jsonTempFile = path + ".tmp"; await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) { using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter()); int readBytes;
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
string jsonChunk = new string(jsonBuffer, 0, readBytes);
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
}
}
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)]; await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
jsonStream.Position = 0; }
}
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) File.Delete(jsonTempFile);
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) { }
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) { public async void OnClickOpenViewer() {
int readBytes; string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) { string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
string jsonChunk = new string(jsonBuffer, 0, readBytes); string fullPath = Path.Combine(rootPath, filenameBase + ".html");
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk)); int counter = 0;
while (File.Exists(fullPath)) {
++counter;
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
}
TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath);
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
}
public async void OnClickSaveViewer() {
var dialog = new SaveFileDialog {
Title = "Save Viewer",
InitialFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
Directory = Path.GetDirectoryName(db.Path),
Filters = new List<FileDialogFilter> {
new() {
Name = "Discord History Viewer",
Extensions = { "html" }
} }
} }
}.ShowAsync(window);
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]); string? path = await dialog;
if (!string.IsNullOrEmpty(path)) {
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
} }
} }
File.Delete(jsonTempFile); public async void OnClickApplyFiltersToDatabase() {
} var filter = FilterModel.CreateFilter();
public async void OnClickOpenViewer() { if (DatabaseToolFilterModeKeep) {
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker"); if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd"); db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
string fullPath = Path.Combine(rootPath, filenameBase + ".html"); }
int counter = 0;
while (File.Exists(fullPath)) {
++counter;
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
}
TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath);
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
}
private static readonly FilePickerFileType[] ViewerFileTypes = {
FileDialogs.CreateFilter("Discord History Viewer", new string[] { "html" }),
};
public async void OnClickSaveViewer() {
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
Title = "Save Viewer",
FileTypeChoices = ViewerFileTypes,
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)),
});
if (path != null) {
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
}
}
public async void OnClickApplyFiltersToDatabase() {
var filter = FilterModel.CreateFilter();
if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
} }
} else if (DatabaseToolFilterModeRemove) {
else if (DatabaseToolFilterModeRemove) { if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) { db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching); }
} }
} }
} }

View File

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

View File

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

View File

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

View File

@ -7,64 +7,67 @@ using DHT.Desktop.Dialogs.Message;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Screens; namespace DHT.Desktop.Main.Screens {
sealed class WelcomeScreenModel : BaseModel, IDisposable {
public string Version => Program.Version;
sealed class WelcomeScreenModel : BaseModel, IDisposable { public IDatabaseFile? Db { get; private set; }
public string Version => Program.Version; public bool HasDatabase => Db != null;
public IDatabaseFile? Db { get; private set; } private readonly Window window;
public bool HasDatabase => Db != null;
private readonly Window window; private string? dbFilePath;
private string? dbFilePath; [Obsolete("Designer")]
public WelcomeScreenModel() : this(null!) {}
[Obsolete("Designer")] public WelcomeScreenModel(Window window) {
public WelcomeScreenModel() : this(null!) {} this.window = window;
public WelcomeScreenModel(Window window) {
this.window = window;
}
public async void OpenOrCreateDatabase() {
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
if (path != null) {
await OpenOrCreateDatabaseFromPath(path);
} }
}
public async Task OpenOrCreateDatabaseFromPath(string path) { public async void OpenOrCreateDatabase() {
if (Db != null) { var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog();
dialog.Directory = Path.GetDirectoryName(dbFilePath);
string? path = await dialog.ShowAsync(window);
if (!string.IsNullOrWhiteSpace(path)) {
await OpenOrCreateDatabaseFromPath(path);
}
}
public async Task OpenOrCreateDatabaseFromPath(string path) {
if (Db != null) {
Db = null;
}
dbFilePath = path;
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
private async Task<bool> CheckCanUpgradeDatabase() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
}
public void CloseDatabase() {
Dispose();
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
public async void ShowAboutDialog() {
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
}
public void Exit() {
window.Close();
}
public void Dispose() {
Db?.Dispose();
Db = null; Db = null;
} }
dbFilePath = path;
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase);
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
private async Task<bool> CheckCanUpgradeDatabase() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
}
public void CloseDatabase() {
Dispose();
OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase));
}
public async void ShowAboutDialog() {
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
}
public void Exit() {
window.Close();
}
public void Dispose() {
Db?.Dispose();
Db = null;
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
namespace DHT.Server.Data.Aggregations; namespace DHT.Server.Data.Aggregations {
public sealed class DownloadStatusStatistics {
public int EnqueuedCount { get; internal set; }
public ulong EnqueuedSize { get; internal set; }
public sealed class DownloadStatusStatistics { public int SuccessfulCount { get; internal set; }
public int EnqueuedCount { get; internal set; } public ulong SuccessfulSize { get; internal set; }
public ulong EnqueuedSize { get; internal set; }
public int SuccessfulCount { get; internal set; } public int FailedCount { get; internal set; }
public ulong SuccessfulSize { get; internal set; } public ulong FailedSize { get; internal set; }
public int FailedCount { get; internal set; } public int SkippedCount { get; internal set; }
public ulong FailedSize { get; internal set; } public ulong SkippedSize { get; internal set; }
}
public int SkippedCount { get; internal set; }
public ulong SkippedSize { get; internal set; }
} }

View File

@ -1,11 +1,11 @@
namespace DHT.Server.Data; namespace DHT.Server.Data {
public readonly struct Attachment {
public readonly struct Attachment { public ulong Id { get; internal init; }
public ulong Id { get; internal init; } public string Name { get; internal init; }
public string Name { get; internal init; } public string? Type { get; internal init; }
public string? Type { get; internal init; } public string Url { get; internal init; }
public string Url { get; internal init; } public ulong Size { get; internal init; }
public ulong Size { get; internal init; } public int? Width { get; internal init; }
public int? Width { get; internal init; } public int? Height { get; internal init; }
public int? Height { get; internal init; } }
} }

View File

@ -1,11 +1,11 @@
namespace DHT.Server.Data; namespace DHT.Server.Data {
public readonly struct Channel {
public readonly struct Channel { public ulong Id { get; init; }
public ulong Id { get; init; } public ulong Server { get; init; }
public ulong Server { get; init; } public string Name { get; init; }
public string Name { get; init; } public ulong? ParentId { get; init; }
public ulong? ParentId { get; init; } public int? Position { get; init; }
public int? Position { get; init; } public string? Topic { get; init; }
public string? Topic { get; init; } public bool? Nsfw { get; init; }
public bool? Nsfw { get; init; } }
} }

View File

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

View File

@ -1,12 +1,12 @@
using System.Net; using System.Net;
namespace DHT.Server.Data; namespace DHT.Server.Data {
/// <summary>
/// <summary> /// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99. /// </summary>
/// </summary> public enum DownloadStatus {
public enum DownloadStatus { Enqueued = 0,
Enqueued = 0, GenericError = 1,
GenericError = 1, Success = HttpStatusCode.OK
Success = HttpStatusCode.OK }
} }

View File

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

View File

@ -1,5 +1,5 @@
namespace DHT.Server.Data; namespace DHT.Server.Data {
public readonly struct Embed {
public readonly struct Embed { public string Json { get; internal init; }
public string Json { get; internal init; } }
} }

View File

@ -1,9 +1,9 @@
using System; using System;
namespace DHT.Server.Data; namespace DHT.Server.Data {
[Flags]
[Flags] public enum EmojiFlags : ushort {
public enum EmojiFlags : ushort { None = 0,
None = 0, Animated = 0b1
Animated = 0b1 }
} }

View File

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

View File

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

View File

@ -1,6 +1,6 @@
namespace DHT.Server.Data.Filters; namespace DHT.Server.Data.Filters {
public enum FilterRemovalMode {
public enum FilterRemovalMode { KeepMatching,
KeepMatching, RemoveMatching
RemoveMatching }
} }

View File

@ -1,19 +1,19 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace DHT.Server.Data.Filters; namespace DHT.Server.Data.Filters {
public sealed class MessageFilter {
public DateTime? StartDate { get; set; } = null;
public DateTime? EndDate { get; set; } = null;
public sealed class MessageFilter { public HashSet<ulong>? ChannelIds { get; set; } = null;
public DateTime? StartDate { get; set; } = null; public HashSet<ulong>? UserIds { get; set; } = null;
public DateTime? EndDate { get; set; } = null; public HashSet<ulong>? MessageIds { get; set; } = null;
public HashSet<ulong>? ChannelIds { get; set; } = null; public bool IsEmpty => StartDate == null &&
public HashSet<ulong>? UserIds { get; set; } = null; EndDate == null &&
public HashSet<ulong>? MessageIds { get; set; } = null; ChannelIds == null &&
UserIds == null &&
public bool IsEmpty => StartDate == null && MessageIds == null;
EndDate == null && }
ChannelIds == null &&
UserIds == null &&
MessageIds == null;
} }

View File

@ -1,16 +1,16 @@
using System.Collections.Immutable; using System.Collections.Immutable;
namespace DHT.Server.Data; namespace DHT.Server.Data {
public readonly struct Message {
public readonly struct Message { public ulong Id { get; init; }
public ulong Id { get; init; } public ulong Sender { get; init; }
public ulong Sender { get; init; } public ulong Channel { get; init; }
public ulong Channel { get; init; } public string Text { get; init; }
public string Text { get; init; } public long Timestamp { get; init; }
public long Timestamp { get; init; } public long? EditTimestamp { get; init; }
public long? EditTimestamp { get; init; } public ulong? RepliedToId { get; init; }
public ulong? RepliedToId { get; init; } public ImmutableArray<Attachment> Attachments { get; init; }
public ImmutableArray<Attachment> Attachments { get; init; } public ImmutableArray<Embed> Embeds { get; init; }
public ImmutableArray<Embed> Embeds { get; init; } public ImmutableArray<Reaction> Reactions { get; init; }
public ImmutableArray<Reaction> Reactions { get; init; } }
} }

View File

@ -1,8 +1,8 @@
namespace DHT.Server.Data; namespace DHT.Server.Data {
public readonly struct Reaction {
public readonly struct Reaction { public ulong? EmojiId { get; internal init; }
public ulong? EmojiId { get; internal init; } public string? EmojiName { get; internal init; }
public string? EmojiName { get; internal init; } public EmojiFlags EmojiFlags { get; internal init; }
public EmojiFlags EmojiFlags { get; internal init; } public int Count { get; internal init; }
public int Count { get; internal init; } }
} }

View File

@ -1,7 +1,7 @@
namespace DHT.Server.Data; namespace DHT.Server.Data {
public readonly struct Server {
public readonly struct Server { public ulong Id { get; init; }
public ulong Id { get; init; } public string Name { get; init; }
public string Name { get; init; } public ServerType? Type { get; init; }
public ServerType? Type { get; init; } }
} }

View File

@ -1,45 +1,45 @@
namespace DHT.Server.Data; namespace DHT.Server.Data {
public enum ServerType {
public enum ServerType { Server,
Server, Group,
Group, DirectMessage
DirectMessage
}
public static class ServerTypes {
public static ServerType? FromString(string? str) {
return str switch {
"SERVER" => ServerType.Server,
"GROUP" => ServerType.Group,
"DM" => ServerType.DirectMessage,
_ => null
};
} }
public static string ToString(ServerType? type) { public static class ServerTypes {
return type switch { public static ServerType? FromString(string? str) {
ServerType.Server => "SERVER", return str switch {
ServerType.Group => "GROUP", "SERVER" => ServerType.Server,
ServerType.DirectMessage => "DM", "GROUP" => ServerType.Group,
_ => "UNKNOWN" "DM" => ServerType.DirectMessage,
}; _ => null
} };
}
public static string ToNiceString(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"
}; };
} }
internal static string ToJsonViewerString(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 => "user", ServerType.DirectMessage => "DM",
_ => "unknown" _ => "Unknown"
}; };
}
internal static string ToJsonViewerString(ServerType? type) {
return type switch {
ServerType.Server => "server",
ServerType.Group => "group",
ServerType.DirectMessage => "user",
_ => "unknown"
};
}
} }
} }

View File

@ -1,8 +1,8 @@
namespace DHT.Server.Data; namespace DHT.Server.Data {
public readonly struct User {
public readonly struct User { public ulong Id { get; init; }
public ulong Id { get; init; } public string Name { get; init; }
public string Name { get; init; } public string? AvatarUrl { get; init; }
public string? AvatarUrl { get; init; } public string? Discriminator { get; init; }
public string? Discriminator { get; init; } }
} }

View File

@ -1,29 +1,29 @@
using System.Collections.Generic; using System.Collections.Generic;
using DHT.Server.Data; using DHT.Server.Data;
namespace DHT.Server.Database; namespace DHT.Server.Database {
public static class DatabaseExtensions {
public static class DatabaseExtensions { public static void AddFrom(this IDatabaseFile target, IDatabaseFile source) {
public static void AddFrom(this IDatabaseFile target, IDatabaseFile source) { target.AddServers(source.GetAllServers());
target.AddServers(source.GetAllServers()); target.AddChannels(source.GetAllChannels());
target.AddChannels(source.GetAllChannels()); target.AddUsers(source.GetAllUsers().ToArray());
target.AddUsers(source.GetAllUsers().ToArray()); target.AddMessages(source.GetMessages().ToArray());
target.AddMessages(source.GetMessages().ToArray());
foreach (var download in source.GetDownloadsWithoutData()) {
foreach (var download in source.GetDownloadsWithoutData()) { target.AddDownload(download.Status == DownloadStatus.Success ? source.GetDownloadWithData(download) : download);
target.AddDownload(download.Status == DownloadStatus.Success ? source.GetDownloadWithData(download) : download); }
} }
}
internal static void AddServers(this IDatabaseFile target, IEnumerable<Data.Server> servers) { internal static void AddServers(this IDatabaseFile target, IEnumerable<Data.Server> servers) {
foreach (var server in servers) { foreach (var server in servers) {
target.AddServer(server); target.AddServer(server);
}
} }
}
internal static void AddChannels(this IDatabaseFile target, IEnumerable<Channel> channels) { internal static void AddChannels(this IDatabaseFile target, IEnumerable<Channel> channels) {
foreach (var channel in channels) { foreach (var channel in channels) {
target.AddChannel(channel); target.AddChannel(channel);
}
} }
} }
} }

View File

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

View File

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

View File

@ -1,90 +1,88 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using DHT.Server.Data; 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.Download; using DHT.Server.Download;
namespace DHT.Server.Database; namespace DHT.Server.Database {
public sealed class DummyDatabaseFile : IDatabaseFile {
public static DummyDatabaseFile Instance { get; } = new();
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")] public string Path => "";
public sealed class DummyDatabaseFile : IDatabaseFile { public DatabaseStatistics Statistics { get; } = new();
public static DummyDatabaseFile Instance { get; } = new();
public string Path => ""; private DummyDatabaseFile() {}
public DatabaseStatistics Statistics { get; } = new();
private DummyDatabaseFile() {} public DatabaseStatisticsSnapshot SnapshotStatistics() {
return new();
}
public DatabaseStatisticsSnapshot SnapshotStatistics() { public void AddServer(Data.Server server) {}
return new();
public List<Data.Server> GetAllServers() {
return new();
}
public void AddChannel(Channel channel) {}
public List<Channel> GetAllChannels() {
return new();
}
public void AddUsers(User[] users) {}
public List<User> GetAllUsers() {
return new();
}
public void AddMessages(Message[] messages) {}
public int CountMessages(MessageFilter? filter = null) {
return 0;
}
public List<Message> GetMessages(MessageFilter? filter = null) {
return new();
}
public HashSet<ulong> GetMessageIds(MessageFilter? filter = null) {
return new();
}
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {}
public int CountAttachments(AttachmentFilter? filter = null) {
return new();
}
public List<Data.Download> GetDownloadsWithoutData() {
return new();
}
public Data.Download GetDownloadWithData(Data.Download download) {
return download;
}
public DownloadedAttachment? GetDownloadedAttachment(string url) {
return null;
}
public void AddDownload(Data.Download download) {}
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {}
public List<DownloadItem> GetEnqueuedDownloadItems(int count) {
return new();
}
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {}
public DownloadStatusStatistics GetDownloadStatusStatistics() {
return new();
}
public void Vacuum() {}
public void Dispose() {}
} }
public void AddServer(Data.Server server) {}
public List<Data.Server> GetAllServers() {
return new();
}
public void AddChannel(Channel channel) {}
public List<Channel> GetAllChannels() {
return new();
}
public void AddUsers(User[] users) {}
public List<User> GetAllUsers() {
return new();
}
public void AddMessages(Message[] messages) {}
public int CountMessages(MessageFilter? filter = null) {
return 0;
}
public List<Message> GetMessages(MessageFilter? filter = null) {
return new();
}
public HashSet<ulong> GetMessageIds(MessageFilter? filter = null) {
return new();
}
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {}
public int CountAttachments(AttachmentFilter? filter = null) {
return new();
}
public List<Data.Download> GetDownloadsWithoutData() {
return new();
}
public Data.Download GetDownloadWithData(Data.Download download) {
return download;
}
public DownloadedAttachment? GetDownloadedAttachment(string url) {
return null;
}
public void AddDownload(Data.Download download) {}
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {}
public List<DownloadItem> GetEnqueuedDownloadItems(int count) {
return new();
}
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {}
public DownloadStatusStatistics GetDownloadStatusStatistics() {
return new();
}
public void Vacuum() {}
public void Dispose() {}
} }

View File

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

View File

@ -1,11 +1,11 @@
using System; using System;
namespace DHT.Server.Database.Exceptions; namespace DHT.Server.Database.Exceptions {
public sealed class InvalidDatabaseVersionException : Exception {
public string Version { get; }
public sealed class InvalidDatabaseVersionException : Exception { internal InvalidDatabaseVersionException(string version) : base("Invalid database version: " + version) {
public string Version { get; } this.Version = version;
}
internal InvalidDatabaseVersionException(string version) : base("Invalid database version: " + version) {
this.Version = version;
} }
} }

View File

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

View File

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

View File

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

View File

@ -9,207 +9,207 @@ using DHT.Server.Data.Filters;
using DHT.Server.Database.Export.Strategy; using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Server.Database.Export; namespace DHT.Server.Database.Export {
public static class ViewerJsonExport {
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
public static class ViewerJsonExport { public static async Task Generate(Stream stream, IViewerExportStrategy strategy, IDatabaseFile db, MessageFilter? filter = null) {
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport)); var perf = Log.Start();
public static async Task Generate(Stream stream, IViewerExportStrategy strategy, IDatabaseFile db, MessageFilter? filter = null) { var includedUserIds = new HashSet<ulong>();
var perf = Log.Start(); var includedChannelIds = new HashSet<ulong>();
var includedServerIds = new HashSet<ulong>();
var includedUserIds = new HashSet<ulong>(); var includedMessages = db.GetMessages(filter);
var includedChannelIds = new HashSet<ulong>(); var includedChannels = new List<Channel>();
var includedServerIds = new HashSet<ulong>();
var includedMessages = db.GetMessages(filter); foreach (var message in includedMessages) {
var includedChannels = new List<Channel>(); includedUserIds.Add(message.Sender);
includedChannelIds.Add(message.Channel);
foreach (var message in includedMessages) {
includedUserIds.Add(message.Sender);
includedChannelIds.Add(message.Channel);
}
foreach (var channel in db.GetAllChannels()) {
if (includedChannelIds.Contains(channel.Id)) {
includedChannels.Add(channel);
includedServerIds.Add(channel.Server);
}
}
var users = GenerateUserList(db, includedUserIds, out var userindex, out var userIndices);
var servers = GenerateServerList(db, includedServerIds, out var serverindex);
var channels = GenerateChannelList(includedChannels, serverindex);
perf.Step("Collect database data");
var value = new {
meta = new { users, userindex, servers, channels },
data = GenerateMessageList(includedMessages, userIndices, strategy),
};
perf.Step("Generate value object");
var opts = new JsonSerializerOptions();
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
await JsonSerializer.SerializeAsync(stream, value, opts);
perf.Step("Serialize to JSON");
perf.End();
}
private static Dictionary<string, object> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) {
var users = new Dictionary<string, object>();
userindex = new List<string>();
userIndices = new Dictionary<ulong, object>();
foreach (var user in db.GetAllUsers()) {
var id = user.Id;
if (!userIds.Contains(id)) {
continue;
} }
var obj = new Dictionary<string, object> { foreach (var channel in db.GetAllChannels()) {
["name"] = user.Name if (includedChannelIds.Contains(channel.Id)) {
includedChannels.Add(channel);
includedServerIds.Add(channel.Server);
}
}
var users = GenerateUserList(db, includedUserIds, out var userindex, out var userIndices);
var servers = GenerateServerList(db, includedServerIds, out var serverindex);
var channels = GenerateChannelList(includedChannels, serverindex);
perf.Step("Collect database data");
var value = new {
meta = new { users, userindex, servers, channels },
data = GenerateMessageList(includedMessages, userIndices, strategy)
}; };
perf.Step("Generate value object");
var opts = new JsonSerializerOptions();
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
if (user.AvatarUrl != null) { await JsonSerializer.SerializeAsync(stream, value, opts);
obj["avatar"] = user.AvatarUrl;
}
if (user.Discriminator != null) { perf.Step("Serialize to JSON");
obj["tag"] = user.Discriminator; perf.End();
}
var idStr = id.ToString();
userIndices[id] = users.Count;
userindex.Add(idStr);
users[idStr] = obj;
} }
return users; private static object GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) {
} var users = new Dictionary<string, object>();
userindex = new List<string>();
userIndices = new Dictionary<ulong, object>();
private static List<object> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, object> serverIndices) { foreach (var user in db.GetAllUsers()) {
var servers = new List<object>(); var id = user.Id;
serverIndices = new Dictionary<ulong, object>(); if (!userIds.Contains(id)) {
continue;
}
foreach (var server in db.GetAllServers()) {
var id = server.Id;
if (!serverIds.Contains(id)) {
continue;
}
serverIndices[id] = servers.Count;
servers.Add(new Dictionary<string, object> {
["name"] = server.Name,
["type"] = ServerTypes.ToJsonViewerString(server.Type),
});
}
return servers;
}
private static Dictionary<string, object> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, object> serverIndices) {
var channels = new Dictionary<string, object>();
foreach (var channel in includedChannels) {
var obj = new Dictionary<string, object> {
["server"] = serverIndices[channel.Server],
["name"] = channel.Name,
};
if (channel.ParentId != null) {
obj["parent"] = channel.ParentId;
}
if (channel.Position != null) {
obj["position"] = channel.Position;
}
if (channel.Topic != null) {
obj["topic"] = channel.Topic;
}
if (channel.Nsfw != null) {
obj["nsfw"] = channel.Nsfw;
}
channels[channel.Id.ToString()] = obj;
}
return channels;
}
private static Dictionary<string, Dictionary<string, object>> GenerateMessageList( List<Message> includedMessages, Dictionary<ulong, object> userIndices, IViewerExportStrategy strategy) {
var data = new Dictionary<string, Dictionary<string, object>>();
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
var channel = grouping.Key.ToString();
var channelData = new Dictionary<string, object>();
foreach (var message in grouping) {
var obj = new Dictionary<string, object> { var obj = new Dictionary<string, object> {
["u"] = userIndices[message.Sender], ["name"] = user.Name
["t"] = message.Timestamp,
}; };
if (!string.IsNullOrEmpty(message.Text)) { if (user.AvatarUrl != null) {
obj["m"] = message.Text; obj["avatar"] = user.AvatarUrl;
} }
if (message.EditTimestamp != null) { if (user.Discriminator != null) {
obj["te"] = message.EditTimestamp; obj["tag"] = user.Discriminator;
} }
if (message.RepliedToId != null) { var idStr = id.ToString();
obj["r"] = message.RepliedToId.Value; userIndices[id] = users.Count;
} userindex.Add(idStr);
users[idStr] = obj;
if (!message.Attachments.IsEmpty) {
obj["a"] = message.Attachments.Select(attachment => {
var a = new Dictionary<string, object> {
{ "url", strategy.GetAttachmentUrl(attachment) },
{ "name", Uri.TryCreate(attachment.Url, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.Url },
};
if (attachment is { Width: not null, Height: not null }) {
a["width"] = attachment.Width;
a["height"] = attachment.Height;
}
return a;
}).ToArray();
}
if (!message.Embeds.IsEmpty) {
obj["e"] = message.Embeds.Select(static embed => embed.Json).ToArray();
}
if (!message.Reactions.IsEmpty) {
obj["re"] = message.Reactions.Select(static reaction => {
var r = new Dictionary<string, object>();
if (reaction.EmojiId != null) {
r["id"] = reaction.EmojiId.Value;
}
if (reaction.EmojiName != null) {
r["n"] = reaction.EmojiName;
}
r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
r["c"] = reaction.Count;
return r;
}).ToArray();
}
channelData[message.Id.ToString()] = obj;
} }
data[channel] = channelData; return users;
} }
return data; private static object GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, object> serverIndices) {
var servers = new List<object>();
serverIndices = new Dictionary<ulong, object>();
foreach (var server in db.GetAllServers()) {
var id = server.Id;
if (!serverIds.Contains(id)) {
continue;
}
serverIndices[id] = servers.Count;
servers.Add(new Dictionary<string, object> {
["name"] = server.Name,
["type"] = ServerTypes.ToJsonViewerString(server.Type)
});
}
return servers;
}
private static object GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, object> serverIndices) {
var channels = new Dictionary<string, object>();
foreach (var channel in includedChannels) {
var obj = new Dictionary<string, object> {
["server"] = serverIndices[channel.Server],
["name"] = channel.Name
};
if (channel.ParentId != null) {
obj["parent"] = channel.ParentId;
}
if (channel.Position != null) {
obj["position"] = channel.Position;
}
if (channel.Topic != null) {
obj["topic"] = channel.Topic;
}
if (channel.Nsfw != null) {
obj["nsfw"] = channel.Nsfw;
}
channels[channel.Id.ToString()] = obj;
}
return channels;
}
private static object GenerateMessageList( List<Message> includedMessages, Dictionary<ulong, object> userIndices, IViewerExportStrategy strategy) {
var data = new Dictionary<string, Dictionary<string, object>>();
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
var channel = grouping.Key.ToString();
var channelData = new Dictionary<string, object>();
foreach (var message in grouping) {
var obj = new Dictionary<string, object> {
["u"] = userIndices[message.Sender],
["t"] = message.Timestamp
};
if (!string.IsNullOrEmpty(message.Text)) {
obj["m"] = message.Text;
}
if (message.EditTimestamp != null) {
obj["te"] = message.EditTimestamp;
}
if (message.RepliedToId != null) {
obj["r"] = message.RepliedToId.Value;
}
if (!message.Attachments.IsEmpty) {
obj["a"] = message.Attachments.Select(attachment => {
var a = new Dictionary<string, object> {
{ "url", strategy.GetAttachmentUrl(attachment) },
{ "name", Uri.TryCreate(attachment.Url, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.Url }
};
if (attachment.Width != null && attachment.Height != null) {
a["width"] = attachment.Width;
a["height"] = attachment.Height;
}
return a;
}).ToArray();
}
if (!message.Embeds.IsEmpty) {
obj["e"] = message.Embeds.Select(static embed => embed.Json).ToArray();
}
if (!message.Reactions.IsEmpty) {
obj["re"] = message.Reactions.Select(static reaction => {
var r = new Dictionary<string, object>();
if (reaction.EmojiId != null) {
r["id"] = reaction.EmojiId.Value;
}
if (reaction.EmojiName != null) {
r["n"] = reaction.EmojiName;
}
r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
r["c"] = reaction.Count;
return r;
}).ToArray();
}
channelData[message.Id.ToString()] = obj;
}
data[channel] = channelData;
}
return data;
}
} }
} }

View File

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

View File

@ -5,39 +5,39 @@ using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Download; using DHT.Server.Download;
namespace DHT.Server.Database; namespace DHT.Server.Database {
public interface IDatabaseFile : IDisposable {
string Path { get; }
DatabaseStatistics Statistics { get; }
DatabaseStatisticsSnapshot SnapshotStatistics();
public interface IDatabaseFile : IDisposable { void AddServer(Data.Server server);
string Path { get; } List<Data.Server> GetAllServers();
DatabaseStatistics Statistics { get; }
DatabaseStatisticsSnapshot SnapshotStatistics();
void AddServer(Data.Server server); void AddChannel(Channel channel);
List<Data.Server> GetAllServers(); List<Channel> GetAllChannels();
void AddChannel(Channel channel); void AddUsers(User[] users);
List<Channel> GetAllChannels(); List<User> GetAllUsers();
void AddUsers(User[] users); void AddMessages(Message[] messages);
List<User> GetAllUsers(); int CountMessages(MessageFilter? filter = null);
List<Message> GetMessages(MessageFilter? filter = null);
HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
void AddMessages(Message[] messages); int CountAttachments(AttachmentFilter? filter = null);
int CountMessages(MessageFilter? filter = null);
List<Message> GetMessages(MessageFilter? filter = null);
HashSet<ulong> GetMessageIds(MessageFilter? filter = null);
void RemoveMessages(MessageFilter filter, FilterRemovalMode mode);
int CountAttachments(AttachmentFilter? filter = null); void AddDownload(Data.Download download);
List<Data.Download> GetDownloadsWithoutData();
Data.Download GetDownloadWithData(Data.Download download);
DownloadedAttachment? GetDownloadedAttachment(string url);
void AddDownload(Data.Download download); void EnqueueDownloadItems(AttachmentFilter? filter = null);
List<Data.Download> GetDownloadsWithoutData(); List<DownloadItem> GetEnqueuedDownloadItems(int count);
Data.Download GetDownloadWithData(Data.Download download); void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
DownloadedAttachment? GetDownloadedAttachment(string url); DownloadStatusStatistics GetDownloadStatusStatistics();
void EnqueueDownloadItems(AttachmentFilter? filter = null); void Vacuum();
List<DownloadItem> GetEnqueuedDownloadItems(int count); }
void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
DownloadStatusStatistics GetDownloadStatusStatistics();
void Vacuum();
} }

View File

@ -1,21 +1,21 @@
using System; using System;
namespace DHT.Server.Database.Import; namespace DHT.Server.Database.Import {
/// <summary>
/// https://discord.com/developers/docs/reference#snowflakes
/// </summary>
public sealed class FakeSnowflake {
private const ulong DiscordEpoch = 1420070400000UL;
private ulong id;
/// <summary> public FakeSnowflake() {
/// https://discord.com/developers/docs/reference#snowflakes var unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
/// </summary> this.id = (unixMillis - DiscordEpoch) << 22;
public sealed class FakeSnowflake { }
private const ulong DiscordEpoch = 1420070400000UL;
private ulong id; internal ulong Next() {
return id++;
public FakeSnowflake() { }
var unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
this.id = (unixMillis - DiscordEpoch) << 22;
}
internal ulong Next() {
return id++;
} }
} }

View File

@ -12,248 +12,252 @@ using DHT.Utils.Http;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
namespace DHT.Server.Database.Import; namespace DHT.Server.Database.Import {
public static class LegacyArchiveImport {
private static readonly Log Log = Log.ForType(typeof(LegacyArchiveImport));
public static class LegacyArchiveImport { private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new ();
private static readonly Log Log = Log.ForType(typeof(LegacyArchiveImport));
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) {
var perf = Log.Start();
var root = await JsonSerializer.DeserializeAsync<JsonElement>(stream);
public static async Task<bool> Read(Stream stream, IDatabaseFile db, FakeSnowflake fakeSnowflake, Func<Data.Server[], Task<Dictionary<Data.Server, ulong>?>> askForServerIds) { try {
var perf = Log.Start(); var meta = root.RequireObject("meta");
var root = await JsonSerializer.DeserializeAsync<JsonElement>(stream); var data = root.RequireObject("data");
try { perf.Step("Deserialize JSON");
var meta = root.RequireObject("meta");
var data = root.RequireObject("data");
perf.Step("Deserialize JSON"); var users = ReadUserList(meta);
var servers = ReadServerList(meta, fakeSnowflake);
var users = ReadUserList(meta); var newServersOnly = new HashSet<Data.Server>(servers);
var servers = ReadServerList(meta, fakeSnowflake); var oldServersById = db.GetAllServers().ToDictionary(static server => server.Id, static server => server);
var newServersOnly = new HashSet<Data.Server>(servers); var oldChannels = db.GetAllChannels();
var oldServersById = db.GetAllServers().ToDictionary(static server => server.Id, static server => server); var oldChannelsById = oldChannels.ToDictionary(static channel => channel.Id, static channel => channel);
var oldChannels = db.GetAllChannels(); foreach (var (channelId, serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) {
var oldChannelsById = oldChannels.ToDictionary(static channel => channel.Id, static channel => channel); if (oldChannelsById.TryGetValue(channelId, out var oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out var oldServer) && newServersOnly.Remove(servers[serverIndex])) {
servers[serverIndex] = oldServer;
foreach (var (channelId, serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) {
if (oldChannelsById.TryGetValue(channelId, out var oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out var oldServer) && newServersOnly.Remove(servers[serverIndex])) {
servers[serverIndex] = oldServer;
}
}
perf.Step("Read server and user list");
if (newServersOnly.Count > 0) {
var askedServerIds = await askForServerIds(newServersOnly.ToArray());
if (askedServerIds == null) {
return false;
}
perf.Step("Ask for server IDs");
for (var i = 0; i < servers.Length; i++) {
var server = servers[i];
if (askedServerIds.TryGetValue(server, out var serverId)) {
servers[i] = server with { Id = serverId };
} }
} }
perf.Step("Read server and user list");
if (newServersOnly.Count > 0) {
var askedServerIds = await askForServerIds(newServersOnly.ToArray());
if (askedServerIds == null) {
return false;
}
perf.Step("Ask for server IDs");
for (var i = 0; i < servers.Length; i++) {
var server = servers[i];
if (askedServerIds.TryGetValue(server, out var serverId)) {
servers[i] = new Data.Server {
Id = serverId,
Name = server.Name,
Type = server.Type
};
}
}
}
var channels = ReadChannelList(meta, servers);
perf.Step("Read channel list");
var oldMessageIds = db.GetMessageIds();
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
.Where(message => !oldMessageIds.Contains(message.Id))
.ToArray();
perf.Step("Read messages");
db.AddUsers(users);
db.AddServers(servers);
db.AddChannels(channels);
db.AddMessages(newMessages);
perf.Step("Import into database");
} catch (HttpException e) {
throw new JsonException(e.Message);
} }
var channels = ReadChannelList(meta, servers); perf.End();
return true;
perf.Step("Read channel list");
var oldMessageIds = db.GetMessageIds();
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
.Where(message => !oldMessageIds.Contains(message.Id))
.ToArray();
perf.Step("Read messages");
db.AddUsers(users);
db.AddServers(servers);
db.AddChannels(channels);
db.AddMessages(newMessages);
perf.Step("Import into database");
} catch (HttpException e) {
throw new JsonException(e.Message);
} }
perf.End(); private static User[] ReadUserList(JsonElement meta) {
return true; const string UsersPath = "meta.users[]";
}
private static User[] ReadUserList(JsonElement meta) { static ulong ParseUserIndex(JsonElement element, int index) {
const string UsersPath = "meta.users[]"; return ulong.Parse(element.GetString() ?? throw new JsonException("Expected key 'meta.userindex[" + index + "]' to be a string."));
}
static ulong ParseUserIndex(JsonElement element, int index) { var userindex = meta.RequireArray("userindex", "meta")
return ulong.Parse(element.GetString() ?? throw new JsonException("Expected key 'meta.userindex[" + index + "]' to be a string.")); .Select(static (item, index) => (ParseUserIndex(item, index), index))
} .ToDictionary();
var userindex = meta.RequireArray("userindex", "meta") var users = new User[userindex.Count];
.Select(static (item, index) => (ParseUserIndex(item, index), index))
.ToDictionary();
var users = new User[userindex.Count]; foreach (var item in meta.RequireObject("users", "meta").EnumerateObject()) {
var path = UsersPath + "." + item.Name;
var userId = ulong.Parse(item.Name);
var userObj = item.Value;
foreach (var item in meta.RequireObject("users", "meta").EnumerateObject()) { users[userindex[userId]] = new User {
var path = UsersPath + "." + item.Name; Id = userId,
var userId = ulong.Parse(item.Name); Name = userObj.RequireString("name", path),
var userObj = item.Value; AvatarUrl = userObj.HasKey("avatar") ? userObj.RequireString("avatar", path) : null,
Discriminator = userObj.HasKey("tag") ? userObj.RequireString("tag", path) : null
users[userindex[userId]] = new User {
Id = userId,
Name = userObj.RequireString("name", path),
AvatarUrl = userObj.HasKey("avatar") ? userObj.RequireString("avatar", path) : null,
Discriminator = userObj.HasKey("tag") ? userObj.RequireString("tag", path) : null,
};
}
return users;
}
private static Data.Server[] ReadServerList(JsonElement meta, FakeSnowflake fakeSnowflake) {
const string ServersPath = "meta.servers[]";
return meta.RequireArray("servers", "meta").Select(serverObj => new Data.Server {
Id = fakeSnowflake.Next(),
Name = serverObj.RequireString("name", ServersPath),
Type = ServerTypes.FromString(serverObj.RequireString("type", ServersPath)),
}).ToArray();
}
private const string ChannelsPath = "meta.channels";
private static Dictionary<ulong, int> ReadChannelToServerIndexMapping(JsonElement meta, Data.Server[] servers) {
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
var path = ChannelsPath + "." + item.Name;
var channelId = ulong.Parse(item.Name);
var channelObj = item.Value;
return (channelId, channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1));
}).ToDictionary();
}
private static Channel[] ReadChannelList(JsonElement meta, Data.Server[] servers) {
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
var path = ChannelsPath + "." + item.Name;
var channelId = ulong.Parse(item.Name);
var channelObj = item.Value;
return new Channel {
Id = channelId,
Server = servers[channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1)].Id,
Name = channelObj.RequireString("name", path),
Position = channelObj.HasKey("position") ? channelObj.RequireInt("position", path, min: 0) : null,
Topic = channelObj.HasKey("topic") ? channelObj.RequireString("topic", path) : null,
Nsfw = channelObj.HasKey("nsfw") ? channelObj.RequireBool("nsfw", path) : null,
};
}).ToArray();
}
private static Message[] ReadMessages(JsonElement data, Channel channel, User[] users, FakeSnowflake fakeSnowflake) {
const string DataPath = "data";
var channelId = channel.Id;
var channelIdStr = channelId.ToString();
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
if (messagesObj == null) {
return Array.Empty<Message>();
}
return messagesObj.Value.EnumerateObject().Select(item => {
var path = DataPath + "." + item.Name;
var messageId = ulong.Parse(item.Name);
var messageObj = item.Value;
return new Message {
Id = messageId,
Sender = users[messageObj.RequireInt("u", path, min: 0, max: users.Length - 1)].Id,
Channel = channelId,
Text = messageObj.HasKey("m") ? messageObj.RequireString("m", path) : string.Empty,
Timestamp = messageObj.RequireLong("t", path),
EditTimestamp = messageObj.HasKey("te") ? messageObj.RequireLong("te", path) : null,
RepliedToId = messageObj.HasKey("r") ? messageObj.RequireSnowflake("r", path) : null,
Attachments = messageObj.HasKey("a") ? ReadMessageAttachments(messageObj.RequireArray("a", path), fakeSnowflake, path + ".a[]").ToImmutableArray() : ImmutableArray<Attachment>.Empty,
Embeds = messageObj.HasKey("e") ? ReadMessageEmbeds(messageObj.RequireArray("e", path), path + ".e[]").ToImmutableArray() : ImmutableArray<Embed>.Empty,
Reactions = messageObj.HasKey("re") ? ReadMessageReactions(messageObj.RequireArray("re", path), path + ".re[]").ToImmutableArray() : ImmutableArray<Reaction>.Empty,
};
}).ToArray();
}
[SuppressMessage("ReSharper", "ConvertToLambdaExpression")]
private static IEnumerable<Attachment> ReadMessageAttachments(JsonElement.ArrayEnumerator attachmentsArray, FakeSnowflake fakeSnowflake, string path) {
return attachmentsArray.Select(attachmentObj => {
string url = attachmentObj.RequireString("url", path);
string name = url[(url.LastIndexOf('/') + 1)..];
string? type = ContentTypeProvider.TryGetContentType(name, out var contentType) ? contentType : null;
return new Attachment {
Id = fakeSnowflake.Next(),
Name = name,
Type = type,
Url = url,
Size = 0, // unknown size
};
}).DistinctByKeyStable(static attachment => {
// Some Discord messages have duplicate attachments with the same id for unknown reasons.
return attachment.Id;
});
}
private static IEnumerable<Embed> ReadMessageEmbeds(JsonElement.ArrayEnumerator embedsArray, string path) {
// Some rich embeds are missing URLs which causes a missing 'url' key.
return embedsArray.Where(static embedObj => embedObj.HasKey("url")).Select(embedObj => {
string url = embedObj.RequireString("url", path);
string type = embedObj.RequireString("type", path);
var embedJson = new Dictionary<string, object> {
{ "url", url },
{ "type", type },
{ "dht_legacy", true },
};
if (type == "image") {
embedJson["image"] = new Dictionary<string, string> {
{ "url", url }
}; };
} }
else if (type == "rich") {
if (embedObj.HasKey("t")) {
embedJson["title"] = embedObj.RequireString("t", path);
}
if (embedObj.HasKey("d")) { return users;
embedJson["description"] = embedObj.RequireString("d", path); }
}
private static Data.Server[] ReadServerList(JsonElement meta, FakeSnowflake fakeSnowflake) {
const string ServersPath = "meta.servers[]";
return meta.RequireArray("servers", "meta").Select(serverObj => new Data.Server {
Id = fakeSnowflake.Next(),
Name = serverObj.RequireString("name", ServersPath),
Type = ServerTypes.FromString(serverObj.RequireString("type", ServersPath))
}).ToArray();
}
private const string ChannelsPath = "meta.channels";
private static Dictionary<ulong, int> ReadChannelToServerIndexMapping(JsonElement meta, Data.Server[] servers) {
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
var path = ChannelsPath + "." + item.Name;
var channelId = ulong.Parse(item.Name);
var channelObj = item.Value;
return (channelId, channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1));
}).ToDictionary();
}
private static Channel[] ReadChannelList(JsonElement meta, Data.Server[] servers) {
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
var path = ChannelsPath + "." + item.Name;
var channelId = ulong.Parse(item.Name);
var channelObj = item.Value;
return new Channel {
Id = channelId,
Server = servers[channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1)].Id,
Name = channelObj.RequireString("name", path),
Position = channelObj.HasKey("position") ? channelObj.RequireInt("position", path, min: 0) : null,
Topic = channelObj.HasKey("topic") ? channelObj.RequireString("topic", path) : null,
Nsfw = channelObj.HasKey("nsfw") ? channelObj.RequireBool("nsfw", path) : null
};
}).ToArray();
}
private static Message[] ReadMessages(JsonElement data, Channel channel, User[] users, FakeSnowflake fakeSnowflake) {
const string DataPath = "data";
var channelId = channel.Id;
var channelIdStr = channelId.ToString();
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
if (messagesObj == null) {
return Array.Empty<Message>();
} }
return new Embed { return messagesObj.Value.EnumerateObject().Select(item => {
Json = JsonSerializer.Serialize(embedJson) var path = DataPath + "." + item.Name;
}; var messageId = ulong.Parse(item.Name);
}); var messageObj = item.Value;
}
private static IEnumerable<Reaction> ReadMessageReactions(JsonElement.ArrayEnumerator reactionsArray, string path) { return new Message {
return reactionsArray.Select(reactionObj => { Id = messageId,
var id = reactionObj.HasKey("id") ? reactionObj.RequireSnowflake("id", path) : (ulong?) null; Sender = users[messageObj.RequireInt("u", path, min: 0, max: users.Length - 1)].Id,
var name = reactionObj.HasKey("n") ? reactionObj.RequireString("n", path) : null; Channel = channelId,
Text = messageObj.HasKey("m") ? messageObj.RequireString("m", path) : string.Empty,
Timestamp = messageObj.RequireLong("t", path),
EditTimestamp = messageObj.HasKey("te") ? messageObj.RequireLong("te", path) : null,
RepliedToId = messageObj.HasKey("r") ? messageObj.RequireSnowflake("r", path) : null,
Attachments = messageObj.HasKey("a") ? ReadMessageAttachments(messageObj.RequireArray("a", path), fakeSnowflake, path + ".a[]").ToImmutableArray() : ImmutableArray<Attachment>.Empty,
Embeds = messageObj.HasKey("e") ? ReadMessageEmbeds(messageObj.RequireArray("e", path), path + ".e[]").ToImmutableArray() : ImmutableArray<Embed>.Empty,
Reactions = messageObj.HasKey("re") ? ReadMessageReactions(messageObj.RequireArray("re", path), path + ".re[]").ToImmutableArray() : ImmutableArray<Reaction>.Empty
};
}).ToArray();
}
if (id == null && name == null) { [SuppressMessage("ReSharper", "ConvertToLambdaExpression")]
throw new JsonException("Expected key '" + path + ".id' and/or '" + path + ".n' to be present."); private static IEnumerable<Attachment> ReadMessageAttachments(JsonElement.ArrayEnumerator attachmentsArray, FakeSnowflake fakeSnowflake, string path) {
} return attachmentsArray.Select(attachmentObj => {
string url = attachmentObj.RequireString("url", path);
string name = url[(url.LastIndexOf('/') + 1)..];
string? type = ContentTypeProvider.TryGetContentType(name, out var contentType) ? contentType : null;
return new Reaction { return new Attachment {
EmojiId = id, Id = fakeSnowflake.Next(),
EmojiName = name, Name = name,
EmojiFlags = reactionObj.HasKey("an") && reactionObj.RequireBool("an", path) ? EmojiFlags.Animated : EmojiFlags.None, Type = type,
Count = reactionObj.RequireInt("c", path, min: 0), Url = url,
}; Size = 0 // unknown size
}); };
}).DistinctByKeyStable(static attachment => {
// Some Discord messages have duplicate attachments with the same id for unknown reasons.
return attachment.Id;
});
}
private static IEnumerable<Embed> ReadMessageEmbeds(JsonElement.ArrayEnumerator embedsArray, string path) {
// Some rich embeds are missing URLs which causes a missing 'url' key.
return embedsArray.Where(static embedObj => embedObj.HasKey("url")).Select(embedObj => {
string url = embedObj.RequireString("url", path);
string type = embedObj.RequireString("type", path);
var embedJson = new Dictionary<string, object> {
{ "url", url },
{ "type", type },
{ "dht_legacy", true }
};
if (type == "image") {
embedJson["image"] = new Dictionary<string, string> {
{ "url", url }
};
}
else if (type == "rich") {
if (embedObj.HasKey("t")) {
embedJson["title"] = embedObj.RequireString("t", path);
}
if (embedObj.HasKey("d")) {
embedJson["description"] = embedObj.RequireString("d", path);
}
}
return new Embed {
Json = JsonSerializer.Serialize(embedJson)
};
});
}
private static IEnumerable<Reaction> ReadMessageReactions(JsonElement.ArrayEnumerator reactionsArray, string path) {
return reactionsArray.Select(reactionObj => {
var id = reactionObj.HasKey("id") ? reactionObj.RequireSnowflake("id", path) : (ulong?) null;
var name = reactionObj.HasKey("n") ? reactionObj.RequireString("n", path) : null;
if (id == null && name == null) {
throw new JsonException("Expected key '" + path + ".id' and/or '" + path + ".n' to be present.");
}
return new Reaction {
EmojiId = id,
EmojiName = name,
EmojiFlags = reactionObj.HasKey("an") && reactionObj.RequireBool("an", path) ? EmojiFlags.Animated : EmojiFlags.None,
Count = reactionObj.RequireInt("c", path, min: 0)
};
});
}
} }
} }

View File

@ -4,61 +4,60 @@ using DHT.Server.Database.Exceptions;
using DHT.Server.Database.Sqlite.Utils; using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Server.Database.Sqlite; namespace DHT.Server.Database.Sqlite {
sealed class Schema {
internal const int Version = 5;
sealed class Schema { private static readonly Log Log = Log.ForType<Schema>();
internal const int Version = 5;
private static readonly Log Log = Log.ForType<Schema>(); private readonly ISqliteConnection conn;
private readonly ISqliteConnection conn; public Schema(ISqliteConnection conn) {
this.conn = conn;
public Schema(ISqliteConnection conn) {
this.conn = conn;
}
private void Execute(string sql) {
conn.Command(sql).ExecuteNonQuery();
}
public async Task<bool> Setup(Func<Task<bool>> checkCanUpgradeSchemas) {
Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
if (dbVersionStr == null) {
InitializeSchemas();
} }
else if (!int.TryParse(dbVersionStr.ToString(), out int dbVersion) || dbVersion < 1) {
throw new InvalidDatabaseVersionException(dbVersionStr.ToString() ?? "<null>"); private void Execute(string sql) {
conn.Command(sql).ExecuteNonQuery();
} }
else if (dbVersion > Version) {
throw new DatabaseTooNewException(dbVersion); public async Task<bool> Setup(Func<Task<bool>> checkCanUpgradeSchemas) {
} Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
else if (dbVersion < Version) {
var proceed = await checkCanUpgradeSchemas(); var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
if (!proceed) { if (dbVersionStr == null) {
return false; InitializeSchemas();
}
else if (!int.TryParse(dbVersionStr.ToString(), out int dbVersion) || dbVersion < 1) {
throw new InvalidDatabaseVersionException(dbVersionStr.ToString() ?? "<null>");
}
else if (dbVersion > Version) {
throw new DatabaseTooNewException(dbVersion);
}
else if (dbVersion < Version) {
var proceed = await checkCanUpgradeSchemas();
if (!proceed) {
return false;
}
UpgradeSchemas(dbVersion);
} }
UpgradeSchemas(dbVersion); return true;
} }
return true; private void InitializeSchemas() {
} Execute(@"CREATE TABLE users (
private void InitializeSchemas() {
Execute(@"CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
avatar_url TEXT, avatar_url TEXT,
discriminator TEXT)"); discriminator TEXT)");
Execute(@"CREATE TABLE servers ( Execute(@"CREATE TABLE servers (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
type TEXT NOT NULL)"); type TEXT NOT NULL)");
Execute(@"CREATE TABLE channels ( Execute(@"CREATE TABLE channels (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
server INTEGER NOT NULL, server INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -67,14 +66,14 @@ sealed class Schema {
topic TEXT, topic TEXT,
nsfw INTEGER)"); nsfw INTEGER)");
Execute(@"CREATE TABLE messages ( Execute(@"CREATE TABLE messages (
message_id INTEGER PRIMARY KEY NOT NULL, message_id INTEGER PRIMARY KEY NOT NULL,
sender_id INTEGER NOT NULL, sender_id INTEGER NOT NULL,
channel_id INTEGER NOT NULL, channel_id INTEGER NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
timestamp INTEGER NOT NULL)"); timestamp INTEGER NOT NULL)");
Execute(@"CREATE TABLE attachments ( Execute(@"CREATE TABLE attachments (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL, attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -84,90 +83,91 @@ sealed class Schema {
width INTEGER, width INTEGER,
height INTEGER)"); height INTEGER)");
Execute(@"CREATE TABLE embeds ( Execute(@"CREATE TABLE embeds (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
json TEXT NOT NULL)"); json TEXT NOT NULL)");
Execute(@"CREATE TABLE reactions ( Execute(@"CREATE TABLE reactions (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
emoji_id INTEGER, emoji_id INTEGER,
emoji_name TEXT, emoji_name TEXT,
emoji_flags INTEGER NOT NULL, emoji_flags INTEGER NOT NULL,
count INTEGER NOT NULL)"); count INTEGER NOT NULL)");
CreateMessageEditTimestampTable(); CreateMessageEditTimestampTable();
CreateMessageRepliedToTable(); CreateMessageRepliedToTable();
CreateDownloadsTable(); CreateDownloadsTable();
Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)"); Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)");
Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)"); Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)");
Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)"); Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)");
Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")"); Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
} }
private void CreateMessageEditTimestampTable() { private void CreateMessageEditTimestampTable() {
Execute(@"CREATE TABLE edit_timestamps ( Execute(@"CREATE TABLE edit_timestamps (
message_id INTEGER PRIMARY KEY NOT NULL, message_id INTEGER PRIMARY KEY NOT NULL,
edit_timestamp INTEGER NOT NULL)"); edit_timestamp INTEGER NOT NULL)");
} }
private void CreateMessageRepliedToTable() { private void CreateMessageRepliedToTable() {
Execute(@"CREATE TABLE replied_to ( Execute(@"CREATE TABLE replied_to (
message_id INTEGER PRIMARY KEY NOT NULL, message_id INTEGER PRIMARY KEY NOT NULL,
replied_to_id INTEGER NOT NULL)"); replied_to_id INTEGER NOT NULL)");
} }
private void CreateDownloadsTable() { private void CreateDownloadsTable() {
Execute(@"CREATE TABLE downloads ( Execute(@"CREATE TABLE downloads (
url TEXT NOT NULL PRIMARY KEY, url TEXT NOT NULL PRIMARY KEY,
status INTEGER NOT NULL, status INTEGER NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
blob BLOB)"); blob BLOB)");
}
private void UpgradeSchemas(int dbVersion) {
var perf = Log.Start("from version " + dbVersion);
Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
if (dbVersion <= 1) {
Execute("ALTER TABLE channels ADD parent_id INTEGER");
perf.Step("Upgrade to version 2");
} }
if (dbVersion <= 2) { private void UpgradeSchemas(int dbVersion) {
CreateMessageEditTimestampTable(); var perf = Log.Start("from version " + dbVersion);
CreateMessageRepliedToTable();
Execute(@"INSERT INTO edit_timestamps (message_id, edit_timestamp) Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
if (dbVersion <= 1) {
Execute("ALTER TABLE channels ADD parent_id INTEGER");
perf.Step("Upgrade to version 2");
}
if (dbVersion <= 2) {
CreateMessageEditTimestampTable();
CreateMessageRepliedToTable();
Execute(@"INSERT INTO edit_timestamps (message_id, edit_timestamp)
SELECT message_id, edit_timestamp FROM messages SELECT message_id, edit_timestamp FROM messages
WHERE edit_timestamp IS NOT NULL"); WHERE edit_timestamp IS NOT NULL");
Execute(@"INSERT INTO replied_to (message_id, replied_to_id) Execute(@"INSERT INTO replied_to (message_id, replied_to_id)
SELECT message_id, replied_to_id FROM messages SELECT message_id, replied_to_id FROM messages
WHERE replied_to_id IS NOT NULL"); WHERE replied_to_id IS NOT NULL");
Execute("ALTER TABLE messages DROP COLUMN replied_to_id"); Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
Execute("ALTER TABLE messages DROP COLUMN edit_timestamp"); Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
perf.Step("Upgrade to version 3"); perf.Step("Upgrade to version 3");
Execute("VACUUM"); Execute("VACUUM");
perf.Step("Vacuum"); perf.Step("Vacuum");
}
if (dbVersion <= 3) {
CreateDownloadsTable();
perf.Step("Upgrade to version 4");
}
if (dbVersion <= 4) {
Execute("ALTER TABLE attachments ADD width INTEGER");
Execute("ALTER TABLE attachments ADD height INTEGER");
perf.Step("Upgrade to version 5");
}
perf.End();
} }
if (dbVersion <= 3) {
CreateDownloadsTable();
perf.Step("Upgrade to version 4");
}
if (dbVersion <= 4) {
Execute("ALTER TABLE attachments ADD width INTEGER");
Execute("ALTER TABLE attachments ADD height INTEGER");
perf.Step("Upgrade to version 5");
}
perf.End();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -5,79 +5,79 @@ using DHT.Server.Data;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database.Sqlite.Utils; using DHT.Server.Database.Sqlite.Utils;
namespace DHT.Server.Database.Sqlite; namespace DHT.Server.Database.Sqlite {
static class SqliteFilters {
public static string GenerateWhereClause(this MessageFilter? filter, string? tableAlias = null, bool invert = false) {
if (filter == null) {
return "";
}
static class SqliteFilters { var where = new SqliteWhereGenerator(tableAlias, invert);
public static string GenerateWhereClause(this MessageFilter? filter, string? tableAlias = null, bool invert = false) {
if (filter == null) { if (filter.StartDate != null) {
return ""; where.AddCondition("timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds());
}
if (filter.EndDate != null) {
where.AddCondition("timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds());
}
if (filter.ChannelIds != null) {
where.AddCondition("channel_id IN (" + string.Join(",", filter.ChannelIds) + ")");
}
if (filter.UserIds != null) {
where.AddCondition("sender_id IN (" + string.Join(",", filter.UserIds) + ")");
}
if (filter.MessageIds != null) {
where.AddCondition("message_id IN (" + string.Join(",", filter.MessageIds) + ")");
}
return where.Generate();
} }
var where = new SqliteWhereGenerator(tableAlias, invert); public static string GenerateWhereClause(this AttachmentFilter? filter, string? tableAlias = null, bool invert = false) {
if (filter == null) {
return "";
}
if (filter.StartDate != null) { var where = new SqliteWhereGenerator(tableAlias, invert);
where.AddCondition("timestamp >= " + new DateTimeOffset(filter.StartDate.Value).ToUnixTimeMilliseconds());
if (filter.MaxBytes != null) {
where.AddCondition("size <= " + filter.MaxBytes);
}
if (filter.DownloadItemRule == AttachmentFilter.DownloadItemRules.OnlyNotPresent) {
where.AddCondition("url NOT IN (SELECT url FROM downloads)");
}
else if (filter.DownloadItemRule == AttachmentFilter.DownloadItemRules.OnlyPresent) {
where.AddCondition("url IN (SELECT url FROM downloads)");
}
return where.Generate();
} }
if (filter.EndDate != null) { public static string GenerateWhereClause(this DownloadItemFilter? filter, string? tableAlias = null, bool invert = false) {
where.AddCondition("timestamp <= " + new DateTimeOffset(filter.EndDate.Value).ToUnixTimeMilliseconds()); if (filter == null) {
return "";
}
var where = new SqliteWhereGenerator(tableAlias, invert);
if (filter.IncludeStatuses != null) {
where.AddCondition("status IN (" + filter.IncludeStatuses.In() + ")");
}
if (filter.ExcludeStatuses != null) {
where.AddCondition("status NOT IN (" + filter.ExcludeStatuses.In() + ")");
}
return where.Generate();
} }
if (filter.ChannelIds != null) { private static string In(this ISet<DownloadStatus> statuses) {
where.AddCondition("channel_id IN (" + string.Join(",", filter.ChannelIds) + ")"); return string.Join(",", statuses.Select(static status => (int) status));
} }
if (filter.UserIds != null) {
where.AddCondition("sender_id IN (" + string.Join(",", filter.UserIds) + ")");
}
if (filter.MessageIds != null) {
where.AddCondition("message_id IN (" + string.Join(",", filter.MessageIds) + ")");
}
return where.Generate();
}
public static string GenerateWhereClause(this AttachmentFilter? filter, string? tableAlias = null, bool invert = false) {
if (filter == null) {
return "";
}
var where = new SqliteWhereGenerator(tableAlias, invert);
if (filter.MaxBytes != null) {
where.AddCondition("size <= " + filter.MaxBytes);
}
if (filter.DownloadItemRule == AttachmentFilter.DownloadItemRules.OnlyNotPresent) {
where.AddCondition("url NOT IN (SELECT url FROM downloads)");
}
else if (filter.DownloadItemRule == AttachmentFilter.DownloadItemRules.OnlyPresent) {
where.AddCondition("url IN (SELECT url FROM downloads)");
}
return where.Generate();
}
public static string GenerateWhereClause(this DownloadItemFilter? filter, string? tableAlias = null, bool invert = false) {
if (filter == null) {
return "";
}
var where = new SqliteWhereGenerator(tableAlias, invert);
if (filter.IncludeStatuses != null) {
where.AddCondition("status IN (" + filter.IncludeStatuses.In() + ")");
}
if (filter.ExcludeStatuses != null) {
where.AddCondition("status NOT IN (" + filter.ExcludeStatuses.In() + ")");
}
return where.Generate();
}
private static string In(this ISet<DownloadStatus> statuses) {
return string.Join(",", statuses.Select(static status => (int) status));
} }
} }

View File

@ -1,8 +1,8 @@
using System; using System;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils; namespace DHT.Server.Database.Sqlite.Utils {
interface ISqliteConnection : IDisposable {
interface ISqliteConnection : IDisposable { SqliteConnection InnerConnection { get; }
SqliteConnection InnerConnection { get; } }
} }

View File

@ -5,110 +5,112 @@ using System.Threading;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils; namespace DHT.Server.Database.Sqlite.Utils {
sealed class SqliteConnectionPool : IDisposable {
sealed class SqliteConnectionPool : IDisposable { private static string GetConnectionString(SqliteConnectionStringBuilder connectionStringBuilder) {
private static string GetConnectionString(SqliteConnectionStringBuilder connectionStringBuilder) { connectionStringBuilder.Pooling = false;
connectionStringBuilder.Pooling = false; return connectionStringBuilder.ToString();
return connectionStringBuilder.ToString();
}
private readonly object monitor = new ();
private readonly Random rand = new ();
private volatile bool isDisposed;
private readonly BlockingCollection<PooledConnection> free = new (new ConcurrentStack<PooledConnection>());
private readonly List<PooledConnection> used;
public SqliteConnectionPool(SqliteConnectionStringBuilder connectionStringBuilder, int poolSize) {
var connectionString = GetConnectionString(connectionStringBuilder);
for (int i = 0; i < poolSize; i++) {
var conn = new SqliteConnection(connectionString);
conn.Open();
var pooledConn = new PooledConnection(this, conn);
using (var cmd = pooledConn.Command("PRAGMA journal_mode=WAL")) {
cmd.ExecuteNonQuery();
}
free.Add(pooledConn);
} }
used = new List<PooledConnection>(poolSize); private readonly object monitor = new ();
} private readonly Random rand = new ();
private volatile bool isDisposed;
private void ThrowIfDisposed() { private readonly BlockingCollection<PooledConnection> free = new (new ConcurrentStack<PooledConnection>());
ObjectDisposedException.ThrowIf(isDisposed, nameof(SqliteConnectionPool)); private readonly List<PooledConnection> used;
}
public ISqliteConnection Take() { public SqliteConnectionPool(SqliteConnectionStringBuilder connectionStringBuilder, int poolSize) {
while (true) { var connectionString = GetConnectionString(connectionStringBuilder);
for (int i = 0; i < poolSize; i++) {
var conn = new SqliteConnection(connectionString);
conn.Open();
var pooledConn = new PooledConnection(this, conn);
using (var cmd = pooledConn.Command("PRAGMA journal_mode=WAL")) {
cmd.ExecuteNonQuery();
}
free.Add(pooledConn);
}
used = new List<PooledConnection>(poolSize);
}
private void ThrowIfDisposed() {
if (isDisposed) {
throw new ObjectDisposedException(nameof(SqliteConnectionPool));
}
}
public ISqliteConnection Take() {
while (true) {
ThrowIfDisposed();
lock (monitor) {
if (free.TryTake(out var conn)) {
used.Add(conn);
return conn;
}
else {
Log.ForType<SqliteConnectionPool>().Warn("Thread " + Thread.CurrentThread.ManagedThreadId + " is starving for connections.");
}
}
Thread.Sleep(TimeSpan.FromMilliseconds(rand.Next(100, 200)));
}
}
private void Return(PooledConnection conn) {
ThrowIfDisposed(); ThrowIfDisposed();
lock (monitor) { lock (monitor) {
if (free.TryTake(out var conn)) { if (used.Remove(conn)) {
used.Add(conn); free.Add(conn);
return conn;
}
else {
Log.ForType<SqliteConnectionPool>().Warn("Thread " + Environment.CurrentManagedThreadId + " is starving for connections.");
} }
} }
Thread.Sleep(TimeSpan.FromMilliseconds(rand.Next(100, 200)));
}
}
private void Return(PooledConnection conn) {
ThrowIfDisposed();
lock (monitor) {
if (used.Remove(conn)) {
free.Add(conn);
}
}
}
public void Dispose() {
if (isDisposed) {
return;
} }
isDisposed = true; public void Dispose() {
if (isDisposed) {
lock (monitor) { return;
while (free.TryTake(out var conn)) {
Close(conn.InnerConnection);
} }
foreach (var conn in used) { isDisposed = true;
Close(conn.InnerConnection);
lock (monitor) {
while (free.TryTake(out var conn)) {
Close(conn.InnerConnection);
}
foreach (var conn in used) {
Close(conn.InnerConnection);
}
free.Dispose();
used.Clear();
}
}
private static void Close(SqliteConnection conn) {
conn.Close();
conn.Dispose();
}
private sealed class PooledConnection : ISqliteConnection {
public SqliteConnection InnerConnection { get; }
private readonly SqliteConnectionPool pool;
public PooledConnection(SqliteConnectionPool pool, SqliteConnection conn) {
this.pool = pool;
this.InnerConnection = conn;
} }
free.Dispose(); void IDisposable.Dispose() {
used.Clear(); pool.Return(this);
} }
}
private static void Close(SqliteConnection conn) {
conn.Close();
conn.Dispose();
}
private sealed class PooledConnection : ISqliteConnection {
public SqliteConnection InnerConnection { get; }
private readonly SqliteConnectionPool pool;
public PooledConnection(SqliteConnectionPool pool, SqliteConnection conn) {
this.pool = pool;
this.InnerConnection = conn;
}
void IDisposable.Dispose() {
pool.Return(this);
} }
} }
} }

View File

@ -2,70 +2,70 @@ using System;
using System.Linq; using System.Linq;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils; namespace DHT.Server.Database.Sqlite.Utils {
static class SqliteExtensions {
public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
var cmd = conn.InnerConnection.CreateCommand();
cmd.CommandText = sql;
return cmd;
}
static class SqliteExtensions { public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
public static SqliteCommand Command(this ISqliteConnection conn, string sql) { return conn.InnerConnection.BeginTransaction();
var cmd = conn.InnerConnection.CreateCommand(); }
cmd.CommandText = sql;
return cmd;
}
public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) { public static object? SelectScalar(this ISqliteConnection conn, string sql) {
return conn.InnerConnection.BeginTransaction(); using var cmd = conn.Command(sql);
} return cmd.ExecuteScalar();
}
public static object? SelectScalar(this ISqliteConnection conn, string sql) { public static SqliteCommand Insert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) {
using var cmd = conn.Command(sql); string columnNames = string.Join(',', columns.Select(static c => c.Name));
return cmd.ExecuteScalar(); string columnParams = string.Join(',', columns.Select(static c => ':' + c.Name));
}
public static SqliteCommand Insert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) { var cmd = conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" +
string columnNames = string.Join(',', columns.Select(static c => c.Name)); "VALUES (" + columnParams + ")");
string columnParams = string.Join(',', columns.Select(static c => ':' + c.Name));
var cmd = conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" + CreateParameters(cmd, columns);
"VALUES (" + columnParams + ")"); return cmd;
}
CreateParameters(cmd, columns); public static SqliteCommand Upsert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) {
return cmd; string columnNames = string.Join(',', columns.Select(static c => c.Name));
} string columnParams = string.Join(',', columns.Select(static c => ':' + c.Name));
string columnUpdates = string.Join(',', columns.Skip(1).Select(static c => c.Name + " = excluded." + c.Name));
public static SqliteCommand Upsert(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type)[] columns) { var cmd = conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" +
string columnNames = string.Join(',', columns.Select(static c => c.Name)); "VALUES (" + columnParams + ")" +
string columnParams = string.Join(',', columns.Select(static c => ':' + c.Name)); "ON CONFLICT (" + columns[0].Name + ")" +
string columnUpdates = string.Join(',', columns.Skip(1).Select(static c => c.Name + " = excluded." + c.Name)); "DO UPDATE SET " + columnUpdates);
var cmd = conn.Command("INSERT INTO " + tableName + " (" + columnNames + ")" + CreateParameters(cmd, columns);
"VALUES (" + columnParams + ")" + return cmd;
"ON CONFLICT (" + columns[0].Name + ")" + }
"DO UPDATE SET " + columnUpdates);
CreateParameters(cmd, columns); public static SqliteCommand Delete(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type) column) {
return cmd; var cmd = conn.Command("DELETE FROM " + tableName + " WHERE " + column.Name + " = :" + column.Name);
} CreateParameters(cmd, new [] { column });
return cmd;
}
public static SqliteCommand Delete(this ISqliteConnection conn, string tableName, (string Name, SqliteType Type) column) { private static void CreateParameters(SqliteCommand cmd, (string Name, SqliteType Type)[] columns) {
var cmd = conn.Command("DELETE FROM " + tableName + " WHERE " + column.Name + " = :" + column.Name); foreach (var (name, type) in columns) {
CreateParameters(cmd, new [] { column }); cmd.Parameters.Add(":" + name, type);
return cmd; }
} }
private static void CreateParameters(SqliteCommand cmd, (string Name, SqliteType Type)[] columns) { public static void AddAndSet(this SqliteCommand cmd, string key, SqliteType type, object? value) {
foreach (var (name, type) in columns) { cmd.Parameters.Add(key, type).Value = value ?? DBNull.Value;
cmd.Parameters.Add(":" + name, type); }
public static void Set(this SqliteCommand cmd, string key, object? value) {
cmd.Parameters[key].Value = value ?? DBNull.Value;
}
public static ulong GetUint64(this SqliteDataReader reader, int ordinal) {
return (ulong) reader.GetInt64(ordinal);
} }
} }
public static void AddAndSet(this SqliteCommand cmd, string key, SqliteType type, object? value) {
cmd.Parameters.Add(key, type).Value = value ?? DBNull.Value;
}
public static void Set(this SqliteCommand cmd, string key, object? value) {
cmd.Parameters[key].Value = value ?? DBNull.Value;
}
public static ulong GetUint64(this SqliteDataReader reader, int ordinal) {
return (ulong) reader.GetInt64(ordinal);
}
} }

View File

@ -1,31 +1,31 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace DHT.Server.Database.Sqlite.Utils; namespace DHT.Server.Database.Sqlite.Utils {
sealed class SqliteWhereGenerator {
private readonly string? tableAlias;
private readonly bool invert;
private readonly List<string> conditions = new ();
sealed class SqliteWhereGenerator { public SqliteWhereGenerator(string? tableAlias, bool invert) {
private readonly string? tableAlias; this.tableAlias = tableAlias;
private readonly bool invert; this.invert = invert;
private readonly List<string> conditions = new ();
public SqliteWhereGenerator(string? tableAlias, bool invert) {
this.tableAlias = tableAlias;
this.invert = invert;
}
public void AddCondition(string condition) {
conditions.Add(tableAlias == null ? condition : tableAlias + '.' + condition);
}
public string Generate() {
if (conditions.Count == 0) {
return "";
} }
if (invert) { public void AddCondition(string condition) {
return " WHERE NOT (" + string.Join(" AND ", conditions) + ")"; conditions.Add(tableAlias == null ? condition : tableAlias + '.' + condition);
} }
else {
return " WHERE " + string.Join(" AND ", conditions); public string Generate() {
if (conditions.Count == 0) {
return "";
}
if (invert) {
return " WHERE NOT (" + string.Join(" AND ", conditions) + ")";
}
else {
return " WHERE " + string.Join(" AND ", conditions);
}
} }
} }
} }

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