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

36 Commits
v42.0 ... v45.0

Author SHA1 Message Date
50d5bdba97 Release v45.0 2025-02-07 16:44:16 +01:00
295c32c7b4 Remove some runtimes from the portable build 2025-02-06 14:12:37 +01:00
5e4bb5b44d Remove osx-x64 from dedicated runtime builds 2025-02-06 14:11:05 +01:00
96c19afa66 Improve reproducibility of builds 2025-02-06 13:36:13 +01:00
a45f60b528 Update to .NET 9 2025-02-06 13:15:11 +01:00
62b97807b8 Update FUNDING.yml 2025-02-06 06:25:39 +01:00
0fff3e8eaf Add option to delete orphaned downloads
Closes #273
2025-02-03 12:29:09 +01:00
02b773db8f Handle uncaught exceptions in progress dialogs 2025-02-03 12:28:07 +01:00
261a583b50 Remove orphaned servers, channels, users, and attachments after removing messages 2025-02-03 12:28:07 +01:00
2ecb658e42 Reformat code 2025-02-03 12:28:07 +01:00
7a6dd23912 Add foreign keys to tables linked to messages 2025-02-03 12:28:07 +01:00
60761d80ab Fix showing wrong keyboard shortcut for opening dev tools on macOS 2025-01-15 03:47:57 +01:00
e11db62015 Update Avalonia to 11.2.3 2024-12-31 10:30:18 +01:00
bfac9b91d9 Release v44.0 2024-12-31 09:23:17 +01:00
4215dc76f4 Add main menu button to check for updates
Closes #165
2024-12-31 08:58:56 +01:00
86adda610b Fix wrong reaction handling in messages with polls
Closes #259
2024-12-31 04:59:33 +01:00
f1c4cd040e Tweak tracking script for easier debugging 2024-12-31 04:55:53 +01:00
737ab43ea4 Add option to hide previews while tracking and autoscrolling 2024-11-05 18:38:49 +01:00
0229c00259 Rewrite tracker to use Discord client functions instead of React properties 2024-10-23 02:01:23 +02:00
95361d2e01 Write downloads to temporary files to reduce blocking concurrent database accesses
References #168
2024-10-22 11:55:27 +02:00
5b7312109b Release v43.1 2024-08-04 21:49:40 +02:00
9f1db9a662 Fix attachment migration error when database contains attachments for deleted messages
Closes #264
2024-08-04 21:47:34 +02:00
d3e1bb901c Release v43.0 2024-07-10 12:28:20 +02:00
9bd105f57e Update About page with a link to issue tracker and libraries 2024-07-10 12:28:20 +02:00
e1eae393c3 Update SQLite provider to 8.0.7 (SQLite version 3.41.2) 2024-07-09 20:39:39 +02:00
fbed74529d Update Avalonia to 11.0.11 2024-07-09 20:39:39 +02:00
8924aa7c06 Ask about automatic downloads when creating a new database 2024-07-09 20:39:39 +02:00
9738880ba7 Remember downloading state and filter settings in database 2024-07-09 20:39:39 +02:00
1700f99bf7 Rename database tables related to messages 2024-07-05 05:02:06 +02:00
84acf5f5d5 Fix error when tracking announcements with attachments across multiple channels
Closes #261
2024-07-04 21:56:23 +02:00
ae56433836 Fix not updating download URLs and metadata when re-tracking messages with failed downloads 2024-07-01 05:45:36 +02:00
c3d4fa5532 Release v42.1 2024-05-09 00:08:15 +02:00
a6225b9721 Hide user discriminators & fix places that still displayed usernames instead of display names
References #216
2024-05-09 00:04:31 +02:00
943163473a Add support for global display names of users
Closes #216
2024-05-08 23:39:13 +02:00
fa00df10d8 Fix viewer menu styling 2024-05-08 21:47:16 +02:00
f54465e5fe Fix viewer compatibility with older browsers and (hopefully) Safari
Closes #260
2024-05-08 21:08:24 +02:00
166 changed files with 3204 additions and 2171 deletions

2
.github/FUNDING.yml vendored
View File

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

View File

@@ -53,7 +53,7 @@
<Setter Property="MinHeight" Value="40" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
</Application.Styles>
<Application.Resources>
@@ -107,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,4 +1,3 @@
using System;
using System.Collections.Generic;
using DHT.Utils.Logging;
@@ -6,33 +5,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 (Array.Empty<string>());
public static Arguments Empty => new ([]);
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";
@@ -44,29 +43,29 @@ sealed class Arguments {
else {
value = args[++i];
}
switch (key) {
case "-db":
DatabaseFile = value;
continue;
case "-port": {
if (!ushort.TryParse(value, out var port)) {
if (!ushort.TryParse(value, out ushort port)) {
Log.Warn("Invalid port number: " + value);
}
else {
ServerPort = port;
}
continue;
}
case "-token":
ServerToken = value;
continue;
case "-concurrentdownloads":
if (!ulong.TryParse(value, out var concurrentDownloads) || concurrentDownloads == 0) {
if (!ulong.TryParse(value, out ulong concurrentDownloads) || concurrentDownloads == 0) {
Log.Warn("Invalid concurrent downloads count: " + value);
}
else if (concurrentDownloads > 10) {
@@ -78,7 +77,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

@@ -0,0 +1,9 @@
using System.Diagnostics;
namespace DHT.Desktop.Common;
static class SystemUtils {
public static void OpenUrl(string url) {
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}

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

@@ -15,14 +15,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.6" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.6" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.3" Condition=" '$(Configuration)' == 'Debug' " />
<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="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
</ItemGroup>
@@ -40,5 +40,5 @@
<LogicalName>tracker-loader.js</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>

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,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
@@ -8,49 +7,49 @@ namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxDialogModel : ObservableObject {
public string Title { get; init; } = "";
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
private IReadOnlyList<CheckBoxItem> items = [];
public IReadOnlyList<CheckBoxItem> Items {
get => items;
protected set {
foreach (var item in items) {
foreach (CheckBoxItem item in items) {
item.PropertyChanged -= OnItemPropertyChanged;
}
items = value;
foreach (var item in items) {
foreach (CheckBoxItem item in items) {
item.PropertyChanged += OnItemPropertyChanged;
}
}
}
private bool pauseCheckEvents = false;
public bool AreAllSelected => Items.All(static item => item.IsChecked);
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 (var item in Items) {
foreach (CheckBoxItem item in Items) {
item.IsChecked = isChecked;
}
pauseCheckEvents = false;
UpdateBulkButtons();
}
private void UpdateBulkButtons() {
OnPropertyChanged(nameof(AreAllSelected));
OnPropertyChanged(nameof(AreNoneSelected));
}
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
UpdateBulkButtons();
@@ -60,11 +59,11 @@ class CheckBoxDialogModel : ObservableObject {
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 = this.Items;
base.Items = Items;
}
}

View File

@@ -5,14 +5,14 @@ namespace DHT.Desktop.Dialogs.CheckBox;
partial class CheckBoxItem : ObservableObject {
public string Title { get; init; } = "";
public object? Item { get; init; } = null;
[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

@@ -11,25 +11,25 @@ static class FileDialogs {
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
}
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
}
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
return new FilePickerFileType(name) {
Patterns = extensions.Select(static ext => "*." + ext).ToArray()
Patterns = extensions.Select(static ext => "*." + ext).ToArray(),
};
}
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
}
private static string ToLocalPath(this IStorageFile file) {
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
}
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
return files.Select(ToLocalPath).ToArray();
}

View File

@@ -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
}
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNo();
}
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
}
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true,
IsCancelVisible = true
}
},
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNo();
}
public static async Task<DialogResult.YesNoCancel> ShowYesNoCancel(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) {
return await Dispatcher.UIThread.InvokeAsync(() => ShowYesNoCancel(owner, title, message));
}
var result = await new MessageDialog {
DataContext = new MessageDialogModel {
Title = title,
Message = message,
IsYesVisible = true,
IsNoVisible = true,
IsCancelVisible = true,
},
}.ShowDialog<DialogResult.All?>(owner);
return result.ToYesNoCancel();
}
}

View File

@@ -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

@@ -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

@@ -2,11 +2,15 @@ using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
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 taskCompletionSource = new TaskCompletionSource();
var dialog = new ProgressDialog();
@@ -45,7 +49,7 @@ public sealed partial class ProgressDialog : Window {
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
var taskCompletionSource = new TaskCompletionSource<T>();
var dialog = new ProgressDialog();
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
await callbacks[0].UpdateIndeterminate(message);
try {
@@ -58,32 +62,37 @@ public sealed partial class ProgressDialog : Window {
await dialog.ShowProgressDialog(owner);
return await taskCompletionSource.Task;
}
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;
try {
await progressTask;
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(owner, "Unexpected Error", e.Message);
}
}
}

View File

@@ -9,35 +9,35 @@ namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressDialogModel {
public string Title { get; init; } = "";
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
public IReadOnlyList<ProgressItem> Items { get; } = [];
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(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
this.Items = Enumerable.Range(start: 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, int finishedItems, int totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => {
item.Message = message;
@@ -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, 0, 0);
return Update(string.Empty, finishedItems: 0, totalItems: 0);
}
}
}

View File

