1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-09-17 16:24:47 +02:00

1 Commits

Author SHA1 Message Date
ae64747ce4 Add foreign keys to tables linked to messages 2025-02-02 14:16:14 +01:00
166 changed files with 2086 additions and 2884 deletions

2
.github/FUNDING.yml vendored
View File

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

View File

@@ -18,7 +18,7 @@ Folder organization:
* `app/` contains a Visual Studio solution for the desktop app
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
To start editing source code for the desktop app, install the [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
### Building
@@ -28,18 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
#### Release Windows (64-bit)
1. Install Debian in WSL and open a terminal in the project folder.
2. Run the `app/build.wsl.sh` script.
3. Read the [Distribution](#distribution) section below.
1. Install [Powershell 5](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) or newer (on Windows 10, the included version of Powershell should be enough)
Note: The build script expects `dotnet.exe` to be installed in `C:\Program Files\dotnet`.
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
#### Release Other Operating Systems
1. Install the `zip` package from your repository.
2. Run the `app/build.sh` script.
3. Read the [Distribution](#distribution) section below.
1. Install the `zip` package from your repository
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
#### Distribution
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires the ASP.NET Core Runtime to be installed.
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 8 to be installed.
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking the executable. Since .NET 8 fixed several issues with publishing Windows executables on Linux, I recommend using Linux to build the app for all operating systems.

View File

@@ -1,2 +0,0 @@
[*.cs]
propertychanged.auto_notify = false

View File

@@ -13,7 +13,6 @@
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
<entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
@@ -26,4 +25,4 @@
</map>
</option>
</component>
</project>
</project>

View File

@@ -53,23 +53,7 @@
<Setter Property="MinHeight" Value="40" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
<Style Selector="Panel.buttons">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.left Button">
<Setter Property="Margin" Value="0 0 8 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right Button">
<Setter Property="Margin" Value="8 0 0 0" />
</Style>
</Application.Styles>
<Application.Resources>
@@ -123,7 +107,7 @@
<SolidColorBrush x:Key="TextControlPlaceholderForegroundPointerOver" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundFocused" Color="#AAAAAA" />
<SolidColorBrush x:Key="TextControlPlaceholderForegroundDisabled" Color="#AAAAAA" />
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
<Thickness x:Key="ExpanderContentPadding">12</Thickness>
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />

View File

@@ -9,12 +9,12 @@ sealed class App : Application {
public override void Initialize() {
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted() {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow = new MainWindow(Program.Arguments);
}
base.OnFrameworkInitializationCompleted();
}
}

View File

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

View File

@@ -8,17 +8,17 @@ 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 Unit("B", decimalPlaces: 0),
new Unit("kB", decimalPlaces: 0),
@@ -26,15 +26,15 @@ sealed class BytesValueConverter : IValueConverter {
new Unit("GB", decimalPlaces: 1),
new Unit("TB", decimalPlaces: 1)
];
private const int Scale = 1000;
public static string Convert(ulong size) {
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
int unit = power >= Units.Length ? Units.Length - 1 : power;
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);
@@ -46,7 +46,7 @@ sealed class BytesValueConverter : IValueConverter {
return "-";
}
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
throw new NotSupportedException();
}

View File

@@ -16,35 +16,35 @@ namespace DHT.Desktop.Common;
static class DatabaseGui {
private static readonly Log Log = Log.ForType(typeof(DatabaseGui));
private const string DatabaseFileInitialName = "archive.dht";
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"]),
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"])
];
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Database File",
FileTypeFilter = DatabaseFileDialogFilter,
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, suggestedDirectory),
AllowMultiple = true,
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,
ShowOverwritePrompt = false
});
}
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
IDatabaseFile? file = null;
try {
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
} catch (InvalidDatabaseVersionException ex) {
@@ -55,14 +55,14 @@ static class DatabaseGui {
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

@@ -8,7 +8,7 @@ sealed class NumberValueConverter : IValueConverter {
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
return value == null ? "-" : string.Format(Program.Culture, "{0:n0}", value);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
throw new NotSupportedException();
}

View File

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

View File

@@ -23,10 +23,7 @@
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="PropertyChanged.SourceGenerator" Version="1.1.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
</ItemGroup>
<ItemGroup>
@@ -43,5 +40,5 @@
<LogicalName>tracker-loader.js</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -15,6 +15,24 @@
<namespace:CheckBoxDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="Panel.buttons">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.left Button">
<Setter Property="Margin" Value="0 0 8 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right Button">
<Setter Property="Margin" Value="8 0 0 0" />
</Style>
</Window.Styles>
<StackPanel Margin="20">
<ScrollViewer MaxHeight="400">
<ItemsRepeater ItemsSource="{Binding Items}">

View File

@@ -10,11 +10,11 @@ public sealed partial class CheckBoxDialog : Window {
public CheckBoxDialog() {
InitializeComponent();
}
public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Ok);
}
public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel);
}

View File

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

View File

@@ -1,18 +1,18 @@
using PropertyChanged.SourceGenerator;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.CheckBox;
partial class CheckBoxItem {
partial class CheckBoxItem : ObservableObject {
public string Title { get; init; } = "";
public object? Item { get; init; } = null;
[Notify]
[ObservableProperty]
private bool isChecked = false;
}
sealed class CheckBoxItem<T> : CheckBoxItem {
public new T Item { get; }
public CheckBoxItem(T item) {
this.Item = item;
base.Item = item;

View File

@@ -8,33 +8,29 @@ using Avalonia.Platform.Storage;
namespace DHT.Desktop.Dialogs.File;
static class FileDialogs {
public static async Task<string[]> OpenFolders(this IStorageProvider storageProvider, FolderPickerOpenOptions options) {
return (await storageProvider.OpenFolderPickerAsync(options)).ToLocalPaths();
}
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(),
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 IStorageItem itme) {
return itme.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
private static string ToLocalPath(this IStorageFile file) {
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
}
private static string[] ToLocalPaths(this IReadOnlyList<IStorageItem> items) {
return items.Select(ToLocalPath).ToArray();
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
return files.Select(ToLocalPath).ToArray();
}
}

View File

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

View File

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

View File

@@ -20,18 +20,24 @@
<Setter Property="FontSize" Value="15" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="WrapPanel">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Margin" Value="0 8 0 0" />
</Style>
<Style Selector="Button">
<Setter Property="Margin" Value="8 0 0 0" />
<Setter Property="MinWidth" Value="80" />
</Style>
</Window.Styles>
<StackPanel Margin="20">
<TextBlock Text="{Binding Message}" />
<Panel Classes="buttons">
<WrapPanel Classes="right">
<Button Click="ClickOk" IsVisible="{Binding IsOkVisible}">OK</Button>
<Button Click="ClickYes" IsVisible="{Binding IsYesVisible}">Yes</Button>
<Button Click="ClickNo" IsVisible="{Binding IsNoVisible}">No</Button>
<Button Click="ClickCancel" IsVisible="{Binding IsCancelVisible}">Cancel</Button>
</WrapPanel>
</Panel>
<WrapPanel>
<Button Click="ClickOk" IsVisible="{Binding IsOkVisible}">OK</Button>
<Button Click="ClickYes" IsVisible="{Binding IsYesVisible}">Yes</Button>
<Button Click="ClickNo" IsVisible="{Binding IsNoVisible}">No</Button>
<Button Click="ClickCancel" IsVisible="{Binding IsCancelVisible}">Cancel</Button>
</WrapPanel>
</StackPanel>
</Window>

View File

@@ -9,19 +9,19 @@ public sealed partial class MessageDialog : Window {
public MessageDialog() {
InitializeComponent();
}
public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Ok);
}
public void ClickYes(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Yes);
}
public void ClickNo(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.No);
}
public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Cancel);
}

View File

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

View File

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

View File

@@ -2,69 +2,86 @@ using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Utils.Logging;
namespace DHT.Desktop.Dialogs.Progress;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class ProgressDialog : Window {
private static readonly Log Log = Log.ForType<ProgressDialog>();
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => await action(dialog, callbacks[0]));
await dialog.ShowProgressDialog(owner);
}
internal static async Task<T> Show<T>(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task<T>> action) {
var taskCompletionSource = new TaskCompletionSource<T>();
var taskCompletionSource = new TaskCompletionSource();
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
taskCompletionSource.SetResult(await action(dialog, callbacks[0]));
try {
await action(dialog, callbacks[0]);
taskCompletionSource.SetResult();
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
});
await dialog.ShowProgressDialog(owner);
await taskCompletionSource.Task;
}
internal static async Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
var taskCompletionSource = new TaskCompletionSource();
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
await callbacks[0].UpdateIndeterminate(message);
try {
await action(dialog);
taskCompletionSource.SetResult();
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
});
await dialog.ShowProgressDialog(owner);
await taskCompletionSource.Task;
}
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
var taskCompletionSource = new TaskCompletionSource<T>();
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
await callbacks[0].UpdateIndeterminate(message);
try {
taskCompletionSource.SetResult(await action(dialog));
} catch (Exception e) {
taskCompletionSource.SetException(e);
}
});
await dialog.ShowProgressDialog(owner);
return await taskCompletionSource.Task;
}
internal static Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
return Show(owner, title, async (dialog, callback) => {
await callback.UpdateIndeterminate(message);
await action(dialog);
});
}
internal static Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
return Show<T>(owner, title, async (dialog, callback) => {
await callback.UpdateIndeterminate(message);
return await action(dialog);
});
}
private bool isFinished = false;
private Task progressTask = Task.CompletedTask;
public ProgressDialog() {
InitializeComponent();
}
public void OnOpened(object? sender, EventArgs e) {
if (DataContext is ProgressDialogModel model) {
progressTask = Task.Run(model.StartTask);
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
}
}
public void OnClosing(object? sender, WindowClosingEventArgs e) {
e.Cancel = !isFinished;
}
private void OnFinished(Task task) {
isFinished = true;
Close();
}
public async Task ShowProgressDialog(Window owner) {
await ShowDialog(owner);
await progressTask;

View File

@@ -9,36 +9,36 @@ namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressDialogModel {
public string Title { get; init; } = "";
public IReadOnlyList<ProgressItem> Items { get; } = [];
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
private readonly TaskRunner? task;
[Obsolete("Designer")]
public ProgressDialogModel() {}
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
this.Title = title;
this.task = task;
this.Items = Enumerable.Range(start: 0, progressItems).Select(static _ => new ProgressItem()).ToArray();
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
}
internal async Task StartTask() {
if (task != null) {
await task(Items.Select(static item => new Callback(item)).ToArray());
}
}
public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
private sealed class Callback : IProgressCallback {
private readonly ProgressItem item;
public Callback(ProgressItem item) {
this.item = item;
}
public async Task Update(string message, long finishedItems, long totalItems) {
public async Task Update(string message, int finishedItems, int totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => {
item.Message = message;
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
@@ -46,7 +46,7 @@ sealed class ProgressDialogModel {
item.IsIndeterminate = false;
});
}
public async Task UpdateIndeterminate(string message) {
await Dispatcher.UIThread.InvokeAsync(() => {
item.Message = message;
@@ -55,9 +55,9 @@ sealed class ProgressDialogModel {
item.IsIndeterminate = true;
});
}
public Task Hide() {
return Update(string.Empty, finishedItems: 0, totalItems: 0);
return Update(string.Empty, 0, 0);
}
}
}

View File

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

View File

@@ -15,6 +15,19 @@
<namespace:TextBoxDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="Panel.buttons">
<Setter Property="Margin" Value="0 20 0 0" />
</Style>
<Style Selector="Panel.buttons > WrapPanel.right">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="Panel.buttons Button">
<Setter Property="MinWidth" Value="80" />
<Setter Property="Margin" Value="8 0 0 0" />
</Style>
</Window.Styles>
<StackPanel Margin="20">
<ScrollViewer MaxHeight="400">
<StackPanel Spacing="10">

View File

@@ -10,11 +10,11 @@ public sealed partial class TextBoxDialog : Window {
public TextBoxDialog() {
InitializeComponent();
}
public void ClickOk(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Ok);
}
public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel);
}

View File

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

View File

