mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-17 19:31:42 +02:00
Compare commits
49 Commits
v44.0
...
6c0e0ff697
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6c0e0ff697 | ||
45593cb140
|
|||
c770a066c4
|
|||
43bf1fcafe
|
|||
8857f6c3b1
|
|||
87de0b98b5
|
|||
295370a3d4
|
|||
312be6609d
|
|||
44b42657ef
|
|||
![]() |
7e29c7f837 | ||
0d3600492e
|
|||
3b569ad5d6
|
|||
![]() |
7ba012ef5c | ||
![]() |
c52572b387 | ||
![]() |
5ba80dc12f | ||
![]() |
37a0feddcc | ||
![]() |
47f448dcde | ||
![]() |
0281b49815 | ||
![]() |
648b221bb8 | ||
![]() |
7d8558ae04 | ||
![]() |
41053549ab | ||
![]() |
f279bb4d16 | ||
b2276600c7
|
|||
40269f591b
|
|||
dea3b272c0
|
|||
e66206b4a8
|
|||
780d5ae421
|
|||
38f79dee7d
|
|||
e30b305eb5
|
|||
fa17d0e224
|
|||
5741fad528
|
|||
e7c52fd1d5
|
|||
fb9b31175c
|
|||
f8e56ae367
|
|||
7ed31fbc8b
|
|||
e92aeb5a1b
|
|||
50d5bdba97
|
|||
295c32c7b4
|
|||
5e4bb5b44d
|
|||
96c19afa66
|
|||
a45f60b528
|
|||
62b97807b8
|
|||
0fff3e8eaf
|
|||
02b773db8f
|
|||
261a583b50
|
|||
2ecb658e42
|
|||
7a6dd23912
|
|||
60761d80ab
|
|||
e11db62015
|
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,3 +1 @@
|
||||
github: chylex
|
||||
patreon: chylex
|
||||
ko_fi: chylex
|
||||
|
18
README.md
18
README.md
@@ -18,7 +18,7 @@ Folder organization:
|
||||
* `app/` contains a Visual Studio solution for the desktop app
|
||||
* `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.
|
||||
|
2
app/.editorconfig
Normal file
2
app/.editorconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
[*.cs]
|
||||
propertychanged.auto_notify = false
|
@@ -13,6 +13,7 @@
|
||||
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
|
||||
@@ -25,4 +26,4 @@
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
@@ -51,9 +51,20 @@
|
||||
|
||||
<Style Selector="Expander">
|
||||
<Setter Property="MinHeight" Value="40" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
|
||||
|
||||
<Style Selector="Panel.buttons">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons > WrapPanel">
|
||||
<Setter Property="ItemSpacing" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons Button">
|
||||
<Setter Property="MinWidth" Value="80" />
|
||||
</Style>
|
||||
|
||||
</Application.Styles>
|
||||
|
||||
<Application.Resources>
|
||||
@@ -107,7 +118,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" />
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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?");
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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");
|
||||
}
|
||||
|
@@ -15,15 +15,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.11" Condition=" '$(Configuration)' == 'Debug' " />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.11" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition=" '$(Configuration)' == 'Debug' " />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
||||
<PackageReference Include="PropertyChanged.SourceGenerator" Version="1.1.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -40,5 +43,5 @@
|
||||
<LogicalName>tracker-loader.js</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
|
@@ -15,24 +15,6 @@
|
||||
<namespace:CheckBoxDialogModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Panel.buttons">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons > WrapPanel.right">
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons Button">
|
||||
<Setter Property="MinWidth" Value="80" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons > WrapPanel.left Button">
|
||||
<Setter Property="Margin" Value="0 0 8 0" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons > WrapPanel.right Button">
|
||||
<Setter Property="Margin" Value="8 0 0 0" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<StackPanel Margin="20">
|
||||
<ScrollViewer MaxHeight="400">
|
||||
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||
@@ -48,11 +30,11 @@
|
||||
</ItemsRepeater>
|
||||
</ScrollViewer>
|
||||
<Panel Classes="buttons">
|
||||
<WrapPanel Classes="left">
|
||||
<WrapPanel>
|
||||
<Button Command="{Binding SelectAll}" IsEnabled="{Binding !AreAllSelected}">Select All</Button>
|
||||
<Button Command="{Binding SelectNone}" IsEnabled="{Binding !AreNoneSelected}">Select None</Button>
|
||||
</WrapPanel>
|
||||
<WrapPanel Classes="right">
|
||||
<WrapPanel HorizontalAlignment="Right">
|
||||
<Button Click="ClickOk">OK</Button>
|
||||
<Button Click="ClickCancel">Cancel</Button>
|
||||
</WrapPanel>
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -1,56 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
|
||||
class CheckBoxDialogModel : ObservableObject {
|
||||
partial class CheckBoxDialogModel {
|
||||
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;
|
||||
|
||||
|
||||
[DependsOn(nameof(Items))]
|
||||
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
||||
|
||||
[DependsOn(nameof(Items))]
|
||||
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
||||
|
||||
|
||||
public void SelectAll() => SetAllChecked(true);
|
||||
public void SelectNone() => SetAllChecked(false);
|
||||
|
||||
|
||||
private void SetAllChecked(bool isChecked) {
|
||||
pauseCheckEvents = true;
|
||||
|
||||
foreach (var item in Items) {
|
||||
|
||||
foreach (CheckBoxItem item in Items) {
|
||||
item.IsChecked = isChecked;
|
||||
}
|
||||
|
||||
|
||||
pauseCheckEvents = false;
|
||||
UpdateBulkButtons();
|
||||
}
|
||||
|
||||
|
||||
private void UpdateBulkButtons() {
|
||||
OnPropertyChanged(nameof(AreAllSelected));
|
||||
OnPropertyChanged(nameof(AreNoneSelected));
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Items)));
|
||||
}
|
||||
|
||||
|
||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
||||
UpdateBulkButtons();
|
||||
@@ -60,11 +61,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;
|
||||
}
|
||||
}
|
||||
|
@@ -1,18 +1,18 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
|
||||
partial class CheckBoxItem : ObservableObject {
|
||||
partial class CheckBoxItem {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private bool isChecked = false;
|
||||
}
|
||||
|
||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||
public new T Item { get; }
|
||||
|
||||
|
||||
public CheckBoxItem(T item) {
|
||||
this.Item = item;
|
||||
base.Item = item;
|
||||
|
@@ -8,29 +8,33 @@ using Avalonia.Platform.Storage;
|
||||
namespace DHT.Desktop.Dialogs.File;
|
||||
|
||||
static class FileDialogs {
|
||||
public static async Task<string[]> OpenFolders(this IStorageProvider storageProvider, FolderPickerOpenOptions options) {
|
||||
return (await storageProvider.OpenFolderPickerAsync(options)).ToLocalPaths();
|
||||
}
|
||||
|
||||
public static async Task<string[]> OpenFiles(this IStorageProvider storageProvider, FilePickerOpenOptions options) {
|
||||
return (await storageProvider.OpenFilePickerAsync(options)).ToLocalPaths();
|
||||
}
|
||||
|
||||
|
||||
public static async Task<string?> SaveFile(this IStorageProvider storageProvider, FilePickerSaveOptions options) {
|
||||
return (await storageProvider.SaveFilePickerAsync(options))?.ToLocalPath();
|
||||
}
|
||||
|
||||
|
||||
public static FilePickerFileType CreateFilter(string name, string[] extensions) {
|
||||
return new FilePickerFileType(name) {
|
||||
Patterns = extensions.Select(static ext => "*." + ext).ToArray()
|
||||
Patterns = extensions.Select(static ext => "*." + ext).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public static Task<IStorageFolder?> GetSuggestedStartLocation(Window window, string? suggestedDirectory) {
|
||||
return suggestedDirectory == null ? Task.FromResult<IStorageFolder?>(null) : window.StorageProvider.TryGetFolderFromPathAsync(suggestedDirectory);
|
||||
}
|
||||
|
||||
private static string ToLocalPath(this IStorageFile file) {
|
||||
return file.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
||||
|
||||
private static string ToLocalPath(this IStorageItem itme) {
|
||||
return itme.TryGetLocalPath() ?? throw new NotSupportedException("Local filesystem is not supported.");
|
||||
}
|
||||
|
||||
private static string[] ToLocalPaths(this IReadOnlyList<IStorageFile> files) {
|
||||
return files.Select(ToLocalPath).ToArray();
|
||||
|
||||
private static string[] ToLocalPaths(this IReadOnlyList<IStorageItem> items) {
|
||||
return items.Select(ToLocalPath).ToArray();
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
@@ -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."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -20,24 +20,18 @@
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel">
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
<Setter Property="Margin" Value="0 8 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Margin" Value="8 0 0 0" />
|
||||
<Setter Property="MinWidth" Value="80" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<StackPanel Margin="20">
|
||||
<TextBlock Text="{Binding Message}" />
|
||||
<WrapPanel>
|
||||
<Button Click="ClickOk" IsVisible="{Binding IsOkVisible}">OK</Button>
|
||||
<Button Click="ClickYes" IsVisible="{Binding IsYesVisible}">Yes</Button>
|
||||
<Button Click="ClickNo" IsVisible="{Binding IsNoVisible}">No</Button>
|
||||
<Button Click="ClickCancel" IsVisible="{Binding IsCancelVisible}">Cancel</Button>
|
||||
</WrapPanel>
|
||||
<Panel Classes="buttons">
|
||||
<WrapPanel HorizontalAlignment="Right">
|
||||
<Button Click="ClickOk" IsVisible="{Binding IsOkVisible}">OK</Button>
|
||||
<Button Click="ClickYes" IsVisible="{Binding IsYesVisible}">Yes</Button>
|
||||
<Button Click="ClickNo" IsVisible="{Binding IsNoVisible}">No</Button>
|
||||
<Button Click="ClickCancel" IsVisible="{Binding IsCancelVisible}">Cancel</Button>
|
||||
</WrapPanel>
|
||||
</Panel>
|
||||
</StackPanel>
|
||||
|
||||
</Window>
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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;
|
||||
|
24
app/Desktop/Dialogs/Progress/DelayedProgressDialog.cs
Normal file
24
app/Desktop/Dialogs/Progress/DelayedProgressDialog.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
static class DelayedProgressDialog {
|
||||
public static async ValueTask Await(Func<Task> taskProvider, TimeSpan delay, Window window, string progressDialogTitle, string progressDialogDescription) {
|
||||
Dispatcher.UIThread.VerifyAccess();
|
||||
|
||||
Task task = Task.Run(taskProvider);
|
||||
if (task.IsCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Freeze the UI thread for a short while in case the task finishes quickly.
|
||||
_ = Task.WhenAny(Task.Delay(delay), task).GetAwaiter().GetResult();
|
||||
|
||||
if (!task.IsCompleted) {
|
||||
await ProgressDialog.ShowIndeterminate(window, progressDialogTitle, progressDialogDescription, _ => task);
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,7 +3,7 @@ using System.Threading.Tasks;
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
interface IProgressCallback {
|
||||
Task Update(string message, int finishedItems, int totalItems);
|
||||
Task Update(string message, long finishedItems, long totalItems);
|
||||
Task UpdateIndeterminate(string message);
|
||||
Task Hide();
|
||||
}
|
||||
|
@@ -7,81 +7,72 @@ namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class ProgressDialog : Window {
|
||||
private static readonly TimeSpan MinimumShowDuration = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
try {
|
||||
await action(dialog, callbacks[0]);
|
||||
taskCompletionSource.SetResult();
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => await action(dialog, callbacks[0]));
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
internal static async Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
await callbacks[0].UpdateIndeterminate(message);
|
||||
try {
|
||||
await action(dialog);
|
||||
taskCompletionSource.SetResult();
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
||||
internal static async Task<T> Show<T>(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task<T>> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource<T>();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
await callbacks[0].UpdateIndeterminate(message);
|
||||
try {
|
||||
taskCompletionSource.SetResult(await action(dialog));
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
taskCompletionSource.SetResult(await action(dialog, callbacks[0]));
|
||||
});
|
||||
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
return await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
|
||||
internal static Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
|
||||
return Show(owner, title, async (dialog, callback) => {
|
||||
await callback.UpdateIndeterminate(message);
|
||||
await action(dialog);
|
||||
});
|
||||
}
|
||||
|
||||
internal static Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
||||
return Show<T>(owner, title, async (dialog, callback) => {
|
||||
await callback.UpdateIndeterminate(message);
|
||||
return await action(dialog);
|
||||
});
|
||||
}
|
||||
|
||||
private bool isFinished = false;
|
||||
private DateTime startTime = DateTime.Now;
|
||||
private Task progressTask = Task.CompletedTask;
|
||||
|
||||
|
||||
public ProgressDialog() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
|
||||
public void OnOpened(object? sender, EventArgs e) {
|
||||
startTime = DateTime.Now;
|
||||
|
||||
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) {
|
||||
|
||||
private async Task OnFinished(Task task) {
|
||||
isFinished = true;
|
||||
|
||||
TimeSpan elapsedTime = DateTime.Now - startTime;
|
||||
if (elapsedTime < MinimumShowDuration) {
|
||||
await Task.Delay(MinimumShowDuration - elapsedTime);
|
||||
}
|
||||
|
||||
Close();
|
||||
}
|
||||
|
||||
|
||||
public async Task ShowProgressDialog(Window owner) {
|
||||
await ShowDialog(owner);
|
||||
await progressTask;
|
||||
|
@@ -9,36 +9,36 @@ 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) {
|
||||
|
||||
public async Task Update(string message, long finishedItems, long totalItems) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||
item.Message = message;
|
||||
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||
@@ -46,7 +46,7 @@ sealed class ProgressDialogModel {
|
||||
item.IsIndeterminate = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public async Task UpdateIndeterminate(string message) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||
item.Message = message;
|
||||
@@ -55,9 +55,9 @@ sealed class ProgressDialogModel {
|
||||
item.IsIndeterminate = true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public Task Hide() {
|
||||
return Update(string.Empty, 0, 0);
|
||||
return Update(string.Empty, finishedItems: 0, totalItems: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,30 +1,23 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
sealed partial class ProgressItem : ObservableObject {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(Opacity))]
|
||||
private bool isVisible = false;
|
||||
|
||||
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||
|
||||
sealed partial class ProgressItem {
|
||||
[Notify]
|
||||
private string message = "";
|
||||
|
||||
public string Message {
|
||||
get => message;
|
||||
set {
|
||||
SetProperty(ref message, value);
|
||||
IsVisible = !string.IsNullOrEmpty(value);
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private string items = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private int progress = 0;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private long progress = 0L;
|
||||
|
||||
[Notify]
|
||||
private bool isIndeterminate;
|
||||
|
||||
[DependsOn(nameof(Message))]
|
||||
public bool IsVisible => !string.IsNullOrEmpty(Message);
|
||||
|
||||
[DependsOn(nameof(IsVisible))]
|
||||
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||
}
|
||||
|
@@ -15,19 +15,6 @@
|
||||
<namespace:TextBoxDialogModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Panel.buttons">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons > WrapPanel.right">
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style Selector="Panel.buttons Button">
|
||||
<Setter Property="MinWidth" Value="80" />
|
||||
<Setter Property="Margin" Value="8 0 0 0" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<StackPanel Margin="20">
|
||||
<ScrollViewer MaxHeight="400">
|
||||
<StackPanel Spacing="10">
|
||||
@@ -47,7 +34,7 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<Panel Classes="buttons">
|
||||
<WrapPanel Classes="right">
|
||||
<WrapPanel HorizontalAlignment="Right">
|
||||
<Button Click="ClickOk" IsEnabled="{Binding !HasErrors}">OK</Button>
|
||||
<Button Click="ClickCancel">Cancel</Button>
|
||||
</WrapPanel>
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -1,45 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
class TextBoxDialogModel : ObservableObject {
|
||||
partial class TextBoxDialogModel {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[DependsOn(nameof(Items))]
|
||||
public bool HasErrors => Items.Any(static item => !item.IsValid);
|
||||
|
||||
|
||||
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
|
||||
OnPropertyChanged(nameof(HasErrors));
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Items)));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
@@ -1,40 +1,37 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||
partial class TextBoxItem : INotifyDataErrorInfo {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
|
||||
public Func<string, bool> ValidityCheck { get; init; } = static _ => true;
|
||||
public bool IsValid => ValidityCheck(Value);
|
||||
|
||||
|
||||
[Notify]
|
||||
private string value = string.Empty;
|
||||
|
||||
public string Value {
|
||||
get => this.value;
|
||||
set {
|
||||
SetProperty(ref this.value, value);
|
||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||
}
|
||||
|
||||
private void OnValueChanged() {
|
||||
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;
|
||||
|
@@ -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);
|
||||
|
@@ -6,5 +6,5 @@ enum SettingsJsonResult {
|
||||
FileNotFound,
|
||||
ReadError,
|
||||
InvalidJson,
|
||||
WriteError
|
||||
WriteError,
|
||||
}
|
||||
|
@@ -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="360"
|
||||
mc:Ignorable="d" d:DesignWidth="510" d:DesignHeight="425"
|
||||
x:Class="DHT.Desktop.Main.AboutWindow"
|
||||
x:DataType="main:AboutWindowModel"
|
||||
Title="About Discord History Tracker"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="480" Height="360" CanResize="False"
|
||||
Width="510" Height="425" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Design.DataContext>
|
||||
@@ -16,16 +16,12 @@
|
||||
</Design.DataContext>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="StackPanel">
|
||||
<Setter Property="Orientation" Value="Horizontal" />
|
||||
<Setter Property="Spacing" Value="5" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Grid > Button">
|
||||
<Setter Property="Margin" Value="0 4" />
|
||||
<Setter Property="Margin" Value="0 3" />
|
||||
<Setter Property="Padding" Value="8 4" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
@@ -33,44 +29,55 @@
|
||||
|
||||
<StackPanel Orientation="Vertical" Margin="20" Spacing="12">
|
||||
|
||||
<TextBlock VerticalAlignment="Center">
|
||||
Discord History Tracker was created by chylex and released under the MIT license.
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel>
|
||||
<Button Command="{Binding ShowOfficialWebsite}">Official Website</Button>
|
||||
<Button Command="{Binding ShowIssueTracker}">Issue Tracker</Button>
|
||||
<Button Command="{Binding ShowSourceCode}">Source Code</Button>
|
||||
<StackPanel Orientation="Vertical" Spacing="3">
|
||||
<TextBlock TextWrapping="Wrap">Discord History Tracker was created by chylex.</TextBlock>
|
||||
<TextBlock>It is available under the MIT license.</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="175,125,*" Margin="0 10 0 0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Command="{Binding OpenOfficialWebsite}">Official Website</Button>
|
||||
<Button Command="{Binding OpenIssueTracker}">Issue Tracker</Button>
|
||||
<Button Command="{Binding OpenSourceCode}">Source Code</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*,115,90" Margin="0 10 0 0">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Third-Party Software</TextBlock>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0">.NET 8</TextBlock>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0">.NET</TextBlock>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
|
||||
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>
|
||||
<Button Grid.Row="2" Grid.Column="2" Command="{Binding OpenThirdPartyNetCore}">GitHub</Button>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0">Avalonia</TextBlock>
|
||||
<TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock>
|
||||
<Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">NuGet</Button>
|
||||
<Button Grid.Row="3" Grid.Column="2" Command="{Binding OpenThirdPartyAvalonia}">GitHub</Button>
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</TextBlock>
|
||||
<TextBlock Grid.Row="4" Grid.Column="0">Rx.NET</TextBlock>
|
||||
<TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock>
|
||||
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryCommunityToolkit}">GitHub</Button>
|
||||
|
||||
<Button Grid.Row="4" Grid.Column="2" Command="{Binding OpenThirdPartyRxNet}">GitHub</Button>
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock>
|
||||
<TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock>
|
||||
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
|
||||
<Button Grid.Row="5" Grid.Column="2" Command="{Binding OpenThirdPartySqlite}">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>
|
||||
<Button Grid.Row="6" Grid.Column="2" Command="{Binding OpenThirdPartyMicrosoftDataSqlite}">NuGet</Button>
|
||||
|
||||
<TextBlock Grid.Row="7" Grid.Column="0">PropertyChanged.SourceGenerator</TextBlock>
|
||||
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
|
||||
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button>
|
||||
<Button Grid.Row="7" Grid.Column="2" Command="{Binding OpenThirdPartyPropertyChangedSourceGenerator}">GitHub</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*,115,90" Margin="0 5 0 0">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Additional Thanks</TextBlock>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0">BetterDiscord, for Webpack hooks</TextBlock>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1">Apache-2.0</TextBlock>
|
||||
<Button Grid.Row="2" Grid.Column="2" Command="{Binding OpenThirdPartyBetterDiscord}">GitHub</Button>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
|
@@ -3,39 +3,43 @@ using DHT.Desktop.Common;
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
sealed class AboutWindowModel {
|
||||
public void ShowOfficialWebsite() {
|
||||
public void OpenOfficialWebsite() {
|
||||
SystemUtils.OpenUrl(Program.Website);
|
||||
}
|
||||
|
||||
public void ShowIssueTracker() {
|
||||
|
||||
public void OpenIssueTracker() {
|
||||
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
||||
}
|
||||
|
||||
public void ShowSourceCode() {
|
||||
public void OpenSourceCode() {
|
||||
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||
}
|
||||
|
||||
public void ShowLibraryNetCore() {
|
||||
|
||||
public void OpenThirdPartyNetCore() {
|
||||
SystemUtils.OpenUrl("https://github.com/dotnet/core");
|
||||
}
|
||||
|
||||
public void ShowLibraryAvalonia() {
|
||||
SystemUtils.OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||
|
||||
public void OpenThirdPartyAvalonia() {
|
||||
SystemUtils.OpenUrl("https://github.com/AvaloniaUI/Avalonia");
|
||||
}
|
||||
|
||||
public void ShowLibraryCommunityToolkit() {
|
||||
SystemUtils.OpenUrl("https://github.com/CommunityToolkit/dotnet");
|
||||
|
||||
public void OpenThirdPartyPropertyChangedSourceGenerator() {
|
||||
SystemUtils.OpenUrl("https://github.com/canton7/PropertyChanged.SourceGenerator");
|
||||
}
|
||||
|
||||
public void ShowLibrarySqlite() {
|
||||
|
||||
public void OpenThirdPartySqlite() {
|
||||
SystemUtils.OpenUrl("https://www.sqlite.org");
|
||||
}
|
||||
|
||||
public void ShowLibrarySqliteAdoNet() {
|
||||
|
||||
public void OpenThirdPartyMicrosoftDataSqlite() {
|
||||
SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||
}
|
||||
|
||||
public void ShowLibraryRxNet() {
|
||||
|
||||
public void OpenThirdPartyRxNet() {
|
||||
SystemUtils.OpenUrl("https://github.com/dotnet/reactive");
|
||||
}
|
||||
|
||||
public void OpenThirdPartyBetterDiscord() {
|
||||
SystemUtils.OpenUrl("https://github.com/BetterDiscord/BetterDiscord");
|
||||
}
|
||||
}
|
||||
|
@@ -13,12 +13,8 @@
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Margin" Value="0 20 40 0" />
|
||||
<Setter Property="Spacing" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Grid > Label">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
@@ -30,9 +26,9 @@
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel>
|
||||
<StackPanel Spacing="20">
|
||||
<TextBlock Text="{Binding FilterStatisticsText}" />
|
||||
<WrapPanel>
|
||||
<WrapPanel ItemSpacing="40" LineSpacing="20">
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
|
@@ -5,88 +5,89 @@ using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Data.Settings;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Tasks;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisposable {
|
||||
sealed partial class DownloadItemFilterPanelModel : 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]
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private string filterStatisticsText = "";
|
||||
|
||||
[Notify]
|
||||
private bool limitSize = false;
|
||||
|
||||
[ObservableProperty]
|
||||
[Notify]
|
||||
private ulong maximumSize = 0UL;
|
||||
|
||||
[ObservableProperty]
|
||||
[Notify]
|
||||
private Unit maximumSizeUnit = AllUnits[0];
|
||||
|
||||
|
||||
public IEnumerable<Unit> Units => AllUnits;
|
||||
|
||||
|
||||
private readonly State state;
|
||||
private readonly string verb;
|
||||
|
||||
|
||||
private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask;
|
||||
private bool isLoadingFilterSettings;
|
||||
|
||||
|
||||
private readonly RestartableTask<long> downloadItemCountTask;
|
||||
private long? matchingItemCount;
|
||||
|
||||
private readonly IDisposable downloadItemCountSubscription;
|
||||
private long? totalItemCount;
|
||||
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DownloadItemFilterPanelModel() : this(State.Dummy) {}
|
||||
|
||||
|
||||
public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
|
||||
this.state = state;
|
||||
this.verb = verb;
|
||||
|
||||
|
||||
this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings);
|
||||
|
||||
|
||||
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
|
||||
|
||||
|
||||
UpdateFilterStatistics();
|
||||
|
||||
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
}
|
||||
|
||||
|
||||
public async Task Initialize() {
|
||||
isLoadingFilterSettings = true;
|
||||
|
||||
LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize);
|
||||
MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize);
|
||||
|
||||
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
|
||||
|
||||
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, defaultValue: null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
|
||||
MaximumSizeUnit = unitValue;
|
||||
}
|
||||
|
||||
isLoadingFilterSettings = false;
|
||||
}
|
||||
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
saveFilterSettingsTask.Dispose();
|
||||
|
||||
@@ -112,7 +113,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
||||
Log.Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||
if (!isLoadingFilterSettings) {
|
||||
@@ -128,9 +129,9 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void UpdateFilterStatistics() {
|
||||
var filter = CreateFilter();
|
||||
DownloadItemFilter filter = CreateFilter();
|
||||
if (filter.IsEmpty) {
|
||||
downloadItemCountTask.Cancel();
|
||||
matchingItemCount = totalItemCount;
|
||||
@@ -142,23 +143,21 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
||||
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;
|
||||
@@ -166,7 +165,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisp
|
||||
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
|
@@ -13,12 +13,8 @@
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Margin" Value="0 20 40 0" />
|
||||
<Setter Property="Spacing" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel > StackPanel:nth-last-child(1)">
|
||||
<Setter Property="Margin" Value="0 20 0 0" />
|
||||
</Style>
|
||||
<Style Selector="Grid > Label">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
@@ -36,9 +32,9 @@
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel>
|
||||
<StackPanel Spacing="20">
|
||||
<TextBlock Text="{Binding FilterStatisticsText}" />
|
||||
<WrapPanel>
|
||||
<WrapPanel ItemSpacing="40" LineSpacing="20">
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding FilterByDate}">Filter by Date</CheckBox>
|
||||
<Grid ColumnDefinitions="Auto, 4, 125" RowDefinitions="Auto, 4, Auto" Margin="4 0">
|
||||
|
@@ -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;
|
||||
|
@@ -7,7 +7,6 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.CheckBox;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
@@ -16,10 +15,11 @@ using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Utils.Tasks;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
sealed partial class MessageFilterPanelModel : IDisposable {
|
||||
private static readonly HashSet<string> FilterProperties = [
|
||||
nameof(FilterByDate),
|
||||
nameof(StartDate),
|
||||
@@ -27,46 +27,47 @@ 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]
|
||||
|
||||
[Notify]
|
||||
private string filterStatisticsText = "";
|
||||
|
||||
[Notify]
|
||||
private bool filterByDate = false;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private DateTime? startDate = null;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private DateTime? endDate = null;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private bool filterByChannel = false;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private HashSet<ulong>? includedChannels = null;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private bool filterByUser = false;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private HashSet<ulong>? includedUsers = null;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private string channelFilterLabel = "";
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
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 +79,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 +108,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 +122,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 +137,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
totalUserCount = newUserCount;
|
||||
UpdateUserFilterLabel();
|
||||
}
|
||||
|
||||
|
||||
private void UpdateChannelFilterLabel() {
|
||||
if (totalChannelCount.HasValue) {
|
||||
long total = totalChannelCount.Value;
|
||||
@@ -147,7 +148,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
ChannelFilterLabel = "Loading...";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void UpdateUserFilterLabel() {
|
||||
if (totalUserCount.HasValue) {
|
||||
long total = totalUserCount.Value;
|
||||
@@ -158,9 +159,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 +173,36 @@ 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 +211,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,29 +236,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()) {
|
||||
|
||||
await foreach (User user in state.Db.Users.Get()) {
|
||||
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
|
||||
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
||||
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return checkBoxItems;
|
||||
}
|
||||
|
||||
|
||||
const string Title = "Included Users";
|
||||
|
||||
|
||||
List<CheckBoxItem<ulong>> items;
|
||||
try {
|
||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
||||
@@ -267,42 +266,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;
|
||||
}
|
||||
}
|
||||
|
@@ -16,18 +16,21 @@
|
||||
<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" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel>
|
||||
<StackPanel Spacing="10">
|
||||
<Button Command="{Binding OnClickToggleServerButton}" Content="{Binding ToggleServerButtonText}" IsEnabled="{Binding IsToggleServerButtonEnabled}" />
|
||||
<TextBlock TextWrapping="Wrap" Margin="0 15">
|
||||
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script again.
|
||||
<TextBlock TextWrapping="Wrap" Margin="0 5">
|
||||
The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script or connection code again.
|
||||
</TextBlock>
|
||||
<WrapPanel>
|
||||
<WrapPanel ItemSpacing="10" LineSpacing="10">
|
||||
<StackPanel>
|
||||
<Label Target="Port">Port</Label>
|
||||
<TextBox x:Name="Port" Width="70" Text="{Binding InputPort}" />
|
||||
|
@@ -1,40 +1,40 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable {
|
||||
sealed partial class ServerConfigurationPanelModel : IDisposable {
|
||||
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||
[Notify]
|
||||
private string inputPort;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||
|
||||
[Notify]
|
||||
private string inputToken;
|
||||
|
||||
|
||||
[DependsOn(nameof(InputPort), nameof(InputToken))]
|
||||
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
|
||||
[Notify(Setter.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));
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(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,25 +92,25 @@ 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;
|
||||
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(nameof(HasMadeChanges)));
|
||||
|
||||
await StartServer();
|
||||
}
|
||||
|
||||
|
||||
public void OnClickCancelChanges() {
|
||||
InputPort = ServerConfiguration.Port.ToString();
|
||||
InputToken = ServerConfiguration.Token;
|
||||
|
@@ -2,62 +2,62 @@ using System;
|
||||
using System.Reactive.Linq;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Service;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class StatusBarModel : ObservableObject, IDisposable {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
sealed partial class StatusBarModel : IDisposable {
|
||||
[Notify(Setter.Private)]
|
||||
private long? serverCount;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[Notify(Setter.Private)]
|
||||
private long? channelCount;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[Notify(Setter.Private)]
|
||||
private long? messageCount;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private ServerManager.Status serverStatus;
|
||||
|
||||
public string ServerStatusText => serverStatus switch {
|
||||
|
||||
[DependsOn(nameof(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;
|
||||
|
||||
state.Server.StatusChanged += OnStateServerStatusChanged;
|
||||
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
||||
}
|
||||
|
||||
|
||||
public void Dispose() {
|
||||
serverCountSubscription.Dispose();
|
||||
channelCountSubscription.Dispose();
|
||||
messageCountSubscription.Dispose();
|
||||
|
||||
state.Server.StatusChanged -= OnServerStatusChanged;
|
||||
state.Server.StatusChanged -= OnStateServerStatusChanged;
|
||||
}
|
||||
|
||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||
|
||||
private void OnStateServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
|
||||
}
|
||||
}
|
||||
|
57
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml
Normal file
57
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml
Normal file
@@ -0,0 +1,57 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:namespace="clr-namespace:DHT.Desktop.Main.Dialogs"
|
||||
mc:Ignorable="d" d:DesignWidth="450"
|
||||
x:Class="DHT.Desktop.Main.Dialogs.NewDatabaseSettingsDialog"
|
||||
x:DataType="namespace:NewDatabaseSettingsDialogModel"
|
||||
Title="New Database Settings"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Closing="OnClosing"
|
||||
Width="450" SizeToContent="Height" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Window.DataContext>
|
||||
<namespace:NewDatabaseSettingsDialogModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Label > StackPanel">
|
||||
<Setter Property="Margin" Value="0 4 0 0" />
|
||||
<Setter Property="Orientation" Value="Vertical" />
|
||||
<Setter Property="Spacing" Value="5" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.dim">
|
||||
<Setter Property="Foreground" Value="DimGray" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<StackPanel Orientation="Vertical" Margin="20">
|
||||
<StackPanel Orientation="Vertical" Spacing="15">
|
||||
<CheckBox IsChecked="{Binding SeparateFileForDownloads}">
|
||||
<Label>
|
||||
<StackPanel>
|
||||
<TextBlock>Use a Separate Database File for Downloads</TextBlock>
|
||||
<TextBlock Classes="dim">Downloaded files will be stored separately from messages. This cannot be changed later.</TextBlock>
|
||||
</StackPanel>
|
||||
</Label>
|
||||
</CheckBox>
|
||||
<CheckBox IsChecked="{Binding DownloadsAutoStart}">
|
||||
<Label>
|
||||
<StackPanel>
|
||||
<TextBlock>Download Files Automatically</TextBlock>
|
||||
<TextBlock Classes="dim">Enables automatic downloads of files hosted on Discord. This can be changed later in the Downloads tab.</TextBlock>
|
||||
</StackPanel>
|
||||
</Label>
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
<Panel Classes="buttons">
|
||||
<WrapPanel HorizontalAlignment="Right">
|
||||
<Button Click="ClickOk">OK</Button>
|
||||
</WrapPanel>
|
||||
</Panel>
|
||||
</StackPanel>
|
||||
|
||||
</Window>
|
22
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml.cs
Normal file
22
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialog.axaml.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace DHT.Desktop.Main.Dialogs;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class NewDatabaseSettingsDialog : Window {
|
||||
public NewDatabaseSettingsDialog() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||
if (!e.IsProgrammatic) {
|
||||
e.Cancel = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClickOk(object? sender, RoutedEventArgs e) {
|
||||
Close();
|
||||
}
|
||||
}
|
11
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialogModel.cs
Normal file
11
app/Desktop/Main/Dialogs/NewDatabaseSettingsDialogModel.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main.Dialogs;
|
||||
|
||||
sealed partial class NewDatabaseSettingsDialogModel {
|
||||
[Notify]
|
||||
private bool separateFileForDownloads = true;
|
||||
|
||||
[Notify]
|
||||
private bool downloadsAutoStart = true;
|
||||
}
|
@@ -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 {
|
||||
|
@@ -3,59 +3,60 @@ using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Main.Screens;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Logging;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||
sealed partial class MainWindowModel : IAsyncDisposable {
|
||||
private const string DefaultTitle = "Discord History Tracker";
|
||||
|
||||
|
||||
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private string title = DefaultTitle;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
|
||||
[Notify(Setter.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,35 +64,35 @@ 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;
|
||||
|
||||
@@ -99,37 +100,38 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||
|
||||
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
||||
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||
|
||||
|
||||
window.Focus();
|
||||
}
|
||||
|
||||
|
||||
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||
await DisposeContent();
|
||||
|
||||
Title = DefaultTitle;
|
||||
CurrentScreen = welcomeScreen;
|
||||
|
||||
await DisposeState();
|
||||
|
||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||
}
|
||||
|
||||
private async Task DisposeContent() {
|
||||
if (mainContentScreenModel != null) {
|
||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||
await mainContentScreenModel.DisposeAsync();
|
||||
mainContentScreenModel = null;
|
||||
}
|
||||
|
||||
await DisposeState();
|
||||
|
||||
Title = DefaultTitle;
|
||||
CurrentScreen = welcomeScreen;
|
||||
|
||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||
}
|
||||
|
||||
|
||||
private async Task DisposeState() {
|
||||
if (state != null) {
|
||||
await state.DisposeAsync();
|
||||
await DelayedProgressDialog.Await(() => state.DisposeAsync().AsTask(), TimeSpan.FromMilliseconds(200), window, "Close Database", "Please wait for the database to close...");
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
if (mainContentScreenModel != null) {
|
||||
await mainContentScreenModel.DisposeAsync();
|
||||
}
|
||||
|
||||
await DisposeContent();
|
||||
await DisposeState();
|
||||
}
|
||||
}
|
||||
|
@@ -16,11 +16,5 @@
|
||||
<Expander Header="Internal Server Configuration" IsExpanded="True">
|
||||
<controls:ServerConfigurationPanel DataContext="{Binding ServerConfigurationModel}" />
|
||||
</Expander>
|
||||
<Expander Header="Database Tools" IsExpanded="True">
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<TextBlock TextWrapping="Wrap">Recreates the database to remove any traces of deleted data, which frees up space and prevents forensic recovery.</TextBlock>
|
||||
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
@@ -1,8 +1,5 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Server;
|
||||
|
||||
@@ -11,26 +8,14 @@ namespace DHT.Desktop.Main.Pages;
|
||||
sealed class AdvancedPageModel : IDisposable {
|
||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public AdvancedPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
|
||||
public AdvancedPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
|
||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
||||
}
|
||||
|
||||
|
||||
public void Dispose() {
|
||||
ServerConfigurationModel.Dispose();
|
||||
}
|
||||
|
||||
public async Task VacuumDatabase() {
|
||||
const string Title = "Vacuum Database";
|
||||
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
|
||||
await Dialog.ShowOk(window, Title, "Done.");
|
||||
}
|
||||
}
|
||||
|
@@ -11,22 +11,22 @@
|
||||
<pages:DatabasePageModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="WrapPanel > Button">
|
||||
<Setter Property="Margin" Value="0 0 10 10" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
<DockPanel>
|
||||
<Button Command="{Binding CloseDatabase}" DockPanel.Dock="Right">Close Database</Button>
|
||||
<TextBox Text="{Binding Db.Path}" Width="NaN" Margin="0 0 10 0" IsReadOnly="True" />
|
||||
</DockPanel>
|
||||
<WrapPanel>
|
||||
<WrapPanel ItemSpacing="10" LineSpacing="10">
|
||||
<Button Command="{Binding OpenDatabaseFolder}">Open Database Folder</Button>
|
||||
<Button Command="{Binding MergeWithDatabase}">Merge with Database(s)...</Button>
|
||||
<Button Command="{Binding ImportLegacyArchive}">Import Legacy Archive(s)...</Button>
|
||||
</WrapPanel>
|
||||
<Expander Header="Advanced Tools" Margin="0 25 0 0">
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<TextBlock TextWrapping="Wrap">Recreate the database to free up space after deleting data.</TextBlock>
|
||||
<Button Command="{Binding VacuumDatabase}">Vacuum Database</Button>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
|
@@ -19,75 +19,89 @@ using DHT.Server.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Import;
|
||||
using DHT.Server.Database.Sqlite.Schema;
|
||||
using DHT.Server.Database.Sqlite.Utils;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed class DatabasePageModel {
|
||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||
|
||||
|
||||
public IDatabaseFile Db { get; }
|
||||
|
||||
|
||||
public event EventHandler? DatabaseClosed;
|
||||
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DatabasePageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
|
||||
public DatabasePageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.Db = state.Db;
|
||||
}
|
||||
|
||||
|
||||
public async Task OpenDatabaseFolder() {
|
||||
string file = Db.Path;
|
||||
string? folder = Path.GetDirectoryName(file);
|
||||
|
||||
|
||||
if (folder == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
switch (Environment.OSVersion.Platform) {
|
||||
case PlatformID.Win32NT:
|
||||
Process.Start("explorer.exe", "/select,\"" + file + "\"");
|
||||
break;
|
||||
|
||||
|
||||
case PlatformID.Unix:
|
||||
Process.Start("xdg-open", new string[] { folder });
|
||||
Process.Start("xdg-open", [ folder ]);
|
||||
break;
|
||||
|
||||
|
||||
case PlatformID.MacOSX:
|
||||
Process.Start("open", new string[] { folder });
|
||||
Process.Start("open", [ 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));
|
||||
if (paths.Length > 0) {
|
||||
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
||||
string[] paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||
if (paths.Length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const string Title = "Database Merge";
|
||||
|
||||
ImportResult? result;
|
||||
try {
|
||||
result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, Title, "Could not merge databases: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "database file"));
|
||||
}
|
||||
|
||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||
|
||||
private static async Task<ImportResult?> 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 => {
|
||||
|
||||
return await PerformImport(target, paths, dialog, callback, "Database Merge", async path => {
|
||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||
|
||||
|
||||
if (db == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await target.AddFrom(db);
|
||||
return true;
|
||||
@@ -96,60 +110,70 @@ sealed class DatabasePageModel {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
|
||||
private readonly ProgressDialog dialog;
|
||||
private readonly int total;
|
||||
|
||||
private sealed class SchemaUpgradeCallbacks(ProgressDialog dialog, int total) : ISchemaUpgradeCallbacks {
|
||||
private bool? decision;
|
||||
|
||||
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||
this.total = total;
|
||||
this.dialog = dialog;
|
||||
|
||||
public Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() {
|
||||
return Task.FromResult<InitialDatabaseSettings?>(null);
|
||||
}
|
||||
|
||||
|
||||
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));
|
||||
|
||||
if (paths.Length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const string Title = "Legacy Archive Import";
|
||||
|
||||
ImportResult? result;
|
||||
try {
|
||||
result = await ProgressDialog.Show(window, Title, async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, Title, "Could not import legacy archives: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
await Dialog.ShowOk(window, Title, GetImportDialogMessage(result, "archive file"));
|
||||
}
|
||||
|
||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||
|
||||
private static async Task<ImportResult?> 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 => {
|
||||
|
||||
return await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", 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,75 +183,74 @@ 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) {
|
||||
|
||||
private static async Task<ImportResult?> PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string dialogTitle, 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.");
|
||||
await Dialog.ShowOk(dialog, dialogTitle, "File '" + Path.GetFileName(path) + "' no longer exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (await performImport(path)) {
|
||||
++successful;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(dialog, errorDialogTitle, "File '" + Path.GetFileName(path) + "' could not be imported: " + ex.Message);
|
||||
await Dialog.ShowOk(dialog, dialogTitle, "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;
|
||||
return null;
|
||||
}
|
||||
|
||||
var newStatistics = await DatabaseStatistics.Take(target);
|
||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
||||
|
||||
DatabaseStatistics newStatistics = await DatabaseStatistics.Take(target);
|
||||
return new ImportResult(oldStatistics, newStatistics, successful, total);
|
||||
}
|
||||
|
||||
|
||||
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 +261,52 @@ sealed class DatabasePageModel {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
|
||||
|
||||
private sealed record ImportResult(DatabaseStatistics OldStatistics, DatabaseStatistics NewStatistics, int SuccessfulItems, int TotalItems);
|
||||
|
||||
private static string GetImportDialogMessage(ImportResult? result, string itemName) {
|
||||
if (result == null) {
|
||||
return "Nothing was imported.";
|
||||
}
|
||||
|
||||
var oldStatistics = result.OldStatistics;
|
||||
var newStatistics = result.NewStatistics;
|
||||
|
||||
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));
|
||||
|
||||
if (result.SuccessfulItems == result.TotalItems) {
|
||||
message.Append(result.SuccessfulItems.Pluralize(itemName));
|
||||
}
|
||||
else {
|
||||
message.Append(successfulItems.Format()).Append(" out of ").Append(totalItems.Pluralize(itemName));
|
||||
message.Append(result.SuccessfulItems.Format()).Append(" out of ").Append(result.TotalItems.Pluralize(itemName));
|
||||
}
|
||||
|
||||
|
||||
message.Append(" and added:\n\n \u2022 ");
|
||||
message.Append(newServers.Pluralize("server")).Append("\n \u2022 ");
|
||||
message.Append(newChannels.Pluralize("channel")).Append("\n \u2022 ");
|
||||
message.Append(newUsers.Pluralize("user")).Append("\n \u2022 ");
|
||||
message.Append(newMessages.Pluralize("message"));
|
||||
|
||||
|
||||
return message.ToString();
|
||||
}
|
||||
|
||||
public async Task VacuumDatabase() {
|
||||
const string Title = "Vacuum Database";
|
||||
|
||||
try {
|
||||
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => Db.Vacuum());
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, Title, "Could not vacuum database: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
await Dialog.ShowOk(window, Title, "Done.");
|
||||
}
|
||||
}
|
||||
|
@@ -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,24 +16,27 @@
|
||||
<Setter Property="FontFamily" Value="Consolas,Courier" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
</Style>
|
||||
<Style Selector="Label">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="WrapPanel > StackPanel">
|
||||
<Setter Property="Orientation" Value="Vertical" />
|
||||
<Setter Property="Margin" Value="0 0 10 10" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<Expander Header="Generate Random Data" IsExpanded="True">
|
||||
<WrapPanel>
|
||||
<StackPanel>
|
||||
<WrapPanel ItemSpacing="10" LineSpacing="10">
|
||||
<StackPanel Spacing="8">
|
||||
<Label Target="Channels">Channels</Label>
|
||||
<TextBox x:Name="Channels" Width="100" Text="{Binding GenerateChannels}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<StackPanel Spacing="8">
|
||||
<Label Target="Users">Users</Label>
|
||||
<TextBox x:Name="Users" Width="100" Text="{Binding GenerateUsers}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<StackPanel Spacing="8">
|
||||
<Label Target="Messages">Messages</Label>
|
||||
<TextBox x:Name="Messages" Width="100" Text="{Binding GenerateMessages}" />
|
||||
</StackPanel>
|
||||
|
@@ -16,90 +16,90 @@ sealed class DebugPageModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
|
||||
public DebugPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
|
||||
public async void OnClickAddRandomDataToDatabase() {
|
||||
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||
}
|
||||
|
||||
|
||||
private const int BatchSize = 500;
|
||||
|
||||
|
||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
||||
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, finishedItems: 0, totalItems: 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(),
|
||||
AvatarHash = null,
|
||||
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,
|
||||
@@ -108,28 +108,28 @@ sealed class DebugPageModel {
|
||||
Reactions = ImmutableList<Reaction>.Empty,
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
|
||||
await state.Db.Messages.Add(messages);
|
||||
|
||||
|
||||
messageCount -= BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static ulong RandomId(Random rand) {
|
||||
ulong h = unchecked((ulong) rand.Next());
|
||||
ulong l = unchecked((ulong) rand.Next());
|
||||
return (h << 32) | l;
|
||||
}
|
||||
|
||||
|
||||
private static string RandomName(string prefix) {
|
||||
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
||||
}
|
||||
|
||||
|
||||
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||
}
|
||||
|
||||
|
||||
private static readonly string[] RandomWords = [
|
||||
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
||||
@@ -153,12 +153,12 @@ sealed class DebugPageModel {
|
||||
"vanilla",
|
||||
"watercress", "watermelon",
|
||||
"yam",
|
||||
"zucchini"
|
||||
"zucchini",
|
||||
];
|
||||
|
||||
|
||||
private static string RandomText(Random rand, int maxWords) {
|
||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 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
|
||||
|
@@ -13,9 +13,6 @@
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Expander">
|
||||
<Setter Property="Margin" Value="0 5 0 0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
@@ -31,24 +28,24 @@
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Orientation="Vertical" Spacing="20">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<WrapPanel Orientation="Horizontal" ItemSpacing="10" LineSpacing="10">
|
||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
|
||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
||||
</StackPanel>
|
||||
<Button Command="{Binding OnClickRetryFailed}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed</Button>
|
||||
<Button Command="{Binding OnClickDeleteOrphaned}">Delete Orphaned</Button>
|
||||
<Button Command="{Binding OnClickExportAll}" IsEnabled="{Binding HasSuccessfulDownloads}">Export All...</Button>
|
||||
</WrapPanel>
|
||||
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
||||
<TextBlock TextWrapping="Wrap">
|
||||
Downloading state and filter settings are remembered per-database.
|
||||
</TextBlock>
|
||||
<StackPanel Orientation="Vertical" Spacing="12">
|
||||
<Expander Header="Download Status" IsExpanded="True">
|
||||
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" />
|
||||
<DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
<Expander Header="Download Status" IsExpanded="True">
|
||||
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" />
|
||||
<DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
@@ -1,149 +1,161 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.File;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Aggregations;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Data.Settings;
|
||||
using DHT.Server.Download;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Tasks;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
||||
sealed partial class DownloadsPageModel : IAsyncDisposable {
|
||||
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private bool isToggleDownloadButtonEnabled = true;
|
||||
|
||||
|
||||
[DependsOn(nameof(IsDownloading))]
|
||||
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private bool isRetryingFailedDownloads = false;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private bool hasSuccessfulDownloads;
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private bool hasFailedDownloads;
|
||||
|
||||
|
||||
[DependsOn(nameof(IsRetryingFailedDownloads), nameof(HasFailedDownloads))]
|
||||
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
|
||||
[Notify(Setter.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 async Task Initialize() {
|
||||
await FilterModel.Initialize();
|
||||
|
||||
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, false)) {
|
||||
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, defaultValue: false)) {
|
||||
await StartDownload();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
finishedItemsSubscription?.Dispose();
|
||||
|
||||
downloadItemCountSubscription.Dispose();
|
||||
downloadStatisticsTask.Dispose();
|
||||
|
||||
|
||||
await FilterModel.DisposeAsync();
|
||||
}
|
||||
|
||||
|
||||
private void OnDownloadCountChanged(long newDownloadCount) {
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
|
||||
public async Task OnClickToggleDownload() {
|
||||
IsToggleDownloadButtonEnabled = false;
|
||||
|
||||
|
||||
if (IsDownloading) {
|
||||
await StopDownload();
|
||||
}
|
||||
else {
|
||||
await StartDownload();
|
||||
}
|
||||
|
||||
|
||||
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
|
||||
IsToggleDownloadButtonEnabled = true;
|
||||
}
|
||||
|
||||
|
||||
private async Task StartDownload() {
|
||||
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||
|
||||
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
||||
IObservable<DownloadItem> finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
||||
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
|
||||
|
||||
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));
|
||||
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsDownloading)));
|
||||
}
|
||||
|
||||
|
||||
private void OnItemFinished(DownloadItem item) {
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
public async Task OnClickRetryFailedDownloads() {
|
||||
|
||||
public async Task OnClickRetryFailed() {
|
||||
IsRetryingFailedDownloads = true;
|
||||
|
||||
|
||||
try {
|
||||
await state.Db.Downloads.RetryFailed();
|
||||
RecomputeDownloadStatistics();
|
||||
@@ -153,20 +165,110 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
||||
IsRetryingFailedDownloads = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void RecomputeDownloadStatistics() {
|
||||
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
|
||||
public async Task OnClickDeleteOrphaned() {
|
||||
const string Title = "Delete Orphaned Downloads";
|
||||
|
||||
try {
|
||||
await ProgressDialog.Show(window, Title, async (_, callback) => {
|
||||
await callback.UpdateIndeterminate("Searching for orphaned downloads...");
|
||||
|
||||
HashSet<string> reachableNormalizedUrls = [];
|
||||
HashSet<string> orphanedNormalizedUrls = [];
|
||||
|
||||
await foreach (FileUrl fileUrl in state.Db.Downloads.FindReachableFiles()) {
|
||||
reachableNormalizedUrls.Add(fileUrl.NormalizedUrl);
|
||||
}
|
||||
|
||||
await foreach (Download download in state.Db.Downloads.Get()) {
|
||||
string normalizedUrl = download.NormalizedUrl;
|
||||
if (!reachableNormalizedUrls.Contains(normalizedUrl)) {
|
||||
orphanedNormalizedUrls.Add(normalizedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedNormalizedUrls.Count == 0) {
|
||||
await Dialog.ShowOk(window, Title, "No orphaned downloads found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await Dialog.ShowYesNo(window, Title, orphanedNormalizedUrls.Count + " orphaned download(s) will be removed from this database. This action cannot be undone. Proceed?") != DialogResult.YesNo.Yes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await callback.UpdateIndeterminate("Deleting orphaned downloads...");
|
||||
await state.Db.Downloads.Remove(orphanedNormalizedUrls);
|
||||
RecomputeDownloadStatistics();
|
||||
|
||||
if (await Dialog.ShowYesNo(window, Title, "Orphaned downloads deleted. Vacuum database now to reclaim space?") != DialogResult.YesNo.Yes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await callback.UpdateIndeterminate("Vacuuming database...");
|
||||
await state.Db.Vacuum();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, Title, "Could not delete orphaned downloads: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnClickExportAll() {
|
||||
const string Title = "Export Downloaded Files";
|
||||
|
||||
string[] folders = await window.StorageProvider.OpenFolders(new FolderPickerOpenOptions {
|
||||
Title = Title,
|
||||
AllowMultiple = false,
|
||||
});
|
||||
|
||||
if (folders.Length != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
string folderPath = folders[0];
|
||||
|
||||
DownloadExporter exporter = new DownloadExporter(state.Db, folderPath);
|
||||
DownloadExporter.Result result;
|
||||
try {
|
||||
result = await ProgressDialog.Show(window, Title, async (_, callback) => {
|
||||
await callback.UpdateIndeterminate("Exporting downloaded files...");
|
||||
return await exporter.Export(new ExportProgressReporter(callback));
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, Title, "Could not export downloaded files: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
string messageStart = "Exported " + result.SuccessfulCount.Pluralize("file");
|
||||
|
||||
if (result.FailedCount > 0L) {
|
||||
await Dialog.ShowOk(window, Title, messageStart + " (" + result.FailedCount.Format() + " failed).");
|
||||
}
|
||||
else {
|
||||
await Dialog.ShowOk(window, Title, messageStart + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ExportProgressReporter(IProgressCallback callback) : DownloadExporter.IProgressReporter {
|
||||
public Task ReportProgress(long processedCount, long totalCount) {
|
||||
return callback.Update("Exporting downloaded files...", processedCount, totalCount);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||
statisticsPending.Items = statusStatistics.PendingCount;
|
||||
statisticsPending.Size = statusStatistics.PendingTotalSize;
|
||||
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
|
||||
|
||||
|
||||
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
||||
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
|
||||
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
|
||||
|
||||
|
||||
statisticsFailed.Items = statusStatistics.FailedCount;
|
||||
statisticsFailed.Size = statusStatistics.FailedTotalSize;
|
||||
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
|
||||
@@ -174,25 +276,24 @@ sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
|
||||
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
|
||||
|
||||
|
||||
HasSuccessfulDownloads = statusStatistics.SuccessfulCount > 0;
|
||||
HasFailedDownloads = statusStatistics.FailedCount > 0;
|
||||
}
|
||||
|
||||
[ObservableObject]
|
||||
|
||||
public sealed partial class StatisticsRow(string state) {
|
||||
public string State { get; } = state;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
private int items;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||
|
||||
[Notify]
|
||||
private ulong? size;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||
[Notify]
|
||||
private bool hasFilesWithUnknownSize;
|
||||
|
||||
|
||||
[DependsOn(nameof(Size), nameof(HasFilesWithUnknownSize))]
|
||||
public string SizeText {
|
||||
get {
|
||||
if (size == null) {
|
||||
|
@@ -11,17 +11,44 @@
|
||||
<pages:TrackingPageModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<Button DockPanel.Dock="Right" Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
||||
<UserControl.Styles>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Spacing="20">
|
||||
<Expander Header="Method 1: Manual" IsExpanded="True">
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<TextBlock>
|
||||
<TextBlock.Text>
|
||||
<MultiBinding StringFormat="Use {0} to open Dev Tools in your browser or the Discord app, copy the tracking script, and paste it into the console.">
|
||||
<Binding Path="OpenDevToolsShortcutText" />
|
||||
</MultiBinding>
|
||||
</TextBlock.Text>
|
||||
</TextBlock>
|
||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
||||
<TextBlock Margin="0 5 0 0">
|
||||
By default, the Discord app blocks the Dev Tools shortcut. The button below changes a hidden setting to unblock the shortcut.
|
||||
</TextBlock>
|
||||
<Button Command="{Binding OnClickToggleAppDevTools}" Content="{Binding ToggleAppDevToolsButtonText}" IsEnabled="{Binding IsToggleAppDevToolsButtonEnabled}" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
<Expander Header="Method 2: Userscript" IsExpanded="True">
|
||||
<StackPanel Orientation="Vertical" Spacing="10">
|
||||
<TextBlock>
|
||||
Requires a userscript manager in your browser. The userscript adds a DHT icon next to the Help icon on Discord.
|
||||
If the icon does not appear, update this app and reinstall the userscript.
|
||||
</TextBlock>
|
||||
<TextBlock>
|
||||
Copy the Connection Code, click the DHT icon, and paste the code into the prompt.
|
||||
</TextBlock>
|
||||
<WrapPanel ItemSpacing="10" LineSpacing="10">
|
||||
<Button Command="{Binding OnClickInstallOrUpdateUserscript}">Install or Update Userscript</Button>
|
||||
<Button x:Name="CopyConnectionCode" Click="CopyConnectionScriptButton_OnClick">Copy Connection Code</Button>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
@@ -8,24 +9,36 @@ namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class TrackingPage : UserControl {
|
||||
private bool isCopyingScript;
|
||||
|
||||
private readonly HashSet<Button> copyingButtons = new (ReferenceEqualityComparer.Instance);
|
||||
|
||||
public TrackingPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
|
||||
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||
await HandleCopyButton(CopyTrackingScript, "Script Copied!", static model => model.OnClickCopyTrackingScript());
|
||||
}
|
||||
|
||||
public async void CopyConnectionScriptButton_OnClick(object? sender, RoutedEventArgs e) {
|
||||
await HandleCopyButton(CopyConnectionCode, "Code Copied!", static model => model.OnClickCopyConnectionCode());
|
||||
}
|
||||
|
||||
private async Task HandleCopyButton(Button button, string copiedText, Func<TrackingPageModel, Task<bool>> onClick) {
|
||||
if (DataContext is TrackingPageModel model) {
|
||||
var originalText = CopyTrackingScript.Content;
|
||||
CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
|
||||
|
||||
if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
|
||||
isCopyingScript = true;
|
||||
CopyTrackingScript.Content = "Script Copied!";
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
CopyTrackingScript.Content = originalText;
|
||||
isCopyingScript = false;
|
||||
object? originalText = button.Content;
|
||||
button.MinWidth = button.Bounds.Width;
|
||||
|
||||
if (await onClick(model) && copyingButtons.Add(button)) {
|
||||
button.IsEnabled = false;
|
||||
button.Content = copiedText;
|
||||
|
||||
try {
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
} finally {
|
||||
copyingButtons.Remove(button);
|
||||
button.IsEnabled = true;
|
||||
button.Content = originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,85 +1,66 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Avalonia.Input.Platform;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Discord;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Utils.Logging;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
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))]
|
||||
sealed partial class TrackingPageModel {
|
||||
private static readonly Log Log = Log.ForType<TrackingPageModel>();
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private bool? areDevToolsEnabled = null;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||
|
||||
|
||||
public string OpenDevToolsShortcutText { get; } = OperatingSystem.IsMacOS() ? "Cmd+Shift+I" : "Ctrl+Shift+I";
|
||||
|
||||
[DependsOn(nameof(AreDevToolsEnabled), nameof(IsToggleAppDevToolsButtonEnabled))]
|
||||
public string ToggleAppDevToolsButtonText {
|
||||
get {
|
||||
if (!AreDevToolsEnabled.HasValue) {
|
||||
return "Loading...";
|
||||
}
|
||||
|
||||
|
||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
return AreDevToolsEnabled.Value ? "Disable 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 url = ServerConfiguration.HttpHost + $"/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
|
||||
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
||||
|
||||
var clipboard = window.Clipboard;
|
||||
if (clipboard == null) {
|
||||
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await clipboard.SetTextAsync(script);
|
||||
return true;
|
||||
} catch {
|
||||
await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
|
||||
return false;
|
||||
}
|
||||
return await TryCopy(script, "Copy Tracking Script");
|
||||
}
|
||||
|
||||
|
||||
private async Task InitializeDevToolsToggle() {
|
||||
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
|
||||
|
||||
|
||||
if (devToolsEnabled.HasValue) {
|
||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||
IsToggleAppDevToolsButtonEnabled = true;
|
||||
@@ -88,46 +69,86 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnClickInstallOrUpdateUserscript() {
|
||||
try {
|
||||
SystemUtils.OpenUrl(ServerConfiguration.HttpHost + "/get-userscript/dht.user.js");
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, "Install or Update Userscript", "Could not open the browser: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[a-zA-Z0-9]{1,100}$")]
|
||||
private static partial Regex ConnectionCodeTokenRegex();
|
||||
|
||||
public async Task<bool> OnClickCopyConnectionCode() {
|
||||
const string Title = "Copy Connection Code";
|
||||
|
||||
if (ConnectionCodeTokenRegex().IsMatch(ServerConfiguration.Token)) {
|
||||
return await TryCopy(ServerConfiguration.Port + ":" + ServerConfiguration.Token, Title);
|
||||
}
|
||||
else {
|
||||
await Dialog.ShowOk(window, Title, "The internal server token cannot be used to create a connection code.\n\nCheck the 'Advanced' tab and ensure the token is 1-100 characters long, and only contains plain letters and numbers.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryCopy(string script, string errorDialogTitle) {
|
||||
IClipboard? clipboard = window.Clipboard;
|
||||
if (clipboard == null) {
|
||||
await Dialog.ShowOk(window, errorDialogTitle, "Clipboard is not available on this system.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await clipboard.SetTextAsync(script);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, errorDialogTitle, "An error occurred while copying to clipboard.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,6 @@ using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
@@ -12,68 +11,91 @@ using DHT.Desktop.Server;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Service.Viewer;
|
||||
using DHT.Utils.Logging;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||
sealed partial class ViewerPageModel : IDisposable {
|
||||
private static readonly Log Log = Log.ForType<ViewerPageModel>();
|
||||
|
||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||
|
||||
[ObservableProperty]
|
||||
|
||||
[Notify]
|
||||
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();
|
||||
SystemUtils.OpenUrl(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
|
||||
SystemUtils.OpenUrl(ServerConfiguration.HttpHost + "/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));
|
||||
|
||||
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);
|
||||
try {
|
||||
MessageFilter filter = FilterModel.CreateFilter();
|
||||
long messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
||||
|
||||
if (DatabaseToolFilterModeKeep) {
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (DatabaseToolFilterModeRemove) {
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
|
||||
else if (DatabaseToolFilterModeRemove) {
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Apply Filters", "Could not apply filters: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
|
||||
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
|
||||
await ProgressDialog.Show(window, "Apply Filters", async (_, callback) => {
|
||||
await callback.UpdateIndeterminate("Removing messages...");
|
||||
Log.Info("Removed messages: " + await state.Db.Messages.Remove(filter, removalMode));
|
||||
|
||||
await callback.UpdateIndeterminate("Cleaning up attachments...");
|
||||
Log.Info("Removed orphaned attachments: " + await state.Db.Messages.RemoveUnreachableAttachments());
|
||||
|
||||
await callback.UpdateIndeterminate("Cleaning up users...");
|
||||
Log.Info("Removed orphaned users: " + await state.Db.Users.RemoveUnreachable());
|
||||
|
||||
await callback.UpdateIndeterminate("Cleaning up channels...");
|
||||
Log.Info("Removed orphaned channels: " + await state.Db.Channels.RemoveUnreachable());
|
||||
|
||||
await callback.UpdateIndeterminate("Cleaning up servers...");
|
||||
Log.Info("Removed orphaned servers: " + await state.Db.Servers.RemoveUnreachable());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -40,13 +40,13 @@
|
||||
<Style Selector="TabItem:pointerover /template/ Border">
|
||||
<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" />
|
||||
|
@@ -10,72 +10,68 @@ namespace DHT.Desktop.Main.Screens;
|
||||
sealed class MainContentScreenModel : IAsyncDisposable {
|
||||
public DatabasePage DatabasePage { get; }
|
||||
private DatabasePageModel DatabasePageModel { get; }
|
||||
|
||||
|
||||
public TrackingPage TrackingPage { get; }
|
||||
private TrackingPageModel TrackingPageModel { get; }
|
||||
|
||||
|
||||
public DownloadsPage DownloadsPage { get; }
|
||||
private DownloadsPageModel DownloadsPageModel { get; }
|
||||
|
||||
|
||||
public ViewerPage ViewerPage { get; }
|
||||
private ViewerPageModel ViewerPageModel { get; }
|
||||
|
||||
|
||||
public AdvancedPage AdvancedPage { get; }
|
||||
private AdvancedPageModel AdvancedPageModel { get; }
|
||||
|
||||
|
||||
public DebugPage? DebugPage { get; }
|
||||
|
||||
|
||||
#if DEBUG
|
||||
public bool HasDebugPage => true;
|
||||
private DebugPageModel DebugPageModel { get; }
|
||||
#else
|
||||
public bool HasDebugPage => false;
|
||||
#endif
|
||||
|
||||
|
||||
public StatusBarModel StatusBarModel { get; }
|
||||
|
||||
|
||||
public event EventHandler? DatabaseClosed {
|
||||
add {
|
||||
DatabasePageModel.DatabaseClosed += value;
|
||||
}
|
||||
remove {
|
||||
DatabasePageModel.DatabaseClosed -= value;
|
||||
}
|
||||
add => DatabasePageModel.DatabaseClosed += value;
|
||||
remove => DatabasePageModel.DatabaseClosed -= value;
|
||||
}
|
||||
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
||||
|
||||
|
||||
public MainContentScreenModel(Window window, State state) {
|
||||
DatabasePageModel = new DatabasePageModel(window, state);
|
||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||
|
||||
|
||||
TrackingPageModel = new TrackingPageModel(window);
|
||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||
|
||||
DownloadsPageModel = new DownloadsPageModel(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 async Task Initialize() {
|
||||
await DownloadsPageModel.Initialize();
|
||||
}
|
||||
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
await DownloadsPageModel.DisposeAsync();
|
||||
ViewerPageModel.Dispose();
|
||||
|
@@ -30,7 +30,7 @@
|
||||
<Panel Name="RootPanel">
|
||||
<StackPanel Margin="42 30">
|
||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
|
||||
|
||||
|
||||
<Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
|
||||
<Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>
|
||||
|
@@ -5,42 +5,43 @@ 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.Desktop.Main.Dialogs;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Sqlite.Schema;
|
||||
using DHT.Server.Database.Sqlite.Utils;
|
||||
using DHT.Utils.Logging;
|
||||
using PropertyChanged.SourceGenerator;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
sealed partial class WelcomeScreenModel {
|
||||
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
|
||||
|
||||
|
||||
public string Version => Program.Version;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
|
||||
[Notify(Setter.Private)]
|
||||
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||
|
||||
|
||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
|
||||
private string? dbFilePath;
|
||||
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public WelcomeScreenModel() : this(null!) {}
|
||||
|
||||
|
||||
public WelcomeScreenModel(Window window) {
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
|
||||
public async Task OpenOrCreateDatabase() {
|
||||
IsOpenOrCreateDatabaseButtonEnabled = false;
|
||||
try {
|
||||
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||
string? path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||
if (path != null) {
|
||||
await OpenOrCreateDatabaseFromPath(path);
|
||||
}
|
||||
@@ -48,29 +49,30 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
IsOpenOrCreateDatabaseButtonEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||
dbFilePath = path;
|
||||
|
||||
bool isNew = !File.Exists(path);
|
||||
|
||||
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||
if (db == null) {
|
||||
return;
|
||||
|
||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||
if (db != null) {
|
||||
DatabaseSelected?.Invoke(this, db);
|
||||
}
|
||||
|
||||
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
|
||||
await db.Settings.Set(SettingsKey.DownloadsAutoStart, true);
|
||||
}
|
||||
|
||||
DatabaseSelected?.Invoke(this, db);
|
||||
}
|
||||
|
||||
|
||||
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
|
||||
public async Task<InitialDatabaseSettings?> GetInitialDatabaseSettings() {
|
||||
var model = new NewDatabaseSettingsDialogModel();
|
||||
|
||||
var dialog = new NewDatabaseSettingsDialog { DataContext = model };
|
||||
await dialog.ShowDialog(window);
|
||||
|
||||
return new InitialDatabaseSettings(model.SeparateFileForDownloads, model.DownloadsAutoStart);
|
||||
}
|
||||
|
||||
public async Task<bool> CanUpgrade() {
|
||||
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
|
||||
}
|
||||
|
||||
|
||||
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
|
||||
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
|
||||
var reporter = new ProgressReporter(versionSteps, callbacks);
|
||||
@@ -79,28 +81,28 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
await doUpgrade(reporter);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||
}
|
||||
|
||||
|
||||
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
||||
}
|
||||
|
||||
|
||||
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||
private int versionProgress = 0;
|
||||
|
||||
|
||||
public async Task NextVersion() {
|
||||
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
|
||||
await HideChildren(0);
|
||||
}
|
||||
|
||||
|
||||
public async Task MainWork(string message, int finishedItems, int totalItems) {
|
||||
await callbacks[1].Update(message, finishedItems, totalItems);
|
||||
await HideChildren(1);
|
||||
}
|
||||
|
||||
|
||||
public async Task SubWork(string message, int finishedItems, int totalItems) {
|
||||
await callbacks[2].Update(message, finishedItems, totalItems);
|
||||
await HideChildren(2);
|
||||
}
|
||||
|
||||
|
||||
private async Task HideChildren(int parentIndex) {
|
||||
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
|
||||
await callbacks[i].Hide();
|
||||
@@ -108,40 +110,34 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task CheckUpdates() {
|
||||
Version? latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => {
|
||||
var client = new HttpClient(new SocketsHttpHandler {
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false
|
||||
string response;
|
||||
try {
|
||||
response = await ProgressDialog.ShowIndeterminate<string>(window, "Check Updates", "Checking for updates...", static async _ => {
|
||||
var client = new HttpClient(new SocketsHttpHandler {
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
});
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.MaxResponseContentBufferSize = 1024;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
|
||||
|
||||
return await client.GetStringAsync(Program.Website + "/version");
|
||||
});
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.MaxResponseContentBufferSize = 1024;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
|
||||
|
||||
string response;
|
||||
try {
|
||||
response = await client.GetStringAsync(Program.Website + "/version");
|
||||
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(response, out var latestVersion)) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
});
|
||||
|
||||
if (latestVersion == null) {
|
||||
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(response, out Version? latestVersion)) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,11 +151,11 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
Exit();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task ShowAboutDialog() {
|
||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
||||
}
|
||||
|
||||
|
||||
public void Exit() {
|
||||
window.Close();
|
||||
}
|
||||
|
@@ -13,21 +13,21 @@ static class Program {
|
||||
public static CultureInfo Culture { get; }
|
||||
public static ResourceLoader Resources { get; }
|
||||
public static Arguments Arguments { get; }
|
||||
|
||||
|
||||
public const string Website = "https://dht.chylex.com";
|
||||
|
||||
static Program() {
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
AssemblyVersion = assembly.GetName().Version ?? new Version(0, 0, 0, 0);
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -41,12 +41,12 @@ static class Program {
|
||||
|
||||
return versionStr;
|
||||
}
|
||||
|
||||
|
||||
public static void Main(string[] args) {
|
||||
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||
WindowsConsole.AllocConsole();
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
} finally {
|
||||
@@ -55,7 +55,7 @@ static class Program {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp() {
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
|
@@ -3,6 +3,8 @@ 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);
|
||||
|
||||
public static string HttpHost => "http://127.0.0.1:" + Port;
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<LangVersion>12</LangVersion>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<LangVersion>13</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -21,12 +21,13 @@
|
||||
<PropertyGroup>
|
||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<TrimMode>partial</TrimMode>
|
||||
<TrimMode>full</TrimMode>
|
||||
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
|
||||
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
|
||||
<EventSourceSupport>false</EventSourceSupport>
|
||||
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<XmlResolverIsNetworkingEnabledByDefault>false</XmlResolverIsNetworkingEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -39,11 +40,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>
|
||||
|
273
app/Resources/Tracker/loader/dht.user.js
Normal file
273
app/Resources/Tracker/loader/dht.user.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// ==UserScript==
|
||||
// @name Discord History Tracker
|
||||
// @license MIT
|
||||
// @namespace https://chylex.com
|
||||
// @homepageURL https://dht.chylex.com/
|
||||
// @supportURL https://github.com/chylex/Discord-History-Tracker/issues
|
||||
// @include https://discord.com/*
|
||||
// @run-at document-idle
|
||||
// @grant none
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
|
||||
function startMutationObserver(callback) {
|
||||
let hasDebounceStarted = false;
|
||||
|
||||
function handleMutations(mutations, observer) {
|
||||
if (hasDebounceStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasDebounceStarted = true;
|
||||
window.setTimeout(() => {
|
||||
hasDebounceStarted = false;
|
||||
callback(observer);
|
||||
}, 20);
|
||||
}
|
||||
|
||||
new MutationObserver(handleMutations).observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
startMutationObserver(observer => {
|
||||
const triggerButton = installConnectDialogButton();
|
||||
if (triggerButton === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
|
||||
startMutationObserver(() => {
|
||||
if (!triggerButton.isConnected) {
|
||||
getHelpIcon()?.parentElement.parentElement.insertAdjacentElement("afterend", triggerButton);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function installConnectDialogButton() {
|
||||
const helpIcon = getHelpIcon();
|
||||
if (!helpIcon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const helpIconWrapper = helpIcon.parentElement;
|
||||
const helpIconLink = helpIconWrapper.parentElement;
|
||||
|
||||
helpIconLink.insertAdjacentHTML("afterend", `
|
||||
<div id="dht-connector-show-dialog" role="button" class="${helpIconWrapper.getAttribute("class")}">
|
||||
<svg viewBox="0 0 24 20" fill="currentColor" class="${helpIcon.getAttribute("class")}" style="width: auto;">
|
||||
<path d="M7.446,9.924c-0,0.846 -0.105,1.593 -0.315,2.234c-0.21,0.644 -0.497,1.181 -0.86,1.615c-0.365,0.431 -0.795,0.761 -1.292,0.982c-0.494,0.22 -1.029,0.332 -1.605,0.332l-3.374,-0l0,-10.174l3.021,0c0.645,0 1.239,0.1 1.781,0.296c0.542,0.198 1.011,0.501 1.4,0.911c0.391,0.408 0.695,0.928 0.914,1.56c0.22,0.629 0.33,1.378 0.33,2.244Zm-1.765,-0c0,-0.592 -0.067,-1.1 -0.198,-1.524c-0.134,-0.422 -0.318,-0.77 -0.554,-1.042c-0.236,-0.272 -0.518,-0.473 -0.848,-0.604c-0.332,-0.128 -0.697,-0.195 -1.096,-0.195l-1.237,-0l0,6.882l1.483,0c0.351,0 0.676,-0.076 0.977,-0.224c0.298,-0.15 0.556,-0.372 0.776,-0.668c0.22,-0.295 0.389,-0.663 0.513,-1.101c0.122,-0.439 0.184,-0.947 0.184,-1.524Z"/>
|
||||
<path d="M14.147,15.087l-0,-4.362l-3.637,-0l-0,4.362l-1.748,-0l-0,-10.174l1.748,0l-0,4.052l3.637,-0l-0,-4.052l1.75,0l0,10.174l-1.75,-0Z"/>
|
||||
<path d="M21.297,6.559l-0,8.528l-1.748,-0l-0,-8.528l-2.699,-0l0,-1.646l7.15,0l0,1.646l-2.703,-0Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const button = document.getElementById("dht-connector-show-dialog");
|
||||
button.addEventListener("click", showConnectDialog);
|
||||
return button;
|
||||
}
|
||||
|
||||
function getHelpIcon() {
|
||||
return document.querySelector("div[class*='bar_'] a[href$='://support.discord.com'] > div[class*='clickable'] > svg");
|
||||
}
|
||||
|
||||
function showConnectDialog() {
|
||||
if (window.DHT_LOADED) {
|
||||
alert("Discord History Tracker is already loaded.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogElement = document.createElement("dialog");
|
||||
dialogElement.id = "dht-connector-dialog";
|
||||
dialogElement.innerHTML = `
|
||||
<form id="dht-connector-dialog-form">
|
||||
<label for="dht-connector-dialog-input-code">Connection Code</label>
|
||||
<input id="dht-connector-dialog-input-code" type="text">
|
||||
<p id="dht-connector-dialog-input-error"></p>
|
||||
|
||||
<div id="dht-connector-dialog-buttons">
|
||||
<button type="submit" id="dht-connector-dialog-button-connect">Connect</button>
|
||||
<button type="button" id="dht-connector-dialog-button-close">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<style>
|
||||
#dht-connector-dialog {
|
||||
width: 300px;
|
||||
padding: 18px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: #313338;
|
||||
}
|
||||
|
||||
#dht-connector-dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
#dht-connector-dialog label {
|
||||
display: block;
|
||||
margin: 0 0 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .02em;
|
||||
color: #fbfbfb;
|
||||
}
|
||||
|
||||
#dht-connector-dialog-input-error {
|
||||
margin: 11px 1px;
|
||||
color: #ff484c;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#dht-connector-dialog input[type="text"] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 12px 10px;
|
||||
border: 1px solid rgba(151, 151, 159, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #efeff0;
|
||||
background-color: #1d1d21;
|
||||
}
|
||||
|
||||
#dht-connector-dialog-buttons {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#dht-connector-dialog button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background-color: #5865f2;
|
||||
transition: background-color 200ms ease, color 200ms ease;
|
||||
}
|
||||
|
||||
#dht-connector-dialog button:hover {
|
||||
background-color: #4752c4;
|
||||
}
|
||||
|
||||
#dht-connector-dialog button[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentElement("beforeend", dialogElement);
|
||||
|
||||
const formElement = document.getElementById("dht-connector-dialog-form");
|
||||
const codeErrorElement = document.getElementById("dht-connector-dialog-input-error");
|
||||
const codeInputElement = document.getElementById("dht-connector-dialog-input-code");
|
||||
const connectButtonElement = document.getElementById("dht-connector-dialog-button-connect");
|
||||
const closeButtonElement = document.getElementById("dht-connector-dialog-button-close");
|
||||
|
||||
dialogElement.addEventListener("close", function() {
|
||||
dialogElement.remove();
|
||||
});
|
||||
|
||||
closeButtonElement.addEventListener("click", function() {
|
||||
dialogElement.close();
|
||||
});
|
||||
|
||||
codeInputElement.addEventListener("paste", function() {
|
||||
setTimeout(async function() {
|
||||
const code = parseConnectionCode(codeInputElement.value);
|
||||
if (code !== null) {
|
||||
await onSubmit(code);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
formElement.addEventListener("submit", async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const code = parseConnectionCode(codeInputElement.value);
|
||||
if (code === null) {
|
||||
codeErrorElement.innerText = "Code is not valid.";
|
||||
}
|
||||
else {
|
||||
await onSubmit(code);
|
||||
}
|
||||
});
|
||||
|
||||
let isSubmitting = false;
|
||||
|
||||
async function onSubmit(code) {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
connectButtonElement.setAttribute("disabled", "");
|
||||
try {
|
||||
await loadTrackingScript(code);
|
||||
} catch (e) {
|
||||
onError("Could not load tracking script.", e);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
connectButtonElement.removeAttribute("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrackingScript(code) {
|
||||
const trackingScript = await getTrackingScript(code.port, code.token);
|
||||
|
||||
if (dialogElement.isConnected) {
|
||||
// noinspection DynamicallyGeneratedCodeJS
|
||||
eval(trackingScript);
|
||||
dialogElement.close();
|
||||
}
|
||||
}
|
||||
|
||||
function onError(message, e) {
|
||||
console.error(message, e);
|
||||
codeErrorElement.innerText = message;
|
||||
}
|
||||
|
||||
dialogElement.showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @return {?{port: number, token: string}}
|
||||
*/
|
||||
function parseConnectionCode(code) {
|
||||
if (code.length > 5 + 1 + 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = code.match(/^(\d{1,5}):([a-zA-Z0-9]{1,100})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = Number(match[1]);
|
||||
if (port < 0 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { port, token: match[2] };
|
||||
}
|
||||
|
||||
async function getTrackingScript(port, token) {
|
||||
const url = "http://127.0.0.1:" + port + "/get-tracking-script?token=" + encodeURIComponent(token);
|
||||
const response = await fetch(url, {
|
||||
credentials: "omit",
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw response.status + " " + response.statusText;
|
||||
}
|
||||
|
||||
if (response.headers.get("X-DHT") !== "1") {
|
||||
throw "Invalid response";
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
@@ -142,11 +142,19 @@ const STATE = (function() {
|
||||
if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
|
||||
server.id = channelInfo.id;
|
||||
server.name = channel.name = getPrivateChannelName(channelInfo);
|
||||
|
||||
if (channelInfo.icon) {
|
||||
server.icon = channelInfo.icon;
|
||||
}
|
||||
}
|
||||
else if (serverInfo) {
|
||||
server.id = serverInfo.id;
|
||||
server.name = serverInfo.name;
|
||||
channel.name = channelInfo.name;
|
||||
|
||||
if (serverInfo.icon) {
|
||||
server.icon = serverInfo.icon;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return;
|
||||
|
@@ -2,6 +2,7 @@
|
||||
* @name DiscordGuild
|
||||
* @property {String} id
|
||||
* @property {String} name
|
||||
* @property {String|null|undefined} [icon]
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,7 @@
|
||||
* @property {Number} [position]
|
||||
* @property {String} [topic]
|
||||
* @property {Boolean} [nsfw]
|
||||
* @property {String|null|undefined} [icon]
|
||||
* @property {DiscordUser[]} [rawRecipients]
|
||||
*/
|
||||
|
||||
|
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* https://github.com/BetterDiscord/BetterDiscord/blob/2752daf64f98625fc67c569361bd56021307d058/renderer/src/modules/webpackmodules.js
|
||||
* https://github.com/BetterDiscord/BetterDiscord/blob/2752daf64f98625fc67c569361bd56021307d058/LICENSE
|
||||
*/
|
||||
class WEBPACK {
|
||||
static get require() {
|
||||
@@ -17,13 +17,15 @@ class WEBPACK {
|
||||
*/
|
||||
let hookedRequire;
|
||||
|
||||
const id = "dht-webpackmodules-" + new Date().getTime();
|
||||
const id = Symbol("dht-webpackmodules-" + new Date().getTime());
|
||||
if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") {
|
||||
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => hookedRequire = internalRequire ]);
|
||||
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => {
|
||||
if ("b" in internalRequire) {
|
||||
hookedRequire = internalRequire;
|
||||
}
|
||||
}]);
|
||||
}
|
||||
|
||||
delete hookedRequire.m[id];
|
||||
delete hookedRequire.c[id];
|
||||
return this._require = hookedRequire;
|
||||
}
|
||||
|
||||
@@ -91,8 +93,8 @@ class WEBPACK {
|
||||
}
|
||||
|
||||
static findFunction(name, additionalRequiredProps) {
|
||||
const searchedProps = additionalRequiredProps ? [name, ...additionalRequiredProps] : [name];
|
||||
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof(prop) === "function", ...searchedProps));
|
||||
const searchedProps = additionalRequiredProps ? [ name, ...additionalRequiredProps ] : [ name ];
|
||||
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof (prop) === "function", ...searchedProps));
|
||||
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
|
||||
}
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@
|
||||
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
<link rel="stylesheet" href="styles/menu.css">
|
||||
<link rel="stylesheet" href="styles/servers.css">
|
||||
<link rel="stylesheet" href="styles/channels.css">
|
||||
<link rel="stylesheet" href="styles/messages.css">
|
||||
<link rel="stylesheet" href="styles/modal.css">
|
||||
@@ -75,6 +76,7 @@
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<div id="servers"></div>
|
||||
<div id="channels">
|
||||
<div class="loading"></div>
|
||||
</div>
|
||||
@@ -86,4 +88,4 @@
|
||||
<div id="dialog"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
@@ -2,6 +2,7 @@ import discord from "./discord.mjs";
|
||||
import gui from "./gui.mjs";
|
||||
import state from "./state.mjs";
|
||||
import "./polyfills.mjs";
|
||||
import servers from "./servers.mjs";
|
||||
|
||||
window.DISCORD = discord;
|
||||
|
||||
@@ -25,10 +26,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
state.onUsersRefreshed(users => {
|
||||
gui.updateUserList(users);
|
||||
servers.update()
|
||||
});
|
||||
|
||||
state.onChannelsRefreshed((channels, selected) => {
|
||||
gui.updateChannelList(channels, selected, state.selectChannel);
|
||||
servers.update()
|
||||
});
|
||||
|
||||
state.onMessagesRefreshed(messages => {
|
||||
|
@@ -113,16 +113,17 @@ export default (function() {
|
||||
return {
|
||||
setup() {
|
||||
templateChannelServer = new template([
|
||||
"<div class='channel' data-channel='{id}'>",
|
||||
"<div class='channel ServerChannel' data-channel='{id}' server-id='{serverId}' server-name='{server.name}' server-type='{server.type}'>",
|
||||
"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>",
|
||||
"<span class='server'>{server.name} ({server.type})</span>",
|
||||
"<!--<span class='server'>{server.name} ({server.type})</span>-->",
|
||||
"</div>"
|
||||
].join(""));
|
||||
|
||||
templateChannelPrivate = new template([
|
||||
"<div class='channel' data-channel='{id}'>",
|
||||
"<div class='channel UserChannel' data-channel='{id}' server-id='0' server-name='{server.name}' server-type='{server.type}'>",
|
||||
"<div class='avatar'>{icon}</div>",
|
||||
"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>",
|
||||
"<span class='server'>({server.type})</span>",
|
||||
"<!--<span class='server'>{server.name} ({server.type})</span>-->",
|
||||
"</div>"
|
||||
].join(""));
|
||||
|
||||
@@ -166,7 +167,7 @@ export default (function() {
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateEmbedImageWithSize = new template([
|
||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>"
|
||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
@@ -298,13 +299,13 @@ export default (function() {
|
||||
|
||||
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
|
||||
}
|
||||
else if (property === "reactions"){
|
||||
if (!value){
|
||||
else if (property === "reactions") {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "<div class='reactions'>" + value.map(reaction => {
|
||||
if ("id" in reaction){
|
||||
if ("id" in reaction) {
|
||||
const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp";
|
||||
const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
109
app/Resources/Viewer/scripts/servers.mjs
Normal file
109
app/Resources/Viewer/scripts/servers.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import state from "./state.mjs";
|
||||
|
||||
const servers = (() => {
|
||||
let currentServerId = "0";
|
||||
|
||||
function getIcon(name) {
|
||||
return name.split(" ").map(word => {
|
||||
if (word.startsWith("[") && word.length > 1) return word[1];
|
||||
return word[0] || "";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function update() {
|
||||
const channels = document.querySelectorAll("#channels .channel");
|
||||
const serversMap = new Map();
|
||||
|
||||
// Check if there are any channels with server-id 0 (DM)
|
||||
const hasDMChannels = Array.from(channels).some(channel => {
|
||||
return channel.getAttribute("server-id") === "0";
|
||||
});
|
||||
|
||||
if (hasDMChannels) {
|
||||
serversMap.set("0", {
|
||||
id: "0",
|
||||
name: "DM",
|
||||
icon: "DM",
|
||||
});
|
||||
}
|
||||
|
||||
channels.forEach(channel => {
|
||||
const serverId = channel.getAttribute("server-id") || "0";
|
||||
const serverType = channel.getAttribute("server-type");
|
||||
const serverName = channel.getAttribute("server-name");
|
||||
|
||||
if (serverType === "server" && !serversMap.has(serverId)) {
|
||||
serversMap.set(serverId, {
|
||||
id: serverId,
|
||||
name: serverName,
|
||||
icon: getIcon(serverName),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const serversDiv = document.getElementById("servers");
|
||||
serversDiv.innerHTML = "";
|
||||
|
||||
if (hasDMChannels) {
|
||||
const dmServer = serversMap.get("0");
|
||||
const dmElement = document.createElement("div");
|
||||
dmElement.className = `Server${dmServer.id === currentServerId ? " active" : ""}`;
|
||||
dmElement.id = "DM";
|
||||
dmElement.dataset.serverId = dmServer.id;
|
||||
dmElement.innerHTML = `
|
||||
<div class="icon">DM</div>
|
||||
<div class="name" title="Direct Messages">Direct Messages</div>
|
||||
`;
|
||||
dmElement.addEventListener("click", () => selectServer(dmServer.id));
|
||||
serversDiv.appendChild(dmElement);
|
||||
}
|
||||
|
||||
serversMap.forEach(server => {
|
||||
if (server.id === "0") return; // Skip DM since it's already added
|
||||
|
||||
const serverElement = document.createElement("div");
|
||||
serverElement.className = `Server${server.id === currentServerId ? " active" : ""}`;
|
||||
serverElement.dataset.serverId = server.id;
|
||||
serverElement.innerHTML = `
|
||||
<div class="icon">${server.icon}</div>
|
||||
<div class="name" title="${server.name}">${server.name}</div>
|
||||
`;
|
||||
serverElement.addEventListener("click", () => selectServer(server.id));
|
||||
serversDiv.appendChild(serverElement);
|
||||
});
|
||||
|
||||
if (!serversMap.has(currentServerId)) {
|
||||
currentServerId = "0";
|
||||
}
|
||||
|
||||
updateChannelVisibility();
|
||||
}
|
||||
|
||||
function selectServer(serverId) {
|
||||
// Remove active class from all servers
|
||||
document.querySelectorAll("#servers .Server").forEach(server => {
|
||||
server.classList.remove("active");
|
||||
});
|
||||
|
||||
// Add active class to the selected server
|
||||
const selectedServer = document.querySelector(`#servers .Server[data-server-id="${serverId}"]`);
|
||||
if (selectedServer) {
|
||||
selectedServer.classList.add("active");
|
||||
}
|
||||
|
||||
currentServerId = serverId;
|
||||
updateChannelVisibility();
|
||||
state.selectChannel(null);
|
||||
}
|
||||
|
||||
function updateChannelVisibility() {
|
||||
document.querySelectorAll("#channels .channel").forEach(channel => {
|
||||
const channelServerId = channel.getAttribute("server-id") || "0";
|
||||
channel.classList.toggle("visible", channelServerId === currentServerId);
|
||||
});
|
||||
}
|
||||
|
||||
return { update };
|
||||
})();
|
||||
|
||||
export default servers;
|
@@ -2,192 +2,229 @@ import settings from "./settings.mjs";
|
||||
import processor from "./processor.mjs";
|
||||
|
||||
// noinspection FunctionWithInconsistentReturnsJS
|
||||
export default (function() {
|
||||
export default (function () {
|
||||
/**
|
||||
* @type {{}}
|
||||
* @property {{}} users
|
||||
* @property {{}} servers
|
||||
* @property {{}} channels
|
||||
*/
|
||||
|
||||
|
||||
const fileUrlProcessor = function (serverToken) {
|
||||
if (typeof serverToken === "string") {
|
||||
return url => "/get-downloaded-file/" + encodeURIComponent(url) + "?token=" + encodeURIComponent(serverToken);
|
||||
}
|
||||
else {
|
||||
return url => url;
|
||||
}
|
||||
}(window.DHT_SERVER_TOKEN);
|
||||
|
||||
|
||||
let loadedFileMeta;
|
||||
let loadedFileData;
|
||||
|
||||
|
||||
let loadedMessages;
|
||||
|
||||
|
||||
let filterFunction;
|
||||
let selectedChannel;
|
||||
let currentPage;
|
||||
let messagesPerPage;
|
||||
|
||||
const getUser = function(id) {
|
||||
|
||||
const getUser = function (id) {
|
||||
return loadedFileMeta.users[id] || { "name": "<unknown>" };
|
||||
};
|
||||
|
||||
const getUserList = function() {
|
||||
|
||||
const getUserList = function () {
|
||||
return loadedFileMeta ? loadedFileMeta.users : [];
|
||||
};
|
||||
|
||||
const getServer = function(id) {
|
||||
|
||||
const getServer = function (id) {
|
||||
return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" };
|
||||
};
|
||||
|
||||
const generateChannelHierarchy = function() {
|
||||
|
||||
const getUserIDByName = function (name) {
|
||||
for (let userId in loadedFileMeta.users) {
|
||||
let user = loadedFileMeta.users[userId];
|
||||
if (user.name === name) {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getUserByName = function (name) {
|
||||
for (let userId in loadedFileMeta.users) {
|
||||
let user = loadedFileMeta.users[userId];
|
||||
if (user.name === name) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const generateChannelHierarchy = function () {
|
||||
/**
|
||||
* @type {Map<string, Set>}
|
||||
*/
|
||||
const hierarchy = new Map();
|
||||
|
||||
|
||||
if (!loadedFileMeta) {
|
||||
return hierarchy;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @returns {Set}
|
||||
*/
|
||||
function getChildren(parentId) {
|
||||
let children = hierarchy.get(parentId);
|
||||
|
||||
|
||||
if (!children) {
|
||||
children = new Set();
|
||||
hierarchy.set(parentId, children);
|
||||
}
|
||||
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
for (const [ id, channel ] of Object.entries(loadedFileMeta.channels)) {
|
||||
|
||||
for (const [id, channel] of Object.entries(loadedFileMeta.channels)) {
|
||||
getChildren(channel.parent || "").add(id);
|
||||
}
|
||||
|
||||
|
||||
const unreachableIds = new Set(hierarchy.keys());
|
||||
|
||||
|
||||
function reachIds(parentId) {
|
||||
unreachableIds.delete(parentId);
|
||||
|
||||
|
||||
const children = hierarchy.get(parentId);
|
||||
|
||||
|
||||
if (children) {
|
||||
for (const id of children) {
|
||||
reachIds(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
reachIds("");
|
||||
|
||||
|
||||
const rootChildren = getChildren("");
|
||||
|
||||
|
||||
for (const unreachableId of unreachableIds) {
|
||||
for (const id of hierarchy.get(unreachableId)) {
|
||||
rootChildren.add(id);
|
||||
}
|
||||
|
||||
|
||||
hierarchy.delete(unreachableId);
|
||||
}
|
||||
|
||||
|
||||
return hierarchy;
|
||||
};
|
||||
|
||||
const generateChannelOrder = function() {
|
||||
|
||||
const generateChannelOrder = function () {
|
||||
if (!loadedFileMeta) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
const channels = loadedFileMeta.channels;
|
||||
const hierarchy = generateChannelHierarchy();
|
||||
|
||||
|
||||
function getSortedSubTree(parentId) {
|
||||
const children = hierarchy.get(parentId);
|
||||
if (!children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const sortedChildren = Array.from(children);
|
||||
|
||||
|
||||
sortedChildren.sort((id1, id2) => {
|
||||
const c1 = channels[id1];
|
||||
const c2 = channels[id2];
|
||||
const s1 = getServer(c1.server);
|
||||
const s2 = getServer(c2.server);
|
||||
|
||||
|
||||
return s1.type.localeCompare(s2.type, "en") ||
|
||||
s1.name.toLocaleLowerCase().localeCompare(s2.name.toLocaleLowerCase(), undefined, { numeric: true }) ||
|
||||
(c1.position || -1) - (c2.position || -1) ||
|
||||
c1.name.toLocaleLowerCase().localeCompare(c2.name.toLocaleLowerCase(), undefined, { numeric: true });
|
||||
});
|
||||
|
||||
|
||||
const subTree = [];
|
||||
|
||||
|
||||
for (const id of sortedChildren) {
|
||||
subTree.push(id);
|
||||
subTree.push(...getSortedSubTree(id));
|
||||
}
|
||||
|
||||
|
||||
return subTree;
|
||||
}
|
||||
|
||||
|
||||
const orderArray = getSortedSubTree("");
|
||||
const orderMap = {};
|
||||
|
||||
|
||||
for (let i = 0; i < orderArray.length; i++) {
|
||||
orderMap[orderArray[i]] = i;
|
||||
}
|
||||
|
||||
|
||||
return orderMap;
|
||||
};
|
||||
|
||||
const getChannelList = function() {
|
||||
|
||||
const getChannelList = function () {
|
||||
if (!loadedFileMeta) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const channels = loadedFileMeta.channels;
|
||||
const channelOrder = generateChannelOrder();
|
||||
|
||||
|
||||
|
||||
return Object.keys(channels).map(key => ({
|
||||
"id": key,
|
||||
"serverId": channels[key].server,
|
||||
"name": channels[key].name,
|
||||
"server": getServer(channels[key].server),
|
||||
"msgcount": getFilteredMessageKeys(key).length,
|
||||
"topic": channels[key].topic || "",
|
||||
"nsfw": channels[key].nsfw || false,
|
||||
"icon": getServer(channels[key].server).type === "group"
|
||||
? `<!--<span>${channels[key].name.split(" ").map(word => word[0]).join("")}</span>-->` //Discord default naming without a icon
|
||||
: `<img src='${fileUrlProcessor("https://cdn.discordapp.com/avatars/" + getUserIDByName(channels[key].name) + "/" + getUserByName(channels[key].name).avatar+".webp")}'>`
|
||||
})).sort((ac, bc) => {
|
||||
return channelOrder[ac.id] - channelOrder[bc.id];
|
||||
});
|
||||
};
|
||||
|
||||
const getMessages = function(channel) {
|
||||
|
||||
const getMessages = function (channel) {
|
||||
return loadedFileData[channel] || {};
|
||||
};
|
||||
|
||||
const getMessageById = function(id) {
|
||||
|
||||
const getMessageById = function (id) {
|
||||
for (const messages of Object.values(loadedFileData)) {
|
||||
if (id in messages) {
|
||||
return messages[id];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getMessageChannel = function(id) {
|
||||
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
|
||||
|
||||
const getMessageChannel = function (id) {
|
||||
for (const [channel, messages] of Object.entries(loadedFileData)) {
|
||||
if (id in messages) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getMessageList = function() {
|
||||
|
||||
const getMessageList = function () {
|
||||
if (!loadedMessages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const messages = getMessages(selectedChannel);
|
||||
const startIndex = messagesPerPage * (root.getCurrentPage() - 1);
|
||||
|
||||
|
||||
return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => {
|
||||
/**
|
||||
* @type {{}}
|
||||
@@ -203,35 +240,35 @@ export default (function() {
|
||||
const message = messages[key];
|
||||
const user = getUser(message.u);
|
||||
const avatar = user.avatar ? { id: message.u, path: user.avatar } : null;
|
||||
|
||||
|
||||
const obj = {
|
||||
user,
|
||||
avatar,
|
||||
"timestamp": message.t,
|
||||
"jump": key,
|
||||
};
|
||||
|
||||
|
||||
if ("m" in message) {
|
||||
obj["contents"] = message.m;
|
||||
}
|
||||
|
||||
|
||||
if ("e" in message) {
|
||||
obj["embeds"] = message.e.map(embed => JSON.parse(embed));
|
||||
}
|
||||
|
||||
|
||||
if ("a" in message) {
|
||||
obj["attachments"] = message.a;
|
||||
}
|
||||
|
||||
|
||||
if ("te" in message) {
|
||||
obj["edit"] = message.te;
|
||||
}
|
||||
|
||||
|
||||
if ("r" in message) {
|
||||
const replyMessage = getMessageById(message.r);
|
||||
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
|
||||
const replyAvatar = replyUser && replyUser.avatar ? { id: replyMessage.u, path: replyUser.avatar } : null;
|
||||
|
||||
|
||||
obj["reply"] = replyMessage ? {
|
||||
"id": message.r,
|
||||
"user": replyUser,
|
||||
@@ -239,212 +276,212 @@ export default (function() {
|
||||
"contents": replyMessage.m
|
||||
} : null;
|
||||
}
|
||||
|
||||
|
||||
if ("re" in message) {
|
||||
obj["reactions"] = message.re;
|
||||
}
|
||||
|
||||
|
||||
return obj;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
let eventOnUsersRefreshed;
|
||||
let eventOnChannelsRefreshed;
|
||||
let eventOnMessagesRefreshed;
|
||||
|
||||
const triggerUsersRefreshed = function() {
|
||||
|
||||
const triggerUsersRefreshed = function () {
|
||||
eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList());
|
||||
};
|
||||
|
||||
const triggerChannelsRefreshed = function(selectedChannel) {
|
||||
|
||||
const triggerChannelsRefreshed = function (selectedChannel) {
|
||||
eventOnChannelsRefreshed && eventOnChannelsRefreshed(getChannelList(), selectedChannel);
|
||||
};
|
||||
|
||||
const triggerMessagesRefreshed = function() {
|
||||
|
||||
const triggerMessagesRefreshed = function () {
|
||||
eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList());
|
||||
};
|
||||
|
||||
const getFilteredMessageKeys = function(channel) {
|
||||
|
||||
const getFilteredMessageKeys = function (channel) {
|
||||
const messages = getMessages(channel);
|
||||
let keys = Object.keys(messages);
|
||||
|
||||
|
||||
if (filterFunction) {
|
||||
keys = keys.filter(key => filterFunction(messages[key]));
|
||||
}
|
||||
|
||||
|
||||
return keys;
|
||||
};
|
||||
|
||||
|
||||
const root = {
|
||||
onChannelsRefreshed(callback) {
|
||||
eventOnChannelsRefreshed = callback;
|
||||
},
|
||||
|
||||
|
||||
onMessagesRefreshed(callback) {
|
||||
eventOnMessagesRefreshed = callback;
|
||||
},
|
||||
|
||||
|
||||
onUsersRefreshed(callback) {
|
||||
eventOnUsersRefreshed = callback;
|
||||
},
|
||||
|
||||
|
||||
uploadFile(meta, data) {
|
||||
if (loadedFileMeta != null) {
|
||||
throw "A file is already loaded!";
|
||||
}
|
||||
|
||||
|
||||
if (typeof meta !== "object" || typeof data !== "object") {
|
||||
throw "Invalid file format!";
|
||||
}
|
||||
|
||||
|
||||
loadedFileMeta = meta;
|
||||
loadedFileData = data;
|
||||
loadedMessages = null;
|
||||
|
||||
|
||||
selectedChannel = null;
|
||||
currentPage = 1;
|
||||
|
||||
|
||||
triggerUsersRefreshed();
|
||||
triggerChannelsRefreshed();
|
||||
triggerMessagesRefreshed();
|
||||
|
||||
|
||||
settings.onSettingsChanged(() => triggerMessagesRefreshed());
|
||||
},
|
||||
|
||||
|
||||
getChannelName(channel) {
|
||||
const channelObj = loadedFileMeta.channels[channel];
|
||||
return (channelObj && channelObj.name) || channel;
|
||||
},
|
||||
|
||||
|
||||
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;
|
||||
|
||||
|
||||
loadedMessages = getFilteredMessageKeys(channel).sort(processor.SORTER.oldestToNewest);
|
||||
triggerMessagesRefreshed();
|
||||
},
|
||||
|
||||
|
||||
setMessagesPerPage(amount) {
|
||||
messagesPerPage = amount;
|
||||
triggerMessagesRefreshed();
|
||||
},
|
||||
|
||||
|
||||
updateCurrentPage(action) {
|
||||
switch (action) {
|
||||
case "first":
|
||||
currentPage = 1;
|
||||
break;
|
||||
|
||||
|
||||
case "prev":
|
||||
currentPage = Math.max(1, currentPage - 1);
|
||||
break;
|
||||
|
||||
|
||||
case "next":
|
||||
currentPage = Math.min(this.getPageCount(), currentPage + 1);
|
||||
break;
|
||||
|
||||
|
||||
case "last":
|
||||
currentPage = this.getPageCount();
|
||||
break;
|
||||
|
||||
|
||||
case "pick":
|
||||
const page = parseInt(prompt("Select page:", currentPage), 10);
|
||||
|
||||
|
||||
if (!page && page !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
currentPage = Math.max(1, Math.min(this.getPageCount(), page));
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
triggerMessagesRefreshed();
|
||||
},
|
||||
|
||||
|
||||
getCurrentPage() {
|
||||
const total = this.getPageCount();
|
||||
|
||||
|
||||
if (currentPage > total && total > 0) {
|
||||
currentPage = total;
|
||||
}
|
||||
|
||||
|
||||
return currentPage || 1;
|
||||
},
|
||||
|
||||
|
||||
getPageCount() {
|
||||
return !loadedMessages ? 0 : (!messagesPerPage ? 1 : Math.ceil(loadedMessages.length / messagesPerPage));
|
||||
},
|
||||
|
||||
|
||||
navigateToMessage(id) {
|
||||
if (!loadedMessages) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
const channel = getMessageChannel(id);
|
||||
|
||||
|
||||
if (channel !== null && channel !== selectedChannel) {
|
||||
triggerChannelsRefreshed(channel);
|
||||
this.selectChannel(channel);
|
||||
}
|
||||
|
||||
|
||||
const index = loadedMessages.indexOf(id);
|
||||
|
||||
|
||||
if (index === -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
currentPage = Math.max(1, Math.min(this.getPageCount(), 1 + Math.floor(index / messagesPerPage)));
|
||||
triggerMessagesRefreshed();
|
||||
return index % messagesPerPage;
|
||||
},
|
||||
|
||||
|
||||
setActiveFilter(filter) {
|
||||
switch (filter ? filter.type : "") {
|
||||
case "user":
|
||||
filterFunction = processor.FILTER.byUser(filter.value);
|
||||
break;
|
||||
|
||||
|
||||
case "contents":
|
||||
filterFunction = processor.FILTER.byContents(filter.value);
|
||||
break;
|
||||
|
||||
|
||||
case "withimages":
|
||||
filterFunction = processor.FILTER.withImages();
|
||||
break;
|
||||
|
||||
|
||||
case "withdownloads":
|
||||
filterFunction = processor.FILTER.withDownloads();
|
||||
break;
|
||||
|
||||
|
||||
case "edited":
|
||||
filterFunction = processor.FILTER.isEdited();
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
filterFunction = null;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
this.hasActiveFilter = filterFunction != null;
|
||||
|
||||
|
||||
triggerChannelsRefreshed(selectedChannel);
|
||||
|
||||
|
||||
if (selectedChannel) {
|
||||
this.selectChannel(selectedChannel); // resets current page and updates messages
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
root.hasActiveFilter = false;
|
||||
return root;
|
||||
})();
|
||||
|
@@ -8,15 +8,21 @@ export default class {
|
||||
};
|
||||
|
||||
apply(obj, processor) {
|
||||
|
||||
//Keys to not escape
|
||||
const allowHTMLKeys = new Set(["icon"]); //Example with more: Set(["icon", "description", "content"]);
|
||||
|
||||
return this.contents.replace(TEMPLATE_REGEX, (full, match) => {
|
||||
const value = match.split(".").reduce((o, property) => o[property], obj);
|
||||
|
||||
if (processor) {
|
||||
const updated = processor(match, value);
|
||||
return typeof updated === "undefined" ? dom.escapeHTML(value) : updated;
|
||||
return typeof updated === "undefined" ? (allowHTMLKeys.has(match) ? value : dom.escapeHTML(value)) : updated;
|
||||
}
|
||||
|
||||
return dom.escapeHTML(value);
|
||||
|
||||
|
||||
return allowHTMLKeys.has(match) ? value : dom.escapeHTML(value);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -4,8 +4,9 @@
|
||||
max-width: 300px;
|
||||
overflow-y: auto;
|
||||
color: #eee;
|
||||
background-color: #1c1e22;
|
||||
background-color: #2B2D31;
|
||||
font-size: 15px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#channels > div.loading {
|
||||
@@ -14,27 +15,93 @@
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
#channels > div.channel {
|
||||
cursor: pointer;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #333333;
|
||||
#channels > div.loading {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
|
||||
#channels > div.channel:hover, #channels > div.channel.active {
|
||||
background-color: #282b30;
|
||||
|
||||
#channels > div.channel.visible {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
#channels > div.channel {
|
||||
display: none !important;
|
||||
|
||||
cursor: pointer;
|
||||
padding: 5px 8px;
|
||||
|
||||
color: #eee;
|
||||
font-size: 15px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
|
||||
.ServerChannel {
|
||||
padding: 8px 8px !important;
|
||||
}
|
||||
|
||||
#channels > div.channel:hover,
|
||||
#channels > div.channel.active {
|
||||
background-color: rgba(78, 80, 88, 0.6);
|
||||
}
|
||||
|
||||
#channels > div.channel.active > .info > .name {
|
||||
color: oklab(0.999994 0.0000455678 0.0000200868);
|
||||
}
|
||||
|
||||
|
||||
#channels .info {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
margin-bottom: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ServerChannel > .info {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.UserChannel > .info {
|
||||
align-self: center;
|
||||
padding-left: 6px !important;
|
||||
align-items: center;
|
||||
|
||||
width: 150px !important;
|
||||
}
|
||||
|
||||
|
||||
[server-type="group"] > .info {
|
||||
padding: 6px;
|
||||
|
||||
width: 172px !important;
|
||||
}
|
||||
|
||||
.UserChannel > .info > .name {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
#channels .name {
|
||||
flex-grow: 1;
|
||||
overflow-x: hidden;
|
||||
overflow-y: clip;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: oklab(0.686636 -0.00407365 -0.0149199);
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
/* max-width: 200px; */
|
||||
}
|
||||
|
||||
.ServerChannel .name {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
#channels .tag {
|
||||
@@ -46,3 +113,25 @@
|
||||
padding: 2px 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
#channels > div:hover,
|
||||
#channels > div.active {
|
||||
background-color: #404249
|
||||
}
|
||||
|
||||
#channels > div > .avatar {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#channels > div > .avatar > * {
|
||||
width: 30px;
|
||||
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
/*Make not loaded images be 30px*/
|
||||
|
||||
border-radius: 100%;
|
||||
font-size: 1em;
|
||||
}
|
@@ -17,6 +17,10 @@ body {
|
||||
--loading-backdrop: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.loading:hover {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
background: var(--loading-backdrop)
|
||||
|
@@ -4,8 +4,8 @@
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-color: #17181c;
|
||||
border-bottom: 1px dotted #5d626b;
|
||||
background-color: #313338;
|
||||
border-bottom: 2px solid #27292D;
|
||||
}
|
||||
|
||||
#menu .splitter {
|
||||
@@ -23,7 +23,9 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#menu button, #menu select, #menu input[type="text"] {
|
||||
#menu button,
|
||||
#menu select,
|
||||
#menu input[type="text"] {
|
||||
height: 31px;
|
||||
padding: 0 10px;
|
||||
background-color: #7289da;
|
||||
@@ -64,11 +66,13 @@
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#menu .nav > button, #menu .nav > p {
|
||||
#menu .nav > button,
|
||||
#menu .nav > p {
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
#opt-filter-list > select, #opt-filter-list > input {
|
||||
#opt-filter-list > select,
|
||||
#opt-filter-list > input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -78,4 +82,4 @@
|
||||
|
||||
#btn-about {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
@@ -1,13 +1,18 @@
|
||||
#messages {
|
||||
flex: 1 1 0;
|
||||
overflow-y: auto;
|
||||
background-color: #36393E;
|
||||
background-color: #36393e;
|
||||
}
|
||||
|
||||
#messages > div {
|
||||
margin: 0 24px;
|
||||
padding: 4px 0 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
padding: 0 4px 0 24px;
|
||||
|
||||
margin-bottom: 17px;
|
||||
}
|
||||
|
||||
|
||||
#messages > div:hover {
|
||||
background-color: oklab(0.0846607 0.00000385568 0.00000169128 / 0.06);
|
||||
}
|
||||
|
||||
#messages h2 {
|
||||
@@ -23,6 +28,8 @@
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#messages .avatar-wrapper > div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -38,7 +45,7 @@
|
||||
}
|
||||
|
||||
#messages .username {
|
||||
color: #FFF;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-right: 3px;
|
||||
@@ -50,14 +57,9 @@
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
#messages .info::before {
|
||||
content: "\2022";
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
#messages .jump {
|
||||
cursor: pointer;
|
||||
@@ -66,21 +68,23 @@
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 15px;
|
||||
margin-top: 2px;
|
||||
color: oklab(0.89908 -0.00192907 -0.0048306);
|
||||
font-size: 16px;
|
||||
line-height: 1.1em;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message .link, .reply-message .link {
|
||||
color: #7289DA;
|
||||
.message .link,
|
||||
.reply-message .link {
|
||||
color: #7289da;
|
||||
background-color: rgba(115, 139, 215, 0.1);
|
||||
}
|
||||
|
||||
.message a, .reply-message a {
|
||||
color: #0096CF;
|
||||
.message a,
|
||||
.reply-message a {
|
||||
color: #0096cf;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -126,12 +130,13 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.message .embed:first-child, .message .download + .download {
|
||||
.message .embed:first-child,
|
||||
.message .download+.download {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message code {
|
||||
background-color: #2E3136;
|
||||
background-color: #2e3136;
|
||||
border-radius: 5px;
|
||||
font-family: Menlo, Consolas, Monaco, monospace;
|
||||
font-size: 14px;
|
||||
@@ -144,7 +149,7 @@
|
||||
|
||||
.message code.block {
|
||||
display: block;
|
||||
border: 2px solid #282B30;
|
||||
border: 2px solid #282b30;
|
||||
margin-top: 6px;
|
||||
padding: 7px;
|
||||
}
|
||||
@@ -198,7 +203,7 @@
|
||||
}
|
||||
|
||||
.reply-username {
|
||||
color: #FFF;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
@@ -218,7 +223,7 @@
|
||||
}
|
||||
|
||||
.reply-contents code {
|
||||
background-color: #2E3136;
|
||||
background-color: #2e3136;
|
||||
font-family: Menlo, Consolas, Monaco, monospace;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
@@ -257,4 +262,4 @@
|
||||
.reactions .count {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
81
app/Resources/Viewer/styles/servers.css
Normal file
81
app/Resources/Viewer/styles/servers.css
Normal file
@@ -0,0 +1,81 @@
|
||||
#servers {
|
||||
width: 76px;
|
||||
background-color: #1e1f22;
|
||||
padding: 0px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.Server {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
margin-left: 11px;
|
||||
margin-top: 8px;
|
||||
border-radius: 100%;
|
||||
transition: .15s ease-out;
|
||||
cursor: pointer;
|
||||
background-color: #313338;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Server:hover {
|
||||
border-radius: 20px;
|
||||
background-color: #5865f2;
|
||||
}
|
||||
|
||||
.Server.active {
|
||||
border-radius: 20px;
|
||||
background-color: #5865f2;
|
||||
}
|
||||
|
||||
.ServerImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg.ServerImg {
|
||||
width: 100%;
|
||||
height: 70%;
|
||||
/* margin-top: 15%; */
|
||||
color: white;
|
||||
}
|
||||
|
||||
#servers .Server .icon {
|
||||
font-weight: bold;
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#servers .Server .name {
|
||||
display: none;
|
||||
font-weight: bold;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
text-align: center;
|
||||
}
|
||||
#servers .Server .name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#servers .Server:hover .name {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
margin-left: 60px;
|
||||
white-space: nowrap;
|
||||
}
|
@@ -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; }
|
||||
|
@@ -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;
|
||||
|
@@ -10,5 +10,5 @@ public enum DownloadStatus {
|
||||
GenericError = 1,
|
||||
Downloading = 2,
|
||||
LastCustomCode = 99,
|
||||
Success = HttpStatusCode.OK
|
||||
Success = HttpStatusCode.OK,
|
||||
}
|
||||
|
@@ -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; }
|
||||
|
@@ -5,5 +5,5 @@ namespace DHT.Server.Data;
|
||||
[Flags]
|
||||
public enum EmojiFlags : ushort {
|
||||
None = 0,
|
||||
Animated = 0b1
|
||||
Animated = 0b1,
|
||||
}
|
||||
|
9
app/Server/Data/FileUrl.cs
Normal file
9
app/Server/Data/FileUrl.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
public readonly record struct FileUrl(string NormalizedUrl, string DownloadUrl, string? Type) {
|
||||
public FileUrl(string url, string? type) : this(url, url, type) {}
|
||||
|
||||
public Download ToPendingDownload() {
|
||||
return new Download(NormalizedUrl, DownloadUrl, DownloadStatus.Pending, Type, size: null);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -2,5 +2,5 @@ namespace DHT.Server.Data.Filters;
|
||||
|
||||
public enum FilterRemovalMode {
|
||||
KeepMatching,
|
||||
RemoveMatching
|
||||
RemoveMatching,
|
||||
}
|
||||
|
@@ -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 &&
|
||||
|
@@ -1,7 +1,12 @@
|
||||
using DHT.Server.Download;
|
||||
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
public readonly struct Server {
|
||||
public ulong Id { get; init; }
|
||||
public string Name { get; init; }
|
||||
public ServerType? Type { get; init; }
|
||||
public string? IconHash { get; init; }
|
||||
|
||||
internal FileUrl? IconUrl => Type == null || IconHash == null ? null : DownloadLinkExtractor.ServerIcon(Type.Value, Id, IconHash);
|
||||
}
|
||||
|
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -3,17 +3,18 @@ using System.Diagnostics.CodeAnalysis;
|
||||
namespace DHT.Server.Data.Settings;
|
||||
|
||||
public static class SettingsKey {
|
||||
public static Bool SeparateFileForDownloads { get; } = new ("separate_file_for_downloads");
|
||||
public static Bool DownloadsAutoStart { get; } = new ("downloads_auto_start");
|
||||
public static Bool DownloadsLimitSize { get; } = new ("downloads_limit_size");
|
||||
public static UnsignedLong DownloadsMaximumSize { get; } = new ("downloads_maximum_size");
|
||||
public static String DownloadsMaximumSizeUnit { get; } = new ("downloads_maximum_size_unit");
|
||||
|
||||
|
||||
public sealed class String(string key) : SettingsKey<string>(key) {
|
||||
internal override bool FromString(string value, out string result) {
|
||||
result = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
internal override string ToString(string value) {
|
||||
return value;
|
||||
}
|
||||
@@ -25,17 +26,17 @@ public static class SettingsKey {
|
||||
case "1":
|
||||
result = true;
|
||||
return true;
|
||||
|
||||
|
||||
case "0":
|
||||
result = false;
|
||||
return true;
|
||||
|
||||
|
||||
default:
|
||||
result = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal override string ToString(bool value) {
|
||||
return value ? "1" : "0";
|
||||
}
|
||||
@@ -45,7 +46,7 @@ public static class SettingsKey {
|
||||
internal override bool FromString(string value, out ulong result) {
|
||||
return ulong.TryParse(value, out result);
|
||||
}
|
||||
|
||||
|
||||
internal override string ToString(ulong value) {
|
||||
return value.ToString();
|
||||
}
|
||||
@@ -54,7 +55,7 @@ public static class SettingsKey {
|
||||
|
||||
public abstract class SettingsKey<T>(string key) {
|
||||
internal string Key => key;
|
||||
|
||||
|
||||
internal abstract bool FromString(string value, [NotNullWhen(true)] out T result);
|
||||
internal abstract string ToString(T value);
|
||||
}
|
||||
|
@@ -1,9 +1,13 @@
|
||||
using DHT.Server.Download;
|
||||
|
||||
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? AvatarHash { get; init; }
|
||||
public string? Discriminator { get; init; }
|
||||
|
||||
internal FileUrl? AvatarUrl => AvatarHash == null ? null : DownloadLinkExtractor.UserAvatar(Id, AvatarHash);
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ namespace DHT.Server.Database;
|
||||
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
|
||||
sealed class DummyDatabaseFile : IDatabaseFile {
|
||||
public static DummyDatabaseFile Instance { get; } = new ();
|
||||
|
||||
|
||||
public string Path => "";
|
||||
|
||||
public ISettingsRepository Settings { get; } = new ISettingsRepository.Dummy();
|
||||
@@ -18,11 +18,11 @@ sealed class DummyDatabaseFile : IDatabaseFile {
|
||||
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
|
||||
|
||||
private DummyDatabaseFile() {}
|
||||
|
||||
|
||||
public Task Vacuum() {
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
public ValueTask DisposeAsync() {
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -1,23 +1,35 @@
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DHT.Server.Database.Export;
|
||||
|
||||
sealed class SnowflakeJsonSerializer : JsonConverter<Snowflake> {
|
||||
private const int MaxUlongStringLength = 20;
|
||||
|
||||
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
|
||||
return new Snowflake(ulong.Parse(reader.GetString()!));
|
||||
}
|
||||
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
|
||||
writer.WriteStringValue(value.Id.ToString());
|
||||
writer.WriteStringValue(Format(value, stackalloc byte[MaxUlongStringLength]));
|
||||
}
|
||||
|
||||
|
||||
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());
|
||||
writer.WritePropertyName(Format(value, stackalloc byte[MaxUlongStringLength]));
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> Format(Snowflake value, Span<byte> destination) {
|
||||
if (!Utf8Formatter.TryFormat(value.Id, destination, out int bytesWritten)) {
|
||||
Debug.Fail("Failed to format Snowflake value.");
|
||||
}
|
||||
|
||||
return destination[..bytesWritten];
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user