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

56 Commits

Author SHA1 Message Date
4f5e27f651 Release v39.1 2023-12-22 16:31:11 +01:00
cbf81ec95a Fix missing JSON source generator when parsing integrated server requests 2023-12-22 16:31:11 +01:00
8a80cb8c20 Show progress dialog when upgrading database schema 2023-12-22 16:18:03 +01:00
865deb356a Fix progress dialog not propagating exceptions from its task 2023-12-22 14:47:55 +01:00
069ab97196 Disable reflection-based JSON serialization 2023-12-22 05:54:24 +01:00
caab038eaa Use source generators for JSON serialization everywhere 2023-12-22 05:24:28 +01:00
fb837374fc Enable single file compression and disable unnecessary .NET features 2023-12-22 02:30:24 +01:00
65d935cca1 Use compiled bindings in Avalonia XAML 2023-12-21 08:55:56 +01:00
6e64c86d7a Optimize viewer JSON export using source generators 2023-12-21 08:29:07 +01:00
8aeb590bb3 Release v39.0 2023-12-21 05:18:01 +01:00
8dc1adc9f0 Fix viewer converting underscores to italics even when not at the end of a word 2023-12-21 05:09:55 +01:00
ddf70b02e7 Fix not storing channel topic and position if the channel is in a category
Closes #232
2023-12-21 04:49:19 +01:00
ef59fd992e Reword "Do Nothing" option in autoscroll settings to improve clarity 2023-12-21 04:27:24 +01:00
d044627fac Reduce delay in tracker for responding to new messages 2023-12-21 04:27:24 +01:00
a624745602 Force light theme in Avalonia 2023-12-21 04:24:12 +01:00
6da3c185e5 Update Avalonia to 11.0.6 2023-12-21 04:24:01 +01:00
d4d14cab97 Update Rider project configuration files 2023-12-21 00:55:19 +01:00
095c9a061a Update attachment downloads for time-limited Discord CDN URLs
Closes #223
2023-12-21 00:27:08 +01:00
d01f9ed218 Rewrite detection of newly loaded messages 2023-12-20 13:19:11 +01:00
dd6f121059 Fix not seeing messages after a Discord update
Closes #240
2023-12-20 10:51:14 +01:00
8bba33d815 Update SQLite provider to 8.0.0 (SQLite version 3.41.2) 2023-12-20 10:42:17 +01:00
9eab8ac92a Update README section on distribution 2023-11-24 13:47:53 +01:00
fe588686fc Clean up project configuration and build scripts & fix CI builds 2023-11-23 21:45:54 +01:00
7392987165 Remove unused argument in tracker script
Closes #235
2023-11-23 05:22:33 +01:00
492dddb35d Load full tracking script from the integrated server to remove need for minification 2023-11-21 07:08:26 +01:00
d35280a6a6 Release v38.0 2023-10-29 06:42:45 +01:00
ae8a34f938 Log error when reading Discord settings fails 2023-10-29 06:42:45 +01:00
18f5823f2a Fix not seeing messages after a Discord update
Closes #226
2023-10-29 06:42:45 +01:00
37640c97b0 Update to Avalonia 11.0.5 2023-10-29 06:38:26 +01:00
3cc5c75c48 Fix JSON serialization not working in release builds 2023-10-29 06:38:26 +01:00
2424a8ac8d Fix UI freezing due to attachment download statistics being updated on the wrong thread 2023-07-18 18:56:34 +02:00
e7705587c2 Fix .NET 8 warnings and update C# conventions 2023-07-18 18:51:23 +02:00
bd7904b88e Convert namespace declarations to top-level 2023-07-18 16:12:02 +02:00
3db599e901 Update to .NET 8 2023-07-18 15:56:15 +02:00
69a68b463c Update JetBrains.Annotations to 2023.2.0 2023-07-18 15:54:13 +02:00
b2389dd521 Update SQLite provider to 7.0.9 2023-07-18 15:54:13 +02:00
610516de1f Update file dialogs and hide overwrite prompt when opening database files
Closes #198
2023-07-17 23:11:41 +02:00
3a6b83e0ba Update to Avalonia 11 2023-07-17 23:11:41 +02:00
22958536e7 Update Avalonia to 0.10.18 & SQLite provider to 6.0.10 2022-10-22 00:17:48 +02:00
4db8c302d8 Release v37.2 2022-10-07 14:40:37 +02:00
7e7d140957 Fix server name detection after a Discord update
Closes #204
2022-10-07 14:35:30 +02:00
8c68438fbb Release v37.1 2022-07-18 22:06:05 +02:00
f625a39b4d Update SQLite provider to 6.0.7 (SQLite version 3.35.5) 2022-07-18 21:51:19 +02:00
7fd644449c Update JetBrains.Annotations to 2022.1.0 2022-07-18 21:43:45 +02:00
e4a09515b0 Update Avalonia to 0.10.16 2022-07-18 21:29:07 +02:00
9ac9f2246f Fix DHT tracker overlaying part of Discord 2022-07-18 21:20:49 +02:00
bbc734ba9b Track dimensions of image attachments 2022-07-18 21:09:20 +02:00
6837b05b0d Fix missing ToArray call when serializing message reactions to JSON 2022-07-18 21:00:36 +02:00
c94808a15f Show downloaded attachments when viewing via Open Viewer 2022-07-18 20:21:45 +02:00
739e87c5ab Add one decimal place to MB/GB/TB in the table in the Attachments tab 2022-07-18 20:19:51 +02:00
d463b407f4 Fix code for reducing chance of SQLite connection pool livelocks 2022-07-18 20:13:05 +02:00
cd418f4871 Fix viewer image detection and file name parsing when the URL includes a query 2022-07-18 20:13:05 +02:00
176a81e055 Reformat list of allowed origins in CORS 2022-07-15 01:34:08 +02:00
Matojeje
1cf3e76644 Add CORS for Canary and PTB 2022-07-15 01:34:08 +02:00
33f5ab7cce Release v37.0 2022-06-18 14:00:15 +02:00
b9a5664740 Fix not seeing messages after a Discord update
Closes #189
2022-06-18 13:59:29 +02:00
212 changed files with 5793 additions and 46765 deletions

View File