@@ -1,37 +1,40 @@
using System;
using System.Collections;
using System.ComponentModel;
using PropertyChanged.SourceGenerator;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DHT.Desktop.Dialogs.TextBox;
partial class TextBoxItem : INotifyDataErrorInfo {
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
public string Title { get; init; } = "";
public object? Item { get; init; } = null;
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
public bool IsValid => ValidityCheck(Value);
[Notify]
private string value = string.Empty;
private void OnValueChanged() {
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
public string Value {
get => this.value;
set {
SetProperty(ref this.value, value);
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
}
}
public IEnumerable GetErrors(string? propertyName) {
if (propertyName == nameof(Value) && !IsValid) {
yield return string.Empty;
}
}
public bool HasErrors => !IsValid;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
}
sealed class TextBoxItem<T> : TextBoxItem {
public new T Item { get; }
public TextBoxItem(T item) {
this.Item = item;
base.Item = item;

View File

@@ -13,16 +13,16 @@ namespace DHT.Desktop.Discord;
static class DiscordAppSettings {
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
public static string JsonFilePath { get; }
private static string JsonBackupFilePath { get; }
[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
static DiscordAppSettings() {
string rootFolder;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
rootFolder = Path.Combine(Environment.GetFolderPath(ApplicationData, DoNotVerify), "Discord");
}
@@ -32,14 +32,14 @@ static class DiscordAppSettings {
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 {
JsonObject settingsJson = await ReadSettingsJson();
var settingsJson = await ReadSettingsJson();
return AreDevToolsEnabled(settingsJson);
} catch (Exception e) {
Log.Error("Cannot read settings file.");
@@ -47,14 +47,14 @@ static class DiscordAppSettings {
return null;
}
}
private static bool AreDevToolsEnabled(JsonObject json) {
return json.TryGetPropertyValue(JsonKeyDevTools, out JsonNode? node) && node?.GetValueKind() == JsonValueKind.True;
return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
}
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
JsonObject json;
try {
json = await ReadSettingsJson();
} catch (FileNotFoundException) {
@@ -65,56 +65,56 @@ static class DiscordAppSettings {
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, overwrite: true);
File.Move(JsonBackupFilePath, JsonFilePath, true);
Log.Info("Restored settings file from backup.");
} catch (Exception e2) {
Log.Error("Cannot restore settings file from backup.");
Log.Error(e2);
}
}
return SettingsJsonResult.WriteError;
}
try {
File.Delete(JsonBackupFilePath);
} catch (Exception e) {
Log.Error("Cannot delete backup file.");
Log.Error(e);
}
return SettingsJsonResult.Success;
}
private static async Task<JsonObject> ReadSettingsJson() {
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
}
private static async Task WriteSettingsJson(JsonObject json) {
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);

View File

@@ -6,5 +6,5 @@ enum SettingsJsonResult {
FileNotFound,
ReadError,
InvalidJson,
WriteError,
WriteError
}

View File

@@ -3,12 +3,12 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="510" d:DesignHeight="375"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="360"
x:Class="DHT.Desktop.Main.AboutWindow"
x:DataType="main:AboutWindowModel"
Title="About Discord History Tracker"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="510" Height="375" CanResize="False"
Width="480" Height="360" CanResize="False"
WindowStartupLocation="CenterOwner">
<Design.DataContext>
@@ -16,6 +16,10 @@
</Design.DataContext>
<Window.Styles>
<Style Selector="StackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="Spacing" Value="5" />
</Style>
<Style Selector="TextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="VerticalAlignment" Value="Center" />
@@ -29,45 +33,44 @@
<StackPanel Orientation="Vertical" Margin="20" Spacing="12">
<StackPanel Orientation="Vertical" Spacing="3">
<TextBlock TextWrapping="Wrap">Discord History Tracker was created by chylex.</TextBlock>
<TextBlock>It is available under the MIT license.</TextBlock>
</StackPanel>
<TextBlock VerticalAlignment="Center">
Discord History Tracker was created by chylex and released under the MIT license.
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8">
<StackPanel>
<Button Command="{Binding ShowOfficialWebsite}">Official Website</Button>
<Button Command="{Binding ShowIssueTracker}">Issue Tracker</Button>
<Button Command="{Binding ShowSourceCode}">Source Code</Button>
</StackPanel>
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*,115,95" Margin="0 10 0 0">
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="175,125,*" Margin="0 10 0 0">
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Third-Party Software</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0">.NET</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0">.NET 8</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
<TextBlock Grid.Row="3" Grid.Column="0">Avalonia</TextBlock>
<TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">GitHub</Button>
<Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">NuGet</Button>
<TextBlock Grid.Row="4" Grid.Column="0">Rx.NET</TextBlock>
<TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</TextBlock>
<TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button>
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryCommunityToolkit}">GitHub</Button>
<TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock>
<TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock>
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Website</Button>
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
<TextBlock Grid.Row="6" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
<TextBlock Grid.Row="6" Grid.Column="1">Apache-2.0</TextBlock>
<Button Grid.Row="6" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button>
<TextBlock Grid.Row="7" Grid.Column="0">PropertyChanged.SourceGenerator</TextBlock>
<TextBlock Grid.Row="7" Grid.Column="0">Rx.NET</TextBlock>
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryPropertyChangedSourceGenerator}">GitHub</Button>
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button>
</Grid>
</StackPanel>

View File

@@ -6,7 +6,7 @@ sealed class AboutWindowModel {
public void ShowOfficialWebsite() {
SystemUtils.OpenUrl(Program.Website);
}
public void ShowIssueTracker() {
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
}
@@ -14,27 +14,27 @@ sealed class AboutWindowModel {
public void ShowSourceCode() {
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
}
public void ShowLibraryNetCore() {
SystemUtils.OpenUrl("https://github.com/dotnet/core");
}
public void ShowLibraryAvalonia() {
SystemUtils.OpenUrl("https://github.com/AvaloniaUI/Avalonia");
SystemUtils.OpenUrl("https://www.nuget.org/packages/Avalonia");
}
public void ShowLibraryPropertyChangedSourceGenerator() {
SystemUtils.OpenUrl("https://github.com/canton7/PropertyChanged.SourceGenerator");
public void ShowLibraryCommunityToolkit() {
SystemUtils.OpenUrl("https://github.com/CommunityToolkit/dotnet");
}
public void ShowLibrarySqlite() {
SystemUtils.OpenUrl("https://www.sqlite.org");
}
public void ShowLibrarySqliteAdoNet() {
SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
}
public void ShowLibraryRxNet() {
SystemUtils.OpenUrl("https://github.com/dotnet/reactive");
}

View File

@@ -5,89 +5,88 @@ using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Server;
using DHT.Server.Data.Filters;
using DHT.Server.Data.Settings;
using DHT.Utils.Logging;
using DHT.Utils.Tasks;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Controls;
sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable {
sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisposable {
private static readonly Log Log = Log.ForType<DownloadItemFilterPanelModel>();
public sealed record Unit(string Name, uint Scale);
private static readonly Unit[] AllUnits = [
new Unit("B", Scale: 1),
new Unit("kB", Scale: 1024),
new Unit("MB", Scale: 1024 * 1024)
new Unit("B", 1),
new Unit("kB", 1024),
new Unit("MB", 1024 * 1024)
];
private static readonly HashSet<string> FilterProperties = [
nameof(LimitSize),
nameof(MaximumSize),
nameof(MaximumSizeUnit),
nameof(MaximumSizeUnit)
];
[Notify(Setter.Private)]
private string filterStatisticsText = "";
[Notify]
public string FilterStatisticsText { get; private set; } = "";
[ObservableProperty]
private bool limitSize = false;
[Notify]
[ObservableProperty]
private ulong maximumSize = 0UL;
[Notify]
[ObservableProperty]
private Unit maximumSizeUnit = AllUnits[0];
public IEnumerable<Unit> Units => AllUnits;
private readonly State state;
private readonly string verb;
private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask;
private bool isLoadingFilterSettings;
private readonly RestartableTask<long> downloadItemCountTask;
private long? matchingItemCount;
private readonly IDisposable downloadItemCountSubscription;
private long? totalItemCount;
[Obsolete("Designer")]
public DownloadItemFilterPanelModel() : this(State.Dummy) {}
public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
this.state = state;
this.verb = verb;
this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings);
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
UpdateFilterStatistics();
PropertyChanged += OnPropertyChanged;
}
public async Task Initialize() {
isLoadingFilterSettings = true;
LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize);
MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize);
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, defaultValue: null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
MaximumSizeUnit = unitValue;
}
isLoadingFilterSettings = false;
}
public async ValueTask DisposeAsync() {
saveFilterSettingsTask.Dispose();
@@ -113,7 +112,7 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable {
Log.Error(e);
}
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
if (!isLoadingFilterSettings) {
@@ -129,9 +128,9 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable {
UpdateFilterStatistics();
}
private void UpdateFilterStatistics() {
DownloadItemFilter filter = CreateFilter();
var filter = CreateFilter();
if (filter.IsEmpty) {
downloadItemCountTask.Cancel();
matchingItemCount = totalItemCount;
@@ -143,21 +142,23 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable {
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
}
}
private void SetMatchingCount(long matchingAttachmentCount) {
this.matchingItemCount = matchingAttachmentCount;
UpdateFilterStatisticsText();
}
private void UpdateFilterStatisticsText() {
string matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
string totalItemCountStr = totalItemCount?.Format() ?? "(...)";
var matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
var totalItemCountStr = totalItemCount?.Format() ?? "(...)";
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
OnPropertyChanged(nameof(FilterStatisticsText));
}
public DownloadItemFilter CreateFilter() {
DownloadItemFilter filter = new ();
if (LimitSize) {
try {
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
@@ -165,7 +166,7 @@ sealed partial class DownloadItemFilterPanelModel : IAsyncDisposable {
// set no size limit, because the overflown size is larger than any file could possibly be
}
}
return filter;
}
}

View File

@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Controls;
namespace DHT.Desktop.Main.Controls;
@@ -8,16 +7,16 @@ namespace DHT.Desktop.Main.Controls;
public sealed partial class MessageFilterPanel : UserControl {
public MessageFilterPanel() {
InitializeComponent();
CultureInfo culture = Program.Culture;
foreach (CalendarDatePicker picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
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;

View File

@@ -7,6 +7,7 @@ using System.Text;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.CheckBox;
using DHT.Desktop.Dialogs.Message;
@@ -15,11 +16,10 @@ using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Data.Filters;
using DHT.Utils.Tasks;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Controls;
sealed partial class MessageFilterPanelModel : IDisposable {
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
private static readonly HashSet<string> FilterProperties = [
nameof(FilterByDate),
nameof(StartDate),
@@ -27,47 +27,46 @@ sealed partial class MessageFilterPanelModel : IDisposable {
nameof(FilterByChannel),
nameof(IncludedChannels),
nameof(FilterByUser),
nameof(IncludedUsers),
nameof(IncludedUsers)
];
public string FilterStatisticsText { get; private set; } = "";
public event PropertyChangedEventHandler? FilterPropertyChanged;
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
[Notify]
private string filterStatisticsText = "";
[Notify]
[ObservableProperty]
private bool filterByDate = false;
[Notify]
[ObservableProperty]
private DateTime? startDate = null;
[Notify]
[ObservableProperty]
private DateTime? endDate = null;
[Notify]
[ObservableProperty]
private bool filterByChannel = false;
[Notify]
[ObservableProperty]
private HashSet<ulong>? includedChannels = null;
[Notify]
[ObservableProperty]
private bool filterByUser = false;
[Notify]
[ObservableProperty]
private HashSet<ulong>? includedUsers = null;
[Notify]
[ObservableProperty]
private string channelFilterLabel = "";
[Notify]
[ObservableProperty]
private string userFilterLabel = "";
private readonly Window window;
private readonly State state;
private readonly string verb;
private readonly RestartableTask<long> exportedMessageCountTask;
private long? exportedMessageCount;
@@ -79,28 +78,28 @@ sealed partial class MessageFilterPanelModel : IDisposable {
private readonly IDisposable userCountSubscription;
private long? totalUserCount;
[Obsolete("Designer")]
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
this.window = window;
this.state = state;
this.verb = verb;
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
UpdateFilterStatistics();
UpdateChannelFilterLabel();
UpdateUserFilterLabel();
PropertyChanged += OnPropertyChanged;
}
public void Dispose() {
exportedMessageCountTask.Cancel();
@@ -108,13 +107,13 @@ sealed partial class MessageFilterPanelModel : IDisposable {
channelCountSubscription.Dispose();
userCountSubscription.Dispose();
}
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();
}
@@ -122,12 +121,12 @@ sealed partial class MessageFilterPanelModel : IDisposable {
UpdateUserFilterLabel();
}
}
private void OnMessageCountChanged(long newMessageCount) {
totalMessageCount = newMessageCount;
UpdateFilterStatistics();
}
private void OnChannelCountChanged(long newChannelCount) {
totalChannelCount = newChannelCount;
UpdateChannelFilterLabel();
@@ -137,7 +136,7 @@ sealed partial class MessageFilterPanelModel : IDisposable {
totalUserCount = newUserCount;
UpdateUserFilterLabel();
}
private void UpdateChannelFilterLabel() {
if (totalChannelCount.HasValue) {
long total = totalChannelCount.Value;
@@ -148,7 +147,7 @@ sealed partial class MessageFilterPanelModel : IDisposable {
ChannelFilterLabel = "Loading...";
}
}
private void UpdateUserFilterLabel() {
if (totalUserCount.HasValue) {
long total = totalUserCount.Value;
@@ -159,9 +158,9 @@ sealed partial class MessageFilterPanelModel : IDisposable {
UserFilterLabel = "Loading...";
}
}
private void UpdateFilterStatistics() {
MessageFilter filter = CreateFilter();
var filter = CreateFilter();
if (filter.IsEmpty) {
exportedMessageCountTask.Cancel();
exportedMessageCount = totalMessageCount;
@@ -173,36 +172,38 @@ sealed partial class MessageFilterPanelModel : IDisposable {
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
}
}
private void SetExportedMessageCount(long exportedMessageCount) {
this.exportedMessageCount = exportedMessageCount;
UpdateFilterStatisticsText();
}
private void UpdateFilterStatisticsText() {
string exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
string totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
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 Task OpenChannelFilterDialog() {
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
var items = new List<CheckBoxItem<ulong>>();
Dictionary<ulong, DHT.Server.Data.Server> servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
await foreach (Channel channel in state.Db.Channels.Get()) {
ulong channelId = channel.Id;
string channelName = channel.Name;
var servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
await foreach (var channel in state.Db.Channels.Get()) {
var channelId = channel.Id;
var channelName = channel.Name;
string title;
if (servers.TryGetValue(channel.Server, out var server)) {
var titleBuilder = new StringBuilder();
ServerType? serverType = server.Type;
var serverType = server.Type;
titleBuilder.Append('[')
.Append(ServerTypes.ToString(serverType))
.Append("] ");
if (serverType == ServerType.DirectMessage) {
titleBuilder.Append(channelName);
}
@@ -211,24 +212,24 @@ sealed partial class MessageFilterPanelModel : IDisposable {
.Append(" - ")
.Append(channelName);
}
title = titleBuilder.ToString();
}
else {
title = channelName;
}
items.Add(new CheckBoxItem<ulong>(channelId) {
Title = title,
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId),
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId)
});
}
return items;
}
const string Title = "Included Channels";
List<CheckBoxItem<ulong>> items;
try {
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
@@ -236,29 +237,29 @@ sealed partial class MessageFilterPanelModel : IDisposable {
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
return;
}
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
var result = await OpenIdFilterDialog(Title, items);
if (result != null) {
IncludedChannels = result;
}
}
public async Task OpenUserFilterDialog() {
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
var checkBoxItems = new List<CheckBoxItem<ulong>>();
await foreach (User user in state.Db.Users.Get()) {
await foreach (var user in state.Db.Users.Get()) {
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id),
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
});
}
return checkBoxItems;
}
const string Title = "Included Users";
List<CheckBoxItem<ulong>> items;
try {
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
@@ -266,42 +267,42 @@ sealed partial class MessageFilterPanelModel : IDisposable {
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
return;
}
HashSet<ulong>? result = await OpenIdFilterDialog(Title, items);
var result = await OpenIdFilterDialog(Title, items);
if (result != null) {
IncludedUsers = result;
}
}
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) {
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
var model = new CheckBoxDialogModel<ulong>(items) {
Title = title,
Title = title
};
var dialog = new CheckBoxDialog { DataContext = model };
var result = await dialog.ShowDialog<DialogResult.OkCancel>(window);
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
}
public MessageFilter CreateFilter() {
MessageFilter filter = new ();
if (FilterByDate) {
filter.StartDate = StartDate;
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
}
if (FilterByChannel && IncludedChannels != null) {
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
}
if (FilterByUser && IncludedUsers != null) {
filter.UserIds = new HashSet<ulong>(IncludedUsers);
}
return filter;
}
}

View File

@@ -1,40 +1,40 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Service;
using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Controls;
sealed partial class ServerConfigurationPanelModel : IDisposable {
sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable {
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
[Notify]
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputPort;
[Notify]
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputToken;
[DependsOn(nameof(InputPort), nameof(InputToken))]
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
private bool isToggleServerButtonEnabled = true;
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
private readonly Window window;
private readonly ServerManager server;
[Obsolete("Designer")]
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
public ServerConfigurationPanelModel(Window window, State state) {
this.window = window;
this.server = state.Server;
@@ -43,19 +43,19 @@ sealed partial class ServerConfigurationPanelModel : IDisposable {
server.StatusChanged += OnServerStatusChanged;
}
public void Dispose() {
server.StatusChanged -= OnServerStatusChanged;
}
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
}
private void UpdateServerStatus() {
OnPropertyChanged(new PropertyChangedEventArgs(nameof(ToggleServerButtonText)));
OnPropertyChanged(nameof(ToggleServerButtonText));
}
private async Task StartServer() {
IsToggleServerButtonEnabled = false;
@@ -69,7 +69,7 @@ sealed partial class ServerConfigurationPanelModel : IDisposable {
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
private async Task StopServer() {
IsToggleServerButtonEnabled = false;
@@ -83,7 +83,7 @@ sealed partial class ServerConfigurationPanelModel : IDisposable {
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
public async Task OnClickToggleServerButton() {
if (server.IsRunning) {
await StopServer();
@@ -92,25 +92,25 @@ sealed partial class ServerConfigurationPanelModel : IDisposable {
await StartServer();
}
}
public void OnClickRandomizeToken() {
InputToken = ServerUtils.GenerateRandomToken(20);
}
public async Task OnClickApplyChanges() {
if (!ushort.TryParse(InputPort, out ushort port)) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return;
}
ServerConfiguration.Port = port;
ServerConfiguration.Token = inputToken;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(HasMadeChanges)));
OnPropertyChanged(nameof(HasMadeChanges));
await StartServer();
}
public void OnClickCancelChanges() {
InputPort = ServerConfiguration.Port.ToString();
InputToken = ServerConfiguration.Token;

View File

@@ -2,62 +2,62 @@ using System;
using System.Reactive.Linq;
using Avalonia.ReactiveUI;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Server;
using DHT.Server.Service;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Controls;
sealed partial class StatusBarModel : IDisposable {
[Notify(Setter.Private)]
sealed partial class StatusBarModel : ObservableObject, IDisposable {
[ObservableProperty(Setter = Access.Private)]
private long? serverCount;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
private long? channelCount;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
private long? messageCount;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
private ServerManager.Status serverStatus;
[DependsOn(nameof(ServerStatus))]
public string ServerStatusText => ServerStatus switch {
public string ServerStatusText => serverStatus switch {
ServerManager.Status.Starting => "STARTING",
ServerManager.Status.Started => "READY",
ServerManager.Status.Stopping => "STOPPING",
ServerManager.Status.Stopped => "STOPPED",
_ => "",
_ => ""
};
private readonly State state;
private readonly IDisposable serverCountSubscription;
private readonly IDisposable channelCountSubscription;
private readonly IDisposable messageCountSubscription;
[Obsolete("Designer")]
public StatusBarModel() : this(State.Dummy) {}
public StatusBarModel(State state) {
this.state = state;
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
state.Server.StatusChanged += OnStateServerStatusChanged;
state.Server.StatusChanged += OnServerStatusChanged;
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
}
public void Dispose() {
serverCountSubscription.Dispose();
channelCountSubscription.Dispose();
messageCountSubscription.Dispose();
state.Server.StatusChanged -= OnStateServerStatusChanged;
state.Server.StatusChanged -= OnServerStatusChanged;
}
private void OnStateServerStatusChanged(object? sender, ServerManager.Status e) {
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
}
}

View File

@@ -1,57 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:namespace="clr-namespace:DHT.Desktop.Main.Dialogs"
mc:Ignorable="d" d:DesignWidth="450"
x:Class="DHT.Desktop.Main.Dialogs.NewDatabaseSettingsDialog"
x:DataType="namespace:NewDatabaseSettingsDialogModel"
Title="New Database Settings"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Closing="OnClosing"
Width="450" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner">
<Window.DataContext>
<namespace:NewDatabaseSettingsDialogModel />
</Window.DataContext>
<Window.Styles>
<Style Selector="Label > StackPanel">
<Setter Property="Margin" Value="0 4 0 0" />
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Spacing" Value="5" />
</Style>
<Style Selector="TextBlock.dim">
<Setter Property="Foreground" Value="DimGray" />
<Setter Property="FontSize" Value="13" />
</Style>
</Window.Styles>
<StackPanel Orientation="Vertical" Margin="20">
<StackPanel Orientation="Vertical" Spacing="15">
<CheckBox IsChecked="{Binding SeparateFileForDownloads}">
<Label>
<StackPanel>
<TextBlock>Use a Separate Database File for Downloads</TextBlock>
<TextBlock Classes="dim">Downloaded files will be stored separately from messages. This cannot be changed later.</TextBlock>
</StackPanel>
</Label>
</CheckBox>
<CheckBox IsChecked="{Binding DownloadsAutoStart}">
<Label>
<StackPanel>
<TextBlock>Download Files Automatically</TextBlock>
<TextBlock Classes="dim">Enables automatic downloads of files hosted on Discord. This can be changed later in the Downloads tab.</TextBlock>
</StackPanel>
</Label>
</CheckBox>
</StackPanel>
<Panel Classes="buttons">
<WrapPanel Classes="right">
<Button Click="ClickOk">OK</Button>
</WrapPanel>
</Panel>
</StackPanel>
</Window>

View File

@@ -1,22 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace DHT.Desktop.Main.Dialogs;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class NewDatabaseSettingsDialog : Window {
public NewDatabaseSettingsDialog() {
InitializeComponent();
}
private void OnClosing(object? sender, WindowClosingEventArgs e) {
if (!e.IsProgrammatic) {
e.Cancel = true;
}
}
private void ClickOk(object? sender, RoutedEventArgs e) {
Close();
}
}

View File

@@ -1,11 +0,0 @@
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Dialogs;
sealed partial class NewDatabaseSettingsDialogModel {
[Notify]
private bool separateFileForDownloads = true;
[Notify]
private bool downloadsAutoStart = true;
}

View File

@@ -16,23 +16,23 @@ public sealed partial class MainWindow : Window {
InitializeComponent();
DataContext = new MainWindowModel(this, Arguments.Empty);
}
internal MainWindow(Arguments args) {
InitializeComponent();
DataContext = new MainWindowModel(this, args);
}
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
e.Cancel = true;
Closing -= OnClosing;
try {
await Dispose();
} finally {
Close();
}
}
private async Task Dispose() {
if (DataContext is MainWindowModel model) {
try {

View File

@@ -3,59 +3,59 @@ using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Database;
using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main;
sealed partial class MainWindowModel : IAsyncDisposable {
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
private const string DefaultTitle = "Discord History Tracker";
private static readonly Log Log = Log.ForType<MainWindowModel>();
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
private string title = DefaultTitle;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
private UserControl currentScreen;
private readonly WelcomeScreen welcomeScreen;
private readonly WelcomeScreenModel welcomeScreenModel;
private MainContentScreenModel? mainContentScreenModel;
private readonly Window window;
private readonly int? concurrentDownloads;
private State? state;
[Obsolete("Designer")]
public MainWindowModel() : this(null!, Arguments.Empty) {}
public MainWindowModel(Window window, Arguments args) {
this.window = window;
welcomeScreenModel = new WelcomeScreenModel(window);
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
currentScreen = welcomeScreen;
string? dbFile = args.DatabaseFile;
var dbFile = args.DatabaseFile;
if (!string.IsNullOrWhiteSpace(dbFile)) {
async void OnWindowOpened(object? o, EventArgs eventArgs) {
window.Opened -= OnWindowOpened;
// https://github.com/AvaloniaUI/Avalonia/issues/3071
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
await Task.Delay(500);
}
if (File.Exists(dbFile)) {
await welcomeScreenModel.OpenOrCreateDatabaseFromPath(dbFile);
}
@@ -63,35 +63,35 @@ sealed partial class MainWindowModel : IAsyncDisposable {
await Dialog.ShowOk(window, "Database Error", "Database file not found:\n" + dbFile);
}
}
window.Opened += OnWindowOpened;
}
if (args.ServerPort != null) {
ServerConfiguration.Port = args.ServerPort.Value;
}
if (args.ServerToken != null) {
ServerConfiguration.Token = args.ServerToken;
}
concurrentDownloads = args.ConcurrentDownloads;
}
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
await DisposeState();
state = new State(db, concurrentDownloads);
try {
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
}
mainContentScreenModel = new MainContentScreenModel(window, state);
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
@@ -99,32 +99,32 @@ sealed partial class MainWindowModel : IAsyncDisposable {
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
window.Focus();
}
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
if (mainContentScreenModel != null) {
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
await mainContentScreenModel.DisposeAsync();
mainContentScreenModel = null;
}
await DisposeState();
Title = DefaultTitle;
CurrentScreen = welcomeScreen;
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
}
private async Task DisposeState() {
if (state != null) {
await state.DisposeAsync();
state = null;
}
}
public async ValueTask DisposeAsync() {
if (mainContentScreenModel != null) {
await mainContentScreenModel.DisposeAsync();

View File

@@ -16,5 +16,11 @@
<Expander Header="Internal Server Configuration" IsExpanded="True">
<controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" />
</Expander>
<Expander Header="Database Tools" IsExpanded="True">
<StackPanel Orientation="Vertical" Spacing="10">
<TextBlock TextWrapping="Wrap">Recreates the database to remove any traces of deleted data, which frees up space and prevents forensic recovery.</TextBlock>
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
</StackPanel>
</Expander>
</StackPanel>
</UserControl>

View File

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

View File

@@ -27,12 +27,6 @@
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
<Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button>
</WrapPanel>
<Expander Header="Advanced Tools" Margin="0 15 0 0">
<StackPanel Orientation="Vertical" Spacing="10">
<TextBlock TextWrapping="Wrap">Recreate the database to free up space after deleting data.</TextBlock>
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
</StackPanel>
</Expander>
</StackPanel>
</UserControl>

View File

@@ -19,89 +19,75 @@ using DHT.Server.Data;
using DHT.Server.Database;
using DHT.Server.Database.Import;
using DHT.Server.Database.Sqlite.Schema;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Pages;
sealed class DatabasePageModel {
private static readonly Log Log = Log.ForType<DatabasePageModel>();
public IDatabaseFile Db { get; }
public event EventHandler? DatabaseClosed;
private readonly Window window;
[Obsolete("Designer")]
public DatabasePageModel() : this(null!, State.Dummy) {}
public DatabasePageModel(Window window, State state) {
this.window = window;
this.Db = state.Db;
}
public async Task OpenDatabaseFolder() {
string file = Db.Path;
string? folder = Path.GetDirectoryName(file);
if (folder == null) {
return;
}
switch (Environment.OSVersion.Platform) {
case PlatformID.Win32NT:
Process.Start("explorer.exe", "/select,\"" + file + "\"");
break;
case PlatformID.Unix:
Process.Start("xdg-open", [ folder ]);
Process.Start("xdg-open", new string[] { folder });
break;
case PlatformID.MacOSX:
Process.Start("open", [ folder ]);
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 Task MergeWithDatabase() {
string[] paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
if (paths.Length == 0) {
return;
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
if (paths.Length > 0) {
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
}
const string Title = "Database Merge";
ImportResult? result;
try {
result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message);
return;
}
await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "database file"));
}
private static async Task<ImportResult?> MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
return await PerformImport(target, paths, dialog, callback, "Database Merge", async path => {
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
if (db == null) {
return false;
}
try {
await target.AddFrom(db);
return true;
@@ -110,70 +96,60 @@ sealed class DatabasePageModel {
}
});
}
private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, int total) : ISchemaUpgradeCallbacks {
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly ProgressDialog dialog;
private readonly int total;
private bool? decision;
public Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() {
return Task.FromResult<InitialDatabaseSettings?>(null);
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
this.total = total;
this.dialog = dialog;
}
public async Task<bool> CanUpgrade() {
return decision ??= (total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
}
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
return doUpgrade(new NullReporter());
}
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
public Task NextVersion() {
return Task.CompletedTask;
}
public Task MainWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
public Task SubWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
}
}
public async Task ImportLegacyArchive() {
string[] paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Legacy DHT Archive",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
AllowMultiple = true,
AllowMultiple = true
});
if (paths.Length == 0) {
return;
if (paths.Length > 0) {
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
}
const string Title = "Legacy Archive Import";
ImportResult? result;
try {
result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, Title, "Could not import legacy archives: " + e.Message);
return;
}
await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "archive file"));
}
private static async Task<ImportResult?> ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
var fakeSnowflake = new FakeSnowflake();
return await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", async path => {
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
@@ -183,74 +159,75 @@ sealed class DatabasePageModel {
});
});
}
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
static bool IsValidSnowflake(string value) {
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
}
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
foreach (DHT.Server.Data.Server server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
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,
ValidityCheck = IsValidSnowflake
});
}
var model = new TextBoxDialogModel<DHT.Server.Data.Server>(items) {
Title = "Imported Server IDs",
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID.",
Description = "Please fill in the IDs of servers and direct messages. First enable Developer Mode in Discord, then right-click each server or direct message, click 'Copy ID', and paste it into the input field. If a server no longer exists, leave its input field empty to use a random ID."
};
var dialog = new TextBoxDialog { DataContext = model };
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<ImportResult?> PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string dialogTitle, Func<string, Task<bool>> performImport) {
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
int total = paths.Length;
DatabaseStatistics oldStatistics = await DatabaseStatistics.Take(target);
var oldStatistics = await DatabaseStatistics.Take(target);
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, dialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
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, dialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
}
}
await callback.Update("Done", finished, total);
if (successful == 0) {
return null;
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
return;
}
DatabaseStatistics newStatistics = await DatabaseStatistics.Take(target);
return new ImportResult(oldStatistics, newStatistics, successful, total);
var newStatistics = await DatabaseStatistics.Take(target);
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
}
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
return new DatabaseStatistics(
@@ -261,52 +238,29 @@ sealed class DatabasePageModel {
);
}
}
private sealed record ImportResult(DatabaseStatistics OldStatistics, DatabaseStatistics NewStatistics, int SuccessfulItems, int TotalItems);
private static string GetImportDialogMessage(ImportResult? result, string itemName) {
if (result == null) {
return "Nothing was imported.";
}
var oldStatistics = result.OldStatistics;
var newStatistics = result.NewStatistics;
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
var message = new StringBuilder();
StringBuilder message = new StringBuilder();
message.Append("Processed ");
if (result.SuccessfulItems == result.TotalItems) {
message.Append(result.SuccessfulItems.Pluralize(itemName));
if (successfulItems == totalItems) {
message.Append(successfulItems.Pluralize(itemName));
}
else {
message.Append(result.SuccessfulItems.Format()).Append(" out of ").Append(result.TotalItems.Pluralize(itemName));
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();
}
public async Task VacuumDatabase() {
const string Title = "Vacuum Database";
try {
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => Db.Vacuum());
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, Title, "Could not vacuum database: " + e.Message);
return;
}
await Dialog.ShowOk(window, Title, "Done.");
}
}

View File

@@ -6,7 +6,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DebugPage"
x:DataType="pages:DebugPageModel">
<Design.DataContext>
<pages:DebugPageModel />
</Design.DataContext>

View File

@@ -16,90 +16,90 @@ sealed class DebugPageModel {
public string GenerateChannels { get; set; } = "0";
public string GenerateUsers { get; set; } = "0";
public string GenerateMessages { get; set; } = "0";
private readonly Window window;
private readonly State state;
[Obsolete("Designer")]
public DebugPageModel() : this(null!, State.Dummy) {}
public DebugPageModel(Window window, State state) {
this.window = window;
this.state = state;
}
public async void OnClickAddRandomDataToDatabase() {
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
return;
}
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
return;
}
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
return;
}
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
}
private const int BatchSize = 500;
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, finishedItems: 0, totalItems: batchCount);
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
var rand = new Random();
var server = new DHT.Server.Data.Server {
Id = RandomId(rand),
Name = RandomName("s"),
Type = ServerType.Server,
};
Channel[] channels = Enumerable.Range(start: 0, channelCount).Select(i => new Channel {
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
Id = RandomId(rand),
Server = server.Id,
Name = RandomName("c"),
ParentId = null,
Position = i,
Topic = RandomText(rand, maxWords: 10),
Topic = RandomText(rand, 10),
Nsfw = rand.Next(4) == 0,
}).ToArray();
User[] users = Enumerable.Range(start: 0, userCount).Select(_ => new User {
var users = Enumerable.Range(0, userCount).Select(_ => new User {
Id = RandomId(rand),
Name = RandomName("u"),
DisplayName = RandomName("u"),
AvatarUrl = null,
Discriminator = rand.Next(minValue: 0, maxValue: 9999).ToString(),
Discriminator = rand.Next(0, 9999).ToString(),
}).ToArray();
await state.Db.Users.Add(users);
await state.Db.Servers.Add([server]);
await state.Db.Channels.Add(channels);
DateTimeOffset now = DateTimeOffset.Now;
var now = DateTimeOffset.Now;
int batchIndex = 0;
while (messageCount > 0) {
int hourOffset = batchIndex;
Message[] messages = Enumerable.Range(start: 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? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(minValue: 1, maxValue: 60)) : null;
long timeMillis = time.ToUnixTimeMilliseconds();
long? editMillis = edit?.ToUnixTimeMilliseconds();
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
var timeMillis = time.ToUnixTimeMilliseconds();
var editMillis = edit?.ToUnixTimeMilliseconds();
return new Message {
Id = (ulong) timeMillis,
Sender = RandomBiasedIndex(rand, users).Id,
Channel = RandomBiasedIndex(rand, channels).Id,
Text = RandomText(rand, maxWords: 100),
Text = RandomText(rand, 100),
Timestamp = timeMillis,
EditTimestamp = editMillis,
RepliedToId = null,
@@ -108,28 +108,28 @@ sealed class DebugPageModel {
Reactions = ImmutableList<Reaction>.Empty,
};
}).ToArray();
await state.Db.Messages.Add(messages);
messageCount -= BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
}
}
private static ulong RandomId(Random rand) {
ulong h = unchecked((ulong) rand.Next());
ulong l = unchecked((ulong) rand.Next());
return (h << 32) | l;
}
private static string RandomName(string prefix) {
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
}
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
}
private static readonly string[] RandomWords = [
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
@@ -153,12 +153,12 @@ sealed class DebugPageModel {
"vanilla",
"watercress", "watermelon",
"yam",
"zucchini",
"zucchini"
];
private static string RandomText(Random rand, int maxWords) {
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), y: 3));
return string.Join(separator: ' ', Enumerable.Range(start: 0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
}
}
#else