@@ -6,11 +6,11 @@ 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 {
@@ -18,13 +18,13 @@ sealed partial class ProgressItem : ObservableObject {
IsVisible = !string.IsNullOrEmpty(value);
}
}
[ObservableProperty]
private string items = "";
[ObservableProperty]
private int progress = 0;
[ObservableProperty]
private bool isIndeterminate;
}

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,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
@@ -9,27 +8,27 @@ namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxDialogModel : ObservableObject {
public string Title { get; init; } = "";
public string Description { get; init; } = "";
private IReadOnlyList<TextBoxItem> items = Array.Empty<TextBoxItem>();
private IReadOnlyList<TextBoxItem> items = [];
public IReadOnlyList<TextBoxItem> Items {
get => items;
protected set {
foreach (var item in items) {
foreach (TextBoxItem item in items) {
item.ErrorsChanged -= OnItemErrorsChanged;
}
items = value;
foreach (var item in items) {
foreach (TextBoxItem item in items) {
item.ErrorsChanged += OnItemErrorsChanged;
}
}
}
public bool HasErrors => Items.Any(static item => !item.IsValid);
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
OnPropertyChanged(nameof(HasErrors));
}
@@ -37,9 +36,9 @@ class TextBoxDialogModel : ObservableObject {
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

@@ -8,33 +8,33 @@ namespace DHT.Desktop.Dialogs.TextBox;
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);
private string value = string.Empty;
public string Value {
get => this.value;
get => 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 {
var settingsJson = await ReadSettingsJson();
JsonObject 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 var node) && node?.GetValueKind() == JsonValueKind.True;
return json.TryGetPropertyValue(JsonKeyDevTools, out JsonNode? node) && node?.GetValueKind() == JsonValueKind.True;
}
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
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, true);
File.Move(JsonBackupFilePath, JsonFilePath, overwrite: 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="480" d:DesignHeight="295"
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="480" Height="295" CanResize="False"
Width="480" Height="360" CanResize="False"
WindowStartupLocation="CenterOwner">
<Design.DataContext>
@@ -39,10 +39,11 @@
<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" ColumnDefinitions="175,125,*" 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>
@@ -55,13 +56,21 @@
<TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">NuGet</Button>
<TextBlock Grid.Row="4" Grid.Column="0">SQLite</TextBlock>
<TextBlock Grid.Row="4" Grid.Column="1">Public Domain</TextBlock>
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
<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 ShowLibraryCommunityToolkit}">GitHub</Button>
<TextBlock Grid.Row="5" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
<TextBlock Grid.Row="5" Grid.Column="1">Apache-2.0</TextBlock>
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</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}">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">Rx.NET</TextBlock>
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button>
</Grid>
</StackPanel>

View File

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

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.ReactiveUI;
@@ -8,69 +9,116 @@ 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;
namespace DHT.Desktop.Main.Controls;
sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposable {
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", 1),
new Unit("kB", 1024),
new Unit("MB", 1024 * 1024)
new Unit("B", Scale: 1),
new Unit("kB", Scale: 1024),
new Unit("MB", Scale: 1024 * 1024)
];
private static readonly HashSet<string> FilterProperties = [
nameof(LimitSize),
nameof(MaximumSize),
nameof(MaximumSizeUnit)
nameof(MaximumSizeUnit),
];
public string FilterStatisticsText { get; private set; } = "";
[ObservableProperty]
private bool limitSize = false;
[ObservableProperty]
private ulong maximumSize = 0L;
private ulong maximumSize = 0UL;
[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 void Dispose() {
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) {
MaximumSizeUnit = unitValue;
}
isLoadingFilterSettings = false;
}
public async ValueTask DisposeAsync() {
saveFilterSettingsTask.Dispose();
downloadItemCountTask.Cancel();
downloadItemCountSubscription.Dispose();
await SaveFilterSettings(new FilterSettings(this));
}
private sealed record FilterSettings(bool LimitSize, ulong MaximumSize, Unit MaximumSizeUnit) {
public FilterSettings(DownloadItemFilterPanelModel model) : this(model.LimitSize, model.MaximumSize, model.MaximumSizeUnit) {}
}
private async Task SaveFilterSettings(FilterSettings settings) {
try {
await state.Db.Settings.Set(async setter => {
await setter.Set(SettingsKey.DownloadsLimitSize, settings.LimitSize);
await setter.Set(SettingsKey.DownloadsMaximumSize, settings.MaximumSize);
await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name);
});
} catch (Exception e) {
Log.Error("Could not save download filter settings");
Log.Error(e);
}
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
if (!isLoadingFilterSettings) {
saveFilterSettingsTask.Post(new FilterSettings(this));
}
UpdateFilterStatistics();
}
}
@@ -80,9 +128,9 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl
UpdateFilterStatistics();
}
private void UpdateFilterStatistics() {
var filter = CreateFilter();
DownloadItemFilter filter = CreateFilter();
if (filter.IsEmpty) {
downloadItemCountTask.Cancel();
matchingItemCount = totalItemCount;
@@ -94,23 +142,23 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
}
}
private void SetMatchingCount(long matchingAttachmentCount) {
this.matchingItemCount = matchingAttachmentCount;
UpdateFilterStatisticsText();
}
private void UpdateFilterStatisticsText() {
var matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
var totalItemCountStr = totalItemCount?.Format() ?? "(...)";
string matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
string 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;
@@ -118,7 +166,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl
// set no size limit, because the overflown size is larger than any file could possibly be
}
}
return filter;
}
}

View File

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

@@ -27,46 +27,46 @@ sealed partial class MessageFilterPanelModel : ObservableObject, 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;
[ObservableProperty]
private bool filterByDate = false;
[ObservableProperty]
private DateTime? startDate = null;
[ObservableProperty]
private DateTime? endDate = null;
[ObservableProperty]
private bool filterByChannel = false;
[ObservableProperty]
private HashSet<ulong>? includedChannels = null;
[ObservableProperty]
private bool filterByUser = false;
[ObservableProperty]
private HashSet<ulong>? includedUsers = null;
[ObservableProperty]
private string channelFilterLabel = "";
[ObservableProperty]
private string userFilterLabel = "";
private readonly Window window;
private readonly State state;
private readonly string verb;
private readonly RestartableTask<long> exportedMessageCountTask;
private long? exportedMessageCount;
@@ -78,28 +78,28 @@ sealed partial class MessageFilterPanelModel : ObservableObject, 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();
@@ -107,13 +107,13 @@ sealed partial class MessageFilterPanelModel : ObservableObject, 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();
}
@@ -121,12 +121,12 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
UpdateUserFilterLabel();
}
}
private void OnMessageCountChanged(long newMessageCount) {
totalMessageCount = newMessageCount;
UpdateFilterStatistics();
}
private void OnChannelCountChanged(long newChannelCount) {
totalChannelCount = newChannelCount;
UpdateChannelFilterLabel();
@@ -136,7 +136,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
totalUserCount = newUserCount;
UpdateUserFilterLabel();
}
private void UpdateChannelFilterLabel() {
if (totalChannelCount.HasValue) {
long total = totalChannelCount.Value;
@@ -147,7 +147,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
ChannelFilterLabel = "Loading...";
}
}
private void UpdateUserFilterLabel() {
if (totalUserCount.HasValue) {
long total = totalUserCount.Value;
@@ -158,9 +158,9 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
UserFilterLabel = "Loading...";
}
}
private void UpdateFilterStatistics() {
var filter = CreateFilter();
MessageFilter filter = CreateFilter();
if (filter.IsEmpty) {
exportedMessageCountTask.Cancel();
exportedMessageCount = totalMessageCount;
@@ -172,38 +172,38 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
}
}
private void SetExportedMessageCount(long exportedMessageCount) {
this.exportedMessageCount = exportedMessageCount;
UpdateFilterStatisticsText();
}
private void UpdateFilterStatisticsText() {
var exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
var totalMessageCountStr = totalMessageCount?.Format() ?? "(...)";
string exportedMessageCountStr = exportedMessageCount?.Format() ?? "(...)";
string 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>>();
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;
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;
string title;
if (servers.TryGetValue(channel.Server, out var server)) {
var titleBuilder = new StringBuilder();
var serverType = server.Type;
ServerType? serverType = server.Type;
titleBuilder.Append('[')
.Append(ServerTypes.ToString(serverType))
.Append("] ");
if (serverType == ServerType.DirectMessage) {
titleBuilder.Append(channelName);
}
@@ -212,24 +212,24 @@ sealed partial class MessageFilterPanelModel : ObservableObject, 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);
@@ -237,32 +237,29 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
return;
}
var result = await OpenIdFilterDialog(Title, items);
HashSet<ulong>? 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 (var user in state.Db.Users.Get()) {
var name = user.Name;
var discriminator = user.Discriminator;
await foreach (User user in state.Db.Users.Get()) {
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
Title = discriminator == null ? name : name + " #" + discriminator,
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
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);
@@ -270,42 +267,42 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
return;
}
var result = await OpenIdFilterDialog(Title, items);
HashSet<ulong>? 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

@@ -16,6 +16,10 @@
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="Label">
<Setter Property="Margin" Value="0 5" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />

View File