@@ -16,10 +16,9 @@ Fork the repository and clone it to your computer (if you've never used git, you
Folder organization: Folder organization:
* `app/` contains a Visual Studio solution for the desktop app * `app/` contains a Visual Studio solution for the desktop app
* `lib/` contains utilities required to build the project
* `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website * `web/` contains source code of the [official website](https://dht.chylex.com), which can be used as a template when making your own website
To start editing source code for the desktop app, install the [.NET 5 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/5.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/). To start editing source code for the desktop app, install the [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0), and then open `app/DiscordHistoryTracker.sln` in [Visual Studio](https://visualstudio.microsoft.com/downloads/) or [Rider](https://www.jetbrains.com/rider/).
### Building ### Building
@@ -29,27 +28,18 @@ To build a `Release` version of the desktop app, follow the instructions for you
#### Release Windows (64-bit) #### Release Windows (64-bit)
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable is in your `PATH` 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)
2. 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)
The `lib/` folder contains an installation of [Node](https://nodejs.org/en) and [uglify-js](https://www.npmjs.com/package/uglify-js), which are used to minify the tracking script. This installation will only work on 64-bit Windows; building on 32-bit Windows is not supported, but you can try.
Run the `app/build.bat` script, and read the [Distribution](#distribution) section below. Run the `app/build.bat` script, and read the [Distribution](#distribution) section below.
#### Release Other Operating Systems #### Release Other Operating Systems
1. Install [Python 3](https://www.python.org/downloads), and ensure the `python` executable exists and launches Python 3 1. Install the `zip` package from your repository
- On Debian and derivatives, you can install `python-is-python3`
- On other distributions, you can create a link manually, for ex. `ln -s /usr/bin/python3 /usr/bin/python`
- If you don't want `python` to mean Python 3, then edit `Desktop.csproj` and change `python` to `python3`
2. Install [Node + npm](https://nodejs.org/en)
3. Install [uglify-js](https://www.npmjs.com/package/uglify-js) globally (`npm install -g uglify-js`)
4. Install the `zip` package from your repository
Run the `app/build.sh` script, and read the [Distribution](#distribution) section below. Run the `app/build.sh` script, and read the [Distribution](#distribution) section below.
#### Distribution #### Distribution
The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 5 to be installed. The mentioned build scripts will prepare `Release` builds ready for distribution. Once the script finishes, the `app/bin` folder will contain self-contained executables for each major operating system, and a portable version that works on all other systems but requires .NET 8 to be installed.
Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking `DiscordHistoryTracker`. I tried using Python to re-create the archives with correct file permissions, but found that Linux `zip` tools could not see them. The only working solution is building the Windows + portable version on Windows, and Linux + Mac version on Linux. Note that when building on Windows, the generated `.zip` files for Linux and Mac will not have correct file permissions, so it will not be possible to run them by double-clicking the executable. Since .NET 8 fixed several issues with publishing Windows executables on Linux, I recommend using Linux to build the app for all operating systems.

View File

@@ -29,9 +29,6 @@
<H2CodeStyleSettings version="6"> <H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings> </H2CodeStyleSettings>
<H2CodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" />
</H2CodeStyleSettings>
<HSQLCodeStyleSettings version="6"> <HSQLCodeStyleSettings version="6">
<option name="USE_GENERIC_STYLE" value="true" /> <option name="USE_GENERIC_STYLE" value="true" />
</HSQLCodeStyleSettings> </HSQLCodeStyleSettings>

View File

@@ -1,8 +1,8 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project"> <configuration default="false" name="Desktop" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0/DiscordHistoryTracker.exe" /> <option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Desktop/debug/DiscordHistoryTracker.exe" />
<option name="PROGRAM_PARAMETERS" value="" /> <option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Desktop/bin/Debug/net5.0" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.artifacts/bin/Desktop/debug" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
@@ -12,7 +12,7 @@
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net5.0" /> <option name="PROJECT_TFM" value="net8.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -1,23 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Minify" type="PythonConfigurationType" factoryName="Python">
<module name="rider.module" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="false" />
<option name="ADD_SOURCE_ROOTS" value="false" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/minify.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@@ -2,12 +2,13 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="clr-namespace:DHT.Desktop.Common" xmlns:common="clr-namespace:DHT.Desktop.Common"
xmlns:system="clr-namespace:System;assembly=System.Runtime" xmlns:system="clr-namespace:System;assembly=System.Runtime"
x:Class="DHT.Desktop.App"> x:Class="DHT.Desktop.App"
RequestedThemeVariant="Light">
<Application.Styles> <Application.Styles>
<FluentTheme Mode="Light" /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Default.xaml" /> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml" />
<Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader"> <Style Selector="Button, CheckBox, RadioButton, Expander /template/ ToggleButton#ExpanderHeader">
<Setter Property="Cursor" Value="Hand" /> <Setter Property="Cursor" Value="Hand" />
@@ -33,9 +34,6 @@
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement"> <Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
</Style> </Style>
<Style Selector="TextBox:disabled"><!-- TODO bug in Avalonia (https://github.com/AvaloniaUI/Avalonia/pull/7792) -->
<Setter Property="Foreground" Value="{DynamicResource TextControlForegroundDisabled}" />
</Style>
<Style Selector="TextBox:error DataValidationErrors"> <Style Selector="TextBox:error DataValidationErrors">
<Style.Resources> <Style.Resources>
<ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors"> <ControlTemplate x:Key="InlineDataValidationContentTemplate" TargetType="DataValidationErrors">
@@ -51,68 +49,9 @@
<Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" /> <Setter Property="Template" Value="{StaticResource InlineDataValidationContentTemplate}" />
</Style> </Style>
<Style Selector="Expander /template/ ToggleButton#ExpanderHeader"> <Style Selector="Expander">
<Setter Property="HorizontalContentAlignment" Value="Left" /> <Setter Property="MinHeight" Value="40" />
<Setter Property="Template"> <Setter Property="HorizontalAlignment" Value="Stretch" />
<ControlTemplate>
<Border x:Name="ToggleButtonBackground">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="35">
<Border x:Name="ExpandCollapseChevronBorder"
Grid.Column="0"
Width="35"
Height="35"
Margin="2,0"
RenderTransformOrigin="50%,50%">
<Path x:Name="ExpandCollapseChevron"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderTransformOrigin="50%,50%"
Stretch="None"
Stroke="{DynamicResource ExpanderChevronForeground}"
StrokeThickness="1" />
<Border.RenderTransform>
<RotateTransform />
</Border.RenderTransform>
</Border>
<ContentPresenter x:Name="PART_ContentPresenter"
Grid.Column="1"
Margin="0"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="Center"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
TextBlock.Foreground="{DynamicResource ExpanderForeground}" />
</Grid>
</Border>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
<Style.Animations>
<Animation FillMode="Both" Duration="0:0:0.0625">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="180" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader /template/ Border#ExpandCollapseChevronBorder">
<Style.Animations>
<Animation FillMode="Both" Duration="0:0:0.0625">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style> </Style>
</Application.Styles> </Application.Styles>
@@ -171,12 +110,26 @@
<Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness> <Thickness x:Key="ExpanderHeaderPadding">15,0</Thickness>
<Thickness x:Key="ExpanderContentPadding">12</Thickness> <Thickness x:Key="ExpanderContentPadding">12</Thickness>
<SolidColorBrush x:Key="ExpanderBorderBrush" Color="#697DAB" /> <SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderBackground" Color="#697DAB" /> <SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderForeground" Color="#FFFFFF" /> <SolidColorBrush x:Key="ExpanderHeaderBorderBrushPressed" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushDisabled" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBackground" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPointerOver" Color="#536794" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPressed" Color="#47587F" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundDisabled" Color="#697DAB" />
<SolidColorBrush x:Key="ExpanderHeaderForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundPointerOver" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundPressed" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundDisabled" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronBackground" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundPointerOver" Color="#536794" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundPressed" Color="#47587F" />
<SolidColorBrush x:Key="ExpanderChevronBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" /> <SolidColorBrush x:Key="ExpanderChevronForeground" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderDropDownBorderBrush" Color="#697DAB" /> <SolidColorBrush x:Key="ExpanderChevronForegroundPointerOver" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderDropDownBackground" Color="#FFFFFF" /> <SolidColorBrush x:Key="ExpanderChevronForegroundPressed" Color="#FFFFFF" />
<SolidColorBrush x:Key="ExpanderChevronForegroundDisabled" Color="#FFFFFF" />
</Application.Resources> </Application.Resources>

View File

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

View File

@@ -1,8 +1,9 @@
using System; using System;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop { namespace DHT.Desktop;
sealed class Arguments {
sealed class Arguments {
private static readonly Log Log = Log.ForType<Arguments>(); private static readonly Log Log = Log.ForType<Arguments>();
public static Arguments Empty => new(Array.Empty<string>()); public static Arguments Empty => new(Array.Empty<string>());
@@ -61,5 +62,4 @@ namespace DHT.Desktop {
} }
} }
} }
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>DHT.Desktop</RootNamespace>
<AssemblyName>DiscordHistoryTracker</AssemblyName>
<PackageId>DiscordHistoryTracker</PackageId>
</PropertyGroup>
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyName>DiscordHistoryTracker</AssemblyName>
<RootNamespace>DHT.Desktop</RootNamespace>
<PackageId>DiscordHistoryTracker</PackageId>
<Authors>chylex</Authors>
<Company>DiscordHistoryTracker</Company>
<Product>DiscordHistoryTracker</Product>
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon> <ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>none</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="0.10.14" /> <PackageReference Include="Avalonia" Version="11.0.6" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.14" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.6" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.14" /> <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" />
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.14" Condition=" '$(Configuration)' == 'Debug' " /> <PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Server\Server.csproj" /> <ProjectReference Include="..\Server\Server.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="..\Version.cs" Link="Version.cs" /> <Compile Include="..\Version.cs" Link="Version.cs" />
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs"> <Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
@@ -36,22 +35,11 @@
<SubType>Code</SubType> <SubType>Code</SubType>
</Compile> </Compile>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Resources/icon.ico" /> <AvaloniaResource Include="Resources/icon.ico" />
<EmbeddedResource Include="../Resources/Tracker/bootstrap.js"> <EmbeddedResource Include="Resources/tracker-loader.js">
<LogicalName>Tracker\%(RecursiveDir)%(Filename)%(Extension)</LogicalName> <LogicalName>tracker-loader.js</LogicalName>
<Link>Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<EmbeddedResource Include="../Resources/Tracker/scripts/**" Condition=" '$(Configuration)' == 'Debug' ">
<LogicalName>Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource>
<EmbeddedResource Include="../Resources/Tracker/styles/**">
<LogicalName>Tracker\styles\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
<Link>Resources/Tracker/styles/%(RecursiveDir)%(Filename)%(Extension)</Link>
<Visible>false</Visible>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="../Resources/Viewer/**"> <EmbeddedResource Include="../Resources/Viewer/**">
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName> <LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
@@ -59,19 +47,5 @@
<Visible>false</Visible> <Visible>false</Visible>
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<Target Name="MinifyResources" BeforeTargets="PrepareForBuild" Condition=" '$(Configuration)' == 'Release' ">
<PropertyGroup>
<MinifiedResourceDir>$(ProjectDir)bin/.res/scripts</MinifiedResourceDir>
</PropertyGroup>
<ItemGroup>
<UpToDateCheckInput Include="$(ProjectDir)../Resources/Tracker/scripts/**" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/discord.js" LogicalName="Tracker\scripts\discord.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/dom.js" LogicalName="Tracker\scripts\dom.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/gui.js" LogicalName="Tracker\scripts\gui.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/settings.js" LogicalName="Tracker\scripts\settings.js" Visible="false" />
<EmbeddedResource Include="$(MinifiedResourceDir)/state.js" LogicalName="Tracker\scripts\state.js" Visible="false" />
</ItemGroup>
<RemoveDir Directories="$(ProjectDir)bin/.res/scripts" />
<Exec Command="python $(ProjectDir)../Resources/minify.py" WorkingDirectory="$(ProjectDir)../Resources" IgnoreExitCode="false" />
</Target>
</Project> </Project>

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog" x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
x:DataType="namespace:CheckBoxDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False" Width="500" SizeToContent="Height" CanResize="False"
@@ -34,7 +35,7 @@
<StackPanel Margin="20"> <StackPanel Margin="20">
<ScrollViewer MaxHeight="400"> <ScrollViewer MaxHeight="400">
<ItemsRepeater Items="{Binding Items}"> <ItemsRepeater ItemsSource="{Binding Items}">
<ItemsRepeater.ItemTemplate> <ItemsRepeater.ItemTemplate>
<DataTemplate> <DataTemplate>
<CheckBox IsChecked="{Binding Checked}"> <CheckBox IsChecked="{Binding Checked}">

View File

@@ -1,22 +1,14 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.CheckBox { namespace DHT.Desktop.Dialogs.CheckBox;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class CheckBoxDialog : Window { [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class CheckBoxDialog : Window {
public CheckBoxDialog() { public CheckBoxDialog() {
InitializeComponent(); InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
} }
public void ClickOk(object? sender, RoutedEventArgs e) { public void ClickOk(object? sender, RoutedEventArgs e) {
@@ -26,6 +18,4 @@ namespace DHT.Desktop.Dialogs.CheckBox {
public void ClickCancel(object? sender, RoutedEventArgs e) { public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel); Close(DialogResult.OkCancel.Cancel);
} }
}
} }

View File

@@ -4,8 +4,9 @@ using System.ComponentModel;
using System.Linq; using System.Linq;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.CheckBox { namespace DHT.Desktop.Dialogs.CheckBox;
class CheckBoxDialogModel : BaseModel {
class CheckBoxDialogModel : BaseModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>(); private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
@@ -55,9 +56,9 @@ namespace DHT.Desktop.Dialogs.CheckBox {
UpdateBulkButtons(); UpdateBulkButtons();
} }
} }
} }
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel { sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
public new IReadOnlyList<CheckBoxItem<T>> Items { get; } public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked); public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
@@ -66,5 +67,4 @@ namespace DHT.Desktop.Dialogs.CheckBox {
this.Items = new List<CheckBoxItem<T>>(items); this.Items = new List<CheckBoxItem<T>>(items);
base.Items = this.Items; base.Items = this.Items;
} }
}
} }

View File

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

View File

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

View File

@@ -2,8 +2,9 @@ using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
namespace DHT.Desktop.Dialogs.Message { namespace DHT.Desktop.Dialogs.Message;
static class Dialog {
static class Dialog {
public static async Task ShowOk(Window owner, string title, string message) { public static async Task ShowOk(Window owner, string title, string message) {
if (!Dispatcher.UIThread.CheckAccess()) { if (!Dispatcher.UIThread.CheckAccess()) {
await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message)); await Dispatcher.UIThread.InvokeAsync(() => ShowOk(owner, title, message));
@@ -70,5 +71,4 @@ namespace DHT.Desktop.Dialogs.Message {
return result.ToYesNoCancel(); return result.ToYesNoCancel();
} }
}
} }

View File

@@ -1,7 +1,8 @@
using System; using System;
namespace DHT.Desktop.Dialogs.Message { namespace DHT.Desktop.Dialogs.Message;
static class DialogResult {
static class DialogResult {
public enum All { public enum All {
Ok, Ok,
Yes, Yes,
@@ -55,5 +56,4 @@ namespace DHT.Desktop.Dialogs.Message {
_ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.") _ => throw new ArgumentException("Cannot convert dialog result " + result + " to yes/no/cancel.")
}; };
} }
}
} }

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog" x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
x:DataType="namespace:MessageDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False" Width="500" SizeToContent="Height" CanResize="False"

View File

@@ -1,21 +1,13 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Dialogs.Message { namespace DHT.Desktop.Dialogs.Message;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MessageDialog : Window { [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class MessageDialog : Window {
public MessageDialog() { public MessageDialog() {
InitializeComponent(); InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
} }
public void ClickOk(object? sender, RoutedEventArgs e) { public void ClickOk(object? sender, RoutedEventArgs e) {
@@ -33,5 +25,4 @@ namespace DHT.Desktop.Dialogs.Message {
public void ClickCancel(object? sender, RoutedEventArgs e) { public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.All.Cancel); Close(DialogResult.All.Cancel);
} }
}
} }

View File

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

View File

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

View File

@@ -5,9 +5,10 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog" x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
x:DataType="namespace:ProgressDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Opened="Loaded" Opened="OnOpened"
Closing="OnClosing" Closing="OnClosing"
Width="500" SizeToContent="Height" CanResize="False" Width="500" SizeToContent="Height" CanResize="False"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
@@ -31,12 +32,18 @@
</Style> </Style>
</Window.Styles> </Window.Styles>
<StackPanel Margin="20"> <ItemsRepeater ItemsSource="{Binding Items}" Margin="0 10">
<ItemsRepeater.ItemTemplate>
<DataTemplate>
<StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
<DockPanel> <DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" /> <TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" /> <TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
</DockPanel> </DockPanel>
<ProgressBar Value="{Binding Progress}" /> <ProgressBar Value="{Binding Progress}" />
</StackPanel> </StackPanel>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Window> </Window>

View File

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

View File

@@ -1,65 +1,53 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading; using Avalonia.Threading;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.Progress { namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressDialogModel : BaseModel {
sealed class ProgressDialogModel : BaseModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
private string message = ""; public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
public string Message {
get => message;
private set => Change(ref message, value);
}
private string items = "";
public string Items {
get => items;
private set => Change(ref items, value);
}
private int progress = 0;
public int Progress {
get => progress;
private set => Change(ref progress, value);
}
private readonly TaskRunner? task; private readonly TaskRunner? task;
[Obsolete("Designer")] [Obsolete("Designer")]
public ProgressDialogModel() {} public ProgressDialogModel() {}
public ProgressDialogModel(TaskRunner task) { public ProgressDialogModel(TaskRunner task, int progressItems = 1) {
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
this.task = task; this.task = task;
} }
internal async Task StartTask() { internal async Task StartTask() {
if (task != null) { if (task != null) {
await task(new Callback(this)); await task(Items.Select(static item => new Callback(item)).ToArray());
} }
} }
public delegate Task TaskRunner(IProgressCallback callback); public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
private sealed class Callback : IProgressCallback { private sealed class Callback : IProgressCallback {
private readonly ProgressDialogModel model; private readonly ProgressItem item;
public Callback(ProgressDialogModel model) { public Callback(ProgressItem item) {
this.model = model; this.item = item;
} }
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) { public async Task Update(string message, int finishedItems, int totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => { await Dispatcher.UIThread.InvokeAsync(() => {
model.Message = message; item.Message = message;
model.Items = finishedItems.Format() + " / " + totalItems.Format(); item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
model.Progress = 100 * finishedItems / totalItems; item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
}); });
} }
public Task Hide() {
return Update(string.Empty, 0, 0);
} }
} }
} }

View File

@@ -0,0 +1,41 @@
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressItem : BaseModel {
private bool isVisible = false;
public bool IsVisible {
get => isVisible;
private set {
Change(ref isVisible, value);
OnPropertyChanged(nameof(Opacity));
}
}
public double Opacity => IsVisible ? 1.0 : 0.0;
private string message = "";
public string Message {
get => message;
set {
Change(ref message, value);
IsVisible = !string.IsNullOrEmpty(value);
}
}
private string items = "";
public string Items {
get => items;
set => Change(ref items, value);
}
private int progress = 0;
public int Progress {
get => progress;
set => Change(ref progress, value);
}
}

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog" x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
x:DataType="namespace:TextBoxDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False" Width="500" SizeToContent="Height" CanResize="False"
@@ -31,7 +32,7 @@
<ScrollViewer MaxHeight="400"> <ScrollViewer MaxHeight="400">
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" /> <TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
<ItemsRepeater Items="{Binding Items}"> <ItemsRepeater ItemsSource="{Binding Items}">
<ItemsRepeater.ItemTemplate> <ItemsRepeater.ItemTemplate>
<DataTemplate> <DataTemplate>
<DockPanel Margin="0 5 25 0"> <DockPanel Margin="0 5 25 0">

View File

@@ -1,22 +1,14 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
namespace DHT.Desktop.Dialogs.TextBox { namespace DHT.Desktop.Dialogs.TextBox;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class TextBoxDialog : Window { [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class TextBoxDialog : Window {
public TextBoxDialog() { public TextBoxDialog() {
InitializeComponent(); InitializeComponent();
#if DEBUG
this.AttachDevTools();
#endif
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
} }
public void ClickOk(object? sender, RoutedEventArgs e) { public void ClickOk(object? sender, RoutedEventArgs e) {
@@ -26,6 +18,4 @@ namespace DHT.Desktop.Dialogs.TextBox {
public void ClickCancel(object? sender, RoutedEventArgs e) { public void ClickCancel(object? sender, RoutedEventArgs e) {
Close(DialogResult.OkCancel.Cancel); Close(DialogResult.OkCancel.Cancel);
} }
}
} }

View File

@@ -4,8 +4,9 @@ using System.ComponentModel;
using System.Linq; using System.Linq;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.TextBox { namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxDialogModel : BaseModel {
class TextBoxDialogModel : BaseModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public string Description { get; init; } = ""; public string Description { get; init; } = "";
@@ -32,9 +33,9 @@ namespace DHT.Desktop.Dialogs.TextBox {
private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) { private void OnItemErrorsChanged(object? sender, DataErrorsChangedEventArgs e) {
OnPropertyChanged(nameof(HasErrors)); OnPropertyChanged(nameof(HasErrors));
} }
} }
sealed class TextBoxDialogModel<T> : TextBoxDialogModel { sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
public new IReadOnlyList<TextBoxItem<T>> Items { get; } public new IReadOnlyList<TextBoxItem<T>> Items { get; }
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid); public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
@@ -43,5 +44,4 @@ namespace DHT.Desktop.Dialogs.TextBox {
this.Items = new List<TextBoxItem<T>>(items); this.Items = new List<TextBoxItem<T>>(items);
base.Items = this.Items; base.Items = this.Items;
} }
}
} }

View File

@@ -3,8 +3,9 @@ using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.TextBox { namespace DHT.Desktop.Dialogs.TextBox;
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
public object? Item { get; init; } = null; public object? Item { get; init; } = null;
@@ -29,14 +30,13 @@ namespace DHT.Desktop.Dialogs.TextBox {
public bool HasErrors => !IsValid; public bool HasErrors => !IsValid;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged; public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
} }
sealed class TextBoxItem<T> : TextBoxItem { sealed class TextBoxItem<T> : TextBoxItem {
public new T Item { get; } public new T Item { get; }
public TextBoxItem(T item) { public TextBoxItem(T item) {
this.Item = item; this.Item = item;
base.Item = item; base.Item = item;
} }
}
} }

View File

@@ -1,16 +1,17 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using static System.Environment.SpecialFolder; using static System.Environment.SpecialFolder;
using static System.Environment.SpecialFolderOption; using static System.Environment.SpecialFolderOption;
namespace DHT.Desktop.Discord { namespace DHT.Desktop.Discord;
static class DiscordAppSettings {
static class DiscordAppSettings {
private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings)); private static readonly Log Log = Log.ForType(typeof(DiscordAppSettings));
private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING"; private const string JsonKeyDevTools = "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING";
@@ -39,17 +40,19 @@ namespace DHT.Desktop.Discord {
public static async Task<bool?> AreDevToolsEnabled() { public static async Task<bool?> AreDevToolsEnabled() {
try { try {
return AreDevToolsEnabled(await ReadSettingsJson()); return AreDevToolsEnabled(await ReadSettingsJson());
} catch (Exception) { } catch (Exception e) {
Log.Error("Cannot read settings file.");
Log.Error(e);
return null; return null;
} }
} }
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) { private static bool AreDevToolsEnabled(JsonObject json) {
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True }; return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
} }
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) { public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
Dictionary<string, object?> json; JsonObject json;
try { try {
json = await ReadSettingsJson(); json = await ReadSettingsJson();
@@ -106,14 +109,13 @@ namespace DHT.Desktop.Discord {
return SettingsJsonResult.Success; return SettingsJsonResult.Success;
} }
private static async Task<Dictionary<string, object?>> ReadSettingsJson() { private static async Task<JsonObject> ReadSettingsJson() {
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException(); return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
} }
private static async Task WriteSettingsJson(Dictionary<string, object?> json) { private static async Task WriteSettingsJson(JsonObject json) {
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None); await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true }); await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
}
} }
} }

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace DHT.Desktop.Discord;
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
[JsonSerializable(typeof(JsonObject))]
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {}

View File

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

View File

@@ -5,6 +5,7 @@
xmlns:main="clr-namespace:DHT.Desktop.Main" xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295" mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
x:Class="DHT.Desktop.Main.AboutWindow" x:Class="DHT.Desktop.Main.AboutWindow"
x:DataType="main:AboutWindowModel"
Title="About Discord History Tracker" Title="About Discord History Tracker"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="480" Height="295" CanResize="False" Width="480" Height="295" CanResize="False"
@@ -46,7 +47,7 @@
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock> <TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock> <TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0">.NET 5</TextBlock> <TextBlock Grid.Row="2" Grid.Column="0">.NET 8</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock> <TextBlock Grid.Row="2" Grid.Column="1">MIT</TextBlock>
<Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button> <Button Grid.Row="2" Grid.Column="2" Command="{Binding ShowLibraryNetCore}">GitHub</Button>

View File

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

View File

@@ -1,7 +1,8 @@
using System.Diagnostics; using System.Diagnostics;
namespace DHT.Desktop.Main { namespace DHT.Desktop.Main;
sealed class AboutWindowModel {
sealed class AboutWindowModel {
public void ShowOfficialWebsite() { public void ShowOfficialWebsite() {
OpenUrl("https://dht.chylex.com"); OpenUrl("https://dht.chylex.com");
} }
@@ -29,5 +30,4 @@ namespace DHT.Desktop.Main {
private static void OpenUrl(string url) { private static void OpenUrl(string url) {
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
} }
}
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"> x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"
x:DataType="controls:AttachmentFilterPanelModel">
<Design.DataContext> <Design.DataContext>
<controls:AttachmentFilterPanelModel /> <controls:AttachmentFilterPanelModel />
@@ -36,7 +37,7 @@
<CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox> <CheckBox IsChecked="{Binding LimitSize}">Limit Size</CheckBox>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" /> <TextBox Text="{Binding MaximumSize}" IsEnabled="{Binding LimitSize}" HorizontalContentAlignment="Right" />
<ComboBox IsEnabled="{Binding LimitSize}" Items="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}"> <ComboBox IsEnabled="{Binding LimitSize}" ItemsSource="{Binding Units}" SelectedItem="{Binding MaximumSizeUnit}">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding Name}" /> <TextBlock Text="{Binding Name}" />

View File

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

View File

@@ -7,8 +7,9 @@ using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Utils.Models;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Controls { namespace DHT.Desktop.Main.Controls;
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
public sealed record Unit(string Name, uint Scale); public sealed record Unit(string Name, uint Scale);
private static readonly Unit[] AllUnits = { private static readonly Unit[] AllUnits = {
@@ -125,5 +126,4 @@ namespace DHT.Desktop.Main.Controls {
return filter; return filter;
} }
}
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"> x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
x:DataType="controls:MessageFilterPanelModel">
<Design.DataContext> <Design.DataContext>
<controls:MessageFilterPanelModel /> <controls:MessageFilterPanelModel />

View File

@@ -1,19 +1,12 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DHT.Desktop.Main.Controls { namespace DHT.Desktop.Main.Controls;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MessageFilterPanel : UserControl {
private CalendarDatePicker StartDatePicker => this.FindControl<CalendarDatePicker>("StartDatePicker");
private CalendarDatePicker EndDatePicker => this.FindControl<CalendarDatePicker>("EndDatePicker");
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class MessageFilterPanel : UserControl {
public MessageFilterPanel() { public MessageFilterPanel() {
InitializeComponent(); InitializeComponent();
}
private void InitializeComponent() {
AvaloniaXamlLoader.Load(this);
var culture = Program.Culture; var culture = Program.Culture;
foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) { foreach (var picker in new CalendarDatePicker[] { StartDatePicker, EndDatePicker }) {
@@ -30,5 +23,4 @@ namespace DHT.Desktop.Main.Controls {
model.EndDate = EndDatePicker.SelectedDate; model.EndDate = EndDatePicker.SelectedDate;
} }
} }
}
} }

View File

@@ -14,8 +14,9 @@ using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Utils.Models;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Controls { namespace DHT.Desktop.Main.Controls;
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
private static readonly HashSet<string> FilterProperties = new () { private static readonly HashSet<string> FilterProperties = new () {
nameof(FilterByDate), nameof(FilterByDate),
nameof(StartDate), nameof(StartDate),
@@ -281,5 +282,4 @@ namespace DHT.Desktop.Main.Controls {
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null; return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
} }
}
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"> x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
x:DataType="controls:ServerConfigurationPanelModel">
<Design.DataContext> <Design.DataContext>
<controls:ServerConfigurationPanelModel /> <controls:ServerConfigurationPanelModel />

View File

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

View File

@@ -6,8 +6,9 @@ using DHT.Server.Database;
using DHT.Server.Service; using DHT.Server.Service;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls { namespace DHT.Desktop.Main.Controls;
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
private string inputPort; private string inputPort;
public string InputPort { public string InputPort {
@@ -112,5 +113,4 @@ namespace DHT.Desktop.Main.Controls {
InputPort = ServerManager.Port.ToString(); InputPort = ServerManager.Port.ToString();
InputToken = ServerManager.Token; InputToken = ServerManager.Token;
} }
}
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.StatusBar"> x:Class="DHT.Desktop.Main.Controls.StatusBar"
x:DataType="controls:StatusBarModel">
<Design.DataContext> <Design.DataContext>
<controls:StatusBarModel /> <controls:StatusBarModel />

View File

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

View File

@@ -2,8 +2,9 @@ using System;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls { namespace DHT.Desktop.Main.Controls;
sealed class StatusBarModel : BaseModel {
sealed class StatusBarModel : BaseModel {
public DatabaseStatistics DatabaseStatistics { get; } public DatabaseStatistics DatabaseStatistics { get; }
private Status status = Status.Stopped; private Status status = Status.Stopped;
@@ -41,5 +42,4 @@ namespace DHT.Desktop.Main.Controls {
Stopping, Stopping,
Stopped Stopped
} }
}
} }

View File

@@ -5,6 +5,7 @@
xmlns:main="clr-namespace:DHT.Desktop.Main" xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.MainWindow" x:Class="DHT.Desktop.Main.MainWindow"
x:DataType="main:MainWindowModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="800" Height="500" Width="800" Height="500"

View File

@@ -1,31 +1,23 @@
using System; using System;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using DHT.Desktop.Main.Pages; using DHT.Desktop.Main.Pages;
using JetBrains.Annotations; using JetBrains.Annotations;
namespace DHT.Desktop.Main { namespace DHT.Desktop.Main;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class MainWindow : Window { [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class MainWindow : Window {
[UsedImplicitly] [UsedImplicitly]
public MainWindow() { public MainWindow() {
InitializeComponent(Arguments.Empty); InitializeComponent();
DataContext = new MainWindowModel(this, Arguments.Empty);
} }
internal MainWindow(Arguments args) { internal MainWindow(Arguments args) {
InitializeComponent(args); InitializeComponent();
}
private void InitializeComponent(Arguments args) {
AvaloniaXamlLoader.Load(this);
DataContext = new MainWindowModel(this, args); DataContext = new MainWindowModel(this, args);
#if DEBUG
this.AttachDevTools();
#endif
} }
public void OnClosed(object? sender, EventArgs e) { public void OnClosed(object? sender, EventArgs e) {
@@ -41,5 +33,4 @@ namespace DHT.Desktop.Main {
} }
} }
} }
}
} }

View File

@@ -10,8 +10,9 @@ using DHT.Desktop.Server;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main { namespace DHT.Desktop.Main;
sealed class MainWindowModel : BaseModel, IDisposable {
sealed class MainWindowModel : BaseModel, IDisposable {
private const string DefaultTitle = "Discord History Tracker"; private const string DefaultTitle = "Discord History Tracker";
public string Title { get; private set; } = DefaultTitle; public string Title { get; private set; } = DefaultTitle;
@@ -112,5 +113,4 @@ namespace DHT.Desktop.Main {
db?.Dispose(); db?.Dispose();
db = null; db = null;
} }
}
} }

View File

@@ -5,7 +5,8 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AdvancedPage"> x:Class="DHT.Desktop.Main.Pages.AdvancedPage"
x:DataType="pages:AdvancedPageModel">
<Design.DataContext> <Design.DataContext>
<pages:AdvancedPageModel /> <pages:AdvancedPageModel />

View File

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

View File

@@ -6,8 +6,9 @@ using DHT.Desktop.Server;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
sealed class AdvancedPageModel : BaseModel, IDisposable {
sealed class AdvancedPageModel : BaseModel, IDisposable {
public ServerConfigurationPanelModel ServerConfigurationModel { get; } public ServerConfigurationPanelModel ServerConfigurationModel { get; }
private readonly Window window; private readonly Window window;
@@ -35,5 +36,4 @@ namespace DHT.Desktop.Main.Pages {
db.Vacuum(); db.Vacuum();
await Dialog.ShowOk(window, "Vacuum Database", "Done."); await Dialog.ShowOk(window, "Vacuum Database", "Done.");
} }
}
} }

View File

@@ -5,7 +5,8 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"> x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
x:DataType="pages:AttachmentsPageModel">
<Design.DataContext> <Design.DataContext>
<pages:AttachmentsPageModel /> <pages:AttachmentsPageModel />
@@ -35,10 +36,10 @@
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" /> <TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" />
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" /> <ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
</DockPanel> </DockPanel>
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" /> <controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
<StackPanel Orientation="Vertical" Spacing="12"> <StackPanel Orientation="Vertical" Spacing="12">
<Expander Header="Download Status" IsExpanded="True"> <Expander Header="Download Status" IsExpanded="True">
<DataGrid Items="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True"> <DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" /> <DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" /> <DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />

View File

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

View File

@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Threading;
using Avalonia.Threading;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Server.Data; using DHT.Server.Data;
@@ -11,8 +13,9 @@ using DHT.Server.Download;
using DHT.Utils.Models; using DHT.Utils.Models;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
sealed class AttachmentsPageModel : BaseModel, IDisposable {
sealed class AttachmentsPageModel : BaseModel, IDisposable {
private static readonly DownloadItemFilter EnqueuedItemFilter = new() { private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
IncludeStatuses = new HashSet<DownloadStatus> { IncludeStatuses = new HashSet<DownloadStatus> {
DownloadStatus.Enqueued DownloadStatus.Enqueued
@@ -133,9 +136,9 @@ namespace DHT.Desktop.Main.Pages {
} }
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) { private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
++doneItemsCount; Interlocked.Increment(ref doneItemsCount);
UpdateDownloadMessage(); Dispatcher.UIThread.Invoke(UpdateDownloadMessage);
downloadStatisticsComputer.Recompute(); downloadStatisticsComputer.Recompute();
} }
@@ -199,5 +202,4 @@ namespace DHT.Desktop.Main.Pages {
State = state; State = state;
} }
} }
}
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DatabasePage"> x:Class="DHT.Desktop.Main.Pages.DatabasePage"
x:DataType="pages:DatabasePageModel">
<Design.DataContext> <Design.DataContext>
<pages:DatabasePageModel /> <pages:DatabasePageModel />

View File

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

View File

@@ -7,19 +7,23 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Threading; using Avalonia.Threading;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress; using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Dialogs.TextBox; using DHT.Desktop.Dialogs.TextBox;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Import; using DHT.Server.Database.Import;
using DHT.Server.Database.Sqlite;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
sealed class DatabasePageModel : BaseModel {
sealed class DatabasePageModel : BaseModel {
private static readonly Log Log = Log.ForType<DatabasePageModel>(); private static readonly Log Log = Log.ForType<DatabasePageModel>();
public IDatabaseFile Db { get; } public IDatabaseFile Db { get; }
@@ -68,41 +72,24 @@ namespace DHT.Desktop.Main.Pages {
} }
public async void MergeWithDatabase() { public async void MergeWithDatabase() {
var fileDialog = DatabaseGui.NewOpenDatabaseFileDialog(); var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
fileDialog.Directory = Path.GetDirectoryName(Db.Path); if (paths.Length == 0) {
fileDialog.AllowMultiple = true;
string[]? paths = await fileDialog.ShowAsync(window);
if (paths == null || paths.Length == 0) {
return; return;
} }
ProgressDialog progressDialog = new ProgressDialog(); ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) { progressDialog.DataContext = new ProgressDialogModel(async callbacks => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callbacks[0])) {
Title = "Database Merge" Title = "Database Merge"
}; };
await progressDialog.ShowDialog(window); await progressDialog.ShowProgressDialog(window);
} }
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
int total = paths.Length; var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
DialogResult.YesNo? upgradeResult = null;
async Task<bool> CheckCanUpgradeDatabase() {
upgradeResult ??= total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
return DialogResult.YesNo.Yes == upgradeResult;
}
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => { await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current; IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
if (db == null) { if (db == null) {
return false; return false;
@@ -117,24 +104,58 @@ namespace DHT.Desktop.Main.Pages {
}); });
} }
public async void ImportLegacyArchive() { private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
var fileDialog = new OpenFileDialog { private readonly ProgressDialog dialog;
Title = "Open Legacy DHT Archive", private readonly int total;
Directory = Path.GetDirectoryName(Db.Path), private bool? decision;
AllowMultiple = true
};
string[]? paths = await fileDialog.ShowAsync(window); public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
if (paths == null || paths.Length == 0) { this.total = total;
this.dialog = dialog;
}
public async Task<bool> CanUpgrade() {
return decision ??= (total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
}
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
return doUpgrade(new NullReporter());
}
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
public Task NextVersion() {
return Task.CompletedTask;
}
public Task MainWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
public Task SubWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
}
}
public async void ImportLegacyArchive() {
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Legacy DHT Archive",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(Db.Path)),
AllowMultiple = true
});
if (paths.Length == 0) {
return; return;
} }
ProgressDialog progressDialog = new ProgressDialog(); ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) { progressDialog.DataContext = new ProgressDialogModel(async callbacks => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callbacks[0])) {
Title = "Legacy Archive Import" Title = "Legacy Archive Import"
}; };
await progressDialog.ShowDialog(window); await progressDialog.ShowProgressDialog(window);
} }
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
@@ -244,5 +265,4 @@ namespace DHT.Desktop.Main.Pages {
return message.ToString(); return message.ToString();
} }
}
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DebugPage"> x:Class="DHT.Desktop.Main.Pages.DebugPage"
x:DataType="pages:DebugPageModel">
<Design.DataContext> <Design.DataContext>
<pages:DebugPageModel /> <pages:DebugPageModel />

View File

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

View File

@@ -45,12 +45,12 @@ namespace DHT.Desktop.Main.Pages {
} }
ProgressDialog progressDialog = new ProgressDialog { ProgressDialog progressDialog = new ProgressDialog {
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) { DataContext = new ProgressDialogModel(async callbacks => await GenerateRandomData(channels, users, messages, callbacks[0])) {
Title = "Generating Random Data" Title = "Generating Random Data"
} }
}; };
await progressDialog.ShowDialog(window); await progressDialog.ShowProgressDialog(window);
} }
private const int BatchSize = 500; private const int BatchSize = 500;
@@ -63,7 +63,7 @@ namespace DHT.Desktop.Main.Pages {
var server = new DHT.Server.Data.Server { var server = new DHT.Server.Data.Server {
Id = RandomId(rand), Id = RandomId(rand),
Name = RandomName("s"), Name = RandomName("s"),
Type = ServerType.Server Type = ServerType.Server,
}; };
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel { var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
@@ -73,14 +73,14 @@ namespace DHT.Desktop.Main.Pages {
ParentId = null, ParentId = null,
Position = i, Position = i,
Topic = RandomText(rand, 10), Topic = RandomText(rand, 10),
Nsfw = rand.Next(4) == 0 Nsfw = rand.Next(4) == 0,
}).ToArray(); }).ToArray();
var users = Enumerable.Range(0, userCount).Select(_ => new User { var users = Enumerable.Range(0, userCount).Select(_ => new User {
Id = RandomId(rand), Id = RandomId(rand),
Name = RandomName("u"), Name = RandomName("u"),
AvatarUrl = null, AvatarUrl = null,
Discriminator = rand.Next(0, 9999).ToString() Discriminator = rand.Next(0, 9999).ToString(),
}).ToArray(); }).ToArray();
db.AddServer(server); db.AddServer(server);
@@ -97,7 +97,7 @@ namespace DHT.Desktop.Main.Pages {
int hourOffset = batchIndex; int hourOffset = batchIndex;
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => { var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes((i * 60.0) / BatchSize); DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null; DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
var timeMillis = time.ToUnixTimeMilliseconds(); var timeMillis = time.ToUnixTimeMilliseconds();
@@ -113,7 +113,7 @@ namespace DHT.Desktop.Main.Pages {
RepliedToId = null, RepliedToId = null,
Attachments = ImmutableArray<Attachment>.Empty, Attachments = ImmutableArray<Attachment>.Empty,
Embeds = ImmutableArray<Embed>.Empty, Embeds = ImmutableArray<Embed>.Empty,
Reactions = ImmutableArray<Reaction>.Empty Reactions = ImmutableArray<Reaction>.Empty,
}; };
}).ToArray(); }).ToArray();
@@ -161,7 +161,7 @@ namespace DHT.Desktop.Main.Pages {
"vanilla", "vanilla",
"watercress", "watermelon", "watercress", "watermelon",
"yam", "yam",
"zucchini" "zucchini",
}; };
private static string RandomText(Random rand, int maxWords) { private static string RandomText(Random rand, int maxWords) {

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.TrackingPage"> x:Class="DHT.Desktop.Main.Pages.TrackingPage"
x:DataType="pages:TrackingPageModel">
<Design.DataContext> <Design.DataContext>
<pages:TrackingPageModel /> <pages:TrackingPageModel />

View File

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

View File

@@ -1,7 +1,6 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord; using DHT.Desktop.Discord;
@@ -9,8 +8,9 @@ using DHT.Desktop.Server;
using DHT.Utils.Models; using DHT.Utils.Models;
using static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
sealed class TrackingPageModel : BaseModel {
sealed class TrackingPageModel : BaseModel {
private bool areDevToolsEnabled; private bool areDevToolsEnabled;
private bool AreDevToolsEnabled { private bool AreDevToolsEnabled {
@@ -54,14 +54,10 @@ namespace DHT.Desktop.Main.Pages {
} }
public async Task<bool> OnClickCopyTrackingScript() { public async Task<bool> OnClickCopyTrackingScript() {
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js"); string url = $"http://127.0.0.1:{ServerManager.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerManager.Token)}";
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + ServerManager.Port + ";") string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(ServerManager.Token))
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"));
var clipboard = Application.Current?.Clipboard; var clipboard = window.Clipboard;
if (clipboard == null) { if (clipboard == null) {
await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system."); await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
return false; return false;
@@ -113,5 +109,4 @@ namespace DHT.Desktop.Main.Pages {
throw new ArgumentOutOfRangeException(); throw new ArgumentOutOfRangeException();
} }
} }
}
} }

View File

@@ -5,7 +5,8 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.ViewerPage"> x:Class="DHT.Desktop.Main.Pages.ViewerPage"
x:DataType="pages:ViewerPageModel">
<Design.DataContext> <Design.DataContext>
<pages:ViewerPageModel /> <pages:ViewerPageModel />

View File

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

View File

@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@@ -8,17 +7,22 @@ using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Export; using DHT.Server.Database.Export;
using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Models; using DHT.Utils.Models;
using static DHT.Desktop.Program; using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages { namespace DHT.Desktop.Main.Pages;
sealed class ViewerPageModel : BaseModel, IDisposable {
sealed class ViewerPageModel : BaseModel, IDisposable {
public static readonly ConcurrentBag<string> TemporaryFiles = new (); public static readonly ConcurrentBag<string> TemporaryFiles = new ();
public bool DatabaseToolFilterModeKeep { get; set; } = true; public bool DatabaseToolFilterModeKeep { get; set; } = true;
@@ -31,7 +35,7 @@ namespace DHT.Desktop.Main.Pages {
set => Change(ref hasFilters, value); set => Change(ref hasFilters, value);
} }
private MessageFilterPanelModel FilterModel { get; } public MessageFilterPanelModel FilterModel { get; }
private readonly Window window; private readonly Window window;
private readonly IDatabaseFile db; private readonly IDatabaseFile db;
@@ -55,7 +59,7 @@ namespace DHT.Desktop.Main.Pages {
HasFilters = FilterModel.HasAnyFilters; HasFilters = FilterModel.HasAnyFilters;
} }
private async Task WriteViewerFile(string path) { private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
const string ArchiveTag = "/*[ARCHIVE]*/"; const string ArchiveTag = "/*[ARCHIVE]*/";
string indexFile = await Resources.ReadTextAsync("Viewer/index.html"); string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
@@ -68,7 +72,7 @@ namespace DHT.Desktop.Main.Pages {
string jsonTempFile = path + ".tmp"; string jsonTempFile = path + ".tmp";
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) { await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
await ViewerJsonExport.Generate(jsonStream, db, FilterModel.CreateFilter()); await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)]; char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
jsonStream.Position = 0; jsonStream.Position = 0;
@@ -106,27 +110,25 @@ namespace DHT.Desktop.Main.Pages {
TemporaryFiles.Add(fullPath); TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath); Directory.CreateDirectory(rootPath);
await WriteViewerFile(fullPath); await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true }); Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
} }
public async void OnClickSaveViewer() { private static readonly FilePickerFileType[] ViewerFileTypes = {
var dialog = new SaveFileDialog { FileDialogs.CreateFilter("Discord History Viewer", new string[] { "html" }),
Title = "Save Viewer", };
InitialFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
Directory = Path.GetDirectoryName(db.Path),
Filters = new List<FileDialogFilter> {
new() {
Name = "Discord History Viewer",
Extensions = { "html" }
}
}
}.ShowAsync(window);
string? path = await dialog; public async void OnClickSaveViewer() {
if (!string.IsNullOrEmpty(path)) { string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
await WriteViewerFile(path); Title = "Save Viewer",
FileTypeChoices = ViewerFileTypes,
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)),
});
if (path != null) {
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
} }
} }
@@ -144,5 +146,4 @@ namespace DHT.Desktop.Main.Pages {
} }
} }
} }
}
} }

View File

@@ -5,7 +5,8 @@
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens" xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.MainContentScreen"> x:Class="DHT.Desktop.Main.Screens.MainContentScreen"
x:DataType="screens:MainContentScreenModel">
<Design.DataContext> <Design.DataContext>
<screens:MainContentScreenModel /> <screens:MainContentScreenModel />

View File

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

View File

@@ -9,8 +9,9 @@ using DHT.Server.Database;
using DHT.Server.Service; using DHT.Server.Service;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Screens { namespace DHT.Desktop.Main.Screens;
sealed class MainContentScreenModel : IDisposable {
sealed class MainContentScreenModel : IDisposable {
private static readonly Log Log = Log.ForType<MainContentScreenModel>(); private static readonly Log Log = Log.ForType<MainContentScreenModel>();
public DatabasePage DatabasePage { get; } public DatabasePage DatabasePage { get; }
@@ -115,5 +116,4 @@ namespace DHT.Desktop.Main.Screens {
Log.Error(ex); Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message); await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
} }
}
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens" xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"> x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
x:DataType="screens:WelcomeScreenModel">
<Design.DataContext> <Design.DataContext>
<screens:WelcomeScreenModel /> <screens:WelcomeScreenModel />

View File

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

View File

@@ -1,14 +1,18 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Sqlite;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Screens { namespace DHT.Desktop.Main.Screens;
sealed class WelcomeScreenModel : BaseModel, IDisposable {
sealed class WelcomeScreenModel : BaseModel, IDisposable {
public string Version => Program.Version; public string Version => Program.Version;
public IDatabaseFile? Db { get; private set; } public IDatabaseFile? Db { get; private set; }
@@ -26,11 +30,8 @@ namespace DHT.Desktop.Main.Screens {
} }
public async void OpenOrCreateDatabase() { public async void OpenOrCreateDatabase() {
var dialog = DatabaseGui.NewOpenOrCreateDatabaseFileDialog(); var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
dialog.Directory = Path.GetDirectoryName(dbFilePath); if (path != null) {
string? path = await dialog.ShowAsync(window);
if (!string.IsNullOrWhiteSpace(path)) {
await OpenOrCreateDatabaseFromPath(path); await OpenOrCreateDatabaseFromPath(path);
} }
} }
@@ -41,16 +42,73 @@ namespace DHT.Desktop.Main.Screens {
} }
dbFilePath = path; dbFilePath = path;
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase); Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
OnPropertyChanged(nameof(Db)); OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase)); OnPropertyChanged(nameof(HasDatabase));
} }
private async Task<bool> CheckCanUpgradeDatabase() { private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly Window window;
public SchemaUpgradeCallbacks(Window window) {
this.window = window;
}
public async Task<bool> CanUpgrade() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window); return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
} }
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
var reporter = new ProgressReporter(versionSteps, callbacks);
await reporter.NextVersion();
await Task.Delay(TimeSpan.FromMilliseconds(800));
await doUpgrade(reporter);
await Task.Delay(TimeSpan.FromMilliseconds(600));
}
await new ProgressDialog {
DataContext = new ProgressDialogModel(StartUpgrade, progressItems: 3) {
Title = "Upgrading Database"
}
}.ShowProgressDialog(window);
}
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
private readonly IReadOnlyList<IProgressCallback> callbacks;
private readonly int versionSteps;
private int versionProgress = 0;
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
this.callbacks = callbacks;
this.versionSteps = versionSteps;
}
public async Task NextVersion() {
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
await HideChildren(0);
}
public async Task MainWork(string message, int finishedItems, int totalItems) {
await callbacks[1].Update(message, finishedItems, totalItems);
await HideChildren(1);
}
public async Task SubWork(string message, int finishedItems, int totalItems) {
await callbacks[2].Update(message, finishedItems, totalItems);
await HideChildren(2);
}
private async Task HideChildren(int parentIndex) {
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
await callbacks[i].Hide();
}
}
}
}
public void CloseDatabase() { public void CloseDatabase() {
Dispose(); Dispose();
OnPropertyChanged(nameof(Db)); OnPropertyChanged(nameof(Db));
@@ -69,5 +127,4 @@ namespace DHT.Desktop.Main.Screens {
Db?.Dispose(); Db?.Dispose();
Db = null; Db = null;
} }
}
} }

View File

@@ -3,8 +3,9 @@ using System.Reflection;
using Avalonia; using Avalonia;
using DHT.Utils.Resources; using DHT.Utils.Resources;
namespace DHT.Desktop { namespace DHT.Desktop;
static class Program {
static class Program {
public static string Version { get; } public static string Version { get; }
public static CultureInfo Culture { get; } public static CultureInfo Culture { get; }
public static ResourceLoader Resources { get; } public static ResourceLoader Resources { get; }
@@ -33,7 +34,7 @@ namespace DHT.Desktop {
private static AppBuilder BuildAvaloniaApp() { private static AppBuilder BuildAvaloniaApp() {
return AppBuilder.Configure<App>() return AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.WithInterFont()
.LogToTrace(); .LogToTrace();
} }
}
} }

View File

@@ -0,0 +1 @@
fetch("{url}").then(r => r.ok ? (r.headers.get("X-DHT") === "1" ? r.text() : Promise.reject("Invalid response")) : Promise.reject(r.status + " " + r.statusText)).then(s => eval(s)).catch(e => alert("Could not load tracking script:\n" + e));

View File

@@ -2,8 +2,9 @@ using System;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Service; using DHT.Server.Service;
namespace DHT.Desktop.Server { namespace DHT.Desktop.Server;
sealed class ServerManager : IDisposable {
sealed class ServerManager : IDisposable {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000); public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20); public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
@@ -46,5 +47,4 @@ namespace DHT.Desktop.Server {
instance = null; instance = null;
} }
} }
}
} }

49
app/Directory.Build.props Normal file
View File

@@ -0,0 +1,49 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>11</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<Authors>chylex</Authors>
<Company>DiscordHistoryTracker</Company>
<Product>DiscordHistoryTracker</Product>
</PropertyGroup>
<PropertyGroup>
<GenerateAssemblyVersionAttribute>false</GenerateAssemblyVersionAttribute>
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
<GenerateAssemblyInformationalVersionAttribute>false</GenerateAssemblyInformationalVersionAttribute>
</PropertyGroup>
<PropertyGroup>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
<DebugType>none</DebugType>
</PropertyGroup>
<PropertyGroup>
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
</PropertyGroup>
</Project>

View File

@@ -64,9 +64,11 @@
let action = null; let action = null;
if (!DISCORD.hasMoreMessages()) { if (!DISCORD.hasMoreMessages()) {
console.debug("[DHT] Reached first message.");
action = SETTINGS.afterFirstMsg; action = SETTINGS.afterFirstMsg;
} }
if (isNoAction(action) && !anyNewMessages) { if (isNoAction(action) && !anyNewMessages) {
console.debug("[DHT] No new messages.");
action = SETTINGS.afterSavedMsg; action = SETTINGS.afterSavedMsg;
} }
@@ -121,7 +123,7 @@
onTrackingContinued(false); onTrackingContinued(false);
} }
else { else {
const anyNewMessages = await STATE.addDiscordMessages(info.id, messages); const anyNewMessages = await STATE.addDiscordMessages(messages);
onTrackingContinued(anyNewMessages); onTrackingContinued(anyNewMessages);
} }
} catch (e) { } catch (e) {
@@ -156,3 +158,4 @@
GUI.showSettings(); GUI.showSettings();
} }
})(); })();
/*[DEBUGGER]*/

View File

@@ -1,5 +1,23 @@
// noinspection JSUnresolvedVariable // noinspection JSUnresolvedVariable
// noinspection LocalVariableNamingConventionJS
class DISCORD { class DISCORD {
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
static CHANNEL_TYPE = {
DM: 1,
GROUP_DM: 3,
ANNOUNCEMENT_THREAD: 10,
PUBLIC_THREAD: 11,
PRIVATE_THREAD: 12
};
// https://discord.com/developers/docs/resources/channel#message-object-message-types
static MESSAGE_TYPE = {
DEFAULT: 0,
REPLY: 19,
THREAD_STARTER: 21
};
static getMessageOuterElement() { static getMessageOuterElement() {
return DOM.queryReactClass("messagesWrapper"); return DOM.queryReactClass("messagesWrapper");
} }
@@ -9,7 +27,7 @@ class DISCORD {
} }
static getMessageElements() { static getMessageElements() {
return this.getMessageOuterElement().querySelectorAll("[class*='message-']"); return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
} }
static hasMoreMessages() { static hasMoreMessages() {
@@ -28,46 +46,11 @@ class DISCORD {
* Calls the provided function with a list of messages whenever the currently loaded messages change. * Calls the provided function with a list of messages whenever the currently loaded messages change.
*/ */
static setupMessageCallback(callback) { static setupMessageCallback(callback) {
let skipsLeft = 0;
let waitForCleanup = false;
const previousMessages = new Set(); const previousMessages = new Set();
const timer = window.setInterval(() => { const onMessageElementsChanged = function() {
if (skipsLeft > 0) { const messages = DISCORD.getMessages();
--skipsLeft; const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
return;
}
const view = this.getMessageOuterElement();
if (!view) {
skipsLeft = 2;
return;
}
const anyMessage = DOM.queryReactClass("message", this.getMessageOuterElement());
const messageCount = anyMessage ? anyMessage.parentElement.children.length : 0;
if (messageCount > 300) {
if (waitForCleanup) {
return;
}
skipsLeft = 3;
waitForCleanup = true;
window.setTimeout(() => {
const view = this.getMessageScrollerElement();
// noinspection JSUnusedGlobalSymbols
view.scrollTop = view.scrollHeight / 2;
}, 1);
}
else {
waitForCleanup = false;
}
const messages = this.getMessages();
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !this.hasMoreMessages();
if (!hasChanged) { if (!hasChanged) {
return; return;
@@ -79,23 +62,75 @@ class DISCORD {
} }
callback(messages); callback(messages);
}, 200); };
window.DHT_ON_UNLOAD.push(() => window.clearInterval(timer)); let debounceTimer;
/**
* Do not trigger the callback too often due to autoscrolling.
*/
const onMessageElementsChangedLater = function() {
window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
};
const observer = new MutationObserver(function () {
onMessageElementsChangedLater();
});
let skipsLeft = 0;
let observedElement = null;
const observerTimer = window.setInterval(() => {
if (skipsLeft > 0) {
--skipsLeft;
return;
}
const view = this.getMessageOuterElement();
if (!view) {
skipsLeft = 1;
return;
}
if (observedElement !== null && observedElement.isConnected) {
return;
}
observedElement = view.querySelector("[data-list-id='chat-messages']");
if (observedElement) {
console.debug("[DHT] Observed message container.");
observer.observe(observedElement, { childList: true });
onMessageElementsChangedLater();
}
}, 400);
window.DHT_ON_UNLOAD.push(() => {
observer.disconnect();
observedElement = null;
window.clearInterval(observerTimer);
});
} }
/** /**
* Returns the property object of a message element. * Returns the message from a message element.
* @returns { null | { message: DiscordMessage, channel: Object } } * @returns { null | DiscordMessage } }
*/ */
static getMessageElementProps(ele) { static getMessageFromElement(ele) {
const props = DOM.getReactProps(ele); const props = DOM.getReactProps(ele);
if (props.children && props.children.length >= 4) { if (props && Array.isArray(props.children)) {
const childProps = props.children[3].props; for (const child of props.children) {
if (!(child instanceof Object)) {
continue;
}
if ("message" in childProps && "channel" in childProps) { const childProps = child.props;
return childProps; if (childProps instanceof Object && "message" in childProps) {
return childProps.message;
}
} }
} }
@@ -111,10 +146,10 @@ class DISCORD {
for (const ele of this.getMessageElements()) { for (const ele of this.getMessageElements()) {
try { try {
const props = this.getMessageElementProps(ele); const message = this.getMessageFromElement(ele);
if (props != null) { if (message != null) {
messages.push(props.message); messages.push(message);
} }
} catch (e) { } catch (e) {
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele)); console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
@@ -135,7 +170,7 @@ class DISCORD {
*/ */
static getSelectedChannel() { static getSelectedChannel() {
try { try {
let obj; let obj = null;
try { try {
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) { for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
@@ -146,15 +181,6 @@ class DISCORD {
} }
} catch (e) { } catch (e) {
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e); console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
for (const ele of this.getMessageElements()) {
const props = this.getMessageElementProps(ele);
if (props != null) {
obj = props.channel;
break;
}
}
} }
if (!obj || typeof obj.id !== "string") { if (!obj || typeof obj.id !== "string") {
@@ -166,7 +192,7 @@ class DISCORD {
if (dms) { if (dms) {
let name; let name;
for (const ele of dms.querySelectorAll("[class*='channel-'] [class*='selected-'] [class^='name-'] *, [class*='channel-'][class*='selected-'] [class^='name-'] *")) { for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE); const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
if (node) { if (node) {
@@ -183,8 +209,8 @@ class DISCORD {
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types // https://discord.com/developers/docs/resources/channel#channel-object-channel-types
switch (obj.type) { switch (obj.type) {
case 1: type = "DM"; break; case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
case 3: type = "GROUP"; break; case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
default: return null; default: return null;
} }
@@ -195,9 +221,22 @@ class DISCORD {
return { server, channel }; return { server, channel };
} }
else if (obj.guild_id) { else if (obj.guild_id) {
let guild;
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
if (child && child.props && child.props.guild) {
guild = child.props.guild;
break;
}
}
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
return null;
}
const server = { const server = {
"id": obj.guild_id, "id": guild.id,
"name": document.querySelector("nav header h1[class*='name-']").innerText, "name": guild.name,
"type": "SERVER" "type": "SERVER"
}; };
@@ -209,7 +248,7 @@ class DISCORD {
} }
}; };
if (obj.parent_id) { if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
channel["extra"]["parent"] = obj.parent_id; channel["extra"]["parent"] = obj.parent_id;
} }
else { else {
@@ -236,10 +275,10 @@ class DISCORD {
if (dms) { if (dms) {
const currentChannel = DOM.queryReactClass("selected", dms); const currentChannel = DOM.queryReactClass("selected", dms);
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel-']"); const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling; const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel-")) { if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
return false; return false;
} }
@@ -266,7 +305,7 @@ class DISCORD {
let nextChannel = null; let nextChannel = null;
for (let index = 0; index < allTextChannels.length - 1; index++) { for (let index = 0; index < allTextChannels.length - 1; index++) {
if (allTextChannels[index].className.includes("selected-")) { if (allTextChannels[index].className.includes("selected_")) {
nextChannel = allTextChannels[index + 1]; nextChannel = allTextChannels[index + 1];
break; break;
} }

View File

@@ -11,7 +11,7 @@ class DOM {
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document. * Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
*/ */
static queryReactClass(cls, parent) { static queryReactClass(cls, parent) {
return (parent || document).querySelector(`[class*="${cls}-"]`); return (parent || document).querySelector(`[class*="${cls}_"]`);
} }
/** /**

View File

@@ -86,12 +86,12 @@ const GUI = (function() {
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br> <label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
<br> <br>
<label>After reaching the first message in channel...</label><br> <label>After reaching the first message in channel...</label><br>
${radio("afm", "nothing", "Do Nothing")} ${radio("afm", "nothing", "Continue Tracking")}
${radio("afm", "pause", "Pause Tracking")} ${radio("afm", "pause", "Pause Tracking")}
${radio("afm", "switch", "Switch to Next Channel")} ${radio("afm", "switch", "Switch to Next Channel")}
<br> <br>
<label>After reaching a previously saved message...</label><br> <label>After reaching a previously saved message...</label><br>
${radio("asm", "nothing", "Do Nothing")} ${radio("asm", "nothing", "Continue Tracking")}
${radio("asm", "pause", "Pause Tracking")} ${radio("asm", "pause", "Pause Tracking")}
${radio("asm", "switch", "Switch to Next Channel")} ${radio("asm", "switch", "Switch to Next Channel")}
<p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`; <p id='dht-cfg-note'>It is recommended to disable link and image previews to avoid putting unnecessary strain on your browser.</p>`;

View File

@@ -174,12 +174,10 @@ const STATE = (function() {
}, },
/** /**
* @param {String} channelId
* @param {DiscordMessage[]} discordMessageArray * @param {DiscordMessage[]} discordMessageArray
*/ */
async addDiscordMessages(channelId, discordMessageArray) { async addDiscordMessages(discordMessageArray) {
// https://discord.com/developers/docs/resources/channel#message-object-message-types discordMessageArray = discordMessageArray.filter(msg => (msg.type === DISCORD.MESSAGE_TYPE.DEFAULT || msg.type === DISCORD.MESSAGE_TYPE.REPLY || msg.type === DISCORD.MESSAGE_TYPE.THREAD_STARTER) && msg.state === "SENT");
discordMessageArray = discordMessageArray.filter(msg => (msg.type === 0 || msg.type === 19 || msg.type === 21) && msg.state === "SENT");
if (discordMessageArray.length === 0) { if (discordMessageArray.length === 0) {
return false; return false;
@@ -251,6 +249,11 @@ const STATE = (function() {
mapped.type = attachment.content_type; mapped.type = attachment.content_type;
} }
if (attachment.width && attachment.height) {
mapped.width = attachment.width;
mapped.height = attachment.height;
}
return mapped; return mapped;
}); });
} }

View File

@@ -1,9 +1,5 @@
#app-mount div[class*="app-"] { #app-mount {
margin-bottom: 48px !important; height: calc(100% - 48px) !important;
}
#app-mount div[class*="app-"] > div[class*="app-"] {
margin-bottom: 0 !important;
} }
#dht-ctrl { #dht-ctrl {

View File

@@ -1,7 +1,8 @@
const DISCORD = (function() { const DISCORD = (function() {
const regex = { const regex = {
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g, formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
formatItalic: /(.)?([_*])([\s\S]+?)\2(?!\2)/g, formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
formatItalic2: /_([\s\S]+?)_(?!_)\b/g,
formatUnderline: /__([\s\S]+?)__(?!_)/g, formatUnderline: /__([\s\S]+?)__(?!_)/g,
formatStrike: /~~([\s\S]+?)~~(?!~)/g, formatStrike: /~~([\s\S]+?)~~(?!~)/g,
formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g, formatCodeInline: /(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/g,
@@ -48,7 +49,8 @@ const DISCORD = (function() {
.replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch)) .replace(regex.specialEscapedDouble, full => full.replace(/\\/g, "").replace(/(.)/g, escapeHtmlMatch))
.replace(regex.formatBold, "<b>$1</b>") .replace(regex.formatBold, "<b>$1</b>")
.replace(regex.formatUnderline, "<u>$1</u>") .replace(regex.formatUnderline, "<u>$1</u>")
.replace(regex.formatItalic, (full, pre, char, match) => pre === "\\" ? full : (pre || "") + "<i>" + match + "</i>") .replace(regex.formatItalic1, "<i>$1</i>")
.replace(regex.formatItalic2, "<i>$1</i>")
.replace(regex.formatStrike, "<s>$1</s>"); .replace(regex.formatStrike, "<s>$1</s>");
} }
@@ -78,6 +80,12 @@ const DISCORD = (function() {
} }
}; };
const isImageUrl = function(url) {
const dot = url.pathname.lastIndexOf(".");
const ext = dot === -1 ? "" : url.pathname.substring(dot).toLowerCase();
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
};
return { return {
setup() { setup() {
templateChannelServer = new TEMPLATE([ templateChannelServer = new TEMPLATE([
@@ -123,7 +131,7 @@ const DISCORD = (function() {
// noinspection HtmlUnknownTarget // noinspection HtmlUnknownTarget
templateAttachmentDownload = new TEMPLATE([ templateAttachmentDownload = new TEMPLATE([
"<a href='{url}' class='embed download'>Download {filename}</a>" "<a href='{url}' class='embed download'>Download {name}</a>"
].join("")); ].join(""));
// noinspection HtmlUnknownTarget // noinspection HtmlUnknownTarget
@@ -176,9 +184,8 @@ const DISCORD = (function() {
}, },
isImageAttachment(attachment) { isImageAttachment(attachment) {
const dot = attachment.url.lastIndexOf("."); const url = DOM.tryParseUrl(attachment.url);
const ext = dot === -1 ? "" : attachment.url.substring(dot).toLowerCase(); return url != null && isImageUrl(url);
return ext === ".png" || ext === ".gif" || ext === ".jpg" || ext === ".jpeg";
}, },
getChannelHTML(channel) { // noinspection FunctionWithInconsistentReturnsJS getChannelHTML(channel) { // noinspection FunctionWithInconsistentReturnsJS
@@ -235,16 +242,14 @@ const DISCORD = (function() {
} }
return value.map(attachment => { return value.map(attachment => {
if (this.isImageAttachment(attachment) && SETTINGS.enableImagePreviews) { if (!DISCORD.isImageAttachment(attachment) || !SETTINGS.enableImagePreviews) {
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url }); return templateAttachmentDownload.apply(attachment);
}
else if ("width" in attachment && "height" in attachment) {
return templateEmbedImageWithSize.apply({ url: attachment.url, src: attachment.url, width: attachment.width, height: attachment.height });
} }
else { else {
const sliced = attachment.url.split("/"); return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
return templateAttachmentDownload.apply({
"url": attachment.url,
"filename": sliced[sliced.length - 1]
});
} }
}).join(""); }).join("");
} }

View File

@@ -51,4 +51,15 @@ class DOM {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleDateString() + ", " + date.toLocaleTimeString(); return date.toLocaleDateString() + ", " + date.toLocaleTimeString();
}; };
/**
* Parses a url string into a URL object and returns it. If the parsing fails, returns null.
*/
static tryParseUrl(url) {
try {
return new URL(url);
} catch (ignore) {
return null;
}
}
} }

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env python3
import glob
import os
import shutil
import sys
if os.name == "nt":
uglifyjs = os.path.abspath("../../lib/uglifyjs.cmd")
else:
uglifyjs = "uglifyjs"
if shutil.which(uglifyjs) is None:
print("Cannot find executable: {0}".format(uglifyjs))
sys.exit(1)
input_dir = os.path.abspath("./Tracker/scripts")
output_dir = os.path.abspath("../Desktop/bin/.res/scripts")
os.makedirs(output_dir, exist_ok=True)
for file in glob.glob(input_dir + "/*.js"):
name = os.path.basename(file)
print("Minifying {0}...".format(name))
os.system("{0} {1} -o {2}/{3}".format(uglifyjs, file, output_dir, name))
print("Done!")

View File

@@ -1,5 +1,6 @@
namespace DHT.Server.Data.Aggregations { namespace DHT.Server.Data.Aggregations;
public sealed class DownloadStatusStatistics {
public sealed class DownloadStatusStatistics {
public int EnqueuedCount { get; internal set; } public int EnqueuedCount { get; internal set; }
public ulong EnqueuedSize { get; internal set; } public ulong EnqueuedSize { get; internal set; }
@@ -11,5 +12,4 @@ namespace DHT.Server.Data.Aggregations {
public int SkippedCount { get; internal set; } public int SkippedCount { get; internal set; }
public ulong SkippedSize { get; internal set; } public ulong SkippedSize { get; internal set; }
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
namespace DHT.Server.Data.Filters { namespace DHT.Server.Data.Filters;
public sealed class AttachmentFilter {
public sealed class AttachmentFilter {
public ulong? MaxBytes { get; set; } = null; public ulong? MaxBytes { get; set; } = null;
public DownloadItemRules? DownloadItemRule { get; set; } = null; public DownloadItemRules? DownloadItemRule { get; set; } = null;
@@ -11,5 +12,4 @@ namespace DHT.Server.Data.Filters {
OnlyNotPresent, OnlyNotPresent,
OnlyPresent OnlyPresent
} }
}
} }

View File

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

View File

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

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