View File

@@ -13,8 +13,8 @@
</Design.DataContext>
<UserControl.Styles>
<Style Selector="WrapPanel > Button">
<Setter Property="Margin" Value="0 0 10 10" />
<Style Selector="Expander">
<Setter Property="Margin" Value="0 5 0 0" />
</Style>
<Style Selector="DataGridColumnHeader">
<Setter Property="FontWeight" Value="Medium" />
@@ -30,18 +30,16 @@
</Style>
</UserControl.Styles>
<StackPanel Orientation="Vertical">
<WrapPanel Orientation="Horizontal">
<StackPanel Orientation="Vertical" Spacing="20">
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
<Button Command="{Binding OnClickRetryFailed}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed</Button>
<Button Command="{Binding OnClickDeleteOrphaned}">Delete Orphaned</Button>
<Button Command="{Binding OnClickExportAll}" IsEnabled="{Binding HasSuccessfulDownloads}">Export All...</Button>
</WrapPanel>
<StackPanel Orientation="Vertical" Spacing="20" Margin="0 10 0 0">
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
<TextBlock TextWrapping="Wrap">
Downloading state and filter settings are remembered per-database.
</TextBlock>
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
</StackPanel>
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
<TextBlock TextWrapping="Wrap">
Downloading state and filter settings are remembered per-database.
</TextBlock>
<StackPanel Orientation="Vertical" Spacing="12">
<Expander Header="Download Status" IsExpanded="True">
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid.Columns>