@@ -17,24 +17,24 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputPort;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
private string inputToken;
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
[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 : ObservableObject, IDisposab
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(nameof(ToggleServerButtonText));
}
private async Task StartServer() {
IsToggleServerButtonEnabled = false;
@@ -69,7 +69,7 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
private async Task StopServer() {
IsToggleServerButtonEnabled = false;
@@ -83,7 +83,7 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
public async Task OnClickToggleServerButton() {
if (server.IsRunning) {
await StopServer();
@@ -92,17 +92,17 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
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;
@@ -110,7 +110,7 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
await StartServer();
}
public void OnClickCancelChanges() {
InputPort = ServerConfiguration.Port.ToString();
InputToken = ServerConfiguration.Token;

View File

@@ -17,38 +17,38 @@ sealed partial class StatusBarModel : ObservableObject, IDisposable {
[ObservableProperty(Setter = Access.Private)]
private long? messageCount;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
private ServerManager.Status serverStatus;
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 += OnServerStatusChanged;
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
}
public void Dispose() {
serverCountSubscription.Dispose();
channelCountSubscription.Dispose();
@@ -56,7 +56,7 @@ sealed partial class StatusBarModel : ObservableObject, IDisposable {
state.Server.StatusChanged -= OnServerStatusChanged;
}
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
}

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

@@ -15,47 +15,47 @@ namespace DHT.Desktop.Main;
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
private const string DefaultTitle = "Discord History Tracker";
private static readonly Log Log = Log.ForType<MainWindowModel>();
[ObservableProperty(Setter = Access.Private)]
private string title = DefaultTitle;
[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;
var dbFile = args.DatabaseFile;
string? 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,68 +63,73 @@ sealed partial class MainWindowModel : ObservableObject, 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;
await mainContentScreenModel.Initialize();
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
window.Focus();
}
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
if (mainContentScreenModel != null) {
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
mainContentScreenModel.Dispose();
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() {
mainContentScreenModel?.Dispose();
if (mainContentScreenModel != null) {
await mainContentScreenModel.DisposeAsync();
}
await DisposeState();
}
}

View File

@@ -13,21 +13,21 @@ sealed class AdvancedPageModel : IDisposable {
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());

View File

@@ -25,69 +25,69 @@ 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", new string[] { folder });
break;
case PlatformID.MacOSX:
Process.Start("open", new string[] { folder });
break;
default:
await Dialog.ShowOk(window, "Feature Not Supported", "This feature is not supported for your operating system.");
break;
}
}
public void CloseDatabase() {
DatabaseClosed?.Invoke(this, EventArgs.Empty);
}
public async Task MergeWithDatabase() {
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
string[] paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
if (paths.Length > 0) {
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
}
}
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
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;
@@ -96,60 +96,60 @@ sealed class DatabasePageModel {
}
});
}
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly ProgressDialog dialog;
private readonly int total;
private bool? decision;
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
this.total = total;
this.dialog = dialog;
}
public async Task<bool> CanUpgrade() {
return decision ??= (total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
}
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
return doUpgrade(new NullReporter());
}
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
public Task NextVersion() {
return Task.CompletedTask;
}
public Task MainWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
public Task SubWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
}
}
public async Task ImportLegacyArchive() {
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
string[] 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) {
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
}
}
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
var fakeSnowflake = new FakeSnowflake();
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
@@ -159,54 +159,54 @@ sealed class DatabasePageModel {
});
});
}
private static async Task<Dictionary<DHT.Server.Data.Server, ulong>?> AskForServerIds(Window window, DHT.Server.Data.Server[] servers) {
static bool IsValidSnowflake(string value) {
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
}
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
foreach (DHT.Server.Data.Server server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
items.Add(new TextBoxItem<DHT.Server.Data.Server>(server) {
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 PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
int total = paths.Length;
var oldStatistics = await DatabaseStatistics.Take(target);
DatabaseStatistics 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, errorDialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
continue;
}
try {
if (await performImport(path)) {
++successful;
@@ -216,18 +216,18 @@ sealed class DatabasePageModel {
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
}
}
await callback.Update("Done", finished, total);
if (successful == 0) {
await Dialog.ShowOk(dialog, neutralDialogTitle, "Nothing was imported.");
return;
}
var newStatistics = await DatabaseStatistics.Take(target);
DatabaseStatistics 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(
@@ -238,29 +238,29 @@ sealed class DatabasePageModel {
);
}
}
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
StringBuilder message = new StringBuilder();
var message = new StringBuilder();
message.Append("Processed ");
if (successfulItems == totalItems) {
message.Append(successfulItems.Pluralize(itemName));
}
else {
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
}
message.Append(" and added:\n\n \u2022 ");
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
message.Append(newMessages.Pluralize("message"));
return message.ToString();
}
}

View File

@@ -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>
@@ -16,6 +16,10 @@
<Setter Property="FontFamily" Value="Consolas,Courier" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="Label">
<Setter Property="Margin" Value="0 5" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="WrapPanel > StackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="Margin" Value="0 0 10 10" />

View File

@@ -16,89 +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, 0, batchCount);
await callback.Update("Adding messages in batches of " + BatchSize, finishedItems: 0, batchCount);
var rand = new Random();
var server = new DHT.Server.Data.Server {
Id = RandomId(rand),
Name = RandomName("s"),
Type = ServerType.Server,
};
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
Channel[] channels = Enumerable.Range(start: 0, channelCount).Select(i => new Channel {
Id = RandomId(rand),
Server = server.Id,
Name = RandomName("c"),
ParentId = null,
Position = i,
Topic = RandomText(rand, 10),
Topic = RandomText(rand, maxWords: 10),
Nsfw = rand.Next(4) == 0,
}).ToArray();
var users = Enumerable.Range(0, userCount).Select(_ => new User {
User[] users = Enumerable.Range(start: 0, userCount).Select(_ => new User {
Id = RandomId(rand),
Name = RandomName("u"),
DisplayName = RandomName("u"),
AvatarUrl = null,
Discriminator = rand.Next(0, 9999).ToString(),
Discriminator = rand.Next(minValue: 0, maxValue: 9999).ToString(),
}).ToArray();
await state.Db.Users.Add(users);
await state.Db.Servers.Add([server]);
await state.Db.Channels.Add(channels);
var now = DateTimeOffset.Now;
DateTimeOffset now = DateTimeOffset.Now;
int batchIndex = 0;
while (messageCount > 0) {
int hourOffset = batchIndex;
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
Message[] messages = Enumerable.Range(start: 0, Math.Min(messageCount, BatchSize)).Select(i => {
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
var timeMillis = time.ToUnixTimeMilliseconds();
var editMillis = edit?.ToUnixTimeMilliseconds();
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(minValue: 1, maxValue: 60)) : null;
long timeMillis = time.ToUnixTimeMilliseconds();
long? editMillis = edit?.ToUnixTimeMilliseconds();
return new Message {
Id = (ulong) timeMillis,
Sender = RandomBiasedIndex(rand, users).Id,
Channel = RandomBiasedIndex(rand, channels).Id,
Text = RandomText(rand, 100),
Text = RandomText(rand, maxWords: 100),
Timestamp = timeMillis,
EditTimestamp = editMillis,
RepliedToId = null,
@@ -107,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",
@@ -152,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(), 3));
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
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)]));
}
}
#else

View File

@@ -13,8 +13,8 @@
</Design.DataContext>
<UserControl.Styles>
<Style Selector="Expander">
<Setter Property="Margin" Value="0 5 0 0" />
<Style Selector="WrapPanel > Button">
<Setter Property="Margin" Value="0 0 10 10" />
</Style>
<Style Selector="DataGridColumnHeader">
<Setter Property="FontWeight" Value="Medium" />
@@ -30,10 +30,17 @@
</Style>
</UserControl.Styles>
<StackPanel Orientation="Vertical" Spacing="20">
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
<StackPanel Orientation="Vertical" Spacing="12">
<StackPanel Orientation="Vertical">
<WrapPanel Orientation="Horizontal">
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
<Button Command="{Binding OnClickDeleteOrphanedDownloads}">Delete Orphaned Downloads</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>
<Expander Header="Download Status" IsExpanded="True">
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid.Columns>
@@ -43,9 +50,6 @@
</DataGrid.Columns>
</DataGrid>
</Expander>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
</StackPanel>
</StackPanel>
</StackPanel>
</UserControl>

View File

@@ -1,125 +1,156 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.ReactiveUI;
using CommunityToolkit.Mvvm.ComponentModel;
using DHT.Desktop.Common;
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;
namespace DHT.Desktop.Main.Pages;
sealed partial class DownloadsPageModel : ObservableObject, IDisposable {
sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
[ObservableProperty(Setter = Access.Private)]
private bool isToggleDownloadButtonEnabled = true;
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
private bool isRetryingFailedDownloads = false;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
private bool hasFailedDownloads;
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
[ObservableProperty(Setter = Access.Private)]
private string downloadMessage = "";
public DownloadItemFilterPanelModel FilterModel { get; }
private readonly StatisticsRow statisticsPending = new ("Pending");
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
private readonly StatisticsRow statisticsFailed = new ("Failed");
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
public bool IsDownloading => state.Downloader.IsDownloading;
private readonly Window window;
private readonly State state;
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
private readonly IDisposable downloadItemCountSubscription;
private IDisposable? finishedItemsSubscription;
private DownloadItemFilter? currentDownloadFilter;
public DownloadsPageModel() : this(State.Dummy) {}
public DownloadsPageModel(State state) {
public DownloadsPageModel() : this(null!, State.Dummy) {}
public DownloadsPageModel(Window window, State state) {
this.window = window;
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 void Dispose() {
public async Task Initialize() {
await FilterModel.Initialize();
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, defaultValue: false)) {
await StartDownload();
}
}
public async ValueTask DisposeAsync() {
finishedItemsSubscription?.Dispose();
downloadItemCountSubscription.Dispose();
downloadStatisticsTask.Dispose();
FilterModel.Dispose();
await FilterModel.DisposeAsync();
}
private void OnDownloadCountChanged(long newDownloadCount) {
RecomputeDownloadStatistics();
}
public async Task OnClickToggleDownload() {
IsToggleDownloadButtonEnabled = false;
if (IsDownloading) {
await state.Downloader.Stop();
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null;
currentDownloadFilter = null;
await StopDownload();
}
else {
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
await StartDownload();
}
RecomputeDownloadStatistics();
OnPropertyChanged(nameof(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading));
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());
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(nameof(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading));
}
private void OnItemFinished(DownloadItem item) {
RecomputeDownloadStatistics();
}
public async Task OnClickRetryFailedDownloads() {
IsRetryingFailedDownloads = true;
try {
await state.Db.Downloads.RetryFailed();
RecomputeDownloadStatistics();
@@ -129,20 +160,64 @@ sealed partial class DownloadsPageModel : ObservableObject, IDisposable {
IsRetryingFailedDownloads = false;
}
}
private void RecomputeDownloadStatistics() {
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
}
private const string DeleteOrphanedDownloadsTitle = "Delete Orphaned Downloads";
public async Task OnClickDeleteOrphanedDownloads() {
await ProgressDialog.Show(window, DeleteOrphanedDownloadsTitle, DeleteOrphanedDownloads);
}
private async Task DeleteOrphanedDownloads(ProgressDialog dialog, IProgressCallback callback) {
await callback.UpdateIndeterminate("Searching for orphaned downloads...");
HashSet<string> reachableNormalizedUrls = [];
HashSet<string> orphanedNormalizedUrls = [];
await foreach (Download download in state.Db.Downloads.FindAllDownloadableUrls()) {
reachableNormalizedUrls.Add(download.NormalizedUrl);
}
await foreach (Download download in state.Db.Downloads.Get()) {
string normalizedUrl = download.NormalizedUrl;
if (!reachableNormalizedUrls.Contains(normalizedUrl)) {
orphanedNormalizedUrls.Add(normalizedUrl);
}
}
if (orphanedNormalizedUrls.Count == 0) {
await Dialog.ShowOk(window, DeleteOrphanedDownloadsTitle, "No orphaned downloads found.");
return;
}
if (await Dialog.ShowYesNo(window, DeleteOrphanedDownloadsTitle, orphanedNormalizedUrls.Count + " orphaned download(s) will be removed from this database. This action cannot be undone. Proceed?") != DialogResult.YesNo.Yes) {
return;
}
await callback.UpdateIndeterminate("Deleting orphaned downloads...");
await state.Db.Downloads.Remove(orphanedNormalizedUrls);
RecomputeDownloadStatistics();
if (await Dialog.ShowYesNo(window, DeleteOrphanedDownloadsTitle, "Orphaned downloads deleted. Vacuum database now to reclaim space?") != DialogResult.YesNo.Yes) {
return;
}
await callback.UpdateIndeterminate("Vacuuming database...");
await state.Db.Vacuum();
}
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
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;
@@ -150,17 +225,17 @@ sealed partial class DownloadsPageModel : ObservableObject, IDisposable {
statisticsSkipped.Items = statusStatistics.SkippedCount;
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
HasFailedDownloads = statusStatistics.FailedCount > 0;
}
[ObservableObject]
public sealed partial class StatisticsRow(string state) {
public string State { get; } = state;
[ObservableProperty]
private int items;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SizeText))]
private ulong? size;
@@ -168,7 +243,7 @@ sealed partial class DownloadsPageModel : ObservableObject, IDisposable {
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SizeText))]
private bool hasFilesWithUnknownSize;
public string SizeText {
get {
if (size == null) {

View File

@@ -13,13 +13,21 @@
<StackPanel Spacing="10">
<TextBlock TextWrapping="Wrap">
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
<TextBlock.Text>
<MultiBinding StringFormat="To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing {0}.">
<Binding Path="OpenDevToolsShortcutText" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
</StackPanel>
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
<TextBlock.Text>
<MultiBinding StringFormat="By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the {0} shortcut is enabled.">
<Binding Path="OpenDevToolsShortcutText" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
</StackPanel>

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) {
var originalText = CopyTrackingScript.Content;
object? 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,6 +3,7 @@ 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;
@@ -14,60 +15,62 @@ namespace DHT.Desktop.Main.Pages;
sealed partial class TrackingPageModel : ObservableObject {
[ObservableProperty(Setter = Access.Private)]
private bool isCopyTrackingScriptButtonEnabled = true;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool? areDevToolsEnabled = null;
[ObservableProperty(Setter = Access.Private)]
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
private bool isToggleAppDevToolsButtonEnabled = false;
public string OpenDevToolsShortcutText { get; } = OperatingSystem.IsMacOS() ? "Cmd+Shift+I" : "Ctrl+Shift+I";
public string ToggleAppDevToolsButtonText {
get {
if (!AreDevToolsEnabled.HasValue) {
return "Loading...";
}
if (!IsToggleAppDevToolsButtonEnabled) {
return "Unavailable";
}
return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
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);
var clipboard = window.Clipboard;
IClipboard? 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;
@@ -76,10 +79,10 @@ sealed partial class TrackingPageModel : ObservableObject {
return false;
}
}
private async Task InitializeDevToolsToggle() {
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
if (devToolsEnabled.HasValue) {
AreDevToolsEnabled = devToolsEnabled.Value;
IsToggleAppDevToolsButtonEnabled = true;
@@ -88,44 +91,44 @@ sealed partial class TrackingPageModel : ObservableObject {
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, "Ctrl+Shift+I was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " was " + (newState ? "enabled." : "disabled.") + " Restart the Discord app for the change to take effect.");
break;
case SettingsJsonResult.AlreadySet:
await Dialog.ShowOk(window, DialogTitle, "Ctrl+Shift+I is already " + (newState ? "enabled." : "disabled."));
await Dialog.ShowOk(window, DialogTitle, OpenDevToolsShortcutText + " is already " + (newState ? "enabled." : "disabled."));
AreDevToolsEnabled = newState;
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

@@ -1,6 +1,5 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Web;
using Avalonia.Controls;
@@ -13,58 +12,58 @@ using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Data.Filters;
using DHT.Server.Service.Viewer;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Pages;
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
private static readonly Log Log = Log.ForType<ViewerPageModel>();
public bool DatabaseToolFilterModeKeep { get; set; } = true;
public bool DatabaseToolFilterModeRemove { get; set; } = false;
[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;
string serverToken = ServerConfiguration.Token;
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
Process.Start(new ProcessStartInfo(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId)) {
UseShellExecute = true
});
SystemUtils.OpenUrl(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
} catch (Exception e) {
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
}
}
public async Task OnClickApplyFiltersToDatabase() {
var filter = FilterModel.CreateFilter();
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
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);
@@ -76,8 +75,23 @@ sealed partial class ViewerPageModel : ObservableObject, IDisposable {
}
}
}
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
await ProgressDialog.Show(window, "Apply Filters", async (_, callback) => {
await callback.UpdateIndeterminate("Removing messages...");
Log.Info("Removed messages: " + await state.Db.Messages.Remove(filter, removalMode));
await callback.UpdateIndeterminate("Cleaning up attachments...");
Log.Info("Removed orphaned attachments: " + await state.Db.Messages.RemoveUnreachableAttachments());
await callback.UpdateIndeterminate("Cleaning up users...");
Log.Info("Removed orphaned users: " + await state.Db.Users.RemoveUnreachable());
await callback.UpdateIndeterminate("Cleaning up channels...");
Log.Info("Removed orphaned channels: " + await state.Db.Channels.RemoveUnreachable());
await callback.UpdateIndeterminate("Cleaning up servers...");
Log.Info("Removed orphaned servers: " + await state.Db.Servers.RemoveUnreachable());
});
}
}

View File

@@ -40,13 +40,13 @@
<Style Selector="TabItem:pointerover /template/ Border">
<Setter Property="Background" Value="#455785" />
</Style>
<Style Selector="TabItem:pointerover > TextBlock">
<Style Selector="TabItem:pointerover /template/ ContentPresenter">
<Setter Property="Foreground" Value="#E9E9E9" />
</Style>
<Style Selector="TabItem:selected:pointerover /template/ Border">
<Setter Property="Background" Value="#FFFFFF" />
</Style>
<Style Selector="TabItem:selected:pointerover > TextBlock">
<Style Selector="TabItem:selected:pointerover /template/ ContentPresenter">
<Setter Property="Foreground" Value="#1A2234" />
</Style>
<Style Selector="TabItem:selected">
@@ -56,9 +56,11 @@
<Style Selector="TabItem:selected /template/ Border#PART_SelectedPipe">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="TabItem:disabled > TextBlock">
<Style Selector="TabItem:disabled /template/ ContentPresenter">
<Setter Property="Foreground" Value="#B2B2B2" />
<Setter Property="TextDecorations" Value="Strikethrough" />
</Style>
<Style Selector="TabItem:disabled /template/ ContentPresenter">
<Setter Property="AccessText.TextDecorations" Value="Strikethrough" />
</Style>
<Style Selector="TabItem.first">
<Setter Property="Margin" Value="0 13 0 0" />

View File

@@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Main.Pages;
@@ -6,73 +7,73 @@ using DHT.Server;
namespace DHT.Desktop.Main.Screens;
sealed class MainContentScreenModel : IDisposable {
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(state);
DownloadsPageModel = new DownloadsPageModel(window, 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 void Dispose() {
DownloadsPageModel.Dispose();
public async Task Initialize() {
await DownloadsPageModel.Initialize();
}
public async ValueTask DisposeAsync() {
await DownloadsPageModel.DisposeAsync();
ViewerPageModel.Dispose();
AdvancedPageModel.Dispose();
StatusBarModel.Dispose();

View File

@@ -22,20 +22,21 @@
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button">
<Setter Property="Margin" Value="5 0" />
<Style Selector="Grid#ButtonPanel > Button">
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
</UserControl.Styles>
<Panel Name="RootPanel">
<StackPanel Margin="42">
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
<StackPanel Margin="42 30">
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
<Button Command="{Binding ShowAboutDialog}">About</Button>
<Button Command="{Binding Exit}">Exit</Button>
</StackPanel>
<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>
<Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button>
<Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button>
</Grid>
</StackPanel>
</Panel>
</UserControl>

View File

@@ -1,40 +1,46 @@
using System;
using System.Collections.Generic;
using System.IO;
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.Server.Data.Settings;
using DHT.Server.Database;
using DHT.Server.Database.Sqlite.Schema;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Screens;
sealed partial class WelcomeScreenModel : ObservableObject {
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
public string Version => Program.Version;
[ObservableProperty(Setter = Access.Private)]
private bool isOpenOrCreateDatabaseButtonEnabled = true;
public event EventHandler<IDatabaseFile>? DatabaseSelected;
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 {
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
string? path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
if (path != null) {
await OpenOrCreateDatabaseFromPath(path);
}
@@ -42,27 +48,29 @@ sealed partial class WelcomeScreenModel : ObservableObject {
IsOpenOrCreateDatabaseButtonEnabled = true;
}
}
public async Task OpenOrCreateDatabaseFromPath(string path) {
dbFilePath = path;
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
if (db != null) {
DatabaseSelected?.Invoke(this, db);
}
}
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly Window window;
bool isNew = !File.Exists(path);
public SchemaUpgradeCallbacks(Window window) {
this.window = window;
IDatabaseFile? 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, value: true);
}
DatabaseSelected?.Invoke(this, db);
}
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
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);
@@ -74,33 +82,25 @@ sealed partial class WelcomeScreenModel : ObservableObject {
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
}
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
private readonly IReadOnlyList<IProgressCallback> callbacks;
private readonly int versionSteps;
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
private int versionProgress = 0;
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
this.callbacks = callbacks;
this.versionSteps = versionSteps;
}
public async Task NextVersion() {
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
await HideChildren(0);
}
public async Task MainWork(string message, int finishedItems, int totalItems) {
await callbacks[1].Update(message, finishedItems, totalItems);
await HideChildren(1);
}
public async Task SubWork(string message, int finishedItems, int totalItems) {
await callbacks[2].Update(message, finishedItems, totalItems);
await HideChildren(2);
}
private async Task HideChildren(int parentIndex) {
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
await callbacks[i].Hide();
@@ -108,11 +108,58 @@ sealed partial class WelcomeScreenModel : ObservableObject {
}
}
}
public async Task CheckUpdates() {
var latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => {
var client = new HttpClient(new SocketsHttpHandler {
AutomaticDecompression = DecompressionMethods.None,
AllowAutoRedirect = false,
UseCookies = false,
});
client.Timeout = TimeSpan.FromSeconds(30);
client.MaxResponseContentBufferSize = 1024;
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
string response;
try {
response = await client.GetStringAsync(Program.Website + "/version");
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
return null;
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
return null;
}
if (!System.Version.TryParse(response, out Version? latestVersion)) {
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
return null;
}
return latestVersion;
});
if (latestVersion == null) {
return;
}
if (Program.AssemblyVersion >= latestVersion) {
await Dialog.ShowOk(window, "Check Updates", "You are using the latest version.");
return;
}
if (await Dialog.ShowYesNo(window, "Check Updates", "A newer version is available: v" + Program.VersionToString(latestVersion) + "\nVisit the official website and close the app?") == DialogResult.YesNo.Yes) {
SystemUtils.OpenUrl(Program.Website);
Exit();
}
}
public async Task ShowAboutDialog() {
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
}
public void Exit() {
window.Close();
}

View File

@@ -9,33 +9,44 @@ namespace DHT.Desktop;
static class Program {
public static string Version { get; }
public static Version AssemblyVersion { get; }
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();
Version = assembly.GetName().Version?.ToString() ?? "";
while (Version.EndsWith(".0")) {
Version = Version[..^2];
}
AssemblyVersion = assembly.GetName().Version ?? new Version(major: 0, minor: 0, build: 0, revision: 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());
}
public static string VersionToString(Version version) {
string versionStr = version.ToString();
while (versionStr.EndsWith(".0")) {
versionStr = versionStr[..^2];
}
return versionStr;
}
public static void Main(string[] args) {
if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.AllocConsole();
}
try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} finally {
@@ -44,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(50000, 60000);
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(min: 50000, max: 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
}

View File

@@ -1,8 +1,8 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>13</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -39,11 +39,12 @@
<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

@@ -9,6 +9,8 @@ items:
pattern: "^[0-9]+$"
name:
type: string
displayName:
type: string
avatar:
type: string
discriminator:

View File

@@ -10,11 +10,16 @@
return;
}
/*[IMPORTS]*/
if (!DISCORD.isCompatible()) {
alert("Discord History Tracker is not compatible with this version of Discord.");
return;
}
window.DHT_LOADED = true;
window.DHT_ON_UNLOAD = [];
/*[IMPORTS]*/
const port = 0; /*[PORT]*/
const token = "/*[TOKEN]*/";
STATE.setup(port, token);
@@ -46,7 +51,7 @@
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
};
const onTrackingContinued = function(anyNewMessages) {
const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
if (!STATE.isTracking()) {
return;
}
@@ -63,7 +68,7 @@
if (SETTINGS.autoscroll) {
let action = null;
if (!DISCORD.hasMoreMessages()) {
if (!hasMoreBefore) {
console.debug("[DHT] Reached first message.");
action = SETTINGS.afterFirstMsg;
}
@@ -84,7 +89,7 @@
let waitUntilSendingFinishedTimer = null;
const onMessagesUpdated = async messages => {
const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
if (!STATE.isTracking() || delayedStopRequests > 0) {
return;
}
@@ -94,24 +99,16 @@
waitUntilSendingFinishedTimer = window.setTimeout(() => {
waitUntilSendingFinishedTimer = null;
onMessagesUpdated(messages);
onMessagesUpdated(server, channel, messages, hasMoreBefore);
}, 100);
return;
}
const info = DISCORD.getSelectedChannel();
if (!info) {
GUI.setStatus("Error (Unknown Channel)");
stopTrackingDelayed();
return;
}
isSending = true;
try {
await STATE.addDiscordChannel(info.server, info.channel);
await STATE.addDiscordChannel(server, channel);
} catch (e) {
onError(e);
return;
@@ -120,35 +117,33 @@
try {
if (!messages.length) {
isSending = false;
onTrackingContinued(false);
onTrackingContinued(false, hasMoreBefore);
}
else {
const anyNewMessages = await STATE.addDiscordMessages(messages);
onTrackingContinued(anyNewMessages);
onTrackingContinued(anyNewMessages, hasMoreBefore);
}
} catch (e) {
onError(e);
}
};
DISCORD.setupMessageCallback(onMessagesUpdated);
const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
STATE.onTrackingStateChanged(enabled => {
if (enabled) {
const messages = DISCORD.getMessages();
if (messages.length === 0) {
stopTrackingDelayed(() => alert("Cannot see any messages."));
return;
}
GUI.setStatus("Starting");
GUI.createTrackingStyles();
hasJustStarted = true;
// noinspection JSIgnoredPromiseFromCall
onMessagesUpdated(messages);
if (!starter()) {
stopTrackingDelayed(() => alert("Cannot see any messages."));
hasJustStarted = false;
}
}
else {
isSending = false;
GUI.deleteTrackingStyles();
}
});

View File

@@ -4,11 +4,26 @@ class DISCORD {
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
static CHANNEL_TYPE = {
GUILD_TEXT: 0,
DM: 1,
GROUP_DM: 3,
GUILD_ANNOUNCEMENT: 5,
ANNOUNCEMENT_THREAD: 10,
PUBLIC_THREAD: 11,
PRIVATE_THREAD: 12
PRIVATE_THREAD: 12,
isPrivate(type) {
return type === this.DM
|| type === this.GROUP_DM;
},
isNavigableGuildChannel(type) {
return type === this.GUILD_TEXT
|| type === this.GUILD_ANNOUNCEMENT
|| type === this.ANNOUNCEMENT_THREAD
|| type === this.PUBLIC_THREAD
|| type === this.PRIVATE_THREAD;
}
};
// https://discord.com/developers/docs/resources/channel#message-object-message-types
@@ -18,6 +33,74 @@ class DISCORD {
THREAD_STARTER: 21
};
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
static PERMISSION = {
VIEW_CHANNEL: 1n << 10n
};
/**
* @type {Object}
* @property {function(String): ?DiscordGuild} getGuild
*/
static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
/**
* @type {Object}
* @property {function(String): Boolean} isOptInEnabled
* @property {function(String): Set<String>} getOptedInChannels
*/
static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
/**
* @type {Object}
* @property {function(String): ?DiscordChannel} getChannel
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
*/
static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
/**
* @type {function(BigInt, Object): Boolean}
*/
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
/**
* @type {function(String): MessageData}
*/
static #getMessages = WEBPACK.findFunction("getMessages");
/**
* @type {function(String): void}
*/
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
/**
* @type {function(): String}
*/
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
/**
* @type {function(String): void}
*/
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
/**
* @type {function(String, Object, String=null): void}
*/
static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync");
static isCompatible() {
return !!this.#guildStore
&& !!this.#guildSettings
&& !!this.#channelStore
&& !!this.#hasPermission
&& !!this.#getMessages
&& !!this.#jumpToMessage
&& !!this.#getCurrentlySelectedChannelId
&& !!this.#selectPrivateChannel
&& !!this.#transitionToGuildSync;
}
static getMessageOuterElement() {
return DOM.queryReactClass("messagesWrapper");
}
@@ -26,14 +109,6 @@ class DISCORD {
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
}
static getMessageElements() {
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
}
static hasMoreMessages() {
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
}
static loadOlderMessages() {
const view = this.getMessageScrollerElement();
@@ -42,26 +117,43 @@ class DISCORD {
}
}
static getMessagesFromSelectedChannel() {
const channelId = this.#getCurrentlySelectedChannelId();
return channelId ? this.#getMessages(channelId) : null;
}
/**
* Calls the provided function with a list of messages whenever the currently loaded messages change.
* @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)}
*/
static setupMessageCallback(callback) {
const previousMessages = new Set();
const onMessageElementsChanged = function() {
const messages = DISCORD.getMessages();
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
const onMessageElementsChanged = force => {
const messages = this.getMessagesFromSelectedChannel();
if (!messages || !messages.ready || messages.loadingMore) {
return false;
}
const channel = this.#channelStore.getChannel(messages.channelId);
if (!channel) {
return false;
}
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
if (!hasChanged) {
return;
return false;
}
previousMessages.clear();
for (const message of messages) {
for (const message of messages._array) {
previousMessages.add(message.id);
}
callback(messages);
const server = this.#guildStore.getGuild(channel.guild_id);
callback(server, channel, messages._array, messages.hasMoreBefore);
return true;
};
let debounceTimer;
@@ -74,7 +166,7 @@ class DISCORD {
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
};
const observer = new MutationObserver(function () {
const observer = new MutationObserver(function() {
onMessageElementsChangedLater();
});
@@ -112,216 +204,54 @@ class DISCORD {
observedElement = null;
window.clearInterval(observerTimer);
});
}
/**
* Returns the message from a message element.
* @returns { null | DiscordMessage } }
*/
static getMessageFromElement(ele) {
const props = DOM.getReactProps(ele);
if (props && Array.isArray(props.children)) {
for (const child of props.children) {
if (!(child instanceof Object)) {
continue;
}
const childProps = child.props;
if (childProps instanceof Object && "message" in childProps) {
return childProps.message;
}
}
}
return null;
}
/**
* Returns an array containing currently loaded messages.
*/
static getMessages() {
try {
const messages = [];
for (const ele of this.getMessageElements()) {
try {
const message = this.getMessageFromElement(ele);
if (message != null) {
messages.push(message);
}
} catch (e) {
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
}
}
return messages;
} catch (e) {
console.error("[DHT] Error retrieving messages.", e);
return [];
}
}
/**
* Returns an object containing the selected server and channel information.
* For types DM and GROUP, the server and channel ids and names are identical.
* @returns { {} | null }
*/
static getSelectedChannel() {
try {
let obj = null;
try {
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
if (child && child.props && child.props.channel) {
obj = child.props.channel;
break;
}
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
}
if (!obj || typeof obj.id !== "string") {
return null;
}
const dms = DOM.queryReactClass("privateChannels");
if (dms) {
let name;
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
if (node) {
name = node.nodeValue;
break;
}
}
if (!name) {
return null;
}
let type;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
switch (obj.type) {
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
default: return null;
}
const id = obj.id;
const server = { id, name, type };
const channel = { id, name };
return { server, channel };
}
else if (obj.guild_id) {
let guild;
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
if (child && child.props && child.props.guild) {
guild = child.props.guild;
break;
}
}
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
return null;
}
const server = {
"id": guild.id,
"name": guild.name,
"type": "SERVER"
};
const channel = {
"id": obj.id,
"name": obj.name,
"extra": {
"nsfw": obj.nsfw
}
};
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
channel["extra"]["parent"] = obj.parent_id;
}
else {
channel["extra"]["position"] = obj.position;
channel["extra"]["topic"] = obj.topic;
}
return { server, channel };
}
else {
return null;
}
} catch (e) {
console.error("[DHT] Error retrieving selected channel.", e);
return null;
}
return () => onMessageElementsChanged(true);
}
/**
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
*/
static selectNextTextChannel() {
const dms = DOM.queryReactClass("privateChannels");
const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
if (!currentChannel) {
return false;
}
if (dms) {
const currentChannel = DOM.queryReactClass("selected", dms);
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
const privateChannel = this.#channelStore.getSortedPrivateChannels();
const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
return false;
}
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannelLink.scrollIntoView(true);
this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
return true;
}
else {
const channelListEle = document.getElementById("channels");
if (!channelListEle) {
const guildId = currentChannel.guild_id;
let isChannelOptedIn;
if (this.#guildSettings.isOptInEnabled(guildId)) {
const optedInChannels = this.#guildSettings.getOptedInChannels(guildId);
isChannelOptedIn = channel => optedInChannels.has(channel.id);
}
else {
isChannelOptedIn = _ => true;
}
const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId);
const guildChannels = Object.values(guildChannelMap)
.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel))
.sort((a, b) => a.position - b.position);
const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id);
if (currentIndex === -1 || currentIndex === guildChannels.length - 1) {
return false;
}
function getLinkElement(channel) {
return channel.querySelector("a[href^='/channels/'][role='link']");
}
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
let nextChannel = null;
for (let index = 0; index < allTextChannels.length - 1; index++) {
if (allTextChannels[index].className.includes("selected_")) {
nextChannel = allTextChannels[index + 1];
break;
}
}
if (nextChannel === null) {
return false;
}
const nextChannelLink = getLinkElement(nextChannel);
if (!nextChannelLink) {
return false;
}
nextChannelLink.click();
nextChannel.scrollIntoView(true);
this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id);
return true;
}
}