View File

@@ -1,161 +1,149 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Controls;
using DHT.Server;
using DHT.Server.Data;
using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters;
using DHT.Server.Data.Settings;
using DHT.Server.Download;
using DHT.Utils.Logging;
using DHT.Utils.Tasks;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Pages;
sealed partial class DownloadsPageModel : IAsyncDisposable {
sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
private bool isToggleDownloadButtonEnabled = true;
[DependsOn(nameof(IsDownloading))]
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
private bool isRetryingFailedDownloads = false;
[Notify(Setter.Private)]
private bool hasSuccessfulDownloads;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
private bool hasFailedDownloads;
[DependsOn(nameof(IsRetryingFailedDownloads), nameof(HasFailedDownloads))]
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
private string downloadMessage = "";
public DownloadItemFilterPanelModel FilterModel { get; }
private readonly StatisticsRow statisticsPending = new ("Pending");
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
private readonly StatisticsRow statisticsFailed = new ("Failed");
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
public bool IsDownloading => state.Downloader.IsDownloading;
private readonly Window window;
private readonly State state;
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
private readonly IDisposable downloadItemCountSubscription;
private IDisposable? finishedItemsSubscription;
private DownloadItemFilter? currentDownloadFilter;
public DownloadsPageModel() : this(null!, State.Dummy) {}
public DownloadsPageModel(Window window, State state) {
this.window = window;
public DownloadsPageModel() : this(State.Dummy) {}
public DownloadsPageModel(State state) {
this.state = state;
FilterModel = new DownloadItemFilterPanelModel(state);
StatisticsRows = [
statisticsPending,
statisticsDownloaded,
statisticsFailed,
statisticsSkipped,
statisticsSkipped
];
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
RecomputeDownloadStatistics();
}
public async Task Initialize() {
await FilterModel.Initialize();
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, defaultValue: false)) {
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, false)) {
await StartDownload();
}
}
public async ValueTask DisposeAsync() {
finishedItemsSubscription?.Dispose();
downloadItemCountSubscription.Dispose();
downloadStatisticsTask.Dispose();
await FilterModel.DisposeAsync();
}
private void OnDownloadCountChanged(long newDownloadCount) {
RecomputeDownloadStatistics();
}
public async Task OnClickToggleDownload() {
IsToggleDownloadButtonEnabled = false;
if (IsDownloading) {
await StopDownload();
}
else {
await StartDownload();
}
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
IsToggleDownloadButtonEnabled = true;
}
private async Task StartDownload() {
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
OnDownloadStateChanged();
}
private async Task StopDownload() {
await state.Downloader.Stop();
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null;
currentDownloadFilter = null;
OnDownloadStateChanged();
}
private void OnDownloadStateChanged() {
RecomputeDownloadStatistics();
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsDownloading)));
OnPropertyChanged(nameof(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading));
}
private void OnItemFinished(DownloadItem item) {
RecomputeDownloadStatistics();
}
public async Task OnClickRetryFailed() {
public async Task OnClickRetryFailedDownloads() {
IsRetryingFailedDownloads = true;
try {
await state.Db.Downloads.RetryFailed();
RecomputeDownloadStatistics();
@@ -165,110 +153,20 @@ sealed partial class DownloadsPageModel : IAsyncDisposable {
IsRetryingFailedDownloads = false;
}
}
private void RecomputeDownloadStatistics() {
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
}
public async Task OnClickDeleteOrphaned() {
const string Title = "Delete Orphaned Downloads";
try {
await ProgressDialog.Show(window, Title, async (_, callback) => {
await callback.UpdateIndeterminate("Searching for orphaned downloads...");
HashSet<string> reachableNormalizedUrls = [];
HashSet<string> orphanedNormalizedUrls = [];
await foreach (Download download in state.Db.Downloads.FindAllDownloadableUrls()) {
reachableNormalizedUrls.Add(download.NormalizedUrl);
}
await foreach (Download download in state.Db.Downloads.Get()) {
string normalizedUrl = download.NormalizedUrl;
if (!reachableNormalizedUrls.Contains(normalizedUrl)) {
orphanedNormalizedUrls.Add(normalizedUrl);
}
}
if (orphanedNormalizedUrls.Count == 0) {
await Dialog.ShowOk(window, Title, "No orphaned downloads found.");
return;
}
if (await Dialog.ShowYesNo(window, Title, orphanedNormalizedUrls.Count + " orphaned download(s) will be removed from this database. This action cannot be undone. Proceed?") != DialogResult.YesNo.Yes) {
return;
}
await callback.UpdateIndeterminate("Deleting orphaned downloads...");
await state.Db.Downloads.Remove(orphanedNormalizedUrls);
RecomputeDownloadStatistics();
if (await Dialog.ShowYesNo(window, Title, "Orphaned downloads deleted. Vacuum database now to reclaim space?") != DialogResult.YesNo.Yes) {
return;
}
await callback.UpdateIndeterminate("Vacuuming database...");
await state.Db.Vacuum();
});
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, Title, "Could not delete orphaned downloads: " + e.Message);
}
}
public async Task OnClickExportAll() {
const string Title = "Export Downloaded Files";
string[] folders = await window.StorageProvider.OpenFolders(new FolderPickerOpenOptions {
Title = Title,
AllowMultiple = false,
});
if (folders.Length != 1) {
return;
}
string folderPath = folders[0];
DownloadExporter exporter = new DownloadExporter(state.Db, folderPath);
DownloadExporter.Result result;
try {
result = await ProgressDialog.Show(window, Title, async (_, callback) => {
await callback.UpdateIndeterminate("Exporting downloaded files...");
return await exporter.Export(new ExportProgressReporter(callback));
});
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, Title, "Could not export downloaded files: " + e.Message);
return;
}
string messageStart = "Exported " + result.SuccessfulCount.Pluralize("file");
if (result.FailedCount > 0L) {
await Dialog.ShowOk(window, Title, messageStart + " (" + result.FailedCount.Format() + " failed).");
}
else {
await Dialog.ShowOk(window, Title, messageStart + ".");
}
}
private sealed class ExportProgressReporter(IProgressCallback callback) : DownloadExporter.IProgressReporter {
public Task ReportProgress(long processedCount, long totalCount) {
return callback.Update("Exporting downloaded files...", processedCount, totalCount);
}
}
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
statisticsPending.Items = statusStatistics.PendingCount;
statisticsPending.Size = statusStatistics.PendingTotalSize;
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
statisticsFailed.Items = statusStatistics.FailedCount;
statisticsFailed.Size = statusStatistics.FailedTotalSize;
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
@@ -276,24 +174,25 @@ sealed partial class DownloadsPageModel : IAsyncDisposable {
statisticsSkipped.Items = statusStatistics.SkippedCount;
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
HasSuccessfulDownloads = statusStatistics.SuccessfulCount > 0;
HasFailedDownloads = statusStatistics.FailedCount > 0;
}
[ObservableObject]
public sealed partial class StatisticsRow(string state) {
public string State { get; } = state;
[Notify]
[ObservableProperty]
private int items;
[Notify]
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SizeText))]
private ulong? size;
[Notify]
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SizeText))]
private bool hasFilesWithUnknownSize;
[DependsOn(nameof(Size), nameof(HasFilesWithUnknownSize))]
public string SizeText {
get {
if (size == null) {

View File

@@ -9,20 +9,20 @@ namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class TrackingPage : UserControl {
private bool isCopyingScript;
public TrackingPage() {
InitializeComponent();
}
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
if (DataContext is TrackingPageModel model) {
object? originalText = CopyTrackingScript.Content;
var originalText = CopyTrackingScript.Content;
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
isCopyingScript = true;
CopyTrackingScript.Content = "Script Copied!";
await Task.Delay(TimeSpan.FromSeconds(2));
CopyTrackingScript.Content = originalText;
isCopyingScript = false;

View File

@@ -3,73 +3,73 @@ using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord;
using DHT.Desktop.Server;
using PropertyChanged.SourceGenerator;
using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages;
sealed partial class TrackingPageModel {
[Notify(Setter.Private)]
sealed partial class TrackingPageModel : ObservableObject {
[ObservableProperty(Setter = Access.Private)]
private bool isCopyTrackingScriptButtonEnabled = true;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool? areDevToolsEnabled = null;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool isToggleAppDevToolsButtonEnabled = false;
public string OpenDevToolsShortcutText { get; } = OperatingSystem.IsMacOS() ? "Cmd+Shift+I" : "Ctrl+Shift+I";
[DependsOn(nameof(AreDevToolsEnabled), nameof(IsToggleAppDevToolsButtonEnabled))]
public string ToggleAppDevToolsButtonText {
get {
if (!AreDevToolsEnabled.HasValue) {
return "Loading...";
}
if (!IsToggleAppDevToolsButtonEnabled) {
return "Unavailable";
}
return (AreDevToolsEnabled.Value ? "Disable" : "Enable") + " " + OpenDevToolsShortcutText;
}
}
private readonly Window window;
[Obsolete("Designer")]
public TrackingPageModel() : this(null!) {}
public TrackingPageModel(Window window) {
this.window = window;
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
}
public async Task<bool> OnClickCopyTrackingScript() {
IsCopyTrackingScriptButtonEnabled = false;
try {
return await CopyTrackingScript();
} finally {
IsCopyTrackingScriptButtonEnabled = true;
}
}
private async Task<bool> CopyTrackingScript() {
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
IClipboard? clipboard = window.Clipboard;
var clipboard = window.Clipboard;
if (clipboard == null) {
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
return false;
}
try {
await clipboard.SetTextAsync(script);
return true;
@@ -78,10 +78,10 @@ sealed partial class TrackingPageModel {
return false;
}
}
private async Task InitializeDevToolsToggle() {
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
IsToggleAppDevToolsButtonEnabled = true;
@@ -90,44 +90,44 @@ sealed partial class TrackingPageModel {
IsToggleAppDevToolsButtonEnabled = false;
}
}
public async Task OnClickToggleAppDevTools() {
const string DialogTitle = "Discord App Settings File";
if (!AreDevToolsEnabled.HasValue) {
return;
}
bool oldState = AreDevToolsEnabled.Value;
bool newState = !oldState;
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
case SettingsJsonResult.Success:
AreDevToolsEnabled = newState;
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
break;
case SettingsJsonResult.AlreadySet:
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " is already " + (newState ? "enabled." : "disabled."));
AreDevToolsEnabled = newState;
break;
case SettingsJsonResult.FileNotFound:
await Dialog.ShowOk(window, DialogTitle, "Cannot find the settings file:\n" + DiscordAppSettings.JsonFilePath);
break;
case SettingsJsonResult.ReadError:
await Dialog.ShowOk(window, DialogTitle, "Cannot read the settings file:\n" + DiscordAppSettings.JsonFilePath);
break;
case SettingsJsonResult.InvalidJson:
await Dialog.ShowOk(window, DialogTitle, "Unknown format of the settings file:\n" + DiscordAppSettings.JsonFilePath);
break;
case SettingsJsonResult.WriteError:
await Dialog.ShowOk(window, DialogTitle, "Cannot save the settings file:\n" + DiscordAppSettings.JsonFilePath);
break;
default:
throw new ArgumentOutOfRangeException();
}

View File

@@ -3,6 +3,7 @@ using System.ComponentModel;
using System.Threading.Tasks;
using System.Web;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
@@ -11,44 +12,40 @@ using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Data.Filters;
using DHT.Server.Service.Viewer;
using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Pages;
sealed partial class ViewerPageModel : IDisposable {
private static readonly Log Log = Log.ForType<ViewerPageModel>();
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false;
[Notify]
[ObservableProperty]
private bool hasFilters = false;
public MessageFilterPanelModel FilterModel { get; }
private readonly Window window;
private readonly State state;
[Obsolete("Designer")]
public ViewerPageModel() : this(null!, State.Dummy) {}
public ViewerPageModel(Window window, State state) {
this.window = window;
this.state = state;
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
}
public void Dispose() {
FilterModel.Dispose();
}
private void OnFilterPropertyChanged(object? sender, PropertyChangedEventArgs e) {
HasFilters = FilterModel.HasAnyFilters;
}
public async void OnClickOpenViewer() {
try {
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
@@ -59,44 +56,24 @@ sealed partial class ViewerPageModel : IDisposable {
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
}
}
public async Task OnClickApplyFiltersToDatabase() {
try {
MessageFilter filter = FilterModel.CreateFilter();
long messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
}
var filter = FilterModel.CreateFilter();
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
}
else if (DatabaseToolFilterModeRemove) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
}
}
else if (DatabaseToolFilterModeRemove) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
}
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Apply Filters", "Could not apply filters: " + e.Message);
}
}
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
await ProgressDialog.Show(window, "Apply Filters", async (_, callback) => {
await callback.UpdateIndeterminate("Removing messages...");
Log.Info("Removed messages: " + await state.Db.Messages.Remove(filter, removalMode));
await callback.UpdateIndeterminate("Cleaning up attachments...");
Log.Info("Removed orphaned attachments: " + await state.Db.Messages.RemoveUnreachableAttachments());
await callback.UpdateIndeterminate("Cleaning up users...");
Log.Info("Removed orphaned users: " + await state.Db.Users.RemoveUnreachable());
await callback.UpdateIndeterminate("Cleaning up channels...");
Log.Info("Removed orphaned channels: " + await state.Db.Channels.RemoveUnreachable());
await callback.UpdateIndeterminate("Cleaning up servers...");
Log.Info("Removed orphaned servers: " + await state.Db.Servers.RemoveUnreachable());
});
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
}
}

View File

@@ -10,68 +10,72 @@ namespace DHT.Desktop.Main.Screens;
sealed class MainContentScreenModel : IAsyncDisposable {
public DatabasePage DatabasePage { get; }
private DatabasePageModel DatabasePageModel { get; }
public TrackingPage TrackingPage { get; }
private TrackingPageModel TrackingPageModel { get; }
public DownloadsPage DownloadsPage { get; }
private DownloadsPageModel DownloadsPageModel { get; }
public ViewerPage ViewerPage { get; }
private ViewerPageModel ViewerPageModel { get; }
public AdvancedPage AdvancedPage { get; }
private AdvancedPageModel AdvancedPageModel { get; }
public DebugPage? DebugPage { get; }
#if DEBUG
public bool HasDebugPage => true;
private DebugPageModel DebugPageModel { get; }
#else
public bool HasDebugPage => false;
#endif
public StatusBarModel StatusBarModel { get; }
public event EventHandler? DatabaseClosed {
add => DatabasePageModel.DatabaseClosed += value;
remove => DatabasePageModel.DatabaseClosed -= value;
add {
DatabasePageModel.DatabaseClosed += value;
}
remove {
DatabasePageModel.DatabaseClosed -= value;
}
}
[Obsolete("Designer")]
public MainContentScreenModel() : this(null!, State.Dummy) {}
public MainContentScreenModel(Window window, State state) {
DatabasePageModel = new DatabasePageModel(window, state);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
TrackingPageModel = new TrackingPageModel(window);
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
DownloadsPageModel = new DownloadsPageModel(window, state);
DownloadsPageModel = new DownloadsPageModel(state);
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
ViewerPageModel = new ViewerPageModel(window, state);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
AdvancedPageModel = new AdvancedPageModel(window, state);
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
#if DEBUG
DebugPageModel = new DebugPageModel(window, state);
DebugPage = new DebugPage { DataContext = DebugPageModel };
#else
DebugPage = null;
#endif
StatusBarModel = new StatusBarModel(state);
}
public async Task Initialize() {
await DownloadsPageModel.Initialize();
}
public async ValueTask DisposeAsync() {
await DownloadsPageModel.DisposeAsync();
ViewerPageModel.Dispose();

View File

@@ -30,7 +30,7 @@
<Panel Name="RootPanel">
<StackPanel Margin="42 30">
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
<Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
<Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>

View File

@@ -5,43 +5,42 @@ using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Main.Dialogs;
using DHT.Server.Data.Settings;
using DHT.Server.Database;
using DHT.Server.Database.Sqlite.Schema;
using DHT.Server.Database.Sqlite.Utils;
using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
namespace DHT.Desktop.Main.Screens;
sealed partial class WelcomeScreenModel {
sealed partial class WelcomeScreenModel : ObservableObject {
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
public string Version => Program.Version;
[Notify(Setter.Private)]
[ObservableProperty(Setter = Access.Private)]
private bool isOpenOrCreateDatabaseButtonEnabled = true;
public event EventHandler<IDatabaseFile>? DatabaseSelected;
private readonly Window window;
private string? dbFilePath;
[Obsolete("Designer")]
public WelcomeScreenModel() : this(null!) {}
public WelcomeScreenModel(Window window) {
this.window = window;
}
public async Task OpenOrCreateDatabase() {
IsOpenOrCreateDatabaseButtonEnabled = false;
try {
string? path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
if (path != null) {
await OpenOrCreateDatabaseFromPath(path);
}
@@ -49,30 +48,29 @@ sealed partial class WelcomeScreenModel {
IsOpenOrCreateDatabaseButtonEnabled = true;
}
}
public async Task OpenOrCreateDatabaseFromPath(string path) {
dbFilePath = path;
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
if (db != null) {
DatabaseSelected?.Invoke(this, db);
bool isNew = !File.Exists(path);
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
if (db == null) {
return;
}
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
await db.Settings.Set(SettingsKey.DownloadsAutoStart, true);
}
DatabaseSelected?.Invoke(this, db);
}
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
public async Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() {
var model = new NewDatabaseSettingsDialogModel();
var dialog = new NewDatabaseSettingsDialog { DataContext = model };
await dialog.ShowDialog(window);
return new InitialDatabaseSettings(model.SeparateFileForDownloads, model.DownloadsAutoStart);
}
public async Task<bool> CanUpgrade() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
}
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
var reporter = new ProgressReporter(versionSteps, callbacks);
@@ -81,28 +79,28 @@ sealed partial class WelcomeScreenModel {
await doUpgrade(reporter);
await Task.Delay(TimeSpan.FromMilliseconds(600));
}
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
}
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
private int versionProgress = 0;
public async Task NextVersion() {
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
await HideChildren(0);
}
public async Task MainWork(string message, int finishedItems, int totalItems) {
await callbacks[1].Update(message, finishedItems, totalItems);
await HideChildren(1);
}
public async Task SubWork(string message, int finishedItems, int totalItems) {
await callbacks[2].Update(message, finishedItems, totalItems);
await HideChildren(2);
}
private async Task HideChildren(int parentIndex) {
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
await callbacks[i].Hide();
@@ -110,34 +108,40 @@ sealed partial class WelcomeScreenModel {
}
}
}
public async Task CheckUpdates() {
string response;
try {
response = await ProgressDialog.ShowIndeterminate<string>(window, "Check Updates", "Checking for updates...", static async _ => {
var client = new HttpClient(new SocketsHttpHandler {
AutomaticDecompression = DecompressionMethods.None,
AllowAutoRedirect = false,
UseCookies = false,
});
client.Timeout = TimeSpan.FromSeconds(30);
client.MaxResponseContentBufferSize = 1024;
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
return await client.GetStringAsync(Program.Website + "/version");
Version? latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => {
var client = new HttpClient(new SocketsHttpHandler {
AutomaticDecompression = DecompressionMethods.None,
AllowAutoRedirect = false,
UseCookies = false
});
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
return;
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
return;
}
if (!System.Version.TryParse(response, out Version? latestVersion)) {
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
client.Timeout = TimeSpan.FromSeconds(30);
client.MaxResponseContentBufferSize = 1024;
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
string response;
try {
response = await client.GetStringAsync(Program.Website + "/version");
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
return null;
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
return null;
}
if (!System.Version.TryParse(response, out var latestVersion)) {
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
return null;
}
return latestVersion;
});
if (latestVersion == null) {
return;
}
@@ -151,11 +155,11 @@ sealed partial class WelcomeScreenModel {
Exit();
}
}
public async Task ShowAboutDialog() {
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
}
public void Exit() {
window.Close();
}

View File

@@ -13,21 +13,21 @@ static class Program {
public static CultureInfo Culture { get; }
public static ResourceLoader Resources { get; }
public static Arguments Arguments { get; }
public const string Website = "https://dht.chylex.com";
static Program() {
var assembly = Assembly.GetExecutingAssembly();
AssemblyVersion = assembly.GetName().Version ?? new Version(major: 0, minor: 0, build: 0, revision: 0);
AssemblyVersion = assembly.GetName().Version ?? new Version(0, 0, 0, 0);
Version = VersionToString(AssemblyVersion);
Culture = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
Resources = new ResourceLoader(assembly);
Arguments = new Arguments(Environment.GetCommandLineArgs());
}
@@ -41,12 +41,12 @@ static class Program {
return versionStr;
}
public static void Main(string[] args) {
if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.AllocConsole();
}
try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} finally {
@@ -55,7 +55,7 @@ static class Program {
}
}
}
private static AppBuilder BuildAvaloniaApp() {
return AppBuilder.Configure<App>()
.UsePlatformDetect()

View File

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

View File

@@ -1,8 +1,8 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>13</LangVersion>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -21,13 +21,12 @@
<PropertyGroup>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<TrimMode>partial</TrimMode>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<XmlResolverIsNetworkingEnabledByDefault>false</XmlResolverIsNetworkingEnabledByDefault>
</PropertyGroup>
<PropertyGroup>
@@ -40,12 +39,11 @@
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
<DebugType>none</DebugType>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<PropertyGroup>
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
</PropertyGroup>
</Project>

View File

@@ -1,6 +1,6 @@
/**
* Parts copied from Better Discord, licensed under Apache License 2.0.
*
*
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/renderer/src/modules/webpackmodules.js
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/LICENSE
*/
@@ -91,8 +91,8 @@ class WEBPACK {
}
static findFunction(name, additionalRequiredProps) {
const searchedProps = additionalRequiredProps ? [ name, ...additionalRequiredProps ] : [ name ];
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof (prop) === "function", ...searchedProps));
const searchedProps = additionalRequiredProps ? [name, ...additionalRequiredProps] : [name];
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof(prop) === "function", ...searchedProps));
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
}
}

View File

@@ -298,13 +298,13 @@ export default (function() {
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
}
else if (property === "reactions") {
if (!value) {
else if (property === "reactions"){
if (!value){
return "";
}
return "<div class='reactions'>" + value.map(reaction => {
if ("id" in reaction) {
if ("id" in reaction){
const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp";
const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
// noinspection JSUnusedGlobalSymbols

View File

@@ -171,7 +171,7 @@ export default (function() {
};
const getMessageChannel = function(id) {
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
if (id in messages) {
return channel;
}

View File

@@ -1,7 +1,7 @@
#messages {
flex: 1 1 0;
overflow-y: auto;
background-color: #36393e;
background-color: #36393E;
}
#messages > div {
@@ -38,7 +38,7 @@
}
#messages .username {
color: #fff;
color: #FFF;
font-size: 15px;
font-weight: 600;
margin-right: 3px;
@@ -75,12 +75,12 @@
}
.message .link, .reply-message .link {
color: #7289da;
color: #7289DA;
background-color: rgba(115, 139, 215, 0.1);
}
.message a, .reply-message a {
color: #0096cf;
color: #0096CF;
text-decoration: none;
}
@@ -131,7 +131,7 @@
}
.message code {
background-color: #2e3136;
background-color: #2E3136;
border-radius: 5px;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 14px;
@@ -144,7 +144,7 @@
.message code.block {
display: block;
border: 2px solid #282b30;
border: 2px solid #282B30;
margin-top: 6px;
padding: 7px;
}
@@ -198,7 +198,7 @@
}
.reply-username {
color: #fff;
color: #FFF;
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
@@ -218,7 +218,7 @@
}
.reply-contents code {
background-color: #2e3136;
background-color: #2E3136;
font-family: Menlo, Consolas, Monaco, monospace;
padding: 1px 2px;
}

View File

@@ -4,11 +4,11 @@ public sealed class DownloadStatusStatistics {
public int PendingCount { get; internal init; }
public ulong PendingTotalSize { get; internal init; }
public int PendingWithUnknownSizeCount { get; internal init; }
public int SuccessfulCount { get; internal init; }
public ulong SuccessfulTotalSize { get; internal init; }
public int SuccessfulWithUnknownSizeCount { get; internal init; }
public int FailedCount { get; internal init; }
public ulong FailedTotalSize { get; internal init; }
public int FailedWithUnknownSizeCount { get; internal init; }

View File

@@ -6,7 +6,7 @@ public sealed class Download {
public DownloadStatus Status { get; }
public string? Type { get; }
public ulong? Size { get; }
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) {
NormalizedUrl = normalizedUrl;
DownloadUrl = downloadUrl;

View File

@@ -10,5 +10,5 @@ public enum DownloadStatus {
GenericError = 1,
Downloading = 2,
LastCustomCode = 99,
Success = HttpStatusCode.OK,
Success = HttpStatusCode.OK
}

View File

@@ -7,7 +7,7 @@ sealed class DiscordEmbedJson {
public JsonImage? Image { get; set; }
public JsonImage? Thumbnail { get; set; }
public JsonImage? Video { get; set; }
public sealed class JsonImage {
public string? Url { get; set; }
public string? ProxyUrl { get; set; }

View File

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

View File

@@ -7,6 +7,6 @@ public sealed class DownloadItemFilter {
public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
public ulong? MaxBytes { get; set; } = null;
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null && MaxBytes == null;
}

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ namespace DHT.Server.Data;
public enum ServerType {
Server,
Group,
DirectMessage,
DirectMessage
}
public static class ServerTypes {
@@ -12,34 +12,34 @@ public static class ServerTypes {
"SERVER" => ServerType.Server,
"GROUP" => ServerType.Group,
"DM" => ServerType.DirectMessage,
_ => null,
_ => null
};
}
public static string ToString(ServerType? type) {
return type switch {
ServerType.Server => "SERVER",
ServerType.Group => "GROUP",
ServerType.DirectMessage => "DM",
_ => "UNKNOWN",
_ => "UNKNOWN"
};
}
public static string ToNiceString(ServerType? type) {
return type switch {
ServerType.Server => "Server",
ServerType.Group => "Group",
ServerType.DirectMessage => "DM",
_ => "Unknown",
_ => "Unknown"
};
}
internal static string ToJsonViewerString(ServerType? type) {
return type switch {
ServerType.Server => "server",
ServerType.Group => "group",
ServerType.DirectMessage => "user",
_ => "unknown",
_ => "unknown"
};
}
}

View File

@@ -3,18 +3,17 @@ using System.Diagnostics.CodeAnalysis;
namespace DHT.Server.Data.Settings;
public static class SettingsKey {
public static Bool SeparateFileForDownloads { get; } = new ("separate_file_for_downloads");
public static Bool DownloadsAutoStart { get; } = new ("downloads_auto_start");
public static Bool DownloadsLimitSize { get; } = new ("downloads_limit_size");
public static UnsignedLong DownloadsMaximumSize { get; } = new ("downloads_maximum_size");
public static String DownloadsMaximumSizeUnit { get; } = new ("downloads_maximum_size_unit");
public sealed class String(string key) : SettingsKey<string>(key) {
internal override bool FromString(string value, out string result) {
result = value;
return true;
}
internal override string ToString(string value) {
return value;
}
@@ -26,17 +25,17 @@ public static class SettingsKey {
case "1":
result = true;
return true;
case "0":
result = false;
return true;
default:
result = false;
return false;
}
}
internal override string ToString(bool value) {
return value ? "1" : "0";
}
@@ -46,7 +45,7 @@ public static class SettingsKey {
internal override bool FromString(string value, out ulong result) {
return ulong.TryParse(value, out result);
}
internal override string ToString(ulong value) {
return value.ToString();
}
@@ -55,7 +54,7 @@ public static class SettingsKey {
public abstract class SettingsKey<T>(string key) {
internal string Key => key;
internal abstract bool FromString(string value, [NotNullWhen(true)] out T result);
internal abstract string ToString(T value);
}

View File

@@ -10,13 +10,13 @@ public static class DatabaseExtensions {
await target.Users.Add(await source.Users.Get().ToListAsync());
await target.Servers.Add(await source.Servers.Get().ToListAsync());
await target.Channels.Add(await source.Channels.Get().ToListAsync());
const int MessageBatchSize = 100;
List<Message> batchedMessages = new (MessageBatchSize);
await foreach (Message message in source.Messages.Get()) {
await foreach (var message in source.Messages.Get()) {
batchedMessages.Add(message);
if (batchedMessages.Count >= MessageBatchSize) {
await target.Messages.Add(batchedMessages);
batchedMessages.Clear();
@@ -24,8 +24,8 @@ public static class DatabaseExtensions {
}
await target.Messages.Add(batchedMessages);
await foreach (Data.Download download in source.Downloads.Get()) {
await foreach (var download in source.Downloads.Get()) {
if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) {
await target.Downloads.AddDownload(download, stream: null);
}

View File

@@ -7,7 +7,7 @@ namespace DHT.Server.Database;
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
sealed class DummyDatabaseFile : IDatabaseFile {
public static DummyDatabaseFile Instance { get; } = new ();
public string Path => "";
public ISettingsRepository Settings { get; } = new ISettingsRepository.Dummy();
@@ -18,11 +18,11 @@ sealed class DummyDatabaseFile : IDatabaseFile {
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
private DummyDatabaseFile() {}
public Task Vacuum() {
return Task.CompletedTask;
}
public ValueTask DisposeAsync() {
return ValueTask.CompletedTask;
}

View File

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

View File

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

View File

@@ -1,35 +1,23 @@
using System;
using System.Buffers.Text;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
sealed class SnowflakeJsonSerializer : JsonConverter<Snowflake> {
private const int MaxUlongStringLength = 20;
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return new Snowflake(ulong.Parse(reader.GetString()!));
}
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
writer.WriteStringValue(Format(value, stackalloc byte[MaxUlongStringLength]));
writer.WriteStringValue(value.Id.ToString());
}
public override Snowflake ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return new Snowflake(ulong.Parse(reader.GetString()!));
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
writer.WritePropertyName(Format(value, stackalloc byte[MaxUlongStringLength]));
}
private static ReadOnlySpan<byte> Format(Snowflake value, Span<byte> destination) {
if (!Utf8Formatter.TryFormat(value.Id, destination, out int bytesWritten)) {
Debug.Fail("Failed to format Snowflake value.");
}
return destination[..bytesWritten];
writer.WritePropertyName(value.Id.ToString());
}
}

View File

@@ -19,18 +19,18 @@ static class ViewerJson {
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Avatar { get; init; }
}
public sealed class JsonServer {
public required string Name { get; init; }
public required string Type { get; init; }
}
public sealed class JsonChannel {
public required Snowflake Server { get; init; }
public required string Name { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Snowflake? Parent { get; init; }
public string? Parent { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Position { get; init; }
@@ -41,22 +41,22 @@ static class ViewerJson {
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Nsfw { get; init; }
}
public sealed class JsonMessage {
public required Snowflake Id { get; init; }
public required Snowflake C { get; init; }
public required Snowflake U { get; init; }
public required long T { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? M { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Te { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Snowflake? R { get; init; }
public string? R { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonMessageAttachment[]? A { get; init; }
@@ -66,7 +66,7 @@ static class ViewerJson {
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonMessageReaction[]? Re { get; init; }
}
public sealed class JsonMessageAttachment {
public required string Url { get; init; }
public required string Name { get; init; }
@@ -77,14 +77,14 @@ static class ViewerJson {
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Height { get; set; }
}
public sealed class JsonMessageReaction {
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Snowflake? Id { get; init; }
public string? Id { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? N { get; init; }
public required bool A { get; init; }
public required int C { get; init; }
}

View File

@@ -2,167 +2,148 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using DHT.Server.Data;
using DHT.Server.Data.Filters;
using DHT.Utils.Logging;
using Channel = System.Threading.Channels.Channel;
using DiscordChannel = DHT.Server.Data.Channel;
namespace DHT.Server.Database.Export;
static class ViewerJsonExport {
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
public static async Task GetMetadata(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) {
Perf perf = Log.Start();
var includedChannels = new List<DiscordChannel>();
var perf = Log.Start();
var includedChannels = new List<Channel>();
var includedServerIds = new HashSet<ulong>();
HashSet<ulong>? channelIdFilter = filter?.ChannelIds;
await foreach (DiscordChannel channel in db.Channels.Get(cancellationToken)) {
var channelIdFilter = filter?.ChannelIds;
await foreach (var channel in db.Channels.Get(cancellationToken)) {
if (channelIdFilter == null || channelIdFilter.Contains(channel.Id)) {
includedChannels.Add(channel);
includedServerIds.Add(channel.Server);
}
}
Dictionary<Snowflake, ViewerJson.JsonUser> users = await GenerateUserList(db, cancellationToken);
Dictionary<Snowflake, ViewerJson.JsonServer> servers = await GenerateServerList(db, includedServerIds, cancellationToken);
Dictionary<Snowflake, ViewerJson.JsonChannel> channels = GenerateChannelList(includedChannels);
var users = await GenerateUserList(db, cancellationToken);
var servers = await GenerateServerList(db, includedServerIds, cancellationToken);
var channels = GenerateChannelList(includedChannels);
var meta = new ViewerJson.JsonMeta {
Users = users,
Servers = servers,
Channels = channels,
Channels = channels
};
perf.Step("Collect database data");
await JsonSerializer.SerializeAsync(stream, meta, ViewerJsonMetadataContext.Default.JsonMeta, cancellationToken);
perf.Step("Serialize to JSON");
perf.End();
}
public static async Task GetMessages(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) {
Perf perf = Log.Start();
var perf = Log.Start();
ReadOnlyMemory<byte> newLine = "\n"u8.ToArray();
Channel<Message> channel = Channel.CreateBounded<Message>(new BoundedChannelOptions(32) {
SingleWriter = true,
SingleReader = true,
AllowSynchronousContinuations = true,
FullMode = BoundedChannelFullMode.Wait,
});
Task writerTask = Task.Run(async () => {
try {
await foreach (Message message in db.Messages.Get(filter, cancellationToken)) {
await channel.Writer.WriteAsync(message, cancellationToken);
}
} finally {
channel.Writer.Complete();
}
}, cancellationToken);
await foreach (Message message in channel.Reader.ReadAllAsync(cancellationToken)) {
await JsonSerializer.SerializeAsync(stream, ToJsonMessage(message), ViewerJsonMessageContext.Default.JsonMessage, cancellationToken);
await foreach(var message in GenerateMessageList(db, filter, cancellationToken)) {
await JsonSerializer.SerializeAsync(stream, message, ViewerJsonMessageContext.Default.JsonMessage, cancellationToken);
await stream.WriteAsync(newLine, cancellationToken);
}
await writerTask;
perf.Step("Generate and serialize messages to JSON");
perf.End();
}
private static async Task<Dictionary<Snowflake, ViewerJson.JsonUser>> GenerateUserList(IDatabaseFile db, CancellationToken cancellationToken) {
var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
await foreach (User user in db.Users.Get(cancellationToken)) {
await foreach (var user in db.Users.Get(cancellationToken)) {
users[user.Id] = new ViewerJson.JsonUser {
Name = user.Name,
DisplayName = user.DisplayName,
Avatar = user.AvatarUrl,
};
}
return users;
}
private static async Task<Dictionary<Snowflake, ViewerJson.JsonServer>> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, CancellationToken cancellationToken) {
var servers = new Dictionary<Snowflake, ViewerJson.JsonServer>();
await foreach (Data.Server server in db.Servers.Get(cancellationToken)) {
await foreach (var server in db.Servers.Get(cancellationToken)) {
if (!serverIds.Contains(server.Id)) {
continue;
}
servers[server.Id] = new ViewerJson.JsonServer {
Name = server.Name,
Type = ServerTypes.ToJsonViewerString(server.Type),
Type = ServerTypes.ToJsonViewerString(server.Type)
};
}
return servers;
}
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<DiscordChannel> includedChannels) {
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels) {
var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
foreach (DiscordChannel channel in includedChannels) {
foreach (var channel in includedChannels) {
channels[channel.Id] = new ViewerJson.JsonChannel {
Server = channel.Server,
Name = channel.Name,
Parent = channel.ParentId,
Parent = channel.ParentId?.ToString(),
Position = channel.Position,
Topic = channel.Topic,
Nsfw = channel.Nsfw,
Nsfw = channel.Nsfw
};
}
return channels;
}
private static ViewerJson.JsonMessage ToJsonMessage(Message message) {
return new ViewerJson.JsonMessage {
Id = message.Id,
C = message.Channel,
U = message.Sender,
T = message.Timestamp,
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
Te = message.EditTimestamp,
R = message.RepliedToId,
A = message.Attachments.IsEmpty ? null : message.Attachments.Select(static attachment => {
var a = new ViewerJson.JsonMessageAttachment {
Url = attachment.DownloadUrl,
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out Uri? uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl,
};
if (attachment is { Width: not null, Height: not null }) {
a.Width = attachment.Width;
a.Height = attachment.Height;
}
return a;
}).ToArray(),
E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
Id = reaction.EmojiId,
N = reaction.EmojiName,
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
C = reaction.Count,
}).ToArray(),
};
private static async IAsyncEnumerable<ViewerJson.JsonMessage> GenerateMessageList(IDatabaseFile db, MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
await foreach (var message in db.Messages.Get(filter, cancellationToken)) {
yield return new ViewerJson.JsonMessage {
Id = message.Id,
C = message.Channel,
U = message.Sender,
T = message.Timestamp,
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
Te = message.EditTimestamp,
R = message.RepliedToId?.ToString(),
A = message.Attachments.IsEmpty ? null : message.Attachments.Select(static attachment => {
var a = new ViewerJson.JsonMessageAttachment {
Url = attachment.DownloadUrl,
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl
};
if (attachment is { Width: not null, Height: not null }) {
a.Width = attachment.Width;
a.Height = attachment.Height;
}
return a;
}).ToArray(),
E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
Id = reaction.EmojiId?.ToString(),
N = reaction.EmojiName,
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
C = reaction.Count
}).ToArray()
};
}
}
}

View File

@@ -6,13 +6,13 @@ namespace DHT.Server.Database;
public interface IDatabaseFile : IAsyncDisposable {
string Path { get; }
ISettingsRepository Settings { get; }
IUserRepository Users { get; }
IServerRepository Servers { get; }
IChannelRepository Channels { get; }
IMessageRepository Messages { get; }
IDownloadRepository Downloads { get; }
Task Vacuum();
}

View File

@@ -7,16 +7,16 @@ sealed class DiscordEmbedLegacyJson {
public required string Type { get; init; }
public bool DhtLegacy { get; } = true;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Title { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImageJson? Image { get; init; }
public sealed class ImageJson {
public required string Url { get; init; }
}

View File

@@ -7,14 +7,14 @@ namespace DHT.Server.Database.Import;
/// </summary>
public sealed class FakeSnowflake {
private const ulong DiscordEpoch = 1420070400000UL;
private ulong id;
public FakeSnowflake() {
ulong unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
var unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
this.id = (unixMillis - DiscordEpoch) << 22;
}
internal ulong Next() {
return id++;
}

View File

@@ -16,93 +16,93 @@ namespace DHT.Server.Database.Import;
public static class LegacyArchiveImport {
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) {
Perf perf = Log.Start();
JsonElement root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
var perf = Log.Start();
var root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
try {
JsonElement meta = root.RequireObject("meta");
JsonElement data = root.RequireObject("data");
var meta = root.RequireObject("meta");
var data = root.RequireObject("data");
perf.Step("Deserialize JSON");
User[] users = ReadUserList(meta);
Data.Server[] servers = ReadServerList(meta, fakeSnowflake);
var users = ReadUserList(meta);
var servers = ReadServerList(meta, fakeSnowflake);
var newServersOnly = new HashSet<Data.Server>(servers);
Dictionary<ulong, Data.Server> oldServersById = await db.Servers.Get().ToDictionaryAsync(static server => server.Id, static server => server);
Dictionary<ulong, Channel> oldChannelsById = await db.Channels.Get().ToDictionaryAsync(static channel => channel.Id, static channel => channel);
foreach ((ulong channelId, int serverIndex) in ReadChannelToServerIndexMapping(meta, servers)) {
if (oldChannelsById.TryGetValue(channelId, out Channel oldChannel) && oldServersById.TryGetValue(oldChannel.Server, out Data.Server oldServer) && newServersOnly.Remove(servers[serverIndex])) {
var oldServersById = await db.Servers.Get().ToDictionaryAsync(static server => server.Id, static server => server);
var oldChannelsById = await db.Channels.Get().ToDictionaryAsync(static channel => channel.Id, static channel => channel);
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) {
Dictionary<Data.Server, ulong>? askedServerIds = await askForServerIds(newServersOnly.ToArray());
var askedServerIds = await askForServerIds(newServersOnly.ToArray());
if (askedServerIds == null) {
return false;
}
perf.Step("Ask for server IDs");
for (int i = 0; i < servers.Length; i++) {
Data.Server server = servers[i];
if (askedServerIds.TryGetValue(server, out ulong serverId)) {
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 };
}
}
}
Channel[] channels = ReadChannelList(meta, servers);
var channels = ReadChannelList(meta, servers);
perf.Step("Read channel list");
HashSet<ulong> oldMessageIds = await db.Messages.GetIds().ToHashSetAsync();
Message[] newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
.Where(message => !oldMessageIds.Contains(message.Id))
.ToArray();
var oldMessageIds = await db.Messages.GetIds().ToHashSetAsync();
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
.Where(message => !oldMessageIds.Contains(message.Id))
.ToArray();
perf.Step("Read messages");
await db.Users.Add(users);
await db.Servers.Add(servers);
await db.Channels.Add(channels);
await db.Messages.Add(newMessages);
perf.Step("Import into database");
} catch (HttpException e) {
throw new JsonException(e.Message);
}
perf.End();
return true;
}
private static User[] ReadUserList(JsonElement meta) {
const string UsersPath = "meta.users[]";
static ulong ParseUserIndex(JsonElement element, int index) {
return ulong.Parse(element.GetString() ?? throw new JsonException("Expected key 'meta.userindex[" + index + "]' to be a string."));
}
Dictionary<ulong, int> userindex = meta.RequireArray("userindex", "meta")
.Select(static (item, index) => (ParseUserIndex(item, index), index))
.ToDictionary();
var userindex = meta.RequireArray("userindex", "meta")
.Select(static (item, index) => (ParseUserIndex(item, index), index))
.ToDictionary();
var users = new User[userindex.Count];
foreach (JsonProperty item in meta.RequireObject("users", "meta").EnumerateObject()) {
string path = UsersPath + "." + item.Name;
ulong userId = ulong.Parse(item.Name);
JsonElement userObj = item.Value;
foreach (var item in meta.RequireObject("users", "meta").EnumerateObject()) {
var path = UsersPath + "." + item.Name;
var userId = ulong.Parse(item.Name);
var userObj = item.Value;
users[userindex[userId]] = new User {
Id = userId,
Name = userObj.RequireString("name", path),
@@ -110,41 +110,41 @@ public static class LegacyArchiveImport {
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 => {
string path = ChannelsPath + "." + item.Name;
ulong channelId = ulong.Parse(item.Name);
JsonElement channelObj = item.Value;
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 => {
string path = ChannelsPath + "." + item.Name;
ulong channelId = ulong.Parse(item.Name);
JsonElement channelObj = item.Value;
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, servers.Length - 1)].Id,
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,
@@ -152,23 +152,23 @@ public static class LegacyArchiveImport {
};
}).ToArray();
}
private static Message[] ReadMessages(JsonElement data, Channel channel, User[] users, FakeSnowflake fakeSnowflake) {
const string DataPath = "data";
ulong channelId = channel.Id;
string channelIdStr = channelId.ToString();
JsonElement? messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : null;
var channelId = channel.Id;
var channelIdStr = channelId.ToString();
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
if (messagesObj == null) {
return [];
}
return messagesObj.Value.EnumerateObject().Select(item => {
string path = DataPath + "." + item.Name;
ulong messageId = ulong.Parse(item.Name);
JsonElement messageObj = item.Value;
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,
@@ -183,14 +183,14 @@ public static class LegacyArchiveImport {
};
}).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 string? contentType) ? contentType : null;
string? type = ContentTypeProvider.TryGetContentType(name, out var contentType) ? contentType : null;
return new Attachment {
Id = fakeSnowflake.Next(),
Name = name,
@@ -204,7 +204,7 @@ public static class LegacyArchiveImport {
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 => {
@@ -216,24 +216,24 @@ public static class LegacyArchiveImport {
Type = type,
Title = type == "rich" && embedObj.HasKey("t") ? embedObj.RequireString("t", path) : null,
Description = type == "rich" && embedObj.HasKey("d") ? embedObj.RequireString("d", path) : null,
Image = type == "image" ? new DiscordEmbedLegacyJson.ImageJson { Url = url } : null,
Image = type == "image" ? new DiscordEmbedLegacyJson.ImageJson { Url = url } : null
};
return new Embed {
Json = JsonSerializer.Serialize(embed, DiscordEmbedLegacyJsonContext.Default.DiscordEmbedLegacyJson),
Json = JsonSerializer.Serialize(embed, DiscordEmbedLegacyJsonContext.Default.DiscordEmbedLegacyJson)
};
});
}
private static IEnumerable<Reaction> ReadMessageReactions(JsonElement.ArrayEnumerator reactionsArray, string path) {
return reactionsArray.Select(reactionObj => {
ulong? id = reactionObj.HasKey("id") ? reactionObj.RequireSnowflake("id", path) : (ulong?) null;
string? name = reactionObj.HasKey("n") ? reactionObj.RequireString("n", path) : null;
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,

View File

@@ -16,26 +16,20 @@ public interface IChannelRepository {
Task<long> Count(CancellationToken cancellationToken = default);
IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken = default);
Task<int> RemoveUnreachable();
internal sealed class Dummy : IChannelRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<Channel> channels) {
return Task.CompletedTask;
}
public Task<long> Count(CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken) {
return AsyncEnumerable.Empty<Channel>();
}
public Task<int> RemoveUnreachable() {
return Task.FromResult(0);
}
}
}

View File

@@ -13,74 +13,62 @@ namespace DHT.Server.Database.Repositories;
public interface IDownloadRepository {
IObservable<long> TotalCount { get; }
Task AddDownload(Data.Download item, Stream? stream);
Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken = default);
Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken = default);
IAsyncEnumerable<Data.Download> Get(DownloadItemFilter? filter = null);
IAsyncEnumerable<Data.Download> Get();
Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor);
Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken = default);
IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken = default);
Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken = default);
Task<int> RetryFailed(CancellationToken cancellationToken = default);
Task Remove(ICollection<string> normalizedUrls);
IAsyncEnumerable<Data.Download> FindAllDownloadableUrls(CancellationToken cancellationToken = default);
internal sealed class Dummy : IDownloadRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task AddDownload(Data.Download item, Stream? stream) {
return Task.CompletedTask;
}
public Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken) {
return Task.FromResult(new DownloadStatusStatistics());
}
public IAsyncEnumerable<Data.Download> Get(DownloadItemFilter? filter) {
public IAsyncEnumerable<Data.Download> Get() {
return AsyncEnumerable.Empty<Data.Download>();
}
public Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor) {
return Task.FromResult(false);
}
public Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) {
return Task.FromResult(false);
}
public IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken) {
return AsyncEnumerable.Empty<DownloadItem>();
}
public Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken) {
return Task.CompletedTask;
}
public Task<int> RetryFailed(CancellationToken cancellationToken) {
return Task.FromResult(0);
}
public Task Remove(ICollection<string> normalizedUrls) {
return Task.CompletedTask;
}
public IAsyncEnumerable<Data.Download> FindAllDownloadableUrls(CancellationToken cancellationToken) {
return AsyncEnumerable.Empty<Data.Download>();
}
}
}

View File

@@ -20,35 +20,29 @@ public interface IMessageRepository {
IAsyncEnumerable<ulong> GetIds(MessageFilter? filter = null);
Task<int> Remove(MessageFilter filter, FilterRemovalMode mode);
Task<int> RemoveUnreachableAttachments();
Task Remove(MessageFilter filter, FilterRemovalMode mode);
internal sealed class Dummy : IMessageRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<Message> messages) {
return Task.CompletedTask;
}
public Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public IAsyncEnumerable<Message> Get(MessageFilter? filter, CancellationToken cancellationToken) {
return AsyncEnumerable.Empty<Message>();
}
public IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
return AsyncEnumerable.Empty<ulong>();
}
public Task<int> Remove(MessageFilter filter, FilterRemovalMode mode) {
return Task.FromResult(0);
}
public Task<int> RemoveUnreachableAttachments() {
return Task.FromResult(0);
public Task Remove(MessageFilter filter, FilterRemovalMode mode) {
return Task.CompletedTask;
}
}
}

View File

@@ -15,26 +15,20 @@ public interface IServerRepository {
Task<long> Count(CancellationToken cancellationToken = default);
IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken = default);
Task<int> RemoveUnreachable();
internal sealed class Dummy : IServerRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<Data.Server> servers) {
return Task.CompletedTask;
}
public Task<long> Count(CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken) {
return AsyncEnumerable.Empty<Data.Server>();
}
public Task<int> RemoveUnreachable() {
return Task.FromResult(0);
}
}
}

View File

@@ -19,11 +19,11 @@ public interface ISettingsRepository {
public Task Set<T>(SettingsKey<T> key, T value) {
return Task.CompletedTask;
}
public Task Set(Func<ISetter, Task> setter) {
return Task.CompletedTask;
}
public Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) {
return Task.FromResult(defaultValue);
}

View File

@@ -16,26 +16,20 @@ public interface IUserRepository {
Task<long> Count(CancellationToken cancellationToken = default);
IAsyncEnumerable<User> Get(CancellationToken cancellationToken = default);
Task<int> RemoveUnreachable();
internal sealed class Dummy : IUserRepository {
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
public Task Add(IReadOnlyList<User> users) {
return Task.CompletedTask;
}
public Task<long> Count(CancellationToken cancellationToken) {
return Task.FromResult(0L);
}
public IAsyncEnumerable<User> Get(CancellationToken cancellationToken) {
return AsyncEnumerable.Empty<User>();
}
public Task<int> RemoveUnreachable() {
return Task.FromResult(0);
}
}
}

View File

@@ -9,22 +9,22 @@ namespace DHT.Server.Database.Sqlite.Repositories;
abstract class BaseSqliteRepository : IDisposable {
private readonly ObservableThrottledTask<long> totalCountTask;
public IObservable<long> TotalCount { get; }
protected BaseSqliteRepository(Log log) {
totalCountTask = new ObservableThrottledTask<long>(log, TaskScheduler.Default);
TotalCount = totalCountTask.DistinctUntilChanged();
UpdateTotalCount();
}
public void Dispose() {
totalCountTask.Dispose();
}
protected void UpdateTotalCount() {
totalCountTask.Post(Count);
}
public abstract Task<long> Count(CancellationToken cancellationToken);
}

View File

@@ -14,11 +14,11 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository
private static readonly Log Log = Log.ForType<SqliteChannelRepository>();
private readonly SqliteConnectionPool pool;
public SqliteChannelRepository(SqliteConnectionPool pool) : base(Log) {
this.pool = pool;
}
public async Task Add(IReadOnlyList<Channel> channels) {
await using (var conn = await pool.Take()) {
await conn.BeginTransactionAsync();
@@ -30,10 +30,10 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository
("parent_id", SqliteType.Integer),
("position", SqliteType.Integer),
("topic", SqliteType.Text),
("nsfw", SqliteType.Integer),
("nsfw", SqliteType.Integer)
]);
foreach (Channel channel in channels) {
foreach (var channel in channels) {
cmd.Set(":id", channel.Id);
cmd.Set(":server", channel.Server);
cmd.Set(":name", channel.Name);
@@ -43,24 +43,24 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository
cmd.Set(":nsfw", channel.Nsfw);
await cmd.ExecuteNonQueryAsync();
}
await conn.CommitTransactionAsync();
}
UpdateTotalCount();
}
public override async Task<long> Count(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
}
public async IAsyncEnumerable<Channel> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return new Channel {
Id = reader.GetUint64(0),
@@ -73,14 +73,4 @@ sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository
};
}
}
public async Task<int> RemoveUnreachable() {
int removed;
await using (var conn = await pool.Take()) {
removed = await conn.ExecuteAsync("DELETE FROM channels WHERE id NOT IN (SELECT DISTINCT channel_id FROM messages)");
}
UpdateTotalCount();
return removed;
}
}

View File

@@ -17,18 +17,16 @@ namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRepository(Log), IDownloadRepository {
private static readonly Log Log = Log.ForType<SqliteDownloadRepository>();
public const string Schema = "downloads";
internal sealed class NewDownloadCollector : IAsyncDisposable {
private readonly SqliteDownloadRepository repository;
private bool hasChanged = false;
private readonly SqliteCommand metadataCmd;
public NewDownloadCollector(SqliteDownloadRepository repository, ISqliteConnection conn) {
this.repository = repository;
metadataCmd = conn.Command(
"""
INSERT INTO download_metadata (normalized_url, download_url, status, type, size)
@@ -52,7 +50,7 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
metadataCmd.Add(":size", SqliteType.Integer);
metadataCmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
}
public async Task Add(Data.Download download) {
metadataCmd.Set(":normalized_url", download.NormalizedUrl);
metadataCmd.Set(":download_url", download.DownloadUrl);
@@ -61,37 +59,19 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
metadataCmd.Set(":size", download.Size);
hasChanged |= await metadataCmd.ExecuteNonQueryAsync() > 0;
}
public void OnCommitted() {
if (hasChanged) {
repository.UpdateTotalCount();
}
}
public async ValueTask DisposeAsync() {
await metadataCmd.DisposeAsync();
}
}
private static SqliteBlob BlobReference(ISqliteConnection conn, long rowid, bool readOnly) {
string schema = conn.HasAttachedDatabase(Schema) ? Schema : "main";
return new SqliteBlob(conn.InnerConnection, databaseName: schema, tableName: "download_blobs", columnName: "blob", rowid, readOnly);
}
public async Task AddDownload(Data.Download item, Stream? stream) {
ulong? actualSize;
if (stream is not null) {
actualSize = (ulong) stream.Length;
if (actualSize != item.Size) {
Log.Warn("Download size differs from its metadata - metadata size: " + item.Size + " B, actual size: " + actualSize + " B, url: " + item.NormalizedUrl);
}
}
else {
actualSize = item.Size;
}
await using (var conn = await pool.Take()) {
await conn.BeginTransactionAsync();
@@ -102,14 +82,14 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
("type", SqliteType.Text),
("size", SqliteType.Integer),
]);
metadataCmd.Set(":normalized_url", item.NormalizedUrl);
metadataCmd.Set(":download_url", item.DownloadUrl);
metadataCmd.Set(":status", (int) item.Status);
metadataCmd.Set(":type", item.Type);
metadataCmd.Set(":size", actualSize);
metadataCmd.Set(":size", item.Size);
await metadataCmd.ExecuteNonQueryAsync();
if (stream == null) {
await using var deleteBlobCmd = conn.Command("DELETE FROM download_blobs WHERE normalized_url = :normalized_url");
deleteBlobCmd.AddAndSet(":normalized_url", SqliteType.Text, item.NormalizedUrl);
@@ -127,35 +107,35 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
);
upsertBlobCmd.AddAndSet(":normalized_url", SqliteType.Text, item.NormalizedUrl);
upsertBlobCmd.AddAndSet(":blob_length", SqliteType.Integer, actualSize);
upsertBlobCmd.AddAndSet(":blob_length", SqliteType.Integer, item.Size);
long rowid = await upsertBlobCmd.ExecuteLongScalarAsync();
await using var blob = BlobReference(conn, rowid, readOnly: false);
await using var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid);
await stream.CopyToAsync(blob);
}
await conn.CommitTransactionAsync();
}
UpdateTotalCount();
}
public override Task<long> Count(CancellationToken cancellationToken) {
return Count(filter: null, cancellationToken);
}
public async Task<long> Count(DownloadItemFilter? filter, CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM download_metadata" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
}
public async Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken) {
nonSkippedFilter.IncludeStatuses = null;
nonSkippedFilter.ExcludeStatuses = null;
string nonSkippedFilterConditions = nonSkippedFilter.GenerateConditions().Build();
await using var conn = await pool.Take();
await using var cmd = conn.Command(
$"""
SELECT
@@ -174,17 +154,17 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
FROM download_metadata
"""
);
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken)) {
return new DownloadStatusStatistics();
}
return new DownloadStatusStatistics {
PendingCount = reader.GetInt32(0),
PendingTotalSize = reader.GetUint64(1),
@@ -197,53 +177,53 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
FailedWithUnknownSizeCount = reader.GetInt32(8),
SkippedCount = reader.GetInt32(9),
SkippedTotalSize = reader.GetUint64(10),
SkippedWithUnknownSizeCount = reader.GetInt32(11),
SkippedWithUnknownSizeCount = reader.GetInt32(11)
};
}
public async IAsyncEnumerable<Data.Download> Get(DownloadItemFilter? filter) {
public async IAsyncEnumerable<Data.Download> Get() {
await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT normalized_url, download_url, status, type, size FROM download_metadata" + filter.GenerateConditions().BuildWhereClause());
await using var cmd = conn.Command("SELECT normalized_url, download_url, status, type, size FROM download_metadata");
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync()) {
string normalizedUrl = reader.GetString(0);
string downloadUrl = reader.GetString(1);
var status = (DownloadStatus) reader.GetInt32(2);
string? type = reader.IsDBNull(3) ? null : reader.GetString(3);
ulong? size = reader.IsDBNull(4) ? null : reader.GetUint64(4);
yield return new Data.Download(normalizedUrl, downloadUrl, status, type, size);
}
}
public async Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor) {
await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT rowid FROM download_blobs WHERE normalized_url = :normalized_url");
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
long rowid;
await using (SqliteDataReader reader = await cmd.ExecuteReaderAsync()) {
await using (var reader = await cmd.ExecuteReaderAsync()) {
if (!await reader.ReadAsync()) {
return false;
}
rowid = reader.GetInt64(0);
}
await using (var blob = BlobReference(conn, rowid, readOnly: true)) {
await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) {
await dataProcessor(blob);
}
return true;
}
public async Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) {
await using var conn = await pool.Take();
await using var cmd = conn.Command(
"""
SELECT dm.download_url, dm.type, db.rowid FROM download_metadata dm
@@ -251,10 +231,10 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
WHERE dm.normalized_url = :normalized_url AND dm.status = :success IS NOT NULL
"""
);
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
string downloadUrl;
string? type;
long rowid;
@@ -263,77 +243,77 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
if (!await reader.ReadAsync(cancellationToken)) {
return false;
}
downloadUrl = reader.GetString(0);
type = reader.IsDBNull(1) ? null : reader.GetString(1);
rowid = reader.GetInt64(2);
}
await using (var blob = BlobReference(conn, rowid, readOnly: true)) {
await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) {
await dataProcessor(new Data.Download(normalizedUrl, downloadUrl, DownloadStatus.Success, type, (ulong) blob.Length), blob, cancellationToken);
}
return true;
}
public async IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
filter.IncludeStatuses = [DownloadStatus.Pending];
filter.ExcludeStatuses = null;
var found = new List<DownloadItem>();
await using var conn = await pool.Take();
string sql = $"""
SELECT normalized_url, download_url, type, size
FROM download_metadata
{filter.GenerateConditions().BuildWhereClause()}
LIMIT :limit
""";
var sql = $"""
SELECT normalized_url, download_url, type, size
FROM download_metadata
{filter.GenerateConditions().BuildWhereClause()}
LIMIT :limit
""";
await using (var cmd = conn.Command(sql)) {
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(val1: 0, count));
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count));
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
found.Add(new DownloadItem {
NormalizedUrl = reader.GetString(0),
DownloadUrl = reader.GetString(1),
Type = reader.IsDBNull(2) ? null : reader.GetString(2),
Size = reader.IsDBNull(3) ? null : reader.GetUint64(3),
Size = reader.IsDBNull(3) ? null : reader.GetUint64(3)
});
}
}
if (found.Count != 0) {
await using var cmd = conn.Command("UPDATE download_metadata SET status = :downloading WHERE normalized_url = :normalized_url AND status = :pending");
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
cmd.Add(":normalized_url", SqliteType.Text);
foreach (DownloadItem item in found) {
foreach (var item in found) {
cmd.Set(":normalized_url", item.NormalizedUrl);
if (await cmd.ExecuteNonQueryAsync(cancellationToken) == 1) {
yield return item;
}
}
}
}
public async Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
await using var cmd = conn.Command("UPDATE download_metadata SET status = :pending WHERE status = :downloading");
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
public async Task<int> RetryFailed(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
await using var cmd = conn.Command("UPDATE download_metadata SET status = :pending WHERE status = :generic_error OR (status > :last_custom_code AND status != :success)");
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
cmd.AddAndSet(":generic_error", SqliteType.Integer, (int) DownloadStatus.GenericError);
@@ -341,62 +321,4 @@ sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRep
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
return await cmd.ExecuteNonQueryAsync(cancellationToken);
}
public async Task Remove(ICollection<string> normalizedUrls) {
await using (var conn = await pool.Take()) {
await conn.BeginTransactionAsync();
await using (var cmd = conn.Command("DELETE FROM download_metadata WHERE normalized_url = :normalized_url")) {
cmd.Add(":normalized_url", SqliteType.Text);
foreach (string normalizedUrl in normalizedUrls) {
cmd.Set(":normalized_url", normalizedUrl);
await cmd.ExecuteNonQueryAsync();
}
}
await conn.CommitTransactionAsync();
}
UpdateTotalCount();
}
public async IAsyncEnumerable<Data.Download> FindAllDownloadableUrls([EnumeratorCancellation] CancellationToken cancellationToken = default) {
await using var conn = await pool.Take();
await using (var cmd = conn.Command("SELECT normalized_url, download_url, type, size FROM attachments")) {
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return DownloadLinkExtractor.FromAttachment(reader.GetString(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetString(2), reader.GetUint64(3));
}
}
await using (var cmd = conn.Command("SELECT json FROM message_embeds")) {
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
var result = await DownloadLinkExtractor.TryFromEmbedJson(reader.GetStream(0));
if (result is not null) {
yield return result;
}
}
}
await using (var cmd = conn.Command("SELECT id, avatar_url FROM users WHERE avatar_url IS NOT NULL")) {
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return DownloadLinkExtractor.FromUserAvatar(reader.GetUint64(0), reader.GetString(1));
}
}
await using (var cmd = conn.Command("SELECT DISTINCT emoji_id, emoji_flags FROM message_reactions WHERE emoji_id IS NOT NULL")) {
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return DownloadLinkExtractor.FromEmoji(reader.GetUint64(0), (EmojiFlags) reader.GetInt16(1));
}
}
}
}

View File

@@ -16,32 +16,32 @@ namespace DHT.Server.Database.Sqlite.Repositories;
sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : BaseSqliteRepository(Log), IMessageRepository {
private static readonly Log Log = Log.ForType<SqliteMessageRepository>();
public async Task Add(IReadOnlyList<Message> messages) {
if (messages.Count == 0) {
return;
}
static SqliteCommand DeleteByMessageId(ISqliteConnection conn, string tableName) {
return conn.Delete(tableName, ("message_id", SqliteType.Integer));
}
static async Task ExecuteDeleteByMessageId(SqliteCommand cmd, object id) {
cmd.Set(":message_id", id);
await cmd.ExecuteNonQueryAsync();
}
await using (var conn = await pool.Take()) {
await conn.BeginTransactionAsync();
await using var messageCmd = conn.Upsert("messages", [
("message_id", SqliteType.Integer),
("sender_id", SqliteType.Integer),
("channel_id", SqliteType.Integer),
("text", SqliteType.Text),
("timestamp", SqliteType.Integer),
("timestamp", SqliteType.Integer)
]);
await using var attachmentCmd = conn.Upsert("attachments", [
("attachment_id", SqliteType.Integer),
("name", SqliteType.Text),
@@ -50,79 +50,79 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
("download_url", SqliteType.Text),
("size", SqliteType.Integer),
("width", SqliteType.Integer),
("height", SqliteType.Integer),
("height", SqliteType.Integer)
]);
await using var deleteMessageEditTimestampCmd = DeleteByMessageId(conn, "message_edit_timestamps");
await using var deleteMessageRepliedToCmd = DeleteByMessageId(conn, "message_replied_to");
await using var deleteMessageAttachmentsCmd = DeleteByMessageId(conn, "message_attachments");
await using var deleteMessageEmbedsCmd = DeleteByMessageId(conn, "message_embeds");
await using var deleteMessageReactionsCmd = DeleteByMessageId(conn, "message_reactions");
await using var messageEditTimestampCmd = conn.Insert("message_edit_timestamps", [
("message_id", SqliteType.Integer),
("edit_timestamp", SqliteType.Integer),
("edit_timestamp", SqliteType.Integer)
]);
await using var messageRepliedToCmd = conn.Insert("message_replied_to", [
("message_id", SqliteType.Integer),
("replied_to_id", SqliteType.Integer),
("replied_to_id", SqliteType.Integer)
]);
await using var messageAttachmentCmd = conn.Insert("message_attachments", [
("message_id", SqliteType.Integer),
("attachment_id", SqliteType.Integer),
("attachment_id", SqliteType.Integer)
]);
await using var messageEmbedCmd = conn.Insert("message_embeds", [
("message_id", SqliteType.Integer),
("json", SqliteType.Text),
("json", SqliteType.Text)
]);
await using var messageReactionCmd = conn.Insert("message_reactions", [
("message_id", SqliteType.Integer),
("emoji_id", SqliteType.Integer),
("emoji_name", SqliteType.Text),
("emoji_flags", SqliteType.Integer),
("count", SqliteType.Integer),
("count", SqliteType.Integer)
]);
await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn);
foreach (Message message in messages) {
foreach (var message in messages) {
object messageId = message.Id;
messageCmd.Set(":message_id", messageId);
messageCmd.Set(":sender_id", message.Sender);
messageCmd.Set(":channel_id", message.Channel);
messageCmd.Set(":text", message.Text);
messageCmd.Set(":timestamp", message.Timestamp);
await messageCmd.ExecuteNonQueryAsync();
await ExecuteDeleteByMessageId(deleteMessageEditTimestampCmd, messageId);
await ExecuteDeleteByMessageId(deleteMessageRepliedToCmd, messageId);
await ExecuteDeleteByMessageId(deleteMessageAttachmentsCmd, messageId);
await ExecuteDeleteByMessageId(deleteMessageEmbedsCmd, messageId);
await ExecuteDeleteByMessageId(deleteMessageReactionsCmd, messageId);
if (message.EditTimestamp is {} timestamp) {
messageEditTimestampCmd.Set(":message_id", messageId);
messageEditTimestampCmd.Set(":edit_timestamp", timestamp);
await messageEditTimestampCmd.ExecuteNonQueryAsync();
}
if (message.RepliedToId is {} repliedToId) {
messageRepliedToCmd.Set(":message_id", messageId);
messageRepliedToCmd.Set(":replied_to_id", repliedToId);
await messageRepliedToCmd.ExecuteNonQueryAsync();
}
if (!message.Attachments.IsEmpty) {
foreach (Attachment attachment in message.Attachments) {
foreach (var attachment in message.Attachments) {
object attachmentId = attachment.Id;
attachmentCmd.Set(":attachment_id", attachmentId);
attachmentCmd.Set(":name", attachment.Name);
attachmentCmd.Set(":type", attachment.Type);
@@ -132,92 +132,92 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
attachmentCmd.Set(":width", attachment.Width);
attachmentCmd.Set(":height", attachment.Height);
await attachmentCmd.ExecuteNonQueryAsync();
messageAttachmentCmd.Set(":message_id", messageId);
messageAttachmentCmd.Set(":attachment_id", attachmentId);
await messageAttachmentCmd.ExecuteNonQueryAsync();
await downloadCollector.Add(DownloadLinkExtractor.FromAttachment(attachment));
}
}
if (!message.Embeds.IsEmpty) {
foreach (Embed embed in message.Embeds) {
foreach (var embed in message.Embeds) {
messageEmbedCmd.Set(":message_id", messageId);
messageEmbedCmd.Set(":json", embed.Json);
await messageEmbedCmd.ExecuteNonQueryAsync();
if (DownloadLinkExtractor.TryFromEmbedJson(embed.Json) is {} download) {
await downloadCollector.Add(download);
}
}
}
if (!message.Reactions.IsEmpty) {
foreach (Reaction reaction in message.Reactions) {
foreach (var reaction in message.Reactions) {
messageReactionCmd.Set(":message_id", messageId);
messageReactionCmd.Set(":emoji_id", reaction.EmojiId);
messageReactionCmd.Set(":emoji_name", reaction.EmojiName);
messageReactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
messageReactionCmd.Set(":count", reaction.Count);
await messageReactionCmd.ExecuteNonQueryAsync();
if (reaction.EmojiId is {} emojiId) {
await downloadCollector.Add(DownloadLinkExtractor.FromEmoji(emojiId, reaction.EmojiFlags));
}
}
}
}
await conn.CommitTransactionAsync();
downloadCollector.OnCommitted();
}
UpdateTotalCount();
}
public override Task<long> Count(CancellationToken cancellationToken) {
return Count(filter: null, cancellationToken);
}
public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
}
private sealed class MessageToManyCommand<T> : IAsyncDisposable {
private readonly SqliteCommand cmd;
private readonly Func<SqliteDataReader, T> readItem;
public MessageToManyCommand(ISqliteConnection conn, string sql, Func<SqliteDataReader, T> readItem) {
this.cmd = conn.Command(sql);
this.cmd.Add(":message_id", SqliteType.Integer);
this.readItem = readItem;
}
public async Task<ImmutableList<T>> GetItems(ulong messageId) {
cmd.Set(":message_id", messageId);
var items = ImmutableList<T>.Empty;
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync()) {
items = items.Add(readItem(reader));
}
return items;
}
public async ValueTask DisposeAsync() {
await cmd.DisposeAsync();
}
}
public async IAsyncEnumerable<Message> Get(MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
await using var conn = await pool.Take();
const string AttachmentSql =
"""
SELECT attachment_id, name, type, normalized_url, download_url, size, width, height
@@ -225,7 +225,7 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
JOIN message_attachments USING (attachment_id)
WHERE message_attachments.message_id = :message_id
""";
await using var attachmentCmd = new MessageToManyCommand<Attachment>(conn, AttachmentSql, static reader => new Attachment {
Id = reader.GetUint64(0),
Name = reader.GetString(1),
@@ -236,32 +236,32 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
Width = reader.IsDBNull(6) ? null : reader.GetInt32(6),
Height = reader.IsDBNull(7) ? null : reader.GetInt32(7),
});
const string EmbedSql =
"""
SELECT json
FROM message_embeds
WHERE message_id = :message_id
""";
await using var embedCmd = new MessageToManyCommand<Embed>(conn, EmbedSql, static reader => new Embed {
Json = reader.GetString(0),
Json = reader.GetString(0)
});
const string ReactionSql =
"""
SELECT emoji_id, emoji_name, emoji_flags, count
FROM message_reactions
WHERE message_id = :message_id
""";
await using var reactionsCmd = new MessageToManyCommand<Reaction>(conn, ReactionSql, static reader => new Reaction {
EmojiId = reader.IsDBNull(0) ? null : reader.GetUint64(0),
EmojiName = reader.IsDBNull(1) ? null : reader.GetString(1),
EmojiFlags = (EmojiFlags) reader.GetInt16(2),
Count = reader.GetInt32(3),
});
await using var messageCmd = conn.Command(
$"""
SELECT m.message_id, m.sender_id, m.channel_id, m.text, m.timestamp, met.edit_timestamp, mrt.replied_to_id
@@ -271,12 +271,12 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
{filter.GenerateConditions("m").BuildWhereClause()}
"""
);
await using var reader = await messageCmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
ulong messageId = reader.GetUint64(0);
yield return new Message {
Id = messageId,
Sender = reader.GetUint64(1),
@@ -287,40 +287,33 @@ sealed class SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRe
RepliedToId = reader.IsDBNull(6) ? null : reader.GetUint64(6),
Attachments = await attachmentCmd.GetItems(messageId),
Embeds = await embedCmd.GetItems(messageId),
Reactions = await reactionsCmd.GetItems(messageId),
Reactions = await reactionsCmd.GetItems(messageId)
};
}
}
public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateConditions().BuildWhereClause());
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync()) {
yield return reader.GetUint64(0);
}
}
public async Task<int> Remove(MessageFilter filter, FilterRemovalMode mode) {
int removed;
public async Task Remove(MessageFilter filter, FilterRemovalMode mode) {
await using (var conn = await pool.Take()) {
removed = await conn.ExecuteAsync(
$"""
-- noinspection SqlWithoutWhere
DELETE FROM messages
{filter.GenerateConditions(invert: mode == FilterRemovalMode.KeepMatching).BuildWhereClause()}
"""
);
await conn.ExecuteAsync(
$"""
-- noinspection SqlWithoutWhere
DELETE FROM messages
{filter.GenerateConditions(invert: mode == FilterRemovalMode.KeepMatching).BuildWhereClause()}
"""
);
}
UpdateTotalCount();
return removed;
}
public async Task<int> RemoveUnreachableAttachments() {
await using var conn = await pool.Take();
return await conn.ExecuteAsync("DELETE FROM attachments WHERE attachment_id NOT IN (SELECT DISTINCT attachment_id FROM message_attachments)");
}
}

View File

@@ -14,11 +14,11 @@ sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository {
private static readonly Log Log = Log.ForType<SqliteServerRepository>();
private readonly SqliteConnectionPool pool;
public SqliteServerRepository(SqliteConnectionPool pool) : base(Log) {
this.pool = pool;
}
public async Task Add(IReadOnlyList<Data.Server> servers) {
await using (var conn = await pool.Take()) {
await conn.BeginTransactionAsync();
@@ -26,33 +26,33 @@ sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository {
await using var cmd = conn.Upsert("servers", [
("id", SqliteType.Integer),
("name", SqliteType.Text),
("type", SqliteType.Text),
("type", SqliteType.Text)
]);
foreach (Data.Server server in servers) {
foreach (var server in servers) {
cmd.Set(":id", server.Id);
cmd.Set(":name", server.Name);
cmd.Set(":type", ServerTypes.ToString(server.Type));
await cmd.ExecuteNonQueryAsync();
}
await conn.CommitTransactionAsync();
}
UpdateTotalCount();
}
public override async Task<long> Count(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
}
public async IAsyncEnumerable<Data.Server> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT id, name, type FROM servers");
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return new Data.Server {
Id = reader.GetUint64(0),
@@ -61,14 +61,4 @@ sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository {
};
}
}
public async Task<int> RemoveUnreachable() {
int removed;
await using (var conn = await pool.Take()) {
removed = await conn.ExecuteAsync("DELETE FROM servers WHERE id NOT IN (SELECT DISTINCT server FROM channels)");
}
UpdateTotalCount();
return removed;
}
}

View File

@@ -11,7 +11,7 @@ sealed class SqliteSettingsRepository(SqliteConnectionPool pool) : ISettingsRepo
public Task Set<T>(SettingsKey<T> key, T value) {
return Set(setter => setter.Set(key, value));
}
public async Task Set(Func<ISettingsRepository.ISetter, Task> setter) {
await using var conn = await pool.Take();
await conn.BeginTransactionAsync();
@@ -24,12 +24,12 @@ sealed class SqliteSettingsRepository(SqliteConnectionPool pool) : ISettingsRepo
DO UPDATE SET value = excluded.value
"""
);
cmd.Add(":key", SqliteType.Text);
cmd.Add(":value", SqliteType.Text);
await setter(new Setter(cmd));
await cmd.ExecuteNonQueryAsync();
await conn.CommitTransactionAsync();
}
@@ -41,22 +41,18 @@ sealed class SqliteSettingsRepository(SqliteConnectionPool pool) : ISettingsRepo
await cmd.ExecuteNonQueryAsync();
}
}
public async Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) {
await using var conn = await pool.Take();
return await Get(conn, key, defaultValue);
}
public static async Task<T?> Get<T>(ISqliteConnection conn, SettingsKey<T> key, T? defaultValue) {
string? value;
await using (var cmd = conn.Command("SELECT value FROM metadata WHERE key = :key")) {
await using (var conn = await pool.Take()) {
await using var cmd = conn.Command("SELECT value FROM metadata WHERE key = :key");
cmd.AddAndSet(":key", SqliteType.Text, key.Key);
await using var reader = await cmd.ExecuteReaderAsync();
value = await reader.ReadAsync() ? reader.GetString(0) : null;
}
return value != null && key.FromString(value, out T convertedValue) ? convertedValue : defaultValue;
return value != null && key.FromString(value, out var convertedValue) ? convertedValue : defaultValue;
}
}

View File

@@ -16,57 +16,57 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
private readonly SqliteConnectionPool pool;
private readonly SqliteDownloadRepository downloads;
public SqliteUserRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : base(Log) {
this.pool = pool;
this.downloads = downloads;
}
public async Task Add(IReadOnlyList<User> users) {
await using (var conn = await pool.Take()) {
await conn.BeginTransactionAsync();
await using var cmd = conn.Upsert("users", [
("id", SqliteType.Integer),
("name", SqliteType.Text),
("display_name", SqliteType.Text),
("avatar_url", SqliteType.Text),
("discriminator", SqliteType.Text),
("discriminator", SqliteType.Text)
]);
await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn);
foreach (User user in users) {
foreach (var user in users) {
cmd.Set(":id", user.Id);
cmd.Set(":name", user.Name);
cmd.Set(":display_name", user.DisplayName);
cmd.Set(":avatar_url", user.AvatarUrl);
cmd.Set(":discriminator", user.Discriminator);
await cmd.ExecuteNonQueryAsync();
if (user.AvatarUrl is {} avatarUrl) {
await downloadCollector.Add(DownloadLinkExtractor.FromUserAvatar(user.Id, avatarUrl));
}
}
await conn.CommitTransactionAsync();
downloadCollector.OnCommitted();
}
UpdateTotalCount();
}
public override async Task<long> Count(CancellationToken cancellationToken) {
await using var conn = await pool.Take();
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM users", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
}
public async IAsyncEnumerable<User> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
await using var conn = await pool.Take();
await using var cmd = conn.Command("SELECT id, name, display_name, avatar_url, discriminator FROM users");
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken)) {
yield return new User {
Id = reader.GetUint64(0),
@@ -77,14 +77,4 @@ sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
};
}
}
public async Task<int> RemoveUnreachable() {
int removed;
await using (var conn = await pool.Take()) {
removed = await conn.ExecuteAsync("DELETE FROM users WHERE id NOT IN (SELECT DISTINCT sender_id FROM messages)");
}
UpdateTotalCount();
return removed;
}
}

View File

@@ -1,14 +1,12 @@
using System;
using System.Threading.Tasks;
using DHT.Server.Database.Sqlite.Utils;
namespace DHT.Server.Database.Sqlite.Schema;
public interface ISchemaUpgradeCallbacks {
Task<InitialDatabaseSettings?> GetInitialDatabaseSettings();
Task<bool> CanUpgrade();
Task Start(int versionSteps, Func<IProgressReporter, Task> doUpgrade);
public interface IProgressReporter {
Task NextVersion();
Task MainWork(string message, int finishedItems, int totalItems);

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