View File

@@ -1,6 +1,8 @@
class DOM {
/**
* Returns a child element by its ID. Parent defaults to the entire document.
* @param {string} id
* @param {HTMLElement?} [parent]
* @returns {HTMLElement}
*/
static id(id, parent) {
@@ -9,6 +11,9 @@ class DOM {
/**
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
* @param {string} cls
* @param {HTMLElement?} [parent]
* @returns {HTMLElement}
*/
static queryReactClass(cls, parent) {
return (parent || document).querySelector(`[class*="${cls}_"]`);
@@ -55,31 +60,4 @@ class DOM {
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
}
/**
* Returns internal React state object of an element.
*/
static getReactProps(ele) {
const keys = Object.keys(ele || {});
let key = keys.find(key => key.startsWith("__reactInternalInstance"));
if (key) {
// noinspection JSUnresolvedVariable
return ele[key].memoizedProps;
}
key = keys.find(key => key.startsWith("__reactProps$"));
return key ? ele[key] : null;
}
/**
* Returns internal React state object of an element, or null if the retrieval throws.
*/
static tryGetReactProps(ele) {
try {
return this.getReactProps(ele);
} catch (e) {
return null;
}
}
}

View File

@@ -2,14 +2,17 @@
const GUI = (function() {
let controller = null;
let settings = null;
let trackingStyles = null;
const stateChangedEvent = () => {
if (settings) {
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
settings.ui.cbHidePreviewsWhileAutoscrolling.checked = SETTINGS.hidePreviewsWhileAutoscrolling;
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
const autoscrollDisabled = !SETTINGS.autoscroll;
settings.ui.cbHidePreviewsWhileAutoscrolling.disabled = autoscrollDisabled;
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled);
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled);
}
@@ -54,6 +57,7 @@ const GUI = (function() {
controller.ui.btnClose.addEventListener("click", () => {
this.hideController();
this.deleteTrackingStyles();
window.DHT_ON_UNLOAD.forEach(f => f());
delete window.DHT_ON_UNLOAD;
delete window.DHT_LOADED;
@@ -84,6 +88,7 @@ const GUI = (function() {
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
const html = `
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<label><input id='dht-cfg-hide-previews-while-autoscrolling' type='checkbox'> Hide previews to improve browser performance</label><br>
<br>
<label>After reaching the first message in channel...</label><br>
${radio("afm", "nothing", "Continue Tracking")}
@@ -93,8 +98,7 @@ ${radio("afm", "switch", "Switch to Next Channel")}
<label>After reaching a previously saved message...</label><br>
${radio("asm", "nothing", "Continue Tracking")}
${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "switch", "Switch to Next Channel")}
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;
${radio("asm", "switch", "Switch to Next Channel")}`;
settings = {
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
@@ -107,9 +111,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
});
settings.ui = {
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
optsAfterFirstMsg: {},
optsAfterSavedMsg: {}
/** @type {HTMLInputElement} */ cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
/** @type {HTMLInputElement} */ cbHidePreviewsWhileAutoscrolling: DOM.id("dht-cfg-hide-previews-while-autoscrolling"),
/** @type {Object.<number, HTMLInputElement>} */ optsAfterFirstMsg: {},
/** @type {Object.<number, HTMLInputElement>} */ optsAfterSavedMsg: {}
};
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
@@ -124,6 +129,10 @@ ${radio("asm", "switch", "Switch to Next Channel")}
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
});
settings.ui.cbHidePreviewsWhileAutoscrolling.addEventListener("change", () => {
SETTINGS.hidePreviewsWhileAutoscrolling = settings.ui.cbHidePreviewsWhileAutoscrolling.checked;
});
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
SETTINGS.afterFirstMsg = key;
@@ -152,6 +161,29 @@ ${radio("asm", "switch", "Switch to Next Channel")}
if (controller) {
controller.ui.textStatus.innerText = state;
}
},
createTrackingStyles() {
if (trackingStyles) {
return;
}
let style = "";
if (SETTINGS.autoscroll && SETTINGS.hidePreviewsWhileAutoscrolling) {
style += `div[id^="message-accessories-"] { display: none; }`;
}
if (style.length > 0) {
trackingStyles = DOM.createStyle(style);
}
},
deleteTrackingStyles() {
if (trackingStyles) {
DOM.removeElement(trackingStyles);
trackingStyles = null;
}
}
};
})();

View File

@@ -35,17 +35,28 @@ const SETTINGS = (function() {
obj[name] = value;
};
const defaults = {
"_autoscroll": true,
"_hidePreviewsWhileAutoscrolling": true,
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
};
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
let hasChanged = false;
if (!loaded) {
loaded = {
"_autoscroll": true,
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
};
loaded = defaults;
IS_FIRST_RUN = true;
}
else {
for (const property in defaults) {
if (!(property in loaded)) {
loaded[property] = defaults[property];
hasChanged = true;
}
}
}
const root = {
onSettingsChanged(callback) {
@@ -54,10 +65,11 @@ const SETTINGS = (function() {
};
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
defineTriggeringProperty(root, "hidePreviewsWhileAutoscrolling", loaded._hidePreviewsWhileAutoscrolling);
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
if (IS_FIRST_RUN) {
if (IS_FIRST_RUN || hasChanged) {
saveSettings();
}

View File

@@ -58,64 +58,39 @@ const STATE = (function() {
}
};
/**
* @param {DiscordChannel} channel
*/
const getPrivateChannelName = function(channel) {
if (channel.name === "") {
return channel.rawRecipients.map(user => user.username).join(", ");
}
else {
return channel.name;
}
};
/**
* @param {Number} type
*/
const getChannelTypeName = function(type) {
if (type === DISCORD.CHANNEL_TYPE.DM) {
return "DM";
}
else if (type === DISCORD.CHANNEL_TYPE.GROUP_DM) {
return "GROUP";
}
else {
return "SERVER";
}
};
const trackingStateChangedListeners = [];
let isTracking = false;
const addedChannels = new Set();
const addedUsers = new Set();
/**
* @name DiscordUser
* @property {String} id
* @property {String} username
* @property {String} discriminator
* @property {String} [avatar]
* @property {Boolean} [bot]
*/
/**
* @name DiscordMessage
* @property {String} id
* @property {String} channel_id
* @property {DiscordUser} author
* @property {String} content
* @property {Date} timestamp
* @property {Date|null} editedTimestamp
* @property {DiscordAttachment[]} attachments
* @property {Object[]} embeds
* @property {DiscordMessageReaction[]} [reactions]
* @property {DiscordMessageReference} [messageReference]
* @property {Number} type
* @property {String} state
*/
/**
* @name DiscordAttachment
* @property {String} id
* @property {String} filename
* @property {String} [content_type]
* @property {String} size
* @property {String} url
*/
/**
* @name DiscordMessageReaction
* @property {DiscordEmoji} emoji
* @property {Number} count
*/
/**
* @name DiscordMessageReference
* @property {String} [message_id]
*/
/**
* @name DiscordEmoji
* @property {String|null} id
* @property {String|null} name
* @property {Boolean} animated
*/
return {
setup(port, token) {
serverPort = port;
@@ -146,32 +121,51 @@ const STATE = (function() {
}
},
/**
* @param {?DiscordGuild} serverInfo
* @param {DiscordChannel} channelInfo
*/
async addDiscordChannel(serverInfo, channelInfo) {
if (addedChannels.has(channelInfo.id)) {
return;
}
const server = {
id: serverInfo.id,
name: serverInfo.name,
type: serverInfo.type
type: getChannelTypeName(channelInfo.type)
};
const channel = {
id: channelInfo.id,
name: channelInfo.name
extra: {}
};
if ("extra" in channelInfo) {
const extra = channelInfo.extra;
if ("parent" in extra) {
channel.parent = extra.parent;
}
channel.position = extra.position;
channel.topic = extra.topic;
channel.nsfw = extra.nsfw;
if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
server.id = channelInfo.id;
server.name = channel.name = getPrivateChannelName(channelInfo);
}
else if (serverInfo) {
server.id = serverInfo.id;
server.name = serverInfo.name;
channel.name = channelInfo.name;
}
else {
return;
}
if ("nsfw" in channelInfo) {
channel.extra.nsfw = channelInfo.nsfw;
}
if ("topic" in channelInfo) {
channel.extra.topic = channelInfo.topic;
}
if ("position" in channelInfo) {
channel.extra.position = channelInfo.position;
}
if (channelInfo.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
channel.extra.parent = channelInfo.parent_id;
}
await post("/track-channel", { server, channel });
@@ -200,6 +194,10 @@ const STATE = (function() {
name: user.username
};
if (user.globalName) {
obj.displayName = user.globalName;
}
if (user.avatar) {
obj.avatar = user.avatar;
}
@@ -288,7 +286,7 @@ const STATE = (function() {
}
if (msg.reactions.length > 0) {
obj.reactions = msg.reactions.map(reaction => {
obj.reactions = msg.reactions.filter(reaction => reaction.count > 0).map(reaction => {
const emoji = reaction.emoji;
const mapped = {

View File

@@ -0,0 +1,82 @@
/**
* @name DiscordGuild
* @property {String} id
* @property {String} name
*/
/**
* @name DiscordChannel
* @property {String} id
* @property {String} name
* @property {Number} type
* @property {String} [guild_id]
* @property {String} [parent_id]
* @property {Number} [position]
* @property {String} [topic]
* @property {Boolean} [nsfw]
* @property {DiscordUser[]} [rawRecipients]
*/
/**
* @name DiscordUser
* @property {String} id
* @property {String} username
* @property {String} discriminator
* @property {String} [globalName]
* @property {String} [avatar]
* @property {Boolean} [bot]
*/
/**
* @name DiscordMessage
* @property {String} id
* @property {String} channel_id
* @property {DiscordUser} author
* @property {String} content
* @property {Date} timestamp
* @property {Date|null} editedTimestamp
* @property {DiscordAttachment[]} attachments
* @property {Object[]} embeds
* @property {DiscordMessageReaction[]} [reactions]
* @property {DiscordMessageReference} [messageReference]
* @property {Number} type
* @property {String} state
*/
/**
* @name DiscordAttachment
* @property {String} id
* @property {String} filename
* @property {String} [content_type]
* @property {String} size
* @property {String} url
*/
/**
* @name DiscordMessageReaction
* @property {DiscordEmoji} emoji
* @property {Number} count
*/
/**
* @name DiscordMessageReference
* @property {String} [message_id]
*/
/**
* @name DiscordEmoji
* @property {String|null} id
* @property {String|null} name
* @property {Boolean} animated
*/
/**
* @name MessageData
* @type {Object}
* @property {String} channelId
* @property {Boolean} ready
* @property {Boolean} loadingMore
* @property {Boolean} hasMoreAfter
* @property {Boolean} hasMoreBefore
* @property {Array<DiscordMessage>} _array
*/

View File

@@ -0,0 +1,98 @@
/**
* Parts copied from Better Discord, licensed under Apache License 2.0.
*
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/renderer/src/modules/webpackmodules.js
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/LICENSE
*/
class WEBPACK {
static get require() {
if (this._require) {
return this._require;
}
/**
* @type {Object}
* @property {Object} m
* @property {Object} c
*/
let hookedRequire;
const id = "dht-webpackmodules-" + new Date().getTime();
if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") {
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => hookedRequire = internalRequire ]);
}
delete hookedRequire.m[id];
delete hookedRequire.c[id];
return this._require = hookedRequire;
}
static getAllModules() {
return this.require.c;
}
static filterByProps(...props) {
return module => props.every(prop => prop in module);
}
static filterByPropsWithPredicate(predicate, ...props) {
return module => props.every(prop => prop in module && predicate(module[prop]));
}
static findModules(filter) {
const defaultExport = true;
const moduleFilter = module => (typeof module === "object" || typeof module === "function") && filter(module);
const results = [];
for (const module of Object.values(this.getAllModules())) {
/**
* @type {Object}
* @property [Z]
* @property [ZP]
* @property [__esModule]
* @property [default]
*/
const exports = module.exports;
if (!exports || exports === window || exports === document.documentElement || exports[Symbol.toStringTag] === "DOMTokenList") {
continue;
}
let foundModule = null;
if (exports.Z && moduleFilter(exports.Z)) {
foundModule = defaultExport ? exports.Z : exports;
}
if (exports.ZP && moduleFilter(exports.ZP)) {
foundModule = defaultExport ? exports.ZP : exports;
}
if (exports.__esModule && exports.default && moduleFilter(exports.default)) {
foundModule = defaultExport ? exports.default : exports;
}
if (moduleFilter(exports)) {
foundModule = exports;
}
if (foundModule) {
results.push(foundModule);
}
}
return results;
}
static findModule(name, filter) {
const modules = this.findModules(filter);
if (modules.length === 1) {
return modules[0];
}
console.error("[DHT] Cannot find module " + name + ", results found:", modules.length);
return null;
}
static findFunction(name, additionalRequiredProps) {
const searchedProps = additionalRequiredProps ? [ name, ...additionalRequiredProps ] : [ name ];
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof (prop) === "function", ...searchedProps));
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
}
}

View File

@@ -18,11 +18,13 @@
height: 262px;
margin-left: -400px;
margin-top: -131px;
line-height: 120%;
padding: 8px;
background-color: #fff;
z-index: 1000002;
}
#dht-cfg-note {
margin-top: 22px;
#dht-cfg label {
display: inline-block;
margin: 1px 0;
}

View File

@@ -25,6 +25,8 @@
<div id="menu">
<button id="btn-settings">Settings</button>
<div class="splitter"></div>
<div> <!-- needed to stop the select from messing up -->
<select id="opt-messages-per-page">
<option value="50">50 messages per page&nbsp;</option>

View File

@@ -1,6 +1,7 @@
import discord from "./discord.mjs";
import gui from "./gui.mjs";
import state from "./state.mjs";
import "./polyfills.mjs";
window.DISCORD = discord;

View File

@@ -80,7 +80,7 @@ export default (function() {
processed = processed
.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + state.getChannelName(match) + "</span>")
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='#" + (state.getUserTag(match) || "????") + "'>@" + state.getUserName(match) + "</span>")
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='" + state.getUserName(match) + "'>@" + state.getUserDisplayName(match) + "</span>")
.replace(regex.customEmojiStatic, (full, m1, m2) => getEmoji(m1, m2, "webp"))
.replace(regex.customEmojiAnimated, (full, m1, m2) => getEmoji(m1, m2, animatedEmojiExtension));
@@ -129,7 +129,7 @@ export default (function() {
templateMessageNoAvatar = new template([
"<div>",
"<div class='reply-message'>{reply}</div>",
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
"<div class='message'>{contents}{embeds}{attachments}</div>",
"{reactions}",
"</div>"
@@ -141,7 +141,7 @@ export default (function() {
"<div class='avatar-wrapper'>",
"<div class='avatar'>{avatar}</div>",
"<div>",
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
"<div class='message'>{contents}{embeds}{attachments}</div>",
"{reactions}",
"</div>",
@@ -227,8 +227,8 @@ export default (function() {
if (property === "avatar") {
return value ? templateUserAvatar.apply(getAvatarUrlObject(value)) : "";
}
else if (property === "user.tag") {
return value ? value : "????";
else if (property === "user.displayName") {
return value ? value : message.user.name;
}
else if (property === "timestamp") {
return dom.getHumanReadableTime(value);
@@ -292,19 +292,19 @@ export default (function() {
return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : "";
}
const user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>";
const user = "<span class='reply-username' title='" + value.user.name + "'>" + (value.user.displayName ?? value.user.name) + "</span>";
const avatar = settings.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(getAvatarUrlObject(value.avatar)) + "</span>" : "";
const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
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

@@ -243,10 +243,11 @@ export default (function() {
const options = [];
for (const key of Object.keys(users)) {
for (const id of Object.keys(users)) {
const user = users[id];
const option = document.createElement("option");
option.value = key;
option.text = users[key].name;
option.value = id;
option.text = user.displayName ? `${user.displayName} (${user.name})` : user.name;
options.push(option);
}

View File

@@ -0,0 +1,35 @@
// https://gist.github.com/MattiasBuelens/496fc1d37adb50a733edd43853f2f60e/088f061ab79b296f29225467ae9ba86ff990195d
ReadableStream.prototype.values ??= function({ preventCancel = false } = {}) {
const reader = this.getReader();
return {
async next() {
try {
const result = await reader.read();
if (result.done) {
reader.releaseLock();
}
return result;
} catch (e) {
reader.releaseLock();
throw e;
}
},
async return(value) {
if (!preventCancel) {
const cancelPromise = reader.cancel(value);
reader.releaseLock();
await cancelPromise;
}
else {
reader.releaseLock();
}
return { done: true, value };
},
[Symbol.asyncIterator]() {
return this;
}
};
};
ReadableStream.prototype[Symbol.asyncIterator] ??= ReadableStream.prototype.values;

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;
}
@@ -316,16 +316,16 @@ export default (function() {
return (channelObj && channelObj.name) || channel;
},
getUserTag(user) {
const userObj = loadedFileMeta.users[user];
return (userObj && userObj.tag) || "????";
},
getUserName(user) {
const userObj = loadedFileMeta.users[user];
return (userObj && userObj.name) || user;
},
getUserDisplayName(user) {
const userObj = loadedFileMeta.users[user];
return (userObj && (userObj.displayName || userObj.name)) || user;
},
selectChannel(channel) {
currentPage = 1;
selectedChannel = channel;

View File

@@ -1,15 +1,16 @@
#menu {
width: 100%;
height: 48px;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 8px;
padding: 8px;
background-color: #17181c;
border-bottom: 1px dotted #5d626b;
}
#menu .splitter {
width: 1px;
margin: 9px 4px;
flex: 0 0 1px;
margin: 9px 1px;
background-color: #5d626b;
}
@@ -23,7 +24,8 @@
}
#menu button, #menu select, #menu input[type="text"] {
margin: 8px;
height: 31px;
padding: 0 10px;
background-color: #7289da;
color: #fff;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
@@ -31,28 +33,25 @@
#menu button {
font-size: 17px;
padding: 0 12px;
border: 0;
cursor: pointer;
white-space: nowrap;
}
#menu select {
font-size: 14px;
padding: 6px;
border: 0;
cursor: pointer;
}
#menu input[type="text"] {
font-size: 14px;
padding: 7px 12px;
border: 0;
}
#menu .nav {
display: flex;
flex-direction: row;
margin: 0 8px;
}
#menu .nav > button {
@@ -66,7 +65,7 @@
}
#menu .nav > button, #menu .nav > p {
margin: 8px 1px;
margin: 0 1px;
}
#opt-filter-list > select, #opt-filter-list > input {
@@ -76,3 +75,7 @@
#opt-filter-list > .active {
display: block;
}
#btn-about {
margin-left: auto;
}

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

@@ -0,0 +1,60 @@
using System.Diagnostics.CodeAnalysis;
namespace DHT.Server.Data.Settings;
public static class SettingsKey {
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;
}
}
public sealed class Bool(string key) : SettingsKey<bool>(key) {
internal override bool FromString(string value, out bool result) {
switch (value) {
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";
}
}
public sealed class UnsignedLong(string key) : SettingsKey<ulong>(key) {
internal override bool FromString(string value, out ulong result) {
return ulong.TryParse(value, out result);
}
internal override string ToString(ulong value) {
return value.ToString();
}
}
}
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

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

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 (var message in source.Messages.Get()) {
await foreach (Message 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 (var download in source.Downloads.Get()) {
await foreach (Data.Download download in source.Downloads.Get()) {
if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) {
await target.Downloads.AddDownload(download, stream: null);
}

View File

@@ -7,9 +7,10 @@ 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();
public IUserRepository Users { get; } = new IUserRepository.Dummy();
public IServerRepository Servers { get; } = new IServerRepository.Dummy();
public IChannelRepository Channels { get; } = new IChannelRepository.Dummy();
@@ -17,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

@@ -8,15 +8,15 @@ sealed class SnowflakeJsonSerializer : JsonConverter<Snowflake> {
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return new Snowflake(ulong.Parse(reader.GetString()!));
}
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
writer.WriteStringValue(value.Id.ToString());
}
public override Snowflake ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return new Snowflake(ulong.Parse(reader.GetString()!));
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
writer.WritePropertyName(value.Id.ToString());
}

View File

@@ -14,17 +14,17 @@ static class ViewerJson {
public required string Name { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Avatar { get; init; }
public string? DisplayName { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Tag { get; init; }
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; }
@@ -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 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 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

@@ -14,104 +14,104 @@ 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) {
var perf = Log.Start();
Perf perf = Log.Start();
var includedChannels = new List<Channel>();
var includedServerIds = new HashSet<ulong>();
var channelIdFilter = filter?.ChannelIds;
await foreach (var channel in db.Channels.Get(cancellationToken)) {
HashSet<ulong>? channelIdFilter = filter?.ChannelIds;
await foreach (Channel channel in db.Channels.Get(cancellationToken)) {
if (channelIdFilter == null || channelIdFilter.Contains(channel.Id)) {
includedChannels.Add(channel);
includedServerIds.Add(channel.Server);
}
}
var users = await GenerateUserList(db, cancellationToken);
var servers = await GenerateServerList(db, includedServerIds, cancellationToken);
var channels = GenerateChannelList(includedChannels);
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 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) {
var perf = Log.Start();
Perf perf = Log.Start();
ReadOnlyMemory<byte> newLine = "\n"u8.ToArray();
await foreach(var message in GenerateMessageList(db, filter, cancellationToken)) {
await foreach (ViewerJson.JsonMessage message in GenerateMessageList(db, filter, cancellationToken)) {
await JsonSerializer.SerializeAsync(stream, message, ViewerJsonMessageContext.Default.JsonMessage, cancellationToken);
await stream.WriteAsync(newLine, cancellationToken);
}
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 (var user in db.Users.Get(cancellationToken)) {
await foreach (User user in db.Users.Get(cancellationToken)) {
users[user.Id] = new ViewerJson.JsonUser {
Name = user.Name,
DisplayName = user.DisplayName,
Avatar = user.AvatarUrl,
Tag = user.Discriminator
};
}
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 (var server in db.Servers.Get(cancellationToken)) {
await foreach (Data.Server 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<Channel> includedChannels) {
var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
foreach (var channel in includedChannels) {
foreach (Channel channel in includedChannels) {
channels[channel.Id] = new ViewerJson.JsonChannel {
Server = channel.Server,
Name = channel.Name,
Parent = channel.ParentId?.ToString(),
Position = channel.Position,
Topic = channel.Topic,
Nsfw = channel.Nsfw
Nsfw = channel.Nsfw,
};
}
return channels;
}
private static async IAsyncEnumerable<ViewerJson.JsonMessage> GenerateMessageList(IDatabaseFile db, MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
await foreach (var message in db.Messages.Get(filter, cancellationToken)) {
await foreach (Message message in db.Messages.Get(filter, cancellationToken)) {
yield return new ViewerJson.JsonMessage {
Id = message.Id,
C = message.Channel,
@@ -120,29 +120,29 @@ static class ViewerJsonExport {
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
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?.ToString(),
N = reaction.EmojiName,
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
C = reaction.Count
}).ToArray()
C = reaction.Count,
}).ToArray(),
};
}
}

View File

@@ -6,12 +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() {
var unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
ulong unixMillis = (ulong) (DateTime.UtcNow.Subtract(DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond);
this.id = (unixMillis - DiscordEpoch) << 22;
}
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) {
var perf = Log.Start();
var root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
Perf perf = Log.Start();
JsonElement root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
try {
var meta = root.RequireObject("meta");
var data = root.RequireObject("data");
JsonElement meta = root.RequireObject("meta");
JsonElement data = root.RequireObject("data");
perf.Step("Deserialize JSON");
var users = ReadUserList(meta);
var servers = ReadServerList(meta, fakeSnowflake);
User[] users = ReadUserList(meta);
Data.Server[] servers = ReadServerList(meta, fakeSnowflake);
var newServersOnly = new HashSet<Data.Server>(servers);
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])) {
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])) {
servers[serverIndex] = oldServer;
}
}
perf.Step("Read server and user list");
if (newServersOnly.Count > 0) {
var askedServerIds = await askForServerIds(newServersOnly.ToArray());
Dictionary<Data.Server, ulong>? askedServerIds = await askForServerIds(newServersOnly.ToArray());
if (askedServerIds == null) {
return false;
}
perf.Step("Ask for server IDs");
for (var i = 0; i < servers.Length; i++) {
var server = servers[i];
if (askedServerIds.TryGetValue(server, out var serverId)) {
for (int i = 0; i < servers.Length; i++) {
Data.Server server = servers[i];
if (askedServerIds.TryGetValue(server, out ulong serverId)) {
servers[i] = server with { Id = serverId };
}
}
}
var channels = ReadChannelList(meta, servers);
Channel[] channels = ReadChannelList(meta, servers);
perf.Step("Read channel list");
var oldMessageIds = await db.Messages.GetIds().ToHashSetAsync();
var newMessages = channels.SelectMany(channel => ReadMessages(data, channel, users, fakeSnowflake))
.Where(message => !oldMessageIds.Contains(message.Id))
.ToArray();
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();
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."));
}
var userindex = meta.RequireArray("userindex", "meta")
.Select(static (item, index) => (ParseUserIndex(item, index), index))
.ToDictionary();
Dictionary<ulong, int> userindex = meta.RequireArray("userindex", "meta")
.Select(static (item, index) => (ParseUserIndex(item, index), index))
.ToDictionary();
var users = new User[userindex.Count];
foreach (var item in meta.RequireObject("users", "meta").EnumerateObject()) {
var path = UsersPath + "." + item.Name;
var userId = ulong.Parse(item.Name);
var userObj = item.Value;
foreach (JsonProperty item in meta.RequireObject("users", "meta").EnumerateObject()) {
string path = UsersPath + "." + item.Name;
ulong userId = ulong.Parse(item.Name);
JsonElement 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 => {
var path = ChannelsPath + "." + item.Name;
var channelId = ulong.Parse(item.Name);
var channelObj = item.Value;
string path = ChannelsPath + "." + item.Name;
ulong channelId = ulong.Parse(item.Name);
JsonElement channelObj = item.Value;
return (channelId, channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1));
}).ToDictionary();
}
private static Channel[] ReadChannelList(JsonElement meta, Data.Server[] servers) {
return meta.RequireObject("channels", "meta").EnumerateObject().Select(item => {
var path = ChannelsPath + "." + item.Name;
var channelId = ulong.Parse(item.Name);
var channelObj = item.Value;
string path = ChannelsPath + "." + item.Name;
ulong channelId = ulong.Parse(item.Name);
JsonElement channelObj = item.Value;
return new Channel {
Id = channelId,
Server = servers[channelObj.RequireInt("server", path, min: 0, max: servers.Length - 1)].Id,
Server = servers[channelObj.RequireInt("server", path, min: 0, servers.Length - 1)].Id,
Name = channelObj.RequireString("name", path),
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";
var channelId = channel.Id;
var channelIdStr = channelId.ToString();
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
ulong channelId = channel.Id;
string channelIdStr = channelId.ToString();
JsonElement? messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : null;
if (messagesObj == null) {
return [];
}
return messagesObj.Value.EnumerateObject().Select(item => {
var path = DataPath + "." + item.Name;
var messageId = ulong.Parse(item.Name);
var messageObj = item.Value;
string path = DataPath + "." + item.Name;
ulong messageId = ulong.Parse(item.Name);
JsonElement 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 var contentType) ? contentType : null;
string? type = ContentTypeProvider.TryGetContentType(name, out string? 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 => {
var id = reactionObj.HasKey("id") ? reactionObj.RequireSnowflake("id", path) : (ulong?) null;
var name = reactionObj.HasKey("n") ? reactionObj.RequireString("n", path) : null;
ulong? id = reactionObj.HasKey("id") ? reactionObj.RequireSnowflake("id", path) : (ulong?) null;
string? 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,20 +16,26 @@ 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,62 +13,74 @@ 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();
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() {
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,29 +20,35 @@ public interface IMessageRepository {
IAsyncEnumerable<ulong> GetIds(MessageFilter? filter = null);
Task Remove(MessageFilter filter, FilterRemovalMode mode);
Task<int> Remove(MessageFilter filter, FilterRemovalMode mode);
Task<int> RemoveUnreachableAttachments();
internal sealed class Dummy : IMessageRepository {
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 Remove(MessageFilter filter, FilterRemovalMode mode) {
return Task.CompletedTask;
public Task<int> Remove(MessageFilter filter, FilterRemovalMode mode) {
return Task.FromResult(0);
}
public Task<int> RemoveUnreachableAttachments() {
return Task.FromResult(0);
}
}
}

View File

@@ -15,20 +15,26 @@ 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

@@ -0,0 +1,31 @@
using System;
using System.Threading.Tasks;
using DHT.Server.Data.Settings;
namespace DHT.Server.Database.Repositories;
public interface ISettingsRepository {
Task Set<T>(SettingsKey<T> key, T value);
Task Set(Func<ISetter, Task> setter);
Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue);
interface ISetter {
Task Set<T>(SettingsKey<T> key, T value);
}
internal sealed class Dummy : 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,20 +16,26 @@ 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);
}
}
}

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