mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-08-18 13:31:42 +02:00
Compare commits
1 Commits
v44.0
...
wip-viewer
Author | SHA1 | Date | |
---|---|---|---|
b660af4be0
|
@@ -9,15 +9,15 @@
|
||||
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/DownloadItemFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/AttachmentFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/MessageFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Controls/StatusBar.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/MainWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/AdvancedPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/AttachmentsPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/DebugPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/DownloadsPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Pages/ViewerPage.axaml" value="Desktop/Desktop.csproj" />
|
||||
<entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
|
||||
@@ -25,4 +25,4 @@
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
@@ -13,10 +13,10 @@
|
||||
<CssCodeStyleSettings>
|
||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||
</CssCodeStyleSettings>
|
||||
<DB2CodeStyleSettings version="7">
|
||||
<DB2CodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</DB2CodeStyleSettings>
|
||||
<DerbyCodeStyleSettings version="7">
|
||||
<DerbyCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</DerbyCodeStyleSettings>
|
||||
<GoCodeStyleSettings>
|
||||
@@ -26,10 +26,10 @@
|
||||
<option name="WRAP_FUNC_PARAMS" value="5" />
|
||||
<option name="WRAP_FUNC_RESULT" value="5" />
|
||||
</GoCodeStyleSettings>
|
||||
<H2CodeStyleSettings version="7">
|
||||
<H2CodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</H2CodeStyleSettings>
|
||||
<HSQLCodeStyleSettings version="7">
|
||||
<HSQLCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</HSQLCodeStyleSettings>
|
||||
<HTMLCodeStyleSettings>
|
||||
@@ -85,10 +85,10 @@
|
||||
<LessCodeStyleSettings>
|
||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||
</LessCodeStyleSettings>
|
||||
<MSSQLCodeStyleSettings version="7">
|
||||
<MSSQLCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</MSSQLCodeStyleSettings>
|
||||
<MySQLCodeStyleSettings version="7">
|
||||
<MySQLCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</MySQLCodeStyleSettings>
|
||||
<Objective-C>
|
||||
@@ -98,7 +98,7 @@
|
||||
<option name="SPACE_BEFORE_INIT_LIST" value="true" />
|
||||
<option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" />
|
||||
</Objective-C>
|
||||
<OracleCodeStyleSettings version="7">
|
||||
<OracleCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</OracleCodeStyleSettings>
|
||||
<PHPCodeStyleSettings>
|
||||
@@ -116,7 +116,7 @@
|
||||
<option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" />
|
||||
<option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" />
|
||||
</PHPCodeStyleSettings>
|
||||
<PostgresCodeStyleSettings version="7">
|
||||
<PostgresCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</PostgresCodeStyleSettings>
|
||||
<Properties>
|
||||
@@ -143,16 +143,13 @@
|
||||
<option name="INDENT_WHEN_CASES" value="true" />
|
||||
<option name="CHAIN_CALLS_ALIGNMENT" value="2" />
|
||||
</Ruby>
|
||||
<SQLiteCodeStyleSettings version="7">
|
||||
<SQLiteCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</SQLiteCodeStyleSettings>
|
||||
<ScssCodeStyleSettings>
|
||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||
</ScssCodeStyleSettings>
|
||||
<Shell_Script>
|
||||
<option name="KEEP_COLUMN_ALIGNMENT_PADDING" value="true" />
|
||||
</Shell_Script>
|
||||
<SqlCodeStyleSettings version="7">
|
||||
<SqlCodeStyleSettings version="6">
|
||||
<option name="KEYWORD_CASE" value="2" />
|
||||
<option name="TYPE_CASE" value="2" />
|
||||
<option name="CUSTOM_TYPE_CASE" value="2" />
|
||||
@@ -176,10 +173,9 @@
|
||||
<option name="CONSTRAINT_WRAP_3" value="true" />
|
||||
<option name="CONSTRAINT_WRAP_4" value="true" />
|
||||
<option name="VIEW_INDENT_QUERY" value="true" />
|
||||
<option name="IMP_LOOP_LOOP_INDENT" value="false" />
|
||||
<option name="EXPR_CASE_WHEN_WRAP" value="false" />
|
||||
</SqlCodeStyleSettings>
|
||||
<SybaseCodeStyleSettings version="7">
|
||||
<SybaseCodeStyleSettings version="6">
|
||||
<option name="USE_GENERIC_STYLE" value="true" />
|
||||
</SybaseCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
@@ -207,11 +203,6 @@
|
||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Gherkin">
|
||||
<indentOptions>
|
||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Groovy">
|
||||
<option name="ELSE_ON_NEW_LINE" value="true" />
|
||||
<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true" />
|
||||
@@ -291,13 +282,6 @@
|
||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Markdown">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="ObjectiveC">
|
||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
@@ -358,6 +342,7 @@
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Rust">
|
||||
<option name="RIGHT_MARGIN" value="140" />
|
||||
<option name="ALIGN_MULTILINE_CHAINED_METHODS" value="true" />
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
@@ -372,7 +357,6 @@
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="SCSS">
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
@@ -383,6 +367,13 @@
|
||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Shell Script">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="4" />
|
||||
<option name="TAB_SIZE" value="4" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TOML">
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
@@ -436,9 +427,6 @@
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="liquid">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
@@ -454,4 +442,4 @@
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
</component>
|
||||
|
@@ -91,6 +91,7 @@
|
||||
<inspection_tool class="ComparatorNotSerializable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="CompareToUsesNonFinalVariable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ComparisonOfShortAndChar" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ComposeMissingKeys" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||
<inspection_tool class="ConditionSignal" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ConditionalExpression" enabled="true" level="INFORMATION" enabled_by_default="true">
|
||||
<option name="ignoreSimpleAssignmentsAndReturns" value="true" />
|
||||
@@ -361,7 +362,7 @@
|
||||
</inspection_tool>
|
||||
<inspection_tool class="LocalVariableDeclarationSideOnly" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||
<inspection_tool class="LocalVariableNamingConventionJS" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="m_regex" value="[a-z][A-Za-z0-9]*" />
|
||||
<option name="m_regex" value="[a-z][A-Za-z]*" />
|
||||
<option name="m_minLength" value="0" />
|
||||
<option name="m_maxLength" value="99" />
|
||||
</inspection_tool>
|
||||
|
22
app/.idea/.idea.DiscordHistoryTracker/.idea/libraries/Generated_files.xml
generated
Normal file
22
app/.idea/.idea.DiscordHistoryTracker/.idea/libraries/Generated_files.xml
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
<component name="libraryTable">
|
||||
<library name="Generated files" type="javaScript">
|
||||
<properties>
|
||||
<sourceFilesUrls>
|
||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/discord.js" />
|
||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/dom.js" />
|
||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/gui.js" />
|
||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/settings.js" />
|
||||
<item url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/state.js" />
|
||||
</sourceFilesUrls>
|
||||
</properties>
|
||||
<CLASSES>
|
||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/discord.js" />
|
||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/dom.js" />
|
||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/gui.js" />
|
||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/settings.js" />
|
||||
<root url="file://$PROJECT_DIR$/Resources/Tracker/scripts.min/state.js" />
|
||||
</CLASSES>
|
||||
<JAVADOC />
|
||||
<SOURCES />
|
||||
</library>
|
||||
</component>
|
@@ -1,31 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop;
|
||||
|
||||
sealed class Arguments {
|
||||
private static readonly Log Log = Log.ForType<Arguments>();
|
||||
|
||||
|
||||
private const int FirstArgument = 1;
|
||||
|
||||
public static Arguments Empty => new (Array.Empty<string>());
|
||||
public static Arguments Empty => new(Array.Empty<string>());
|
||||
|
||||
public bool Console { get; }
|
||||
public string? DatabaseFile { get; }
|
||||
public ushort? ServerPort { get; }
|
||||
public string? ServerToken { get; }
|
||||
public byte? ConcurrentDownloads { get; }
|
||||
|
||||
public Arguments(IReadOnlyList<string> args) {
|
||||
for (int i = FirstArgument; i < args.Count; i++) {
|
||||
public Arguments(string[] args) {
|
||||
for (int i = FirstArgument; i < args.Length; i++) {
|
||||
string key = args[i];
|
||||
|
||||
switch (key) {
|
||||
case "-debug":
|
||||
Log.IsDebugEnabled = true;
|
||||
continue;
|
||||
|
||||
|
||||
case "-console":
|
||||
Console = true;
|
||||
continue;
|
||||
@@ -37,7 +35,7 @@ sealed class Arguments {
|
||||
value = key;
|
||||
key = "-db";
|
||||
}
|
||||
else if (i >= args.Count - 1) {
|
||||
else if (i >= args.Length - 1) {
|
||||
Log.Warn("Missing value for command line argument: " + key);
|
||||
continue;
|
||||
}
|
||||
@@ -51,11 +49,11 @@ sealed class Arguments {
|
||||
continue;
|
||||
|
||||
case "-port": {
|
||||
if (!ushort.TryParse(value, out var port)) {
|
||||
Log.Warn("Invalid port number: " + value);
|
||||
if (ushort.TryParse(value, out var port)) {
|
||||
ServerPort = port;
|
||||
}
|
||||
else {
|
||||
ServerPort = port;
|
||||
Log.Warn("Invalid port number: " + value);
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -64,20 +62,6 @@ sealed class Arguments {
|
||||
case "-token":
|
||||
ServerToken = value;
|
||||
continue;
|
||||
|
||||
case "-concurrentdownloads":
|
||||
if (!ulong.TryParse(value, out var concurrentDownloads) || concurrentDownloads == 0) {
|
||||
Log.Warn("Invalid concurrent downloads count: " + value);
|
||||
}
|
||||
else if (concurrentDownloads > 10) {
|
||||
Log.Warn("Limiting concurrent downloads to 10");
|
||||
ConcurrentDownloads = 10;
|
||||
}
|
||||
else {
|
||||
ConcurrentDownloads = (byte) concurrentDownloads;
|
||||
}
|
||||
|
||||
continue;
|
||||
|
||||
default:
|
||||
Log.Warn("Unknown command line argument: " + key);
|
||||
|
@@ -19,17 +19,17 @@ sealed class BytesValueConverter : IValueConverter {
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly Unit[] Units = [
|
||||
new Unit("B", decimalPlaces: 0),
|
||||
new Unit("kB", decimalPlaces: 0),
|
||||
new Unit("MB", decimalPlaces: 1),
|
||||
new Unit("GB", decimalPlaces: 1),
|
||||
new Unit("TB", decimalPlaces: 1)
|
||||
];
|
||||
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;
|
||||
|
||||
public static string Convert(ulong size) {
|
||||
private static string Convert(ulong size) {
|
||||
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
||||
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
||||
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||
|
@@ -1,15 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using DHT.Desktop.Dialogs.File;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Exceptions;
|
||||
using DHT.Server.Database.Sqlite;
|
||||
using DHT.Server.Database.Sqlite.Schema;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Common;
|
||||
@@ -19,9 +20,9 @@ static class DatabaseGui {
|
||||
|
||||
private const string DatabaseFileInitialName = "archive.dht";
|
||||
|
||||
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = [
|
||||
FileDialogs.CreateFilter("Discord History Tracker Database", ["dht"])
|
||||
];
|
||||
private static readonly IReadOnlyList<FilePickerFileType> DatabaseFileDialogFilter = new List<FilePickerFileType> {
|
||||
FileDialogs.CreateFilter("Discord History Tracker Database", new [] { "dht" })
|
||||
};
|
||||
|
||||
public static async Task<string[]> NewOpenDatabaseFilesDialog(Window window, string? suggestedDirectory) {
|
||||
return await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
|
||||
@@ -43,10 +44,15 @@ static class DatabaseGui {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
|
||||
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler);
|
||||
} catch (InvalidDatabaseVersionException ex) {
|
||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
||||
} catch (DatabaseTooNewException ex) {
|
||||
|
@@ -1,9 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DHT.Desktop.Common;
|
||||
|
||||
static class SystemUtils {
|
||||
public static void OpenUrl(string url) {
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
}
|
@@ -15,15 +15,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.11" Condition=" '$(Configuration)' == 'Debug' " />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.11" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.11" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
|
||||
<PackageReference Include="Avalonia" Version="11.0.6" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.6" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -32,6 +30,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
|
||||
<DependentUpon>CheckBoxDialog.axaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -39,6 +41,11 @@
|
||||
<EmbeddedResource Include="Resources/tracker-loader.js">
|
||||
<LogicalName>tracker-loader.js</LogicalName>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="../Resources/Viewer/**">
|
||||
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<Visible>false</Visible>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@@ -38,7 +38,7 @@
|
||||
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding IsChecked}">
|
||||
<CheckBox IsChecked="{Binding Checked}">
|
||||
<Label>
|
||||
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
||||
</Label>
|
||||
|
@@ -2,11 +2,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
|
||||
class CheckBoxDialogModel : ObservableObject {
|
||||
class CheckBoxDialogModel : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
||||
@@ -29,8 +29,8 @@ class CheckBoxDialogModel : ObservableObject {
|
||||
|
||||
private bool pauseCheckEvents = false;
|
||||
|
||||
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
||||
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
||||
public bool AreAllSelected => Items.All(static item => item.Checked);
|
||||
public bool AreNoneSelected => Items.All(static item => !item.Checked);
|
||||
|
||||
public void SelectAll() => SetAllChecked(true);
|
||||
public void SelectNone() => SetAllChecked(false);
|
||||
@@ -39,7 +39,7 @@ class CheckBoxDialogModel : ObservableObject {
|
||||
pauseCheckEvents = true;
|
||||
|
||||
foreach (var item in Items) {
|
||||
item.IsChecked = isChecked;
|
||||
item.Checked = isChecked;
|
||||
}
|
||||
|
||||
pauseCheckEvents = false;
|
||||
@@ -52,16 +52,16 @@ class CheckBoxDialogModel : ObservableObject {
|
||||
}
|
||||
|
||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
|
||||
UpdateBulkButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||
private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||
|
||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
|
||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
||||
|
||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||
this.Items = new List<CheckBoxItem<T>>(items);
|
||||
|
@@ -1,13 +1,17 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||
|
||||
partial class CheckBoxItem : ObservableObject {
|
||||
class CheckBoxItem : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isChecked = false;
|
||||
|
||||
public bool Checked {
|
||||
get => isChecked;
|
||||
set => Change(ref isChecked, value);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||
|
@@ -4,6 +4,5 @@ namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
interface IProgressCallback {
|
||||
Task Update(string message, int finishedItems, int totalItems);
|
||||
Task UpdateIndeterminate(string message);
|
||||
Task Hide();
|
||||
}
|
||||
|
@@ -40,7 +40,7 @@
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
|
||||
</DockPanel>
|
||||
<ProgressBar IsIndeterminate="{Binding IsIndeterminate}" Value="{Binding Progress}" />
|
||||
<ProgressBar Value="{Binding Progress}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
|
@@ -7,58 +7,6 @@ namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class ProgressDialog : Window {
|
||||
internal static async Task Show(Window owner, string title, Func<ProgressDialog, IProgressCallback, Task> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
try {
|
||||
await action(dialog, callbacks[0]);
|
||||
taskCompletionSource.SetResult();
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
internal static async Task ShowIndeterminate(Window owner, string title, string message, Func<ProgressDialog, Task> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
await callbacks[0].UpdateIndeterminate(message);
|
||||
try {
|
||||
await action(dialog);
|
||||
taskCompletionSource.SetResult();
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
internal static async Task<T> ShowIndeterminate<T>(Window owner, string title, string message, Func<ProgressDialog, Task<T>> action) {
|
||||
var taskCompletionSource = new TaskCompletionSource<T>();
|
||||
var dialog = new ProgressDialog();
|
||||
|
||||
dialog.DataContext = new ProgressDialogModel(title, async callbacks => {
|
||||
await callbacks[0].UpdateIndeterminate(message);
|
||||
try {
|
||||
taskCompletionSource.SetResult(await action(dialog));
|
||||
} catch (Exception e) {
|
||||
taskCompletionSource.SetException(e);
|
||||
}
|
||||
});
|
||||
|
||||
await dialog.ShowProgressDialog(owner);
|
||||
return await taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
private bool isFinished = false;
|
||||
private Task progressTask = Task.CompletedTask;
|
||||
|
||||
|
@@ -4,10 +4,11 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
sealed class ProgressDialogModel {
|
||||
sealed class ProgressDialogModel : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
||||
@@ -17,10 +18,9 @@ sealed class ProgressDialogModel {
|
||||
[Obsolete("Designer")]
|
||||
public ProgressDialogModel() {}
|
||||
|
||||
public ProgressDialogModel(string title, TaskRunner task, int progressItems = 1) {
|
||||
this.Title = title;
|
||||
this.task = task;
|
||||
public ProgressDialogModel(TaskRunner task, int progressItems = 1) {
|
||||
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
|
||||
this.task = task;
|
||||
}
|
||||
|
||||
internal async Task StartTask() {
|
||||
@@ -43,16 +43,6 @@ sealed class ProgressDialogModel {
|
||||
item.Message = message;
|
||||
item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
|
||||
item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
|
||||
item.IsIndeterminate = false;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task UpdateIndeterminate(string message) {
|
||||
await Dispatcher.UIThread.InvokeAsync(() => {
|
||||
item.Message = message;
|
||||
item.Items = string.Empty;
|
||||
item.Progress = 0;
|
||||
item.IsIndeterminate = true;
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -1,12 +1,18 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
namespace DHT.Desktop.Dialogs.Progress;
|
||||
|
||||
sealed partial class ProgressItem : ObservableObject {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(Opacity))]
|
||||
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 = "";
|
||||
@@ -14,17 +20,22 @@ sealed partial class ProgressItem : ObservableObject {
|
||||
public string Message {
|
||||
get => message;
|
||||
set {
|
||||
SetProperty(ref message, value);
|
||||
Change(ref message, value);
|
||||
IsVisible = !string.IsNullOrEmpty(value);
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string items = "";
|
||||
|
||||
[ObservableProperty]
|
||||
public string Items {
|
||||
get => items;
|
||||
set => Change(ref items, value);
|
||||
}
|
||||
|
||||
private int progress = 0;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isIndeterminate;
|
||||
public int Progress {
|
||||
get => progress;
|
||||
set => Change(ref progress, value);
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class TextBoxDialog : Window {
|
||||
|
@@ -2,11 +2,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
class TextBoxDialogModel : ObservableObject {
|
||||
class TextBoxDialogModel : BaseModel {
|
||||
public string Title { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
@@ -36,7 +36,7 @@ class TextBoxDialogModel : ObservableObject {
|
||||
}
|
||||
|
||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||
|
||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||
|
||||
|
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
namespace DHT.Desktop.Dialogs.TextBox;
|
||||
|
||||
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
||||
public string Title { get; init; } = "";
|
||||
public object? Item { get; init; } = null;
|
||||
|
||||
@@ -17,7 +17,7 @@ class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||
public string Value {
|
||||
get => this.value;
|
||||
set {
|
||||
SetProperty(ref this.value, value);
|
||||
Change(ref this.value, value);
|
||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||
}
|
||||
}
|
||||
|
@@ -39,8 +39,7 @@ static class DiscordAppSettings {
|
||||
|
||||
public static async Task<bool?> AreDevToolsEnabled() {
|
||||
try {
|
||||
var settingsJson = await ReadSettingsJson();
|
||||
return AreDevToolsEnabled(settingsJson);
|
||||
return AreDevToolsEnabled(await ReadSettingsJson());
|
||||
} catch (Exception e) {
|
||||
Log.Error("Cannot read settings file.");
|
||||
Log.Error(e);
|
||||
|
@@ -5,4 +5,4 @@ namespace DHT.Desktop.Discord;
|
||||
|
||||
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
||||
[JsonSerializable(typeof(JsonObject))]
|
||||
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext;
|
||||
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {}
|
||||
|
@@ -3,12 +3,12 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:main="clr-namespace:DHT.Desktop.Main"
|
||||
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="360"
|
||||
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
|
||||
x:Class="DHT.Desktop.Main.AboutWindow"
|
||||
x:DataType="main:AboutWindowModel"
|
||||
Title="About Discord History Tracker"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="480" Height="360" CanResize="False"
|
||||
Width="480" Height="295" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Design.DataContext>
|
||||
@@ -39,11 +39,10 @@
|
||||
|
||||
<StackPanel>
|
||||
<Button Command="{Binding ShowOfficialWebsite}">Official Website</Button>
|
||||
<Button Command="{Binding ShowIssueTracker}">Issue Tracker</Button>
|
||||
<Button Command="{Binding ShowSourceCode}">Source Code</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="175,125,*" Margin="0 10 0 0">
|
||||
<Grid RowDefinitions="Auto,5,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="175,125,*" Margin="0 10 0 0">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="Bold">Third-Party Software</TextBlock>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" FontWeight="Bold">License</TextBlock>
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" FontWeight="Bold">Link</TextBlock>
|
||||
@@ -56,21 +55,13 @@
|
||||
<TextBlock Grid.Row="3" Grid.Column="1">MIT</TextBlock>
|
||||
<Button Grid.Row="3" Grid.Column="2" Command="{Binding ShowLibraryAvalonia}">NuGet</Button>
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0">MVVM Toolkit</TextBlock>
|
||||
<TextBlock Grid.Row="4" Grid.Column="1">MIT</TextBlock>
|
||||
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibraryCommunityToolkit}">GitHub</Button>
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="0">SQLite</TextBlock>
|
||||
<TextBlock Grid.Row="5" Grid.Column="1">Public Domain</TextBlock>
|
||||
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
|
||||
<TextBlock Grid.Row="4" Grid.Column="0">SQLite</TextBlock>
|
||||
<TextBlock Grid.Row="4" Grid.Column="1">Public Domain</TextBlock>
|
||||
<Button Grid.Row="4" Grid.Column="2" Command="{Binding ShowLibrarySqlite}">Official Website</Button>
|
||||
|
||||
<TextBlock Grid.Row="6" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
|
||||
<TextBlock Grid.Row="6" Grid.Column="1">Apache-2.0</TextBlock>
|
||||
<Button Grid.Row="6" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button>
|
||||
|
||||
<TextBlock Grid.Row="7" Grid.Column="0">Rx.NET</TextBlock>
|
||||
<TextBlock Grid.Row="7" Grid.Column="1">MIT</TextBlock>
|
||||
<Button Grid.Row="7" Grid.Column="2" Command="{Binding ShowLibraryRxNet}">GitHub</Button>
|
||||
<TextBlock Grid.Row="5" Grid.Column="0">Microsoft.Data.Sqlite</TextBlock>
|
||||
<TextBlock Grid.Row="5" Grid.Column="1">Apache-2.0</TextBlock>
|
||||
<Button Grid.Row="5" Grid.Column="2" Command="{Binding ShowLibrarySqliteAdoNet}">NuGet</Button>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
|
@@ -1,41 +1,33 @@
|
||||
using DHT.Desktop.Common;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
sealed class AboutWindowModel {
|
||||
public void ShowOfficialWebsite() {
|
||||
SystemUtils.OpenUrl(Program.Website);
|
||||
OpenUrl("https://dht.chylex.com");
|
||||
}
|
||||
|
||||
public void ShowIssueTracker() {
|
||||
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker/issues");
|
||||
}
|
||||
|
||||
public void ShowSourceCode() {
|
||||
SystemUtils.OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||
}
|
||||
|
||||
public void ShowLibraryNetCore() {
|
||||
SystemUtils.OpenUrl("https://github.com/dotnet/core");
|
||||
OpenUrl("https://github.com/chylex/Discord-History-Tracker");
|
||||
}
|
||||
|
||||
public void ShowLibraryAvalonia() {
|
||||
SystemUtils.OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||
}
|
||||
|
||||
public void ShowLibraryCommunityToolkit() {
|
||||
SystemUtils.OpenUrl("https://github.com/CommunityToolkit/dotnet");
|
||||
OpenUrl("https://www.nuget.org/packages/Avalonia");
|
||||
}
|
||||
|
||||
public void ShowLibrarySqlite() {
|
||||
SystemUtils.OpenUrl("https://www.sqlite.org");
|
||||
OpenUrl("https://www.sqlite.org");
|
||||
}
|
||||
|
||||
public void ShowLibrarySqliteAdoNet() {
|
||||
SystemUtils.OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||
OpenUrl("https://www.nuget.org/packages/Microsoft.Data.Sqlite");
|
||||
}
|
||||
|
||||
public void ShowLibraryRxNet() {
|
||||
SystemUtils.OpenUrl("https://github.com/dotnet/reactive");
|
||||
public void ShowLibraryNetCore() {
|
||||
OpenUrl("https://github.com/dotnet/core");
|
||||
}
|
||||
|
||||
private static void OpenUrl(string url) {
|
||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
|
@@ -4,11 +4,11 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="DHT.Desktop.Main.Controls.DownloadItemFilterPanel"
|
||||
x:DataType="controls:DownloadItemFilterPanelModel">
|
||||
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"
|
||||
x:DataType="controls:AttachmentFilterPanelModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<controls:DownloadItemFilterPanelModel />
|
||||
<controls:AttachmentFilterPanelModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
@@ -4,8 +4,8 @@ using Avalonia.Controls;
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class DownloadItemFilterPanel : UserControl {
|
||||
public DownloadItemFilterPanel() {
|
||||
public sealed partial class AttachmentFilterPanel : UserControl {
|
||||
public AttachmentFilterPanel() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
129
app/Desktop/Main/Controls/AttachmentFilterPanelModel.cs
Normal file
129
app/Desktop/Main/Controls/AttachmentFilterPanelModel.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
|
||||
public sealed record Unit(string Name, uint Scale);
|
||||
|
||||
private static readonly Unit[] AllUnits = {
|
||||
new ("B", 1),
|
||||
new ("kB", 1024),
|
||||
new ("MB", 1024 * 1024)
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> FilterProperties = new () {
|
||||
nameof(LimitSize),
|
||||
nameof(MaximumSize),
|
||||
nameof(MaximumSizeUnit)
|
||||
};
|
||||
|
||||
public string FilterStatisticsText { get; private set; } = "";
|
||||
|
||||
private bool limitSize = false;
|
||||
private ulong maximumSize = 0L;
|
||||
private Unit maximumSizeUnit = AllUnits[0];
|
||||
|
||||
public bool LimitSize {
|
||||
get => limitSize;
|
||||
set => Change(ref limitSize, value);
|
||||
}
|
||||
|
||||
public ulong MaximumSize {
|
||||
get => maximumSize;
|
||||
set => Change(ref maximumSize, value);
|
||||
}
|
||||
|
||||
public Unit MaximumSizeUnit {
|
||||
get => maximumSizeUnit;
|
||||
set => Change(ref maximumSizeUnit, value);
|
||||
}
|
||||
|
||||
public IEnumerable<Unit> Units => AllUnits;
|
||||
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly string verb;
|
||||
|
||||
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
|
||||
private long? matchingAttachmentCount;
|
||||
private long? totalAttachmentCount;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {}
|
||||
|
||||
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") {
|
||||
this.db = db;
|
||||
this.verb = verb;
|
||||
|
||||
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
|
||||
|
||||
UpdateFilterStatistics();
|
||||
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
||||
totalAttachmentCount = db.Statistics.TotalAttachments;
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFilterStatistics() {
|
||||
var filter = CreateFilter();
|
||||
if (filter.IsEmpty) {
|
||||
matchingAttachmentCountComputer.Cancel();
|
||||
matchingAttachmentCount = totalAttachmentCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
else {
|
||||
matchingAttachmentCount = null;
|
||||
UpdateFilterStatisticsText();
|
||||
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetAttachmentCounts(long matchingAttachmentCount) {
|
||||
this.matchingAttachmentCount = matchingAttachmentCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
|
||||
private void UpdateFilterStatisticsText() {
|
||||
var matchingAttachmentCountStr = matchingAttachmentCount?.Format() ?? "(...)";
|
||||
var totalAttachmentCountStr = totalAttachmentCount?.Format() ?? "(...)";
|
||||
|
||||
FilterStatisticsText = verb + " " + matchingAttachmentCountStr + " out of " + totalAttachmentCountStr + " attachment" + (totalAttachmentCount is null or 1 ? "." : "s.");
|
||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||
}
|
||||
|
||||
public AttachmentFilter CreateFilter() {
|
||||
AttachmentFilter filter = new();
|
||||
|
||||
if (LimitSize) {
|
||||
try {
|
||||
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||
} catch (ArithmeticException) {
|
||||
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||
}
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
@@ -1,172 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Data.Settings;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisposable {
|
||||
private static readonly Log Log = Log.ForType<DownloadItemFilterPanelModel>();
|
||||
|
||||
public sealed record Unit(string Name, uint Scale);
|
||||
|
||||
private static readonly Unit[] AllUnits = [
|
||||
new Unit("B", 1),
|
||||
new Unit("kB", 1024),
|
||||
new Unit("MB", 1024 * 1024)
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> FilterProperties = [
|
||||
nameof(LimitSize),
|
||||
nameof(MaximumSize),
|
||||
nameof(MaximumSizeUnit)
|
||||
];
|
||||
|
||||
public string FilterStatisticsText { get; private set; } = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool limitSize = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private ulong maximumSize = 0UL;
|
||||
|
||||
[ObservableProperty]
|
||||
private Unit maximumSizeUnit = AllUnits[0];
|
||||
|
||||
public IEnumerable<Unit> Units => AllUnits;
|
||||
|
||||
private readonly State state;
|
||||
private readonly string verb;
|
||||
|
||||
private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask;
|
||||
private bool isLoadingFilterSettings;
|
||||
|
||||
private readonly RestartableTask<long> downloadItemCountTask;
|
||||
private long? matchingItemCount;
|
||||
|
||||
private readonly IDisposable downloadItemCountSubscription;
|
||||
private long? totalItemCount;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DownloadItemFilterPanelModel() : this(State.Dummy) {}
|
||||
|
||||
public DownloadItemFilterPanelModel(State state, string verb = "Matches") {
|
||||
this.state = state;
|
||||
this.verb = verb;
|
||||
|
||||
this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings);
|
||||
|
||||
this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
|
||||
|
||||
UpdateFilterStatistics();
|
||||
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
}
|
||||
|
||||
public async Task Initialize() {
|
||||
isLoadingFilterSettings = true;
|
||||
|
||||
LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize);
|
||||
MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize);
|
||||
|
||||
if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) {
|
||||
MaximumSizeUnit = unitValue;
|
||||
}
|
||||
|
||||
isLoadingFilterSettings = false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
saveFilterSettingsTask.Dispose();
|
||||
|
||||
downloadItemCountTask.Cancel();
|
||||
downloadItemCountSubscription.Dispose();
|
||||
|
||||
await SaveFilterSettings(new FilterSettings(this));
|
||||
}
|
||||
|
||||
private sealed record FilterSettings(bool LimitSize, ulong MaximumSize, Unit MaximumSizeUnit) {
|
||||
public FilterSettings(DownloadItemFilterPanelModel model) : this(model.LimitSize, model.MaximumSize, model.MaximumSizeUnit) {}
|
||||
}
|
||||
|
||||
private async Task SaveFilterSettings(FilterSettings settings) {
|
||||
try {
|
||||
await state.Db.Settings.Set(async setter => {
|
||||
await setter.Set(SettingsKey.DownloadsLimitSize, settings.LimitSize);
|
||||
await setter.Set(SettingsKey.DownloadsMaximumSize, settings.MaximumSize);
|
||||
await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.Error("Could not save download filter settings");
|
||||
Log.Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||
if (!isLoadingFilterSettings) {
|
||||
saveFilterSettingsTask.Post(new FilterSettings(this));
|
||||
}
|
||||
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDownloadItemCountChanged(long newItemCount) {
|
||||
totalItemCount = newItemCount;
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
|
||||
|
||||
private void UpdateFilterStatistics() {
|
||||
var filter = CreateFilter();
|
||||
if (filter.IsEmpty) {
|
||||
downloadItemCountTask.Cancel();
|
||||
matchingItemCount = totalItemCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
else {
|
||||
matchingItemCount = null;
|
||||
UpdateFilterStatisticsText();
|
||||
downloadItemCountTask.Restart(cancellationToken => state.Db.Downloads.Count(filter, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetMatchingCount(long matchingAttachmentCount) {
|
||||
this.matchingItemCount = matchingAttachmentCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
|
||||
private void UpdateFilterStatisticsText() {
|
||||
var matchingItemCountStr = matchingItemCount?.Format() ?? "(...)";
|
||||
var totalItemCountStr = totalItemCount?.Format() ?? "(...)";
|
||||
|
||||
FilterStatisticsText = verb + " " + matchingItemCountStr + " out of " + totalItemCountStr + " file" + (totalItemCount is null or 1 ? "." : "s.");
|
||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||
}
|
||||
|
||||
public DownloadItemFilter CreateFilter() {
|
||||
DownloadItemFilter filter = new ();
|
||||
|
||||
if (LimitSize) {
|
||||
try {
|
||||
filter.MaxBytes = maximumSize * maximumSizeUnit.Scale;
|
||||
} catch (ArithmeticException) {
|
||||
// set no size limit, because the overflown size is larger than any file could possibly be
|
||||
}
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
@@ -2,25 +2,22 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.CheckBox;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
private static readonly HashSet<string> FilterProperties = [
|
||||
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
||||
private static readonly HashSet<string> FilterProperties = new () {
|
||||
nameof(FilterByDate),
|
||||
nameof(StartDate),
|
||||
nameof(EndDate),
|
||||
@@ -28,7 +25,7 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
nameof(IncludedChannels),
|
||||
nameof(FilterByUser),
|
||||
nameof(IncludedUsers)
|
||||
];
|
||||
};
|
||||
|
||||
public string FilterStatisticsText { get; private set; } = "";
|
||||
|
||||
@@ -36,76 +33,91 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
|
||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool filterByDate = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime? startDate = null;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime? endDate = null;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool filterByChannel = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private HashSet<ulong>? includedChannels = null;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool filterByUser = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private HashSet<ulong>? includedUsers = null;
|
||||
|
||||
[ObservableProperty]
|
||||
public bool FilterByDate {
|
||||
get => filterByDate;
|
||||
set => Change(ref filterByDate, value);
|
||||
}
|
||||
|
||||
public DateTime? StartDate {
|
||||
get => startDate;
|
||||
set => Change(ref startDate, value);
|
||||
}
|
||||
|
||||
public DateTime? EndDate {
|
||||
get => endDate;
|
||||
set => Change(ref endDate, value);
|
||||
}
|
||||
|
||||
public bool FilterByChannel {
|
||||
get => filterByChannel;
|
||||
set => Change(ref filterByChannel, value);
|
||||
}
|
||||
|
||||
public HashSet<ulong> IncludedChannels {
|
||||
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
|
||||
set => Change(ref includedChannels, value);
|
||||
}
|
||||
|
||||
public bool FilterByUser {
|
||||
get => filterByUser;
|
||||
set => Change(ref filterByUser, value);
|
||||
}
|
||||
|
||||
public HashSet<ulong> IncludedUsers {
|
||||
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet();
|
||||
set => Change(ref includedUsers, value);
|
||||
}
|
||||
|
||||
private string channelFilterLabel = "";
|
||||
|
||||
[ObservableProperty]
|
||||
public string ChannelFilterLabel {
|
||||
get => channelFilterLabel;
|
||||
set => Change(ref channelFilterLabel, value);
|
||||
}
|
||||
|
||||
private string userFilterLabel = "";
|
||||
|
||||
public string UserFilterLabel {
|
||||
get => userFilterLabel;
|
||||
set => Change(ref userFilterLabel, value);
|
||||
}
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly string verb;
|
||||
|
||||
private readonly RestartableTask<long> exportedMessageCountTask;
|
||||
private readonly AsyncValueComputer<long> exportedMessageCountComputer;
|
||||
private long? exportedMessageCount;
|
||||
|
||||
private readonly IDisposable messageCountSubscription;
|
||||
private long? totalMessageCount;
|
||||
|
||||
private readonly IDisposable channelCountSubscription;
|
||||
private long? totalChannelCount;
|
||||
|
||||
private readonly IDisposable userCountSubscription;
|
||||
private long? totalUserCount;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
||||
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
|
||||
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
this.db = db;
|
||||
this.verb = verb;
|
||||
|
||||
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
|
||||
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
|
||||
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
|
||||
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
|
||||
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
|
||||
|
||||
UpdateFilterStatistics();
|
||||
UpdateChannelFilterLabel();
|
||||
UpdateUserFilterLabel();
|
||||
|
||||
|
||||
PropertyChanged += OnPropertyChanged;
|
||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
exportedMessageCountTask.Cancel();
|
||||
|
||||
messageCountSubscription.Dispose();
|
||||
channelCountSubscription.Dispose();
|
||||
userCountSubscription.Dispose();
|
||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
@@ -122,54 +134,30 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMessageCountChanged(long newMessageCount) {
|
||||
totalMessageCount = newMessageCount;
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
|
||||
private void OnChannelCountChanged(long newChannelCount) {
|
||||
totalChannelCount = newChannelCount;
|
||||
UpdateChannelFilterLabel();
|
||||
}
|
||||
|
||||
private void OnUserCountChanged(long newUserCount) {
|
||||
totalUserCount = newUserCount;
|
||||
UpdateUserFilterLabel();
|
||||
}
|
||||
|
||||
private void UpdateChannelFilterLabel() {
|
||||
if (totalChannelCount.HasValue) {
|
||||
long total = totalChannelCount.Value;
|
||||
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
|
||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
||||
totalMessageCount = db.Statistics.TotalMessages;
|
||||
UpdateFilterStatistics();
|
||||
}
|
||||
else {
|
||||
ChannelFilterLabel = "Loading...";
|
||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
||||
UpdateChannelFilterLabel();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUserFilterLabel() {
|
||||
if (totalUserCount.HasValue) {
|
||||
long total = totalUserCount.Value;
|
||||
long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total;
|
||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
||||
}
|
||||
else {
|
||||
UserFilterLabel = "Loading...";
|
||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
||||
UpdateUserFilterLabel();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFilterStatistics() {
|
||||
var filter = CreateFilter();
|
||||
if (filter.IsEmpty) {
|
||||
exportedMessageCountTask.Cancel();
|
||||
exportedMessageCountComputer.Cancel();
|
||||
exportedMessageCount = totalMessageCount;
|
||||
UpdateFilterStatisticsText();
|
||||
}
|
||||
else {
|
||||
exportedMessageCount = null;
|
||||
UpdateFilterStatisticsText();
|
||||
exportedMessageCountTask.Restart(cancellationToken => state.Db.Messages.Count(filter, cancellationToken));
|
||||
exportedMessageCountComputer.Compute(() => db.CountMessages(filter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,95 +174,103 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
OnPropertyChanged(nameof(FilterStatisticsText));
|
||||
}
|
||||
|
||||
public async Task OpenChannelFilterDialog() {
|
||||
async Task<List<CheckBoxItem<ulong>>> PrepareChannelItems(ProgressDialog dialog) {
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var servers = await state.Db.Servers.Get().ToDictionaryAsync(static server => server.Id);
|
||||
public async void OpenChannelFilterDialog() {
|
||||
var servers = db.GetAllServers().ToDictionary(static server => server.Id);
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var included = IncludedChannels;
|
||||
|
||||
await foreach (var channel in state.Db.Channels.Get()) {
|
||||
var channelId = channel.Id;
|
||||
var channelName = channel.Name;
|
||||
foreach (var channel in db.GetAllChannels()) {
|
||||
var channelId = channel.Id;
|
||||
var channelName = channel.Name;
|
||||
|
||||
string title;
|
||||
if (servers.TryGetValue(channel.Server, out var server)) {
|
||||
var titleBuilder = new StringBuilder();
|
||||
var serverType = server.Type;
|
||||
string title;
|
||||
if (servers.TryGetValue(channel.Server, out var server)) {
|
||||
var titleBuilder = new StringBuilder();
|
||||
var serverType = server.Type;
|
||||
|
||||
titleBuilder.Append('[')
|
||||
.Append(ServerTypes.ToString(serverType))
|
||||
.Append("] ");
|
||||
titleBuilder.Append('[')
|
||||
.Append(ServerTypes.ToString(serverType))
|
||||
.Append("] ");
|
||||
|
||||
if (serverType == ServerType.DirectMessage) {
|
||||
titleBuilder.Append(channelName);
|
||||
}
|
||||
else {
|
||||
titleBuilder.Append(server.Name)
|
||||
.Append(" - ")
|
||||
.Append(channelName);
|
||||
}
|
||||
|
||||
title = titleBuilder.ToString();
|
||||
if (serverType == ServerType.DirectMessage) {
|
||||
titleBuilder.Append(channelName);
|
||||
}
|
||||
else {
|
||||
title = channelName;
|
||||
titleBuilder.Append(server.Name)
|
||||
.Append(" - ")
|
||||
.Append(channelName);
|
||||
}
|
||||
|
||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||
Title = title,
|
||||
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId)
|
||||
});
|
||||
title = titleBuilder.ToString();
|
||||
}
|
||||
else {
|
||||
title = channelName;
|
||||
}
|
||||
|
||||
return items;
|
||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||
Title = title,
|
||||
Checked = included.Contains(channelId)
|
||||
});
|
||||
}
|
||||
|
||||
const string Title = "Included Channels";
|
||||
|
||||
List<CheckBoxItem<ulong>> items;
|
||||
try {
|
||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading channels...", PrepareChannelItems);
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, Title, "Error loading channels: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await OpenIdFilterDialog(Title, items);
|
||||
var result = await OpenIdFilterDialog(window, "Included Channels", items);
|
||||
if (result != null) {
|
||||
IncludedChannels = result;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OpenUserFilterDialog() {
|
||||
async Task<List<CheckBoxItem<ulong>>> PrepareUserItems(ProgressDialog dialog) {
|
||||
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
||||
public async void OpenUserFilterDialog() {
|
||||
var items = new List<CheckBoxItem<ulong>>();
|
||||
var included = IncludedUsers;
|
||||
|
||||
await foreach (var user in state.Db.Users.Get()) {
|
||||
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
|
||||
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
||||
});
|
||||
}
|
||||
foreach (var user in db.GetAllUsers()) {
|
||||
var name = user.Name;
|
||||
var discriminator = user.Discriminator;
|
||||
|
||||
return checkBoxItems;
|
||||
items.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||
Title = discriminator == null ? name : name + " #" + discriminator,
|
||||
Checked = included.Contains(user.Id)
|
||||
});
|
||||
}
|
||||
|
||||
const string Title = "Included Users";
|
||||
|
||||
List<CheckBoxItem<ulong>> items;
|
||||
try {
|
||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, Title, "Error loading users: " + e.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await OpenIdFilterDialog(Title, items);
|
||||
var result = await OpenIdFilterDialog(window, "Included Users", items);
|
||||
if (result != null) {
|
||||
IncludedUsers = result;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HashSet<ulong>?> OpenIdFilterDialog(string title, List<CheckBoxItem<ulong>> items) {
|
||||
private void UpdateChannelFilterLabel() {
|
||||
long total = db.Statistics.TotalChannels;
|
||||
long included = FilterByChannel ? IncludedChannels.Count : total;
|
||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
||||
}
|
||||
|
||||
private void UpdateUserFilterLabel() {
|
||||
long total = db.Statistics.TotalUsers;
|
||||
long included = FilterByUser ? IncludedUsers.Count : total;
|
||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
||||
}
|
||||
|
||||
public MessageFilter CreateFilter() {
|
||||
MessageFilter filter = new();
|
||||
|
||||
if (FilterByDate) {
|
||||
filter.StartDate = StartDate;
|
||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
||||
}
|
||||
|
||||
if (FilterByChannel) {
|
||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
||||
}
|
||||
|
||||
if (FilterByUser) {
|
||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private static async Task<HashSet<ulong>?> OpenIdFilterDialog(Window window, string title, List<CheckBoxItem<ulong>> items) {
|
||||
items.Sort(static (item1, item2) => item1.Title.CompareTo(item2.Title));
|
||||
|
||||
var model = new CheckBoxDialogModel<ulong>(items) {
|
||||
@@ -286,23 +282,4 @@ sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||
|
||||
return result == DialogResult.OkCancel.Ok ? model.SelectedItems.Select(static item => item.Item).ToHashSet() : null;
|
||||
}
|
||||
|
||||
public MessageFilter CreateFilter() {
|
||||
MessageFilter filter = new ();
|
||||
|
||||
if (FilterByDate) {
|
||||
filter.StartDate = StartDate;
|
||||
filter.EndDate = EndDate?.AddDays(1).AddMilliseconds(-1);
|
||||
}
|
||||
|
||||
if (FilterByChannel && IncludedChannels != null) {
|
||||
filter.ChannelIds = new HashSet<ulong>(IncludedChannels);
|
||||
}
|
||||
|
||||
if (FilterByUser && IncludedUsers != null) {
|
||||
filter.UserIds = new HashSet<ulong>(IncludedUsers);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
|
@@ -1,95 +1,96 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable {
|
||||
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
||||
private string inputPort;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||
public string InputPort {
|
||||
get => inputPort;
|
||||
set {
|
||||
Change(ref inputPort, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
private string inputToken;
|
||||
|
||||
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
||||
public string InputToken {
|
||||
get => inputToken;
|
||||
set {
|
||||
Change(ref inputToken, value);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isToggleServerButtonEnabled = true;
|
||||
|
||||
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
||||
public bool IsToggleServerButtonEnabled {
|
||||
get => isToggleServerButtonEnabled;
|
||||
set => Change(ref isToggleServerButtonEnabled, value);
|
||||
}
|
||||
|
||||
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
|
||||
|
||||
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
|
||||
|
||||
private readonly Window window;
|
||||
private readonly ServerManager server;
|
||||
private readonly ServerManager serverManager;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
|
||||
public ServerConfigurationPanelModel() : this(null!, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||
|
||||
public ServerConfigurationPanelModel(Window window, State state) {
|
||||
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
|
||||
this.window = window;
|
||||
this.server = state.Server;
|
||||
this.inputPort = ServerConfiguration.Port.ToString();
|
||||
this.inputToken = ServerConfiguration.Token;
|
||||
|
||||
server.StatusChanged += OnServerStatusChanged;
|
||||
this.serverManager = serverManager;
|
||||
this.inputPort = ServerManager.Port.ToString();
|
||||
this.inputToken = ServerManager.Token;
|
||||
}
|
||||
|
||||
public void Initialize() {
|
||||
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
server.StatusChanged -= OnServerStatusChanged;
|
||||
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
|
||||
}
|
||||
|
||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
|
||||
}
|
||||
|
||||
private void UpdateServerStatus() {
|
||||
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
|
||||
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
|
||||
OnPropertyChanged(nameof(ToggleServerButtonText));
|
||||
}
|
||||
|
||||
private async Task StartServer() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
|
||||
try {
|
||||
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||
}
|
||||
|
||||
UpdateServerStatus();
|
||||
IsToggleServerButtonEnabled = true;
|
||||
}
|
||||
|
||||
private async Task StopServer() {
|
||||
private void BeforeServerStart() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
|
||||
try {
|
||||
await server.Stop();
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
|
||||
}
|
||||
|
||||
UpdateServerStatus();
|
||||
IsToggleServerButtonEnabled = true;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
|
||||
}
|
||||
|
||||
public async Task OnClickToggleServerButton() {
|
||||
if (server.IsRunning) {
|
||||
await StopServer();
|
||||
private void StartServer() {
|
||||
BeforeServerStart();
|
||||
serverManager.Launch();
|
||||
}
|
||||
|
||||
private void StopServer() {
|
||||
IsToggleServerButtonEnabled = false;
|
||||
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
|
||||
serverManager.Stop();
|
||||
}
|
||||
|
||||
public void OnClickToggleServerButton() {
|
||||
if (serverManager.IsRunning) {
|
||||
StopServer();
|
||||
}
|
||||
else {
|
||||
await StartServer();
|
||||
StartServer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,22 +98,19 @@ sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposab
|
||||
InputToken = ServerUtils.GenerateRandomToken(20);
|
||||
}
|
||||
|
||||
public async Task OnClickApplyChanges() {
|
||||
public async void OnClickApplyChanges() {
|
||||
if (!ushort.TryParse(InputPort, out ushort port)) {
|
||||
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
|
||||
return;
|
||||
}
|
||||
|
||||
ServerConfiguration.Port = port;
|
||||
ServerConfiguration.Token = inputToken;
|
||||
|
||||
BeforeServerStart();
|
||||
serverManager.Relaunch(port, InputToken);
|
||||
OnPropertyChanged(nameof(HasMadeChanges));
|
||||
|
||||
await StartServer();
|
||||
}
|
||||
|
||||
public void OnClickCancelChanges() {
|
||||
InputPort = ServerConfiguration.Port.ToString();
|
||||
InputToken = ServerConfiguration.Token;
|
||||
InputPort = ServerManager.Port.ToString();
|
||||
InputToken = ServerManager.Token;
|
||||
}
|
||||
}
|
||||
|
@@ -40,22 +40,22 @@
|
||||
<StackPanel Orientation="Horizontal" Margin="6 3">
|
||||
<StackPanel Orientation="Vertical" Width="65">
|
||||
<TextBlock Classes="label">Status</TextBlock>
|
||||
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
|
||||
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Servers</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding ServerCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Channels</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding ChannelCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
<Rectangle />
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Classes="label">Messages</TextBlock>
|
||||
<TextBlock Classes="value" Text="{Binding MessageCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
@@ -1,63 +1,45 @@
|
||||
using System;
|
||||
using System.Reactive.Linq;
|
||||
using Avalonia.ReactiveUI;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Controls;
|
||||
|
||||
sealed partial class StatusBarModel : ObservableObject, IDisposable {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? serverCount;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? channelCount;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private long? messageCount;
|
||||
sealed class StatusBarModel : BaseModel {
|
||||
public DatabaseStatistics DatabaseStatistics { get; }
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
||||
private ServerManager.Status serverStatus;
|
||||
private Status status = Status.Stopped;
|
||||
|
||||
public string ServerStatusText => serverStatus switch {
|
||||
ServerManager.Status.Starting => "STARTING",
|
||||
ServerManager.Status.Started => "READY",
|
||||
ServerManager.Status.Stopping => "STOPPING",
|
||||
ServerManager.Status.Stopped => "STOPPED",
|
||||
_ => ""
|
||||
};
|
||||
public Status CurrentStatus {
|
||||
get => status;
|
||||
set {
|
||||
status = value;
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly State state;
|
||||
private readonly IDisposable serverCountSubscription;
|
||||
private readonly IDisposable channelCountSubscription;
|
||||
private readonly IDisposable messageCountSubscription;
|
||||
public string StatusText {
|
||||
get {
|
||||
return CurrentStatus switch {
|
||||
Status.Starting => "STARTING",
|
||||
Status.Ready => "READY",
|
||||
Status.Stopping => "STOPPING",
|
||||
Status.Stopped => "STOPPED",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public StatusBarModel() : this(State.Dummy) {}
|
||||
public StatusBarModel() : this(new DatabaseStatistics()) {}
|
||||
|
||||
public StatusBarModel(State state) {
|
||||
this.state = state;
|
||||
|
||||
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
|
||||
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
|
||||
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
|
||||
|
||||
state.Server.StatusChanged += OnServerStatusChanged;
|
||||
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
||||
public StatusBarModel(DatabaseStatistics databaseStatistics) {
|
||||
this.DatabaseStatistics = databaseStatistics;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
serverCountSubscription.Dispose();
|
||||
channelCountSubscription.Dispose();
|
||||
messageCountSubscription.Dispose();
|
||||
|
||||
state.Server.StatusChanged -= OnServerStatusChanged;
|
||||
}
|
||||
|
||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
|
||||
public enum Status {
|
||||
Starting,
|
||||
Ready,
|
||||
Stopping,
|
||||
Stopped
|
||||
}
|
||||
}
|
||||
|
@@ -8,10 +8,10 @@
|
||||
x:DataType="main:MainWindowModel"
|
||||
Title="{Binding Title}"
|
||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||
Width="820" Height="520"
|
||||
Width="800" Height="500"
|
||||
MinWidth="520" MinHeight="300"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Closing="OnClosing">
|
||||
Closed="OnClosed">
|
||||
|
||||
<Design.DataContext>
|
||||
<main:MainWindowModel />
|
||||
|
@@ -1,16 +1,14 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class MainWindow : Window {
|
||||
private static readonly Log Log = Log.ForType<MainWindow>();
|
||||
|
||||
[UsedImplicitly]
|
||||
public MainWindow() {
|
||||
InitializeComponent();
|
||||
@@ -22,23 +20,16 @@ public sealed partial class MainWindow : Window {
|
||||
DataContext = new MainWindowModel(this, args);
|
||||
}
|
||||
|
||||
public async void OnClosing(object? sender, WindowClosingEventArgs e) {
|
||||
e.Cancel = true;
|
||||
Closing -= OnClosing;
|
||||
|
||||
try {
|
||||
await Dispose();
|
||||
} finally {
|
||||
Close();
|
||||
public void OnClosed(object? sender, EventArgs e) {
|
||||
if (DataContext is IDisposable disposable) {
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Dispose() {
|
||||
if (DataContext is MainWindowModel model) {
|
||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
||||
try {
|
||||
await model.DisposeAsync();
|
||||
} catch (Exception ex) {
|
||||
Log.Error("Caught exception while disposing window: " + ex);
|
||||
File.Delete(temporaryFile);
|
||||
} catch (Exception) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,38 +1,33 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Screens;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main;
|
||||
|
||||
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||
sealed class MainWindowModel : BaseModel, IDisposable {
|
||||
private const string DefaultTitle = "Discord History Tracker";
|
||||
|
||||
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
||||
public string Title { get; private set; } = DefaultTitle;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private string title = DefaultTitle;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private UserControl currentScreen;
|
||||
public UserControl CurrentScreen { get; private set; }
|
||||
|
||||
private readonly WelcomeScreen welcomeScreen;
|
||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||
|
||||
private MainContentScreen? mainContentScreen;
|
||||
private MainContentScreenModel? mainContentScreenModel;
|
||||
|
||||
private readonly Window window;
|
||||
private readonly int? concurrentDownloads;
|
||||
|
||||
private State? state;
|
||||
private IDatabaseFile? db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainWindowModel() : this(null!, Arguments.Empty) {}
|
||||
@@ -41,10 +36,10 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||
this.window = window;
|
||||
|
||||
welcomeScreenModel = new WelcomeScreenModel(window);
|
||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||
|
||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||
currentScreen = welcomeScreen;
|
||||
CurrentScreen = welcomeScreen;
|
||||
|
||||
welcomeScreenModel.PropertyChanged += WelcomeScreenModelOnPropertyChanged;
|
||||
|
||||
var dbFile = args.DatabaseFile;
|
||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||
@@ -68,68 +63,54 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||
}
|
||||
|
||||
if (args.ServerPort != null) {
|
||||
ServerConfiguration.Port = args.ServerPort.Value;
|
||||
ServerManager.Port = args.ServerPort.Value;
|
||||
}
|
||||
|
||||
if (args.ServerToken != null) {
|
||||
ServerConfiguration.Token = args.ServerToken;
|
||||
}
|
||||
|
||||
concurrentDownloads = args.ConcurrentDownloads;
|
||||
}
|
||||
|
||||
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
||||
welcomeScreenModel.DatabaseSelected -= OnDatabaseSelected;
|
||||
|
||||
await DisposeState();
|
||||
|
||||
state = new State(db, concurrentDownloads);
|
||||
|
||||
try {
|
||||
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||
} catch (Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||
}
|
||||
|
||||
mainContentScreenModel = new MainContentScreenModel(window, state);
|
||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||
|
||||
await mainContentScreenModel.Initialize();
|
||||
|
||||
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
||||
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||
|
||||
window.Focus();
|
||||
}
|
||||
|
||||
private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||
if (mainContentScreenModel != null) {
|
||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||
await mainContentScreenModel.DisposeAsync();
|
||||
mainContentScreenModel = null;
|
||||
}
|
||||
|
||||
await DisposeState();
|
||||
|
||||
Title = DefaultTitle;
|
||||
CurrentScreen = welcomeScreen;
|
||||
|
||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||
}
|
||||
|
||||
private async Task DisposeState() {
|
||||
if (state != null) {
|
||||
await state.DisposeAsync();
|
||||
state = null;
|
||||
ServerManager.Token = args.ServerToken;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
if (mainContentScreenModel != null) {
|
||||
await mainContentScreenModel.DisposeAsync();
|
||||
private async void WelcomeScreenModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(welcomeScreenModel.Db)) {
|
||||
if (mainContentScreenModel != null) {
|
||||
mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed;
|
||||
mainContentScreenModel.Dispose();
|
||||
}
|
||||
|
||||
db?.Dispose();
|
||||
db = welcomeScreenModel.Db;
|
||||
|
||||
if (db == null) {
|
||||
Title = DefaultTitle;
|
||||
mainContentScreenModel = null;
|
||||
mainContentScreen = null;
|
||||
CurrentScreen = welcomeScreen;
|
||||
}
|
||||
else {
|
||||
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle;
|
||||
mainContentScreenModel = new MainContentScreenModel(window, db);
|
||||
await mainContentScreenModel.Initialize();
|
||||
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
|
||||
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||
CurrentScreen = mainContentScreen;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(CurrentScreen));
|
||||
OnPropertyChanged(nameof(Title));
|
||||
|
||||
window.Focus();
|
||||
}
|
||||
|
||||
await DisposeState();
|
||||
}
|
||||
|
||||
private void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) {
|
||||
welcomeScreenModel.CloseDatabase();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
welcomeScreenModel.Dispose();
|
||||
mainContentScreenModel?.Dispose();
|
||||
db?.Dispose();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
@@ -1,36 +1,39 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Server;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed class AdvancedPageModel : IDisposable {
|
||||
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public AdvancedPageModel() : this(null!, State.Dummy) {}
|
||||
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {}
|
||||
|
||||
public AdvancedPageModel(Window window, State state) {
|
||||
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
this.db = db;
|
||||
|
||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
|
||||
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
|
||||
}
|
||||
|
||||
public void Initialize() {
|
||||
ServerConfigurationModel.Initialize();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerConfigurationModel.Dispose();
|
||||
}
|
||||
|
||||
public async Task VacuumDatabase() {
|
||||
const string Title = "Vacuum Database";
|
||||
await ProgressDialog.ShowIndeterminate(window, Title, "Vacuuming database...", _ => state.Db.Vacuum());
|
||||
await Dialog.ShowOk(window, Title, "Done.");
|
||||
public async void VacuumDatabase() {
|
||||
db.Vacuum();
|
||||
await Dialog.ShowOk(window, "Vacuum Database", "Done.");
|
||||
}
|
||||
}
|
||||
|
@@ -5,11 +5,11 @@
|
||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="DHT.Desktop.Main.Pages.DownloadsPage"
|
||||
x:DataType="pages:DownloadsPageModel">
|
||||
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
|
||||
x:DataType="pages:AttachmentsPageModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<pages:DownloadsPageModel />
|
||||
<pages:AttachmentsPageModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<UserControl.Styles>
|
||||
@@ -31,24 +31,25 @@
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel Orientation="Vertical" Spacing="20">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
|
||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button>
|
||||
</StackPanel>
|
||||
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
||||
<TextBlock TextWrapping="Wrap">
|
||||
Downloading state and filter settings are remembered per-database.
|
||||
</TextBlock>
|
||||
<DockPanel>
|
||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" 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" />
|
||||
</DockPanel>
|
||||
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
|
||||
<StackPanel Orientation="Vertical" Spacing="12">
|
||||
<Expander Header="Download Status" IsExpanded="True">
|
||||
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" />
|
||||
<DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
||||
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding Size, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Expander>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding HasFailedDownloads}">Retry Failed Downloads</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
@@ -4,8 +4,8 @@ using Avalonia.Controls;
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||
public sealed partial class DownloadsPage : UserControl {
|
||||
public DownloadsPage() {
|
||||
public sealed partial class AttachmentsPage : UserControl {
|
||||
public AttachmentsPage() {
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
205
app/Desktop/Main/Pages/AttachmentsPageModel.cs
Normal file
205
app/Desktop/Main/Pages/AttachmentsPageModel.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading;
|
||||
using Avalonia.Threading;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Aggregations;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Download;
|
||||
using DHT.Utils.Models;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
||||
private static readonly DownloadItemFilter EnqueuedItemFilter = new() {
|
||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
||||
DownloadStatus.Enqueued
|
||||
}
|
||||
};
|
||||
|
||||
private bool isThreadDownloadButtonEnabled = true;
|
||||
|
||||
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading";
|
||||
|
||||
public bool IsToggleDownloadButtonEnabled {
|
||||
get => isThreadDownloadButtonEnabled;
|
||||
set => Change(ref isThreadDownloadButtonEnabled, value);
|
||||
}
|
||||
|
||||
public string DownloadMessage { get; set; } = "";
|
||||
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value;
|
||||
|
||||
public AttachmentFilterPanelModel FilterModel { get; }
|
||||
|
||||
private readonly StatisticsRow statisticsEnqueued = new ("Enqueued");
|
||||
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||
|
||||
public List<StatisticsRow> StatisticsRows {
|
||||
get {
|
||||
return new List<StatisticsRow> {
|
||||
statisticsEnqueued,
|
||||
statisticsDownloaded,
|
||||
statisticsFailed,
|
||||
statisticsSkipped
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDownloading => downloadThread != null;
|
||||
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
||||
|
||||
private readonly IDatabaseFile db;
|
||||
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
|
||||
private BackgroundDownloadThread? downloadThread;
|
||||
|
||||
private int doneItemsCount;
|
||||
private int? allItemsCount;
|
||||
|
||||
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {}
|
||||
|
||||
public AttachmentsPageModel(IDatabaseFile db) {
|
||||
this.db = db;
|
||||
this.FilterModel = new AttachmentFilterPanelModel(db);
|
||||
|
||||
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics);
|
||||
this.downloadStatisticsComputer.Recompute();
|
||||
|
||||
db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
||||
|
||||
FilterModel.Dispose();
|
||||
DisposeDownloadThread();
|
||||
}
|
||||
|
||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
||||
if (IsDownloading) {
|
||||
EnqueueDownloadItems();
|
||||
}
|
||||
else {
|
||||
downloadStatisticsComputer.Recompute();
|
||||
}
|
||||
}
|
||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
||||
downloadStatisticsComputer.Recompute();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnqueueDownloadItems() {
|
||||
var filter = FilterModel.CreateFilter();
|
||||
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
||||
db.EnqueueDownloadItems(filter);
|
||||
|
||||
downloadStatisticsComputer.Recompute();
|
||||
}
|
||||
|
||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||
var hadFailedDownloads = HasFailedDownloads;
|
||||
|
||||
statisticsEnqueued.Items = statusStatistics.EnqueuedCount;
|
||||
statisticsEnqueued.Size = statusStatistics.EnqueuedSize;
|
||||
|
||||
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
||||
statisticsDownloaded.Size = statusStatistics.SuccessfulSize;
|
||||
|
||||
statisticsFailed.Items = statusStatistics.FailedCount;
|
||||
statisticsFailed.Size = statusStatistics.FailedSize;
|
||||
|
||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||
statisticsSkipped.Size = statusStatistics.SkippedSize;
|
||||
|
||||
OnPropertyChanged(nameof(StatisticsRows));
|
||||
|
||||
if (hadFailedDownloads != HasFailedDownloads) {
|
||||
OnPropertyChanged(nameof(HasFailedDownloads));
|
||||
}
|
||||
|
||||
allItemsCount = doneItemsCount + statisticsEnqueued.Items;
|
||||
UpdateDownloadMessage();
|
||||
}
|
||||
|
||||
private void UpdateDownloadMessage() {
|
||||
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : "";
|
||||
|
||||
OnPropertyChanged(nameof(DownloadMessage));
|
||||
OnPropertyChanged(nameof(DownloadProgress));
|
||||
}
|
||||
|
||||
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) {
|
||||
Interlocked.Increment(ref doneItemsCount);
|
||||
|
||||
Dispatcher.UIThread.Invoke(UpdateDownloadMessage);
|
||||
downloadStatisticsComputer.Recompute();
|
||||
}
|
||||
|
||||
private void DownloadThreadOnOnServerStopped(object? sender, EventArgs e) {
|
||||
downloadStatisticsComputer.Recompute();
|
||||
IsToggleDownloadButtonEnabled = true;
|
||||
}
|
||||
|
||||
public void OnClickToggleDownload() {
|
||||
if (downloadThread == null) {
|
||||
EnqueueDownloadItems();
|
||||
downloadThread = new BackgroundDownloadThread(db);
|
||||
downloadThread.OnItemFinished += DownloadThreadOnOnItemFinished;
|
||||
downloadThread.OnServerStopped += DownloadThreadOnOnServerStopped;
|
||||
}
|
||||
else {
|
||||
IsToggleDownloadButtonEnabled = false;
|
||||
DisposeDownloadThread();
|
||||
|
||||
db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
|
||||
|
||||
doneItemsCount = 0;
|
||||
allItemsCount = null;
|
||||
UpdateDownloadMessage();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
||||
OnPropertyChanged(nameof(IsDownloading));
|
||||
}
|
||||
|
||||
public void OnClickRetryFailedDownloads() {
|
||||
var allExceptFailedFilter = new DownloadItemFilter {
|
||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
||||
DownloadStatus.Enqueued,
|
||||
DownloadStatus.Success
|
||||
}
|
||||
};
|
||||
|
||||
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
||||
|
||||
if (IsDownloading) {
|
||||
EnqueueDownloadItems();
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeDownloadThread() {
|
||||
if (downloadThread != null) {
|
||||
downloadThread.OnItemFinished -= DownloadThreadOnOnItemFinished;
|
||||
downloadThread.StopThread();
|
||||
}
|
||||
|
||||
downloadThread = null;
|
||||
}
|
||||
|
||||
public sealed class StatisticsRow {
|
||||
public string State { get; }
|
||||
public int Items { get; set; }
|
||||
public ulong? Size { get; set; }
|
||||
|
||||
public StatisticsRow(string state) {
|
||||
State = state;
|
||||
}
|
||||
}
|
||||
}
|
@@ -14,16 +14,16 @@ using DHT.Desktop.Dialogs.File;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Dialogs.TextBox;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Import;
|
||||
using DHT.Server.Database.Sqlite.Schema;
|
||||
using DHT.Server.Database.Sqlite;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed class DatabasePageModel {
|
||||
sealed class DatabasePageModel : BaseModel {
|
||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||
|
||||
public IDatabaseFile Db { get; }
|
||||
@@ -33,14 +33,14 @@ sealed class DatabasePageModel {
|
||||
private readonly Window window;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DatabasePageModel() : this(null!, State.Dummy) {}
|
||||
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public DatabasePageModel(Window window, State state) {
|
||||
public DatabasePageModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.Db = state.Db;
|
||||
this.Db = db;
|
||||
}
|
||||
|
||||
public async Task OpenDatabaseFolder() {
|
||||
public async void OpenDatabaseFolder() {
|
||||
string file = Db.Path;
|
||||
string? folder = Path.GetDirectoryName(file);
|
||||
|
||||
@@ -71,16 +71,23 @@ sealed class DatabasePageModel {
|
||||
DatabaseClosed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task MergeWithDatabase() {
|
||||
public async void MergeWithDatabase() {
|
||||
var paths = await DatabaseGui.NewOpenDatabaseFilesDialog(window, Path.GetDirectoryName(Db.Path));
|
||||
if (paths.Length > 0) {
|
||||
await ProgressDialog.Show(window, "Database Merge", async (dialog, callback) => await MergeWithDatabaseFromPaths(Db, paths, dialog, callback));
|
||||
if (paths.Length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ProgressDialog progressDialog = new ProgressDialog();
|
||||
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
||||
Title = "Database Merge"
|
||||
};
|
||||
|
||||
await progressDialog.ShowProgressDialog(window);
|
||||
}
|
||||
|
||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||
|
||||
|
||||
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||
|
||||
@@ -89,10 +96,10 @@ sealed class DatabasePageModel {
|
||||
}
|
||||
|
||||
try {
|
||||
await target.AddFrom(db);
|
||||
target.AddFrom(db);
|
||||
return true;
|
||||
} finally {
|
||||
await db.DisposeAsync();
|
||||
db.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -101,7 +108,7 @@ sealed class DatabasePageModel {
|
||||
private readonly ProgressDialog dialog;
|
||||
private readonly int total;
|
||||
private bool? decision;
|
||||
|
||||
|
||||
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||
this.total = total;
|
||||
this.dialog = dialog;
|
||||
@@ -132,16 +139,23 @@ sealed class DatabasePageModel {
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ImportLegacyArchive() {
|
||||
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) {
|
||||
await ProgressDialog.Show(window, "Legacy Archive Import", async (dialog, callback) => await ImportLegacyArchiveFromPaths(Db, paths, dialog, callback));
|
||||
|
||||
if (paths.Length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ProgressDialog progressDialog = new ProgressDialog();
|
||||
progressDialog.DataContext = new ProgressDialogModel(async callbacks => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callbacks[0])) {
|
||||
Title = "Legacy Archive Import"
|
||||
};
|
||||
|
||||
await progressDialog.ShowProgressDialog(window);
|
||||
}
|
||||
|
||||
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||
@@ -149,7 +163,7 @@ sealed class DatabasePageModel {
|
||||
|
||||
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
||||
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
|
||||
|
||||
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||
@@ -164,7 +178,7 @@ sealed class DatabasePageModel {
|
||||
static bool IsValidSnowflake(string value) {
|
||||
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||
}
|
||||
|
||||
|
||||
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||
|
||||
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||
@@ -193,7 +207,7 @@ sealed class DatabasePageModel {
|
||||
|
||||
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
||||
int total = paths.Length;
|
||||
var oldStatistics = await DatabaseStatistics.Take(target);
|
||||
var oldStatistics = target.SnapshotStatistics();
|
||||
|
||||
int successful = 0;
|
||||
int finished = 0;
|
||||
@@ -224,26 +238,14 @@ sealed class DatabasePageModel {
|
||||
return;
|
||||
}
|
||||
|
||||
var newStatistics = await DatabaseStatistics.Take(target);
|
||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, target.SnapshotStatistics(), successful, total, itemName));
|
||||
}
|
||||
|
||||
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
|
||||
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
|
||||
return new DatabaseStatistics(
|
||||
await db.Servers.Count(),
|
||||
await db.Channels.Count(),
|
||||
await db.Users.Count(),
|
||||
await db.Messages.Count()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetImportDialogMessage(DatabaseStatistics oldStatistics, DatabaseStatistics newStatistics, int successfulItems, int totalItems, string itemName) {
|
||||
long newServers = newStatistics.ServerCount - oldStatistics.ServerCount;
|
||||
long newChannels = newStatistics.ChannelCount - oldStatistics.ChannelCount;
|
||||
long newUsers = newStatistics.UserCount - oldStatistics.UserCount;
|
||||
long newMessages = newStatistics.MessageCount - oldStatistics.MessageCount;
|
||||
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
|
||||
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
||||
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
||||
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
|
||||
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
|
||||
|
||||
StringBuilder message = new StringBuilder();
|
||||
message.Append("Processed ");
|
||||
|
@@ -6,169 +6,180 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DebugPageModel : BaseModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
|
||||
sealed class DebugPageModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
private readonly Window window;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
[Obsolete("Designer")]
|
||||
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||
|
||||
public DebugPageModel(Window window, State state) {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public async void OnClickAddRandomDataToDatabase() {
|
||||
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
||||
return;
|
||||
public DebugPageModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
||||
return;
|
||||
public async void OnClickAddRandomDataToDatabase() {
|
||||
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
||||
return;
|
||||
}
|
||||
|
||||
ProgressDialog progressDialog = new ProgressDialog {
|
||||
DataContext = new ProgressDialogModel(async callbacks => await GenerateRandomData(channels, users, messages, callbacks[0])) {
|
||||
Title = "Generating Random Data"
|
||||
}
|
||||
};
|
||||
|
||||
await progressDialog.ShowProgressDialog(window);
|
||||
}
|
||||
|
||||
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
||||
return;
|
||||
}
|
||||
private const int BatchSize = 500;
|
||||
|
||||
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||
}
|
||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
||||
|
||||
private const int BatchSize = 500;
|
||||
var rand = new Random();
|
||||
var server = new DHT.Server.Data.Server {
|
||||
Id = RandomId(rand),
|
||||
Name = RandomName("s"),
|
||||
Type = ServerType.Server,
|
||||
};
|
||||
|
||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
||||
|
||||
var rand = new Random();
|
||||
var server = new DHT.Server.Data.Server {
|
||||
Id = RandomId(rand),
|
||||
Name = RandomName("s"),
|
||||
Type = ServerType.Server,
|
||||
};
|
||||
|
||||
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
||||
Id = RandomId(rand),
|
||||
Server = server.Id,
|
||||
Name = RandomName("c"),
|
||||
ParentId = null,
|
||||
Position = i,
|
||||
Topic = RandomText(rand, 10),
|
||||
Nsfw = rand.Next(4) == 0,
|
||||
}).ToArray();
|
||||
|
||||
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
||||
Id = RandomId(rand),
|
||||
Name = RandomName("u"),
|
||||
DisplayName = RandomName("u"),
|
||||
AvatarUrl = null,
|
||||
Discriminator = rand.Next(0, 9999).ToString(),
|
||||
}).ToArray();
|
||||
|
||||
await state.Db.Users.Add(users);
|
||||
await state.Db.Servers.Add([server]);
|
||||
await state.Db.Channels.Add(channels);
|
||||
|
||||
var now = DateTimeOffset.Now;
|
||||
int batchIndex = 0;
|
||||
|
||||
while (messageCount > 0) {
|
||||
int hourOffset = batchIndex;
|
||||
|
||||
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
|
||||
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
|
||||
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
||||
|
||||
var timeMillis = time.ToUnixTimeMilliseconds();
|
||||
var editMillis = edit?.ToUnixTimeMilliseconds();
|
||||
|
||||
return new Message {
|
||||
Id = (ulong) timeMillis,
|
||||
Sender = RandomBiasedIndex(rand, users).Id,
|
||||
Channel = RandomBiasedIndex(rand, channels).Id,
|
||||
Text = RandomText(rand, 100),
|
||||
Timestamp = timeMillis,
|
||||
EditTimestamp = editMillis,
|
||||
RepliedToId = null,
|
||||
Attachments = ImmutableList<Attachment>.Empty,
|
||||
Embeds = ImmutableList<Embed>.Empty,
|
||||
Reactions = ImmutableList<Reaction>.Empty,
|
||||
};
|
||||
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
||||
Id = RandomId(rand),
|
||||
Server = server.Id,
|
||||
Name = RandomName("c"),
|
||||
ParentId = null,
|
||||
Position = i,
|
||||
Topic = RandomText(rand, 10),
|
||||
Nsfw = rand.Next(4) == 0,
|
||||
}).ToArray();
|
||||
|
||||
await state.Db.Messages.Add(messages);
|
||||
var users = Enumerable.Range(0, userCount).Select(_ => new User {
|
||||
Id = RandomId(rand),
|
||||
Name = RandomName("u"),
|
||||
AvatarUrl = null,
|
||||
Discriminator = rand.Next(0, 9999).ToString(),
|
||||
}).ToArray();
|
||||
|
||||
messageCount -= BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||
db.AddServer(server);
|
||||
db.AddUsers(users);
|
||||
|
||||
foreach (var channel in channels) {
|
||||
db.AddChannel(channel);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.Now;
|
||||
int batchIndex = 0;
|
||||
|
||||
while (messageCount > 0) {
|
||||
int hourOffset = batchIndex;
|
||||
|
||||
var messages = Enumerable.Range(0, Math.Min(messageCount, BatchSize)).Select(i => {
|
||||
DateTimeOffset time = now.AddHours(hourOffset).AddMinutes(i * 60.0 / BatchSize);
|
||||
DateTimeOffset? edit = rand.Next(100) == 0 ? time.AddSeconds(rand.Next(1, 60)) : null;
|
||||
|
||||
var timeMillis = time.ToUnixTimeMilliseconds();
|
||||
var editMillis = edit?.ToUnixTimeMilliseconds();
|
||||
|
||||
return new Message {
|
||||
Id = (ulong) timeMillis,
|
||||
Sender = RandomBiasedIndex(rand, users).Id,
|
||||
Channel = RandomBiasedIndex(rand, channels).Id,
|
||||
Text = RandomText(rand, 100),
|
||||
Timestamp = timeMillis,
|
||||
EditTimestamp = editMillis,
|
||||
RepliedToId = null,
|
||||
Attachments = ImmutableArray<Attachment>.Empty,
|
||||
Embeds = ImmutableArray<Embed>.Empty,
|
||||
Reactions = ImmutableArray<Reaction>.Empty,
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
db.AddMessages(messages);
|
||||
|
||||
messageCount -= BatchSize;
|
||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong RandomId(Random rand) {
|
||||
ulong h = unchecked((ulong) rand.Next());
|
||||
ulong l = unchecked((ulong) rand.Next());
|
||||
return (h << 32) | l;
|
||||
}
|
||||
private static ulong RandomId(Random rand) {
|
||||
ulong h = unchecked((ulong) rand.Next());
|
||||
ulong l = unchecked((ulong) rand.Next());
|
||||
return (h << 32) | l;
|
||||
}
|
||||
|
||||
private static string RandomName(string prefix) {
|
||||
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
||||
}
|
||||
private static string RandomName(string prefix) {
|
||||
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
||||
}
|
||||
|
||||
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||
}
|
||||
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||
}
|
||||
|
||||
private static readonly string[] RandomWords = [
|
||||
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
||||
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
|
||||
"daikon", "date", "dewberry", "durian",
|
||||
"edamame", "eggplant", "elderberry", "endive",
|
||||
"fig",
|
||||
"garlic", "ginger", "gooseberry", "grape", "grapefruit", "guava",
|
||||
"honeysuckle", "horseradish", "huckleberry",
|
||||
"jackfruit", "jicama",
|
||||
"kale", "kiwi", "kohlrabi", "kumquat",
|
||||
"leek", "lemon", "lentil", "lettuce", "lime",
|
||||
"mandarin", "mango", "mushroom", "myrtle",
|
||||
"nectarine", "nut",
|
||||
"olive", "okra", "onion", "orange",
|
||||
"papaya", "parsnip", "pawpaw", "peach", "pear", "pea", "pepper", "persimmon", "pineapple", "plum", "plantain", "pomegranate", "pomelo", "potato", "prune", "pumpkin",
|
||||
"quandong", "quinoa",
|
||||
"radicchio", "radish", "raisin", "raspberry", "redcurrant", "rhubarb", "rutabaga",
|
||||
"spinach", "strawberry", "squash",
|
||||
"tamarind", "tangerine", "tomatillo", "tomato", "turnip",
|
||||
"vanilla",
|
||||
"watercress", "watermelon",
|
||||
"yam",
|
||||
"zucchini"
|
||||
];
|
||||
private static readonly string[] RandomWords = {
|
||||
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
||||
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
|
||||
"daikon", "date", "dewberry", "durian",
|
||||
"edamame", "eggplant", "elderberry", "endive",
|
||||
"fig",
|
||||
"garlic", "ginger", "gooseberry", "grape", "grapefruit", "guava",
|
||||
"honeysuckle", "horseradish", "huckleberry",
|
||||
"jackfruit", "jicama",
|
||||
"kale", "kiwi", "kohlrabi", "kumquat",
|
||||
"leek", "lemon", "lentil", "lettuce", "lime",
|
||||
"mandarin", "mango", "mushroom", "myrtle",
|
||||
"nectarine", "nut",
|
||||
"olive", "okra", "onion", "orange",
|
||||
"papaya", "parsnip", "pawpaw", "peach", "pear", "pea", "pepper", "persimmon", "pineapple", "plum", "plantain", "pomegranate", "pomelo", "potato", "prune", "pumpkin",
|
||||
"quandong", "quinoa",
|
||||
"radicchio", "radish", "raisin", "raspberry", "redcurrant", "rhubarb", "rutabaga",
|
||||
"spinach", "strawberry", "squash",
|
||||
"tamarind", "tangerine", "tomatillo", "tomato", "turnip",
|
||||
"vanilla",
|
||||
"watercress", "watermelon",
|
||||
"yam",
|
||||
"zucchini",
|
||||
};
|
||||
|
||||
private static string RandomText(Random rand, int maxWords) {
|
||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
||||
private static string RandomText(Random rand, int maxWords) {
|
||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
sealed class DebugPageModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
namespace DHT.Desktop.Main.Pages {
|
||||
sealed class DebugPageModel : BaseModel {
|
||||
public string GenerateChannels { get; set; } = "0";
|
||||
public string GenerateUsers { get; set; } = "0";
|
||||
public string GenerateMessages { get; set; } = "0";
|
||||
|
||||
public void OnClickAddRandomDataToDatabase() {}
|
||||
public void OnClickAddRandomDataToDatabase() {}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
@@ -1,210 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.ReactiveUI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data.Aggregations;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Data.Settings;
|
||||
using DHT.Server.Download;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Utils.Tasks;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable {
|
||||
private static readonly Log Log = Log.ForType<DownloadsPageModel>();
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isToggleDownloadButtonEnabled = true;
|
||||
|
||||
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||
private bool isRetryingFailedDownloads = false;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(IsRetryFailedOnDownloadsButtonEnabled))]
|
||||
private bool hasFailedDownloads;
|
||||
|
||||
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private string downloadMessage = "";
|
||||
|
||||
public DownloadItemFilterPanelModel FilterModel { get; }
|
||||
|
||||
private readonly StatisticsRow statisticsPending = new ("Pending");
|
||||
private readonly StatisticsRow statisticsDownloaded = new ("Downloaded");
|
||||
private readonly StatisticsRow statisticsFailed = new ("Failed");
|
||||
private readonly StatisticsRow statisticsSkipped = new ("Skipped");
|
||||
|
||||
public ObservableCollection<StatisticsRow> StatisticsRows { get; }
|
||||
|
||||
public bool IsDownloading => state.Downloader.IsDownloading;
|
||||
|
||||
private readonly State state;
|
||||
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
||||
private readonly IDisposable downloadItemCountSubscription;
|
||||
|
||||
private IDisposable? finishedItemsSubscription;
|
||||
private DownloadItemFilter? currentDownloadFilter;
|
||||
|
||||
public DownloadsPageModel() : this(State.Dummy) {}
|
||||
|
||||
public DownloadsPageModel(State state) {
|
||||
this.state = state;
|
||||
|
||||
FilterModel = new DownloadItemFilterPanelModel(state);
|
||||
|
||||
StatisticsRows = [
|
||||
statisticsPending,
|
||||
statisticsDownloaded,
|
||||
statisticsFailed,
|
||||
statisticsSkipped
|
||||
];
|
||||
|
||||
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(Log, UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadCountChanged);
|
||||
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
public async Task Initialize() {
|
||||
await FilterModel.Initialize();
|
||||
|
||||
if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, false)) {
|
||||
await StartDownload();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
finishedItemsSubscription?.Dispose();
|
||||
|
||||
downloadItemCountSubscription.Dispose();
|
||||
downloadStatisticsTask.Dispose();
|
||||
|
||||
await FilterModel.DisposeAsync();
|
||||
}
|
||||
|
||||
private void OnDownloadCountChanged(long newDownloadCount) {
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
public async Task OnClickToggleDownload() {
|
||||
IsToggleDownloadButtonEnabled = false;
|
||||
|
||||
if (IsDownloading) {
|
||||
await StopDownload();
|
||||
}
|
||||
else {
|
||||
await StartDownload();
|
||||
}
|
||||
|
||||
await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading);
|
||||
IsToggleDownloadButtonEnabled = true;
|
||||
}
|
||||
|
||||
private async Task StartDownload() {
|
||||
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||
|
||||
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
||||
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
|
||||
|
||||
OnDownloadStateChanged();
|
||||
}
|
||||
|
||||
private async Task StopDownload() {
|
||||
await state.Downloader.Stop();
|
||||
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||
|
||||
finishedItemsSubscription?.Dispose();
|
||||
finishedItemsSubscription = null;
|
||||
|
||||
currentDownloadFilter = null;
|
||||
OnDownloadStateChanged();
|
||||
}
|
||||
|
||||
private void OnDownloadStateChanged() {
|
||||
RecomputeDownloadStatistics();
|
||||
|
||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
||||
OnPropertyChanged(nameof(IsDownloading));
|
||||
}
|
||||
|
||||
private void OnItemFinished(DownloadItem item) {
|
||||
RecomputeDownloadStatistics();
|
||||
}
|
||||
|
||||
public async Task OnClickRetryFailedDownloads() {
|
||||
IsRetryingFailedDownloads = true;
|
||||
|
||||
try {
|
||||
await state.Db.Downloads.RetryFailed();
|
||||
RecomputeDownloadStatistics();
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
} finally {
|
||||
IsRetryingFailedDownloads = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecomputeDownloadStatistics() {
|
||||
downloadStatisticsTask.Post(cancellationToken => state.Db.Downloads.GetStatistics(currentDownloadFilter ?? new DownloadItemFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
private void UpdateStatistics(DownloadStatusStatistics statusStatistics) {
|
||||
statisticsPending.Items = statusStatistics.PendingCount;
|
||||
statisticsPending.Size = statusStatistics.PendingTotalSize;
|
||||
statisticsPending.HasFilesWithUnknownSize = statusStatistics.PendingWithUnknownSizeCount > 0;
|
||||
|
||||
statisticsDownloaded.Items = statusStatistics.SuccessfulCount;
|
||||
statisticsDownloaded.Size = statusStatistics.SuccessfulTotalSize;
|
||||
statisticsDownloaded.HasFilesWithUnknownSize = statusStatistics.SuccessfulWithUnknownSizeCount > 0;
|
||||
|
||||
statisticsFailed.Items = statusStatistics.FailedCount;
|
||||
statisticsFailed.Size = statusStatistics.FailedTotalSize;
|
||||
statisticsFailed.HasFilesWithUnknownSize = statusStatistics.FailedWithUnknownSizeCount > 0;
|
||||
|
||||
statisticsSkipped.Items = statusStatistics.SkippedCount;
|
||||
statisticsSkipped.Size = statusStatistics.SkippedTotalSize;
|
||||
statisticsSkipped.HasFilesWithUnknownSize = statusStatistics.SkippedWithUnknownSizeCount > 0;
|
||||
|
||||
HasFailedDownloads = statusStatistics.FailedCount > 0;
|
||||
}
|
||||
|
||||
[ObservableObject]
|
||||
public sealed partial class StatisticsRow(string state) {
|
||||
public string State { get; } = state;
|
||||
|
||||
[ObservableProperty]
|
||||
private int items;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||
private ulong? size;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(SizeText))]
|
||||
private bool hasFilesWithUnknownSize;
|
||||
|
||||
public string SizeText {
|
||||
get {
|
||||
if (size == null) {
|
||||
return "-";
|
||||
}
|
||||
else if (hasFilesWithUnknownSize) {
|
||||
return "\u2265 " + BytesValueConverter.Convert(size.Value);
|
||||
}
|
||||
else {
|
||||
return BytesValueConverter.Convert(size.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -16,7 +16,7 @@
|
||||
To start tracking messages, copy the tracking script and paste it into the console of either the Discord app, or your browser. The console is usually opened by pressing Ctrl+Shift+I.
|
||||
</TextBlock>
|
||||
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick" IsEnabled="{Binding IsCopyTrackingScriptButtonEnabled}">Copy Tracking Script</Button>
|
||||
<Button x:Name="CopyTrackingScript" Click="CopyTrackingScriptButton_OnClick">Copy Tracking Script</Button>
|
||||
</StackPanel>
|
||||
<TextBlock TextWrapping="Wrap" Margin="0 5 0 0">
|
||||
By default, the Discord app does not allow opening the console. The button below will change a hidden setting in the Discord app that controls whether the Ctrl+Shift+I shortcut is enabled.
|
||||
|
@@ -1,39 +1,35 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Discord;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed partial class TrackingPageModel : ObservableObject {
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isCopyTrackingScriptButtonEnabled = true;
|
||||
sealed class TrackingPageModel : BaseModel {
|
||||
private bool areDevToolsEnabled;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||
private bool? areDevToolsEnabled = null;
|
||||
private bool AreDevToolsEnabled {
|
||||
get => areDevToolsEnabled;
|
||||
set {
|
||||
Change(ref areDevToolsEnabled, value);
|
||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = true;
|
||||
|
||||
public string ToggleAppDevToolsButtonText {
|
||||
get {
|
||||
if (!AreDevToolsEnabled.HasValue) {
|
||||
return "Loading...";
|
||||
}
|
||||
|
||||
if (!IsToggleAppDevToolsButtonEnabled) {
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
return AreDevToolsEnabled.Value ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||
return AreDevToolsEnabled ? "Disable Ctrl+Shift+I" : "Enable Ctrl+Shift+I";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,22 +40,21 @@ sealed partial class TrackingPageModel : ObservableObject {
|
||||
|
||||
public TrackingPageModel(Window window) {
|
||||
this.window = window;
|
||||
|
||||
Task.Factory.StartNew(InitializeDevToolsToggle, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
public async Task<bool> OnClickCopyTrackingScript() {
|
||||
IsCopyTrackingScriptButtonEnabled = false;
|
||||
|
||||
try {
|
||||
return await CopyTrackingScript();
|
||||
} finally {
|
||||
IsCopyTrackingScriptButtonEnabled = true;
|
||||
public async Task Initialize() {
|
||||
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
||||
if (devToolsEnabled.HasValue) {
|
||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||
}
|
||||
else {
|
||||
IsToggleAppDevToolsButtonEnabled = false;
|
||||
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CopyTrackingScript() {
|
||||
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
|
||||
|
||||
public async Task<bool> OnClickCopyTrackingScript() {
|
||||
string url = $"http://127.0.0.1:{ServerManager.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerManager.Token)}";
|
||||
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
|
||||
|
||||
var clipboard = window.Clipboard;
|
||||
@@ -77,26 +72,10 @@ sealed partial class TrackingPageModel : ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeDevToolsToggle() {
|
||||
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
|
||||
|
||||
if (devToolsEnabled.HasValue) {
|
||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||
IsToggleAppDevToolsButtonEnabled = true;
|
||||
}
|
||||
else {
|
||||
IsToggleAppDevToolsButtonEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnClickToggleAppDevTools() {
|
||||
public async void OnClickToggleAppDevTools() {
|
||||
const string DialogTitle = "Discord App Settings File";
|
||||
|
||||
if (!AreDevToolsEnabled.HasValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool oldState = AreDevToolsEnabled.Value;
|
||||
bool oldState = AreDevToolsEnabled;
|
||||
bool newState = !oldState;
|
||||
|
||||
switch (await DiscordAppSettings.ConfigureDevTools(newState)) {
|
||||
|
@@ -21,6 +21,7 @@
|
||||
<StackPanel Orientation="Vertical" Spacing="20">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
||||
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
|
||||
<Button Command="{Binding OnClickSaveViewer}" Margin="5 0 0 0">Save Viewer</Button>
|
||||
</StackPanel>
|
||||
<controls:MessageFilterPanel DataContext="{Binding FilterModel}" />
|
||||
<Expander Header="Database Tools">
|
||||
|
@@ -1,40 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.File;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Service.Viewer;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Export;
|
||||
using DHT.Server.Database.Export.Strategy;
|
||||
using DHT.Utils.Models;
|
||||
using static DHT.Desktop.Program;
|
||||
|
||||
namespace DHT.Desktop.Main.Pages;
|
||||
|
||||
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
||||
public static readonly ConcurrentBag<string> TemporaryFiles = new ();
|
||||
|
||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasFilters = false;
|
||||
|
||||
public bool HasFilters {
|
||||
get => hasFilters;
|
||||
set => Change(ref hasFilters, value);
|
||||
}
|
||||
|
||||
public MessageFilterPanelModel FilterModel { get; }
|
||||
|
||||
private readonly Window window;
|
||||
private readonly State state;
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public ViewerPageModel() : this(null!, State.Dummy) {}
|
||||
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public ViewerPageModel(Window window, State state) {
|
||||
public ViewerPageModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.state = state;
|
||||
this.db = db;
|
||||
|
||||
FilterModel = new MessageFilterPanelModel(window, state, "Will export");
|
||||
FilterModel = new MessageFilterPanelModel(window, db, "Will export");
|
||||
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -46,34 +59,93 @@ sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||
HasFilters = FilterModel.HasAnyFilters;
|
||||
}
|
||||
|
||||
private async Task WriteViewerFile(string path, IViewerExportStrategy strategy) {
|
||||
const string ArchiveTag = "/*[ARCHIVE]*/";
|
||||
|
||||
string indexFile = await Resources.ReadTextAsync("Viewer/index.html");
|
||||
string viewerTemplate = indexFile.Replace("/*[JS]*/", await Resources.ReadJoinedAsync("Viewer/scripts/", '\n'))
|
||||
.Replace("/*[CSS]*/", await Resources.ReadJoinedAsync("Viewer/styles/", '\n'));
|
||||
|
||||
viewerTemplate = strategy.ProcessViewerTemplate(viewerTemplate);
|
||||
|
||||
int viewerArchiveTagStart = viewerTemplate.IndexOf(ArchiveTag);
|
||||
int viewerArchiveTagEnd = viewerArchiveTagStart + ArchiveTag.Length;
|
||||
|
||||
string jsonTempFile = path + ".tmp";
|
||||
|
||||
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
|
||||
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter());
|
||||
|
||||
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
|
||||
jsonStream.Position = 0;
|
||||
|
||||
await using (var outputStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
|
||||
await using (var outputWriter = new StreamWriter(outputStream, Encoding.UTF8)) {
|
||||
await outputWriter.WriteAsync(viewerTemplate[..viewerArchiveTagStart]);
|
||||
|
||||
using (var jsonReader = new StreamReader(jsonStream, Encoding.UTF8)) {
|
||||
int readBytes;
|
||||
while ((readBytes = await jsonReader.ReadAsync(jsonBuffer, 0, jsonBuffer.Length)) > 0) {
|
||||
string jsonChunk = new string(jsonBuffer, 0, readBytes);
|
||||
await outputWriter.WriteAsync(HttpUtility.JavaScriptStringEncode(jsonChunk));
|
||||
}
|
||||
}
|
||||
|
||||
await outputWriter.WriteAsync(viewerTemplate[viewerArchiveTagEnd..]);
|
||||
}
|
||||
}
|
||||
|
||||
File.Delete(jsonTempFile);
|
||||
}
|
||||
|
||||
public async void OnClickOpenViewer() {
|
||||
try {
|
||||
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
|
||||
string serverToken = ServerConfiguration.Token;
|
||||
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
|
||||
SystemUtils.OpenUrl(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
|
||||
} catch (Exception e) {
|
||||
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
|
||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
||||
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
||||
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
||||
int counter = 0;
|
||||
|
||||
while (File.Exists(fullPath)) {
|
||||
++counter;
|
||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
||||
}
|
||||
|
||||
TemporaryFiles.Add(fullPath);
|
||||
|
||||
Directory.CreateDirectory(rootPath);
|
||||
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
|
||||
|
||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private static readonly FilePickerFileType[] ViewerFileTypes = {
|
||||
FileDialogs.CreateFilter("Discord History Viewer", new string[] { "html" }),
|
||||
};
|
||||
|
||||
public async void OnClickSaveViewer() {
|
||||
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
||||
Title = "Save Viewer",
|
||||
FileTypeChoices = ViewerFileTypes,
|
||||
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html",
|
||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)),
|
||||
});
|
||||
|
||||
if (path != null) {
|
||||
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnClickApplyFiltersToDatabase() {
|
||||
public async void OnClickApplyFiltersToDatabase() {
|
||||
var filter = FilterModel.CreateFilter();
|
||||
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
||||
|
||||
if (DatabaseToolFilterModeKeep) {
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", messageCount.Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.KeepMatching);
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
|
||||
}
|
||||
}
|
||||
else if (DatabaseToolFilterModeRemove) {
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", messageCount.Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
await ApplyFilterToDatabase(filter, FilterRemovalMode.RemoveMatching);
|
||||
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
|
||||
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyFilterToDatabase(MessageFilter filter, FilterRemovalMode removalMode) {
|
||||
await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Removing messages...", _ => state.Db.Messages.Remove(filter, removalMode));
|
||||
}
|
||||
}
|
||||
|
@@ -74,7 +74,7 @@
|
||||
<DockPanel>
|
||||
<Border Classes="statusBar" DockPanel.Dock="Bottom">
|
||||
<DockPanel>
|
||||
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Downloads</TextBlock>
|
||||
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Attachments</TextBlock>
|
||||
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
@@ -94,9 +94,9 @@
|
||||
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabDownloads" Header="Downloads" Grid.Row="2">
|
||||
<TabItem x:Name="TabAttachments" Header="Attachments" Grid.Row="2">
|
||||
<ScrollViewer>
|
||||
<ContentPresenter Content="{Binding DownloadsPage}" Classes="page" />
|
||||
<ContentPresenter Content="{Binding AttachmentsPage}" Classes="page" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
|
||||
|
@@ -1,21 +1,27 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Main.Controls;
|
||||
using DHT.Desktop.Main.Pages;
|
||||
using DHT.Server;
|
||||
using DHT.Desktop.Server;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
using DHT.Utils.Logging;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
sealed class MainContentScreenModel : IAsyncDisposable {
|
||||
sealed class MainContentScreenModel : IDisposable {
|
||||
private static readonly Log Log = Log.ForType<MainContentScreenModel>();
|
||||
|
||||
public DatabasePage DatabasePage { get; }
|
||||
private DatabasePageModel DatabasePageModel { get; }
|
||||
|
||||
public TrackingPage TrackingPage { get; }
|
||||
private TrackingPageModel TrackingPageModel { get; }
|
||||
|
||||
public DownloadsPage DownloadsPage { get; }
|
||||
private DownloadsPageModel DownloadsPageModel { get; }
|
||||
public AttachmentsPage AttachmentsPage { get; }
|
||||
private AttachmentsPageModel AttachmentsPageModel { get; }
|
||||
|
||||
public ViewerPage ViewerPage { get; }
|
||||
private ViewerPageModel ViewerPageModel { get; }
|
||||
@@ -29,7 +35,7 @@ sealed class MainContentScreenModel : IAsyncDisposable {
|
||||
public bool HasDebugPage => true;
|
||||
private DebugPageModel DebugPageModel { get; }
|
||||
#else
|
||||
public bool HasDebugPage => false;
|
||||
public bool HasDebugPage => false;
|
||||
#endif
|
||||
|
||||
public StatusBarModel StatusBarModel { get; }
|
||||
@@ -43,43 +49,71 @@ sealed class MainContentScreenModel : IAsyncDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Designer")]
|
||||
public MainContentScreenModel() : this(null!, State.Dummy) {}
|
||||
private readonly Window window;
|
||||
private readonly ServerManager serverManager;
|
||||
|
||||
public MainContentScreenModel(Window window, State state) {
|
||||
DatabasePageModel = new DatabasePageModel(window, state);
|
||||
[Obsolete("Designer")]
|
||||
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {}
|
||||
|
||||
public MainContentScreenModel(Window window, IDatabaseFile db) {
|
||||
this.window = window;
|
||||
this.serverManager = new ServerManager(db);
|
||||
|
||||
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
|
||||
|
||||
DatabasePageModel = new DatabasePageModel(window, db);
|
||||
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
|
||||
|
||||
TrackingPageModel = new TrackingPageModel(window);
|
||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||
|
||||
DownloadsPageModel = new DownloadsPageModel(state);
|
||||
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
|
||||
AttachmentsPageModel = new AttachmentsPageModel(db);
|
||||
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
||||
|
||||
ViewerPageModel = new ViewerPageModel(window, state);
|
||||
ViewerPageModel = new ViewerPageModel(window, db);
|
||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||
|
||||
AdvancedPageModel = new AdvancedPageModel(window, state);
|
||||
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager);
|
||||
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
|
||||
|
||||
#if DEBUG
|
||||
DebugPageModel = new DebugPageModel(window, state);
|
||||
DebugPageModel = new DebugPageModel(window, db);
|
||||
DebugPage = new DebugPage { DataContext = DebugPageModel };
|
||||
#else
|
||||
DebugPage = null;
|
||||
DebugPage = null;
|
||||
#endif
|
||||
|
||||
StatusBarModel = new StatusBarModel(state);
|
||||
}
|
||||
|
||||
public async Task Initialize() {
|
||||
await DownloadsPageModel.Initialize();
|
||||
StatusBarModel = new StatusBarModel(db.Statistics);
|
||||
|
||||
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
|
||||
DatabaseClosed += OnDatabaseClosed;
|
||||
|
||||
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
await DownloadsPageModel.DisposeAsync();
|
||||
public async Task Initialize() {
|
||||
await TrackingPageModel.Initialize();
|
||||
AdvancedPageModel.Initialize();
|
||||
serverManager.Launch();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
|
||||
AttachmentsPageModel.Dispose();
|
||||
ViewerPageModel.Dispose();
|
||||
AdvancedPageModel.Dispose();
|
||||
StatusBarModel.Dispose();
|
||||
serverManager.Dispose();
|
||||
}
|
||||
|
||||
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
|
||||
StatusBarModel.CurrentStatus = e;
|
||||
}
|
||||
|
||||
private void OnDatabaseClosed(object? sender, EventArgs e) {
|
||||
serverManager.Stop();
|
||||
}
|
||||
|
||||
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
|
||||
Log.Error(ex);
|
||||
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
@@ -22,21 +22,20 @@
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Grid#ButtonPanel > Button">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Margin" Value="5 0" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel Name="RootPanel">
|
||||
<StackPanel Margin="42 30">
|
||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 25" HorizontalAlignment="Center" />
|
||||
|
||||
<Grid Name="ButtonPanel" RowDefinitions="Auto,12,Auto,12,Auto" ColumnDefinitions="*,12,*" Margin="12 0" HorizontalAlignment="Stretch">
|
||||
<Button Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding OpenOrCreateDatabase}" IsEnabled="{Binding IsOpenOrCreateDatabaseButtonEnabled}">Open or Create Database</Button>
|
||||
<Button Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Command="{Binding CheckUpdates}">Check For Updates</Button>
|
||||
<Button Grid.Row="4" Grid.Column="0" Command="{Binding ShowAboutDialog}">About</Button>
|
||||
<Button Grid.Row="4" Grid.Column="2" Command="{Binding Exit}">Exit</Button>
|
||||
</Grid>
|
||||
<StackPanel Margin="42">
|
||||
<TextBlock Text="{Binding Version, StringFormat=Discord History Tracker v{0}}" FontSize="25" Margin="0 0 0 30" HorizontalAlignment="Center" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Command="{Binding OpenOrCreateDatabase}">Open or Create Database</Button>
|
||||
<Button Command="{Binding ShowAboutDialog}">About</Button>
|
||||
<Button Command="{Binding Exit}">Exit</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</UserControl>
|
||||
|
@@ -1,30 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DHT.Desktop.Common;
|
||||
using DHT.Desktop.Dialogs.Message;
|
||||
using DHT.Desktop.Dialogs.Progress;
|
||||
using DHT.Server.Data.Settings;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Database.Sqlite.Schema;
|
||||
using DHT.Utils.Logging;
|
||||
using DHT.Server.Database.Sqlite;
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Desktop.Main.Screens;
|
||||
|
||||
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
private static readonly Log Log = Log.ForType<WelcomeScreenModel>();
|
||||
|
||||
sealed class WelcomeScreenModel : BaseModel, IDisposable {
|
||||
public string Version => Program.Version;
|
||||
|
||||
[ObservableProperty(Setter = Access.Private)]
|
||||
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||
|
||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||
public IDatabaseFile? Db { get; private set; }
|
||||
public bool HasDatabase => Db != null;
|
||||
|
||||
private readonly Window window;
|
||||
|
||||
@@ -37,36 +29,32 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
public async Task OpenOrCreateDatabase() {
|
||||
IsOpenOrCreateDatabaseButtonEnabled = false;
|
||||
try {
|
||||
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||
if (path != null) {
|
||||
await OpenOrCreateDatabaseFromPath(path);
|
||||
}
|
||||
} finally {
|
||||
IsOpenOrCreateDatabaseButtonEnabled = true;
|
||||
public async void OpenOrCreateDatabase() {
|
||||
var path = await DatabaseGui.NewOpenOrCreateDatabaseFileDialog(window, Path.GetDirectoryName(dbFilePath));
|
||||
if (path != null) {
|
||||
await OpenOrCreateDatabaseFromPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OpenOrCreateDatabaseFromPath(string path) {
|
||||
if (Db != null) {
|
||||
Db = null;
|
||||
}
|
||||
|
||||
dbFilePath = path;
|
||||
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||
|
||||
bool isNew = !File.Exists(path);
|
||||
|
||||
var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
|
||||
if (db == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) {
|
||||
await db.Settings.Set(SettingsKey.DownloadsAutoStart, true);
|
||||
}
|
||||
|
||||
DatabaseSelected?.Invoke(this, db);
|
||||
OnPropertyChanged(nameof(Db));
|
||||
OnPropertyChanged(nameof(HasDatabase));
|
||||
}
|
||||
|
||||
private sealed class SchemaUpgradeCallbacks(Window window) : ISchemaUpgradeCallbacks {
|
||||
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);
|
||||
}
|
||||
@@ -80,11 +68,23 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(600));
|
||||
}
|
||||
|
||||
await new ProgressDialog { DataContext = new ProgressDialogModel("Upgrading Database", StartUpgrade, progressItems: 3) }.ShowProgressDialog(window);
|
||||
await new ProgressDialog {
|
||||
DataContext = new ProgressDialogModel(StartUpgrade, progressItems: 3) {
|
||||
Title = "Upgrading Database"
|
||||
}
|
||||
}.ShowProgressDialog(window);
|
||||
}
|
||||
|
||||
private sealed class ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) : ISchemaUpgradeCallbacks.IProgressReporter {
|
||||
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);
|
||||
@@ -109,58 +109,22 @@ sealed partial class WelcomeScreenModel : ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CheckUpdates() {
|
||||
Version? latestVersion = await ProgressDialog.ShowIndeterminate<Version?>(window, "Check Updates", "Checking for updates...", async _ => {
|
||||
var client = new HttpClient(new SocketsHttpHandler {
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false
|
||||
});
|
||||
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.MaxResponseContentBufferSize = 1024;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordHistoryTracker/" + Program.Version);
|
||||
|
||||
string response;
|
||||
try {
|
||||
response = await client.GetStringAsync(Program.Website + "/version");
|
||||
} catch (TaskCanceledException e) when (e.InnerException is TimeoutException) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "Request timed out.");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
Log.Error(e);
|
||||
await Dialog.ShowOk(window, "Check Updates", "Error checking for updates: " + e.Message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(response, out var latestVersion)) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "Server returned an invalid response.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
});
|
||||
|
||||
if (latestVersion == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Program.AssemblyVersion >= latestVersion) {
|
||||
await Dialog.ShowOk(window, "Check Updates", "You are using the latest version.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await Dialog.ShowYesNo(window, "Check Updates", "A newer version is available: v" + Program.VersionToString(latestVersion) + "\nVisit the official website and close the app?") == DialogResult.YesNo.Yes) {
|
||||
SystemUtils.OpenUrl(Program.Website);
|
||||
Exit();
|
||||
}
|
||||
public void CloseDatabase() {
|
||||
Dispose();
|
||||
OnPropertyChanged(nameof(Db));
|
||||
OnPropertyChanged(nameof(HasDatabase));
|
||||
}
|
||||
|
||||
public async Task ShowAboutDialog() {
|
||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(window);
|
||||
public async void ShowAboutDialog() {
|
||||
await new AboutWindow { DataContext = new AboutWindowModel() }.ShowDialog(this.window);
|
||||
}
|
||||
|
||||
public void Exit() {
|
||||
window.Close();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
Db?.Dispose();
|
||||
Db = null;
|
||||
}
|
||||
}
|
||||
|
@@ -9,18 +9,17 @@ namespace DHT.Desktop;
|
||||
|
||||
static class Program {
|
||||
public static string Version { get; }
|
||||
public static Version AssemblyVersion { get; }
|
||||
public static CultureInfo Culture { get; }
|
||||
public static ResourceLoader Resources { get; }
|
||||
public static Arguments Arguments { get; }
|
||||
|
||||
public const string Website = "https://dht.chylex.com";
|
||||
|
||||
static Program() {
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
AssemblyVersion = assembly.GetName().Version ?? new Version(0, 0, 0, 0);
|
||||
Version = VersionToString(AssemblyVersion);
|
||||
Version = assembly.GetName().Version?.ToString() ?? "";
|
||||
while (Version.EndsWith(".0")) {
|
||||
Version = Version[..^2];
|
||||
}
|
||||
|
||||
Culture = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
@@ -31,16 +30,6 @@ static class Program {
|
||||
Resources = new ResourceLoader(assembly);
|
||||
Arguments = new Arguments(Environment.GetCommandLineArgs());
|
||||
}
|
||||
|
||||
public static string VersionToString(Version version) {
|
||||
string versionStr = version.ToString();
|
||||
|
||||
while (versionStr.EndsWith(".0")) {
|
||||
versionStr = versionStr[..^2];
|
||||
}
|
||||
|
||||
return versionStr;
|
||||
}
|
||||
|
||||
public static void Main(string[] args) {
|
||||
if (Arguments.Console && OperatingSystem.IsWindows()) {
|
||||
|
@@ -1,8 +0,0 @@
|
||||
using DHT.Server.Service;
|
||||
|
||||
namespace DHT.Desktop.Server;
|
||||
|
||||
static class ServerConfiguration {
|
||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||
}
|
50
app/Desktop/Server/ServerManager.cs
Normal file
50
app/Desktop/Server/ServerManager.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using DHT.Server.Database;
|
||||
using DHT.Server.Service;
|
||||
|
||||
namespace DHT.Desktop.Server;
|
||||
|
||||
sealed class ServerManager : IDisposable {
|
||||
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
|
||||
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
|
||||
|
||||
private static ServerManager? instance;
|
||||
|
||||
public bool IsRunning => ServerLauncher.IsRunning;
|
||||
|
||||
private readonly IDatabaseFile db;
|
||||
|
||||
public ServerManager(IDatabaseFile db) {
|
||||
if (db != DummyDatabaseFile.Instance) {
|
||||
if (instance != null) {
|
||||
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
|
||||
}
|
||||
|
||||
instance = this;
|
||||
}
|
||||
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public void Launch() {
|
||||
ServerLauncher.Relaunch(Port, Token, db);
|
||||
}
|
||||
|
||||
public void Relaunch(ushort port, string token) {
|
||||
Port = port;
|
||||
Token = token;
|
||||
Launch();
|
||||
}
|
||||
|
||||
public void Stop() {
|
||||
ServerLauncher.Stop();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
Stop();
|
||||
|
||||
if (instance == this) {
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
}
|
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<LangVersion>12</LangVersion>
|
||||
<LangVersion>11</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
|
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<add key="chylex's repository" value="https://nuget.chylex.com/feed/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
@@ -9,8 +9,6 @@ items:
|
||||
pattern: "^[0-9]+$"
|
||||
name:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
avatar:
|
||||
type: string
|
||||
discriminator:
|
||||
|
47
app/Resources/Tracker/bootstrap.js
vendored
47
app/Resources/Tracker/bootstrap.js
vendored
@@ -10,16 +10,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
/*[IMPORTS]*/
|
||||
|
||||
if (!DISCORD.isCompatible()) {
|
||||
alert("Discord History Tracker is not compatible with this version of Discord.");
|
||||
return;
|
||||
}
|
||||
|
||||
window.DHT_LOADED = true;
|
||||
window.DHT_ON_UNLOAD = [];
|
||||
|
||||
/*[IMPORTS]*/
|
||||
|
||||
const port = 0; /*[PORT]*/
|
||||
const token = "/*[TOKEN]*/";
|
||||
STATE.setup(port, token);
|
||||
@@ -51,7 +46,7 @@
|
||||
return action === null || action === CONSTANTS.AUTOSCROLL_ACTION_NOTHING;
|
||||
};
|
||||
|
||||
const onTrackingContinued = function(anyNewMessages, hasMoreBefore) {
|
||||
const onTrackingContinued = function(anyNewMessages) {
|
||||
if (!STATE.isTracking()) {
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +63,7 @@
|
||||
if (SETTINGS.autoscroll) {
|
||||
let action = null;
|
||||
|
||||
if (!hasMoreBefore) {
|
||||
if (!DISCORD.hasMoreMessages()) {
|
||||
console.debug("[DHT] Reached first message.");
|
||||
action = SETTINGS.afterFirstMsg;
|
||||
}
|
||||
@@ -89,7 +84,7 @@
|
||||
|
||||
let waitUntilSendingFinishedTimer = null;
|
||||
|
||||
const onMessagesUpdated = async (server, channel, messages, hasMoreBefore) => {
|
||||
const onMessagesUpdated = async messages => {
|
||||
if (!STATE.isTracking() || delayedStopRequests > 0) {
|
||||
return;
|
||||
}
|
||||
@@ -99,16 +94,24 @@
|
||||
|
||||
waitUntilSendingFinishedTimer = window.setTimeout(() => {
|
||||
waitUntilSendingFinishedTimer = null;
|
||||
onMessagesUpdated(server, channel, messages, hasMoreBefore);
|
||||
onMessagesUpdated(messages);
|
||||
}, 100);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const info = DISCORD.getSelectedChannel();
|
||||
|
||||
if (!info) {
|
||||
GUI.setStatus("Error (Unknown Channel)");
|
||||
stopTrackingDelayed();
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
|
||||
try {
|
||||
await STATE.addDiscordChannel(server, channel);
|
||||
await STATE.addDiscordChannel(info.server, info.channel);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
return;
|
||||
@@ -117,33 +120,35 @@
|
||||
try {
|
||||
if (!messages.length) {
|
||||
isSending = false;
|
||||
onTrackingContinued(false, hasMoreBefore);
|
||||
onTrackingContinued(false);
|
||||
}
|
||||
else {
|
||||
const anyNewMessages = await STATE.addDiscordMessages(messages);
|
||||
onTrackingContinued(anyNewMessages, hasMoreBefore);
|
||||
onTrackingContinued(anyNewMessages);
|
||||
}
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const starter = DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||
DISCORD.setupMessageCallback(onMessagesUpdated);
|
||||
|
||||
STATE.onTrackingStateChanged(enabled => {
|
||||
if (enabled) {
|
||||
GUI.setStatus("Starting");
|
||||
GUI.createTrackingStyles();
|
||||
hasJustStarted = true;
|
||||
const messages = DISCORD.getMessages();
|
||||
|
||||
if (!starter()) {
|
||||
if (messages.length === 0) {
|
||||
stopTrackingDelayed(() => alert("Cannot see any messages."));
|
||||
hasJustStarted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
GUI.setStatus("Starting");
|
||||
hasJustStarted = true;
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
onMessagesUpdated(messages);
|
||||
}
|
||||
else {
|
||||
isSending = false;
|
||||
GUI.deleteTrackingStyles();
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -4,26 +4,11 @@ class DISCORD {
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
static CHANNEL_TYPE = {
|
||||
GUILD_TEXT: 0,
|
||||
DM: 1,
|
||||
GROUP_DM: 3,
|
||||
GUILD_ANNOUNCEMENT: 5,
|
||||
ANNOUNCEMENT_THREAD: 10,
|
||||
PUBLIC_THREAD: 11,
|
||||
PRIVATE_THREAD: 12,
|
||||
|
||||
isPrivate(type) {
|
||||
return type === this.DM
|
||||
|| type === this.GROUP_DM;
|
||||
},
|
||||
|
||||
isNavigableGuildChannel(type) {
|
||||
return type === this.GUILD_TEXT
|
||||
|| type === this.GUILD_ANNOUNCEMENT
|
||||
|| type === this.ANNOUNCEMENT_THREAD
|
||||
|| type === this.PUBLIC_THREAD
|
||||
|| type === this.PRIVATE_THREAD;
|
||||
}
|
||||
PRIVATE_THREAD: 12
|
||||
};
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
@@ -33,74 +18,6 @@ class DISCORD {
|
||||
THREAD_STARTER: 21
|
||||
};
|
||||
|
||||
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
|
||||
static PERMISSION = {
|
||||
VIEW_CHANNEL: 1n << 10n
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {function(String): ?DiscordGuild} getGuild
|
||||
*/
|
||||
static #guildStore = WEBPACK.findModule("guildStore", WEBPACK.filterByProps("getGuild", "getGuilds", "getGuildIds"));
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {function(String): Boolean} isOptInEnabled
|
||||
* @property {function(String): Set<String>} getOptedInChannels
|
||||
*/
|
||||
static #guildSettings = WEBPACK.findModule("guildSettings", WEBPACK.filterByProps("isOptInEnabled", "getOptedInChannels"));
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {function(String): ?DiscordChannel} getChannel
|
||||
* @property {function(String): Array<DiscordChannel>} getMutableGuildChannelsForGuild
|
||||
* @property {function(): Array<DiscordChannel>} getSortedPrivateChannels
|
||||
*/
|
||||
static #channelStore = WEBPACK.findModule("channelStore", WEBPACK.filterByProps("getChannel", "getMutableGuildChannelsForGuild", "getSortedPrivateChannels"));
|
||||
|
||||
/**
|
||||
* @type {function(BigInt, Object): Boolean}
|
||||
*/
|
||||
static #hasPermission = WEBPACK.findFunction("can", [ "getGuildPermissions", "getChannelPermissions" ]);
|
||||
|
||||
/**
|
||||
* @type {function(String): MessageData}
|
||||
*/
|
||||
static #getMessages = WEBPACK.findFunction("getMessages");
|
||||
|
||||
/**
|
||||
* @type {function(String): void}
|
||||
*/
|
||||
static #jumpToMessage = WEBPACK.findFunction("jumpToMessage");
|
||||
|
||||
/**
|
||||
* @type {function(): String}
|
||||
*/
|
||||
static #getCurrentlySelectedChannelId = WEBPACK.findFunction("getCurrentlySelectedChannelId");
|
||||
|
||||
/**
|
||||
* @type {function(String): void}
|
||||
*/
|
||||
static #selectPrivateChannel = WEBPACK.findFunction("selectPrivateChannel", [ "selectChannel" ]);
|
||||
|
||||
/**
|
||||
* @type {function(String, Object, String=null): void}
|
||||
*/
|
||||
static #transitionToGuildSync = WEBPACK.findFunction("transitionToGuildSync");
|
||||
|
||||
static isCompatible() {
|
||||
return !!this.#guildStore
|
||||
&& !!this.#guildSettings
|
||||
&& !!this.#channelStore
|
||||
&& !!this.#hasPermission
|
||||
&& !!this.#getMessages
|
||||
&& !!this.#jumpToMessage
|
||||
&& !!this.#getCurrentlySelectedChannelId
|
||||
&& !!this.#selectPrivateChannel
|
||||
&& !!this.#transitionToGuildSync;
|
||||
}
|
||||
|
||||
static getMessageOuterElement() {
|
||||
return DOM.queryReactClass("messagesWrapper");
|
||||
}
|
||||
@@ -109,6 +26,14 @@ class DISCORD {
|
||||
return DOM.queryReactClass("scroller", this.getMessageOuterElement());
|
||||
}
|
||||
|
||||
static getMessageElements() {
|
||||
return this.getMessageOuterElement().querySelectorAll("[class*='message_']");
|
||||
}
|
||||
|
||||
static hasMoreMessages() {
|
||||
return document.querySelector("#messagesNavigationDescription + [class^=container]") === null;
|
||||
}
|
||||
|
||||
static loadOlderMessages() {
|
||||
const view = this.getMessageScrollerElement();
|
||||
|
||||
@@ -117,43 +42,26 @@ class DISCORD {
|
||||
}
|
||||
}
|
||||
|
||||
static getMessagesFromSelectedChannel() {
|
||||
const channelId = this.#getCurrentlySelectedChannelId();
|
||||
return channelId ? this.#getMessages(channelId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the provided function with a list of messages whenever the currently loaded messages change.
|
||||
* @param callback {function(server: ?DiscordGuild, channel: DiscordChannel, messages: Array<DiscordMessage>, hasMoreBefore: boolean)}
|
||||
*/
|
||||
static setupMessageCallback(callback) {
|
||||
const previousMessages = new Set();
|
||||
|
||||
const onMessageElementsChanged = force => {
|
||||
const messages = this.getMessagesFromSelectedChannel();
|
||||
if (!messages || !messages.ready || messages.loadingMore) {
|
||||
return false;
|
||||
}
|
||||
const onMessageElementsChanged = function() {
|
||||
const messages = DISCORD.getMessages();
|
||||
const hasChanged = messages.some(message => !previousMessages.has(message.id)) || !DISCORD.hasMoreMessages();
|
||||
|
||||
const channel = this.#channelStore.getChannel(messages.channelId);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasChanged = force || !messages.hasMoreBefore || messages.some(message => !previousMessages.has(message.id));
|
||||
if (!hasChanged) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
previousMessages.clear();
|
||||
for (const message of messages._array) {
|
||||
for (const message of messages) {
|
||||
previousMessages.add(message.id);
|
||||
}
|
||||
|
||||
const server = this.#guildStore.getGuild(channel.guild_id);
|
||||
|
||||
callback(server, channel, messages._array, messages.hasMoreBefore);
|
||||
return true;
|
||||
callback(messages);
|
||||
};
|
||||
|
||||
let debounceTimer;
|
||||
@@ -166,7 +74,7 @@ class DISCORD {
|
||||
debounceTimer = window.setTimeout(onMessageElementsChanged, 100);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(function() {
|
||||
const observer = new MutationObserver(function () {
|
||||
onMessageElementsChangedLater();
|
||||
});
|
||||
|
||||
@@ -204,54 +112,216 @@ class DISCORD {
|
||||
observedElement = null;
|
||||
window.clearInterval(observerTimer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message from a message element.
|
||||
* @returns { null | DiscordMessage } }
|
||||
*/
|
||||
static getMessageFromElement(ele) {
|
||||
const props = DOM.getReactProps(ele);
|
||||
|
||||
return () => onMessageElementsChanged(true);
|
||||
if (props && Array.isArray(props.children)) {
|
||||
for (const child of props.children) {
|
||||
if (!(child instanceof Object)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const childProps = child.props;
|
||||
if (childProps instanceof Object && "message" in childProps) {
|
||||
return childProps.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array containing currently loaded messages.
|
||||
*/
|
||||
static getMessages() {
|
||||
try {
|
||||
const messages = [];
|
||||
|
||||
for (const ele of this.getMessageElements()) {
|
||||
try {
|
||||
const message = this.getMessageFromElement(ele);
|
||||
|
||||
if (message != null) {
|
||||
messages.push(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error extracing message data, skipping it.", e, ele, DOM.tryGetReactProps(ele));
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving messages.", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object containing the selected server and channel information.
|
||||
* For types DM and GROUP, the server and channel ids and names are identical.
|
||||
* @returns { {} | null }
|
||||
*/
|
||||
static getSelectedChannel() {
|
||||
try {
|
||||
let obj = null;
|
||||
|
||||
try {
|
||||
for (const child of DOM.getReactProps(DOM.queryReactClass("chatContent")).children) {
|
||||
if (child && child.props && child.props.channel) {
|
||||
obj = child.props.channel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving selected channel from 'chatContent' element.", e);
|
||||
}
|
||||
|
||||
if (!obj || typeof obj.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dms = DOM.queryReactClass("privateChannels");
|
||||
|
||||
if (dms) {
|
||||
let name;
|
||||
|
||||
for (const ele of dms.querySelectorAll("[class*='channel_'] [class*='selected_'] [class^='name_'] *")) {
|
||||
const node = Array.prototype.find.call(ele.childNodes, node => node.nodeType === Node.TEXT_NODE);
|
||||
|
||||
if (node) {
|
||||
name = node.nodeValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let type;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
switch (obj.type) {
|
||||
case DISCORD.CHANNEL_TYPE.DM: type = "DM"; break;
|
||||
case DISCORD.CHANNEL_TYPE.GROUP_DM: type = "GROUP"; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
const id = obj.id;
|
||||
const server = { id, name, type };
|
||||
const channel = { id, name };
|
||||
|
||||
return { server, channel };
|
||||
}
|
||||
else if (obj.guild_id) {
|
||||
let guild;
|
||||
|
||||
for (const child of DOM.getReactProps(document.querySelector("nav header [class*='headerContent_']")).children) {
|
||||
if (child && child.props && child.props.guild) {
|
||||
guild = child.props.guild;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!guild || typeof guild.name !== "string" || obj.guild_id !== guild.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = {
|
||||
"id": guild.id,
|
||||
"name": guild.name,
|
||||
"type": "SERVER"
|
||||
};
|
||||
|
||||
const channel = {
|
||||
"id": obj.id,
|
||||
"name": obj.name,
|
||||
"extra": {
|
||||
"nsfw": obj.nsfw
|
||||
}
|
||||
};
|
||||
|
||||
if (obj.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || obj.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||
channel["extra"]["parent"] = obj.parent_id;
|
||||
}
|
||||
else {
|
||||
channel["extra"]["position"] = obj.position;
|
||||
channel["extra"]["topic"] = obj.topic;
|
||||
}
|
||||
|
||||
return { server, channel };
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[DHT] Error retrieving selected channel.", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the next text channel and returns true, otherwise returns false if there are no more channels.
|
||||
*/
|
||||
static selectNextTextChannel() {
|
||||
const currentChannel = this.#channelStore.getChannel(this.#getCurrentlySelectedChannelId());
|
||||
if (!currentChannel) {
|
||||
return false;
|
||||
}
|
||||
const dms = DOM.queryReactClass("privateChannels");
|
||||
|
||||
if (this.CHANNEL_TYPE.isPrivate(currentChannel.type)) {
|
||||
const privateChannel = this.#channelStore.getSortedPrivateChannels();
|
||||
const currentIndex = privateChannel.findIndex(channel => channel.id === currentChannel.id);
|
||||
if (dms) {
|
||||
const currentChannel = DOM.queryReactClass("selected", dms);
|
||||
const currentChannelContainer = currentChannel && currentChannel.closest("[class*='channel_']");
|
||||
const nextChannel = currentChannelContainer && currentChannelContainer.nextElementSibling;
|
||||
|
||||
if (currentIndex === -1 || currentIndex === privateChannel.length - 1) {
|
||||
if (!nextChannel || !nextChannel.getAttribute("class").includes("channel_")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#selectPrivateChannel(privateChannel[currentIndex + 1].id);
|
||||
const nextChannelLink = nextChannel.querySelector("a[href*='/@me/']");
|
||||
if (!nextChannelLink) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextChannelLink.click();
|
||||
nextChannelLink.scrollIntoView(true);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
const guildId = currentChannel.guild_id;
|
||||
|
||||
let isChannelOptedIn;
|
||||
if (this.#guildSettings.isOptInEnabled(guildId)) {
|
||||
const optedInChannels = this.#guildSettings.getOptedInChannels(guildId);
|
||||
isChannelOptedIn = channel => optedInChannels.has(channel.id);
|
||||
}
|
||||
else {
|
||||
isChannelOptedIn = _ => true;
|
||||
}
|
||||
|
||||
const guildChannelMap = this.#channelStore.getMutableGuildChannelsForGuild(guildId);
|
||||
const guildChannels = Object.values(guildChannelMap)
|
||||
.filter(channel => this.CHANNEL_TYPE.isNavigableGuildChannel(channel.type) && isChannelOptedIn(channel) && this.#hasPermission(this.PERMISSION.VIEW_CHANNEL, channel))
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
const currentIndex = guildChannels.findIndex(channel => channel.id === currentChannel.id);
|
||||
|
||||
if (currentIndex === -1 || currentIndex === guildChannels.length - 1) {
|
||||
const channelListEle = document.getElementById("channels");
|
||||
if (!channelListEle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#transitionToGuildSync(guildId, {}, guildChannels[currentIndex + 1].id);
|
||||
function getLinkElement(channel) {
|
||||
return channel.querySelector("a[href^='/channels/'][role='link']");
|
||||
}
|
||||
|
||||
const allTextChannels = Array.prototype.filter.call(channelListEle.querySelectorAll("[class*='containerDefault']"), ele => getLinkElement(ele) !== null);
|
||||
let nextChannel = null;
|
||||
|
||||
for (let index = 0; index < allTextChannels.length - 1; index++) {
|
||||
if (allTextChannels[index].className.includes("selected_")) {
|
||||
nextChannel = allTextChannels[index + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextChannel === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextChannelLink = getLinkElement(nextChannel);
|
||||
if (!nextChannelLink) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextChannelLink.click();
|
||||
nextChannel.scrollIntoView(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,6 @@
|
||||
class DOM {
|
||||
/**
|
||||
* Returns a child element by its ID. Parent defaults to the entire document.
|
||||
* @param {string} id
|
||||
* @param {HTMLElement?} [parent]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
static id(id, parent) {
|
||||
@@ -11,9 +9,6 @@ class DOM {
|
||||
|
||||
/**
|
||||
* Returns the first child element containing the specified obfuscated class. Parent defaults to the entire document.
|
||||
* @param {string} cls
|
||||
* @param {HTMLElement?} [parent]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
static queryReactClass(cls, parent) {
|
||||
return (parent || document).querySelector(`[class*="${cls}_"]`);
|
||||
@@ -60,4 +55,31 @@ class DOM {
|
||||
const value = document.cookie.replace(new RegExp("(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
|
||||
return value.length ? JSON.parse(decodeURIComponent(value)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns internal React state object of an element.
|
||||
*/
|
||||
static getReactProps(ele) {
|
||||
const keys = Object.keys(ele || {});
|
||||
let key = keys.find(key => key.startsWith("__reactInternalInstance"));
|
||||
|
||||
if (key) {
|
||||
// noinspection JSUnresolvedVariable
|
||||
return ele[key].memoizedProps;
|
||||
}
|
||||
|
||||
key = keys.find(key => key.startsWith("__reactProps$"));
|
||||
return key ? ele[key] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns internal React state object of an element, or null if the retrieval throws.
|
||||
*/
|
||||
static tryGetReactProps(ele) {
|
||||
try {
|
||||
return this.getReactProps(ele);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,17 +2,14 @@
|
||||
const GUI = (function() {
|
||||
let controller = null;
|
||||
let settings = null;
|
||||
let trackingStyles = null;
|
||||
|
||||
const stateChangedEvent = () => {
|
||||
if (settings) {
|
||||
settings.ui.cbAutoscroll.checked = SETTINGS.autoscroll;
|
||||
settings.ui.cbHidePreviewsWhileAutoscrolling.checked = SETTINGS.hidePreviewsWhileAutoscrolling;
|
||||
settings.ui.optsAfterFirstMsg[SETTINGS.afterFirstMsg].checked = true;
|
||||
settings.ui.optsAfterSavedMsg[SETTINGS.afterSavedMsg].checked = true;
|
||||
|
||||
const autoscrollDisabled = !SETTINGS.autoscroll;
|
||||
settings.ui.cbHidePreviewsWhileAutoscrolling.disabled = autoscrollDisabled;
|
||||
Object.values(settings.ui.optsAfterFirstMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
||||
Object.values(settings.ui.optsAfterSavedMsg).forEach(ele => ele.disabled = autoscrollDisabled);
|
||||
}
|
||||
@@ -57,7 +54,6 @@ const GUI = (function() {
|
||||
|
||||
controller.ui.btnClose.addEventListener("click", () => {
|
||||
this.hideController();
|
||||
this.deleteTrackingStyles();
|
||||
window.DHT_ON_UNLOAD.forEach(f => f());
|
||||
delete window.DHT_ON_UNLOAD;
|
||||
delete window.DHT_LOADED;
|
||||
@@ -88,7 +84,6 @@ const GUI = (function() {
|
||||
const radio = (type, id, label) => "<label><input id='dht-cfg-" + type + "-" + id + "' name='dht-" + type + "' type='radio'> " + label + "</label><br>";
|
||||
const html = `
|
||||
<label><input id='dht-cfg-autoscroll' type='checkbox'> Autoscroll</label><br>
|
||||
<label><input id='dht-cfg-hide-previews-while-autoscrolling' type='checkbox'> Hide previews to improve browser performance</label><br>
|
||||
<br>
|
||||
<label>After reaching the first message in channel...</label><br>
|
||||
${radio("afm", "nothing", "Continue Tracking")}
|
||||
@@ -98,7 +93,8 @@ ${radio("afm", "switch", "Switch to Next Channel")}
|
||||
<label>After reaching a previously saved message...</label><br>
|
||||
${radio("asm", "nothing", "Continue Tracking")}
|
||||
${radio("asm", "pause", "Pause Tracking")}
|
||||
${radio("asm", "switch", "Switch to Next Channel")}`;
|
||||
${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>`;
|
||||
|
||||
settings = {
|
||||
styles: DOM.createStyle(`/*[CSS-SETTINGS]*/`),
|
||||
@@ -111,10 +107,9 @@ ${radio("asm", "switch", "Switch to Next Channel")}`;
|
||||
});
|
||||
|
||||
settings.ui = {
|
||||
/** @type {HTMLInputElement} */ cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
||||
/** @type {HTMLInputElement} */ cbHidePreviewsWhileAutoscrolling: DOM.id("dht-cfg-hide-previews-while-autoscrolling"),
|
||||
/** @type {Object.<number, HTMLInputElement>} */ optsAfterFirstMsg: {},
|
||||
/** @type {Object.<number, HTMLInputElement>} */ optsAfterSavedMsg: {}
|
||||
cbAutoscroll: DOM.id("dht-cfg-autoscroll"),
|
||||
optsAfterFirstMsg: {},
|
||||
optsAfterSavedMsg: {}
|
||||
};
|
||||
|
||||
settings.ui.optsAfterFirstMsg[CONSTANTS.AUTOSCROLL_ACTION_NOTHING] = DOM.id("dht-cfg-afm-nothing");
|
||||
@@ -129,10 +124,6 @@ ${radio("asm", "switch", "Switch to Next Channel")}`;
|
||||
SETTINGS.autoscroll = settings.ui.cbAutoscroll.checked;
|
||||
});
|
||||
|
||||
settings.ui.cbHidePreviewsWhileAutoscrolling.addEventListener("change", () => {
|
||||
SETTINGS.hidePreviewsWhileAutoscrolling = settings.ui.cbHidePreviewsWhileAutoscrolling.checked;
|
||||
});
|
||||
|
||||
Object.keys(settings.ui.optsAfterFirstMsg).forEach(key => {
|
||||
settings.ui.optsAfterFirstMsg[key].addEventListener("click", () => {
|
||||
SETTINGS.afterFirstMsg = key;
|
||||
@@ -161,29 +152,6 @@ ${radio("asm", "switch", "Switch to Next Channel")}`;
|
||||
if (controller) {
|
||||
controller.ui.textStatus.innerText = state;
|
||||
}
|
||||
},
|
||||
|
||||
createTrackingStyles() {
|
||||
if (trackingStyles) {
|
||||
return;
|
||||
}
|
||||
|
||||
let style = "";
|
||||
|
||||
if (SETTINGS.autoscroll && SETTINGS.hidePreviewsWhileAutoscrolling) {
|
||||
style += `div[id^="message-accessories-"] { display: none; }`;
|
||||
}
|
||||
|
||||
if (style.length > 0) {
|
||||
trackingStyles = DOM.createStyle(style);
|
||||
}
|
||||
},
|
||||
|
||||
deleteTrackingStyles() {
|
||||
if (trackingStyles) {
|
||||
DOM.removeElement(trackingStyles);
|
||||
trackingStyles = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
@@ -35,28 +35,17 @@ const SETTINGS = (function() {
|
||||
obj[name] = value;
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
"_autoscroll": true,
|
||||
"_hidePreviewsWhileAutoscrolling": true,
|
||||
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||
};
|
||||
|
||||
let loaded = DOM.loadFromCookie("DHT_SETTINGS");
|
||||
let hasChanged = false;
|
||||
|
||||
if (!loaded) {
|
||||
loaded = defaults;
|
||||
loaded = {
|
||||
"_autoscroll": true,
|
||||
"_afterFirstMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE,
|
||||
"_afterSavedMsg": CONSTANTS.AUTOSCROLL_ACTION_PAUSE
|
||||
};
|
||||
|
||||
IS_FIRST_RUN = true;
|
||||
}
|
||||
else {
|
||||
for (const property in defaults) {
|
||||
if (!(property in loaded)) {
|
||||
loaded[property] = defaults[property];
|
||||
hasChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const root = {
|
||||
onSettingsChanged(callback) {
|
||||
@@ -65,11 +54,10 @@ const SETTINGS = (function() {
|
||||
};
|
||||
|
||||
defineTriggeringProperty(root, "autoscroll", loaded._autoscroll);
|
||||
defineTriggeringProperty(root, "hidePreviewsWhileAutoscrolling", loaded._hidePreviewsWhileAutoscrolling);
|
||||
defineTriggeringProperty(root, "afterFirstMsg", loaded._afterFirstMsg);
|
||||
defineTriggeringProperty(root, "afterSavedMsg", loaded._afterSavedMsg);
|
||||
|
||||
if (IS_FIRST_RUN || hasChanged) {
|
||||
if (IS_FIRST_RUN) {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
|
@@ -48,49 +48,69 @@ const STATE = (function() {
|
||||
});
|
||||
};
|
||||
|
||||
const getDate = function(date) {
|
||||
if (date instanceof Date) {
|
||||
return date;
|
||||
}
|
||||
else {
|
||||
// noinspection JSUnresolvedReference
|
||||
return date.toDate();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {DiscordChannel} channel
|
||||
*/
|
||||
const getPrivateChannelName = function(channel) {
|
||||
if (channel.name === "") {
|
||||
return channel.rawRecipients.map(user => user.username).join(", ");
|
||||
}
|
||||
else {
|
||||
return channel.name;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Number} type
|
||||
*/
|
||||
const getChannelTypeName = function(type) {
|
||||
if (type === DISCORD.CHANNEL_TYPE.DM) {
|
||||
return "DM";
|
||||
}
|
||||
else if (type === DISCORD.CHANNEL_TYPE.GROUP_DM) {
|
||||
return "GROUP";
|
||||
}
|
||||
else {
|
||||
return "SERVER";
|
||||
}
|
||||
};
|
||||
|
||||
const trackingStateChangedListeners = [];
|
||||
let isTracking = false;
|
||||
|
||||
const addedChannels = new Set();
|
||||
const addedUsers = new Set();
|
||||
|
||||
/**
|
||||
* @name DiscordUser
|
||||
* @property {String} id
|
||||
* @property {String} username
|
||||
* @property {String} discriminator
|
||||
* @property {String} [avatar]
|
||||
* @property {Boolean} [bot]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessage
|
||||
* @property {String} id
|
||||
* @property {String} channel_id
|
||||
* @property {DiscordUser} author
|
||||
* @property {String} content
|
||||
* @property {Timestamp} timestamp
|
||||
* @property {Timestamp|null} editedTimestamp
|
||||
* @property {DiscordAttachment[]} attachments
|
||||
* @property {Object[]} embeds
|
||||
* @property {DiscordMessageReaction[]} [reactions]
|
||||
* @property {DiscordMessageReference} [messageReference]
|
||||
* @property {Number} type
|
||||
* @property {String} state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordAttachment
|
||||
* @property {String} id
|
||||
* @property {String} filename
|
||||
* @property {String} [content_type]
|
||||
* @property {String} size
|
||||
* @property {String} url
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessageReaction
|
||||
* @property {DiscordEmoji} emoji
|
||||
* @property {Number} count
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessageReference
|
||||
* @property {String} [message_id]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordEmoji
|
||||
* @property {String|null} id
|
||||
* @property {String|null} name
|
||||
* @property {Boolean} animated
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name Timestamp
|
||||
* @property {Function} toDate
|
||||
*/
|
||||
|
||||
return {
|
||||
setup(port, token) {
|
||||
serverPort = port;
|
||||
@@ -121,51 +141,32 @@ const STATE = (function() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {?DiscordGuild} serverInfo
|
||||
* @param {DiscordChannel} channelInfo
|
||||
*/
|
||||
async addDiscordChannel(serverInfo, channelInfo) {
|
||||
if (addedChannels.has(channelInfo.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const server = {
|
||||
type: getChannelTypeName(channelInfo.type)
|
||||
id: serverInfo.id,
|
||||
name: serverInfo.name,
|
||||
type: serverInfo.type
|
||||
};
|
||||
|
||||
const channel = {
|
||||
id: channelInfo.id,
|
||||
extra: {}
|
||||
name: channelInfo.name
|
||||
};
|
||||
|
||||
if (DISCORD.CHANNEL_TYPE.isPrivate(channelInfo.type)) {
|
||||
server.id = channelInfo.id;
|
||||
server.name = channel.name = getPrivateChannelName(channelInfo);
|
||||
}
|
||||
else if (serverInfo) {
|
||||
server.id = serverInfo.id;
|
||||
server.name = serverInfo.name;
|
||||
channel.name = channelInfo.name;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("nsfw" in channelInfo) {
|
||||
channel.extra.nsfw = channelInfo.nsfw;
|
||||
}
|
||||
|
||||
if ("topic" in channelInfo) {
|
||||
channel.extra.topic = channelInfo.topic;
|
||||
}
|
||||
|
||||
if ("position" in channelInfo) {
|
||||
channel.extra.position = channelInfo.position;
|
||||
}
|
||||
|
||||
if (channelInfo.type === DISCORD.CHANNEL_TYPE.ANNOUNCEMENT_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PUBLIC_THREAD || channelInfo.type === DISCORD.CHANNEL_TYPE.PRIVATE_THREAD) {
|
||||
channel.extra.parent = channelInfo.parent_id;
|
||||
if ("extra" in channelInfo) {
|
||||
const extra = channelInfo.extra;
|
||||
|
||||
if ("parent" in extra) {
|
||||
channel.parent = extra.parent;
|
||||
}
|
||||
|
||||
channel.position = extra.position;
|
||||
channel.topic = extra.topic;
|
||||
channel.nsfw = extra.nsfw;
|
||||
}
|
||||
|
||||
await post("/track-channel", { server, channel });
|
||||
@@ -194,10 +195,6 @@ const STATE = (function() {
|
||||
name: user.username
|
||||
};
|
||||
|
||||
if (user.globalName) {
|
||||
obj.displayName = user.globalName;
|
||||
}
|
||||
|
||||
if (user.avatar) {
|
||||
obj.avatar = user.avatar;
|
||||
}
|
||||
@@ -226,12 +223,12 @@ const STATE = (function() {
|
||||
sender: msg.author.id,
|
||||
channel: msg.channel_id,
|
||||
text: msg.content,
|
||||
timestamp: getDate(msg.timestamp).getTime()
|
||||
timestamp: msg.timestamp.toDate().getTime()
|
||||
};
|
||||
|
||||
if (msg.editedTimestamp !== null) {
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
obj.editTimestamp = getDate(msg.editedTimestamp).getTime();
|
||||
obj.editTimestamp = msg.editedTimestamp.toDate().getTime();
|
||||
}
|
||||
|
||||
if (msg.messageReference !== null) {
|
||||
@@ -286,7 +283,7 @@ const STATE = (function() {
|
||||
}
|
||||
|
||||
if (msg.reactions.length > 0) {
|
||||
obj.reactions = msg.reactions.filter(reaction => reaction.count > 0).map(reaction => {
|
||||
obj.reactions = msg.reactions.map(reaction => {
|
||||
const emoji = reaction.emoji;
|
||||
|
||||
const mapped = {
|
||||
|
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* @name DiscordGuild
|
||||
* @property {String} id
|
||||
* @property {String} name
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordChannel
|
||||
* @property {String} id
|
||||
* @property {String} name
|
||||
* @property {Number} type
|
||||
* @property {String} [guild_id]
|
||||
* @property {String} [parent_id]
|
||||
* @property {Number} [position]
|
||||
* @property {String} [topic]
|
||||
* @property {Boolean} [nsfw]
|
||||
* @property {DiscordUser[]} [rawRecipients]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordUser
|
||||
* @property {String} id
|
||||
* @property {String} username
|
||||
* @property {String} discriminator
|
||||
* @property {String} [globalName]
|
||||
* @property {String} [avatar]
|
||||
* @property {Boolean} [bot]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessage
|
||||
* @property {String} id
|
||||
* @property {String} channel_id
|
||||
* @property {DiscordUser} author
|
||||
* @property {String} content
|
||||
* @property {Date} timestamp
|
||||
* @property {Date|null} editedTimestamp
|
||||
* @property {DiscordAttachment[]} attachments
|
||||
* @property {Object[]} embeds
|
||||
* @property {DiscordMessageReaction[]} [reactions]
|
||||
* @property {DiscordMessageReference} [messageReference]
|
||||
* @property {Number} type
|
||||
* @property {String} state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordAttachment
|
||||
* @property {String} id
|
||||
* @property {String} filename
|
||||
* @property {String} [content_type]
|
||||
* @property {String} size
|
||||
* @property {String} url
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessageReaction
|
||||
* @property {DiscordEmoji} emoji
|
||||
* @property {Number} count
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordMessageReference
|
||||
* @property {String} [message_id]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name DiscordEmoji
|
||||
* @property {String|null} id
|
||||
* @property {String|null} name
|
||||
* @property {Boolean} animated
|
||||
*/
|
||||
|
||||
/**
|
||||
* @name MessageData
|
||||
* @type {Object}
|
||||
* @property {String} channelId
|
||||
* @property {Boolean} ready
|
||||
* @property {Boolean} loadingMore
|
||||
* @property {Boolean} hasMoreAfter
|
||||
* @property {Boolean} hasMoreBefore
|
||||
* @property {Array<DiscordMessage>} _array
|
||||
*/
|
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Parts copied from Better Discord, licensed under Apache License 2.0.
|
||||
*
|
||||
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/renderer/src/modules/webpackmodules.js
|
||||
* https://github.com/BetterDiscord/BetterDiscord/blob/78edeb77c60542a57884686c4ba98f997c886fad/LICENSE
|
||||
*/
|
||||
class WEBPACK {
|
||||
static get require() {
|
||||
if (this._require) {
|
||||
return this._require;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property {Object} m
|
||||
* @property {Object} c
|
||||
*/
|
||||
let hookedRequire;
|
||||
|
||||
const id = "dht-webpackmodules-" + new Date().getTime();
|
||||
if (typeof (window["webpackChunkdiscord_app"]) !== "undefined") {
|
||||
window["webpackChunkdiscord_app"].push([ [ id ], {}, internalRequire => hookedRequire = internalRequire ]);
|
||||
}
|
||||
|
||||
delete hookedRequire.m[id];
|
||||
delete hookedRequire.c[id];
|
||||
return this._require = hookedRequire;
|
||||
}
|
||||
|
||||
static getAllModules() {
|
||||
return this.require.c;
|
||||
}
|
||||
|
||||
static filterByProps(...props) {
|
||||
return module => props.every(prop => prop in module);
|
||||
}
|
||||
|
||||
static filterByPropsWithPredicate(predicate, ...props) {
|
||||
return module => props.every(prop => prop in module && predicate(module[prop]));
|
||||
}
|
||||
|
||||
static findModules(filter) {
|
||||
const defaultExport = true;
|
||||
const moduleFilter = module => (typeof module === "object" || typeof module === "function") && filter(module);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const module of Object.values(this.getAllModules())) {
|
||||
/**
|
||||
* @type {Object}
|
||||
* @property [Z]
|
||||
* @property [ZP]
|
||||
* @property [__esModule]
|
||||
* @property [default]
|
||||
*/
|
||||
const exports = module.exports;
|
||||
if (!exports || exports === window || exports === document.documentElement || exports[Symbol.toStringTag] === "DOMTokenList") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let foundModule = null;
|
||||
if (exports.Z && moduleFilter(exports.Z)) {
|
||||
foundModule = defaultExport ? exports.Z : exports;
|
||||
}
|
||||
if (exports.ZP && moduleFilter(exports.ZP)) {
|
||||
foundModule = defaultExport ? exports.ZP : exports;
|
||||
}
|
||||
if (exports.__esModule && exports.default && moduleFilter(exports.default)) {
|
||||
foundModule = defaultExport ? exports.default : exports;
|
||||
}
|
||||
if (moduleFilter(exports)) {
|
||||
foundModule = exports;
|
||||
}
|
||||
|
||||
if (foundModule) {
|
||||
results.push(foundModule);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
static findModule(name, filter) {
|
||||
const modules = this.findModules(filter);
|
||||
if (modules.length === 1) {
|
||||
return modules[0];
|
||||
}
|
||||
|
||||
console.error("[DHT] Cannot find module " + name + ", results found:", modules.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
static findFunction(name, additionalRequiredProps) {
|
||||
const searchedProps = additionalRequiredProps ? [name, ...additionalRequiredProps] : [name];
|
||||
const matchingModule = this.findModule("containing function " + name, this.filterByPropsWithPredicate(prop => typeof(prop) === "function", ...searchedProps));
|
||||
return matchingModule == null ? null : matchingModule[name].bind(matchingModule);
|
||||
}
|
||||
}
|
@@ -18,13 +18,11 @@
|
||||
height: 262px;
|
||||
margin-left: -400px;
|
||||
margin-top: -131px;
|
||||
line-height: 120%;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
z-index: 1000002;
|
||||
}
|
||||
|
||||
#dht-cfg label {
|
||||
display: inline-block;
|
||||
margin: 1px 0;
|
||||
#dht-cfg-note {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
@@ -2,31 +2,22 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
|
||||
<title>Discord Offline History</title>
|
||||
|
||||
<link rel="icon" href="favicon.ico">
|
||||
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
<link rel="stylesheet" href="styles/menu.css">
|
||||
<link rel="stylesheet" href="styles/channels.css">
|
||||
<link rel="stylesheet" href="styles/messages.css">
|
||||
<link rel="stylesheet" href="styles/modal.css">
|
||||
|
||||
<script type="text/javascript">
|
||||
const query = new URLSearchParams(location.search);
|
||||
window.DHT_SERVER_TOKEN = query.get("token");
|
||||
window.DHT_SERVER_SESSION = query.get("session");
|
||||
window.DHT_EMBEDDED = "/*[ARCHIVE]*/";
|
||||
window.DHT_SERVER_URL = "/*[SERVER_URL]*/";
|
||||
window.DHT_SERVER_TOKEN = "/*[SERVER_TOKEN]*/";
|
||||
/*[JS]*/
|
||||
</script>
|
||||
<script type="module" src="scripts/bootstrap.mjs"></script>
|
||||
<style>
|
||||
/*[CSS]*/
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="menu">
|
||||
<button id="btn-settings">Settings</button>
|
||||
|
||||
<div class="splitter"></div>
|
||||
|
||||
<div> <!-- needed to stop the select from messing up -->
|
||||
<select id="opt-messages-per-page">
|
||||
<option value="50">50 messages per page </option>
|
||||
@@ -75,9 +66,7 @@
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<div id="channels">
|
||||
<div class="loading"></div>
|
||||
</div>
|
||||
<div id="channels"></div>
|
||||
<div id="messages"></div>
|
||||
</div>
|
||||
|
||||
|
39
app/Resources/Viewer/scripts/bootstrap.js
vendored
Normal file
39
app/Resources/Viewer/scripts/bootstrap.js
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
DISCORD.setup();
|
||||
GUI.setup();
|
||||
|
||||
GUI.onOptionMessagesPerPageChanged(() => {
|
||||
STATE.setMessagesPerPage(GUI.getOptionMessagesPerPage());
|
||||
});
|
||||
|
||||
STATE.setMessagesPerPage(GUI.getOptionMessagesPerPage());
|
||||
|
||||
GUI.onOptMessageFilterChanged(filter => {
|
||||
STATE.setActiveFilter(filter);
|
||||
});
|
||||
|
||||
GUI.onNavigationButtonClicked(action => {
|
||||
STATE.updateCurrentPage(action);
|
||||
});
|
||||
|
||||
STATE.onUsersRefreshed(users => {
|
||||
GUI.updateUserList(users);
|
||||
});
|
||||
|
||||
STATE.onChannelsRefreshed((channels, selected) => {
|
||||
GUI.updateChannelList(channels, selected, STATE.selectChannel);
|
||||
});
|
||||
|
||||
STATE.onMessagesRefreshed(messages => {
|
||||
GUI.updateNavigation(STATE.getCurrentPage(), STATE.getPageCount());
|
||||
GUI.updateMessageList(messages);
|
||||
GUI.scrollMessagesToTop();
|
||||
});
|
||||
|
||||
try {
|
||||
STATE.uploadFile(JSON.parse(window.DHT_EMBEDDED));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Could not parse embedded file, see console for details.");
|
||||
}
|
||||
});
|
@@ -1,111 +0,0 @@
|
||||
import discord from "./discord.mjs";
|
||||
import gui from "./gui.mjs";
|
||||
import state from "./state.mjs";
|
||||
import "./polyfills.mjs";
|
||||
|
||||
window.DISCORD = discord;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
discord.setup();
|
||||
gui.setup();
|
||||
|
||||
gui.onOptionMessagesPerPageChanged(() => {
|
||||
state.setMessagesPerPage(gui.getOptionMessagesPerPage());
|
||||
});
|
||||
|
||||
state.setMessagesPerPage(gui.getOptionMessagesPerPage());
|
||||
|
||||
gui.onOptMessageFilterChanged(filter => {
|
||||
state.setActiveFilter(filter);
|
||||
});
|
||||
|
||||
gui.onNavigationButtonClicked(action => {
|
||||
state.updateCurrentPage(action);
|
||||
});
|
||||
|
||||
state.onUsersRefreshed(users => {
|
||||
gui.updateUserList(users);
|
||||
});
|
||||
|
||||
state.onChannelsRefreshed((channels, selected) => {
|
||||
gui.updateChannelList(channels, selected, state.selectChannel);
|
||||
});
|
||||
|
||||
state.onMessagesRefreshed(messages => {
|
||||
gui.updateNavigation(state.getCurrentPage(), state.getPageCount());
|
||||
gui.updateMessageList(messages);
|
||||
gui.scrollMessagesToTop();
|
||||
});
|
||||
|
||||
async function fetchUrl(path, contentType) {
|
||||
const response = await fetch("/" + path + "?token=" + encodeURIComponent(window.DHT_SERVER_TOKEN) + "&session=" + encodeURIComponent(window.DHT_SERVER_SESSION), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw "Unexpected response status: " + response.statusText;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function processLines(response, callback) {
|
||||
let body = "";
|
||||
|
||||
for await (const chunk of response.body.pipeThrough(new TextDecoderStream("utf-8"))) {
|
||||
body += chunk;
|
||||
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const endIndex = body.indexOf("\n", startIndex);
|
||||
if (endIndex === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
callback(body.substring(startIndex, endIndex));
|
||||
startIndex = endIndex + 1;
|
||||
}
|
||||
|
||||
body = body.substring(startIndex);
|
||||
}
|
||||
|
||||
if (body !== "") {
|
||||
callback(body);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const metadataResponse = await fetchUrl("get-viewer-metadata", "application/json");
|
||||
const metadataJson = await metadataResponse.json();
|
||||
|
||||
const messagesResponse = await fetchUrl("get-viewer-messages", "application/x-ndjson");
|
||||
const messages = {};
|
||||
|
||||
await processLines(messagesResponse, line => {
|
||||
const message = JSON.parse(line);
|
||||
const channel = message.c;
|
||||
|
||||
const channelMessages = messages[channel] || (messages[channel] = {});
|
||||
channelMessages[message.id] = message;
|
||||
|
||||
delete message.id;
|
||||
delete message.c;
|
||||
});
|
||||
|
||||
state.uploadFile(metadataJson, messages);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Could not load data, see console for details.");
|
||||
document.querySelector("#channels > div.loading").remove();
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
});
|
@@ -1,10 +1,4 @@
|
||||
import discord from "./discord.mjs";
|
||||
import dom from "./dom.mjs";
|
||||
import template from "./template.mjs";
|
||||
import settings from "./settings.mjs";
|
||||
import state from "./state.mjs";
|
||||
|
||||
export default (function() {
|
||||
const DISCORD = (function() {
|
||||
const regex = {
|
||||
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
||||
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
||||
@@ -41,24 +35,10 @@ export default (function() {
|
||||
let templateReaction;
|
||||
let templateReactionCustom;
|
||||
|
||||
const fileUrlProcessor = function(serverToken) {
|
||||
if (typeof serverToken === "string") {
|
||||
return url => "/get-downloaded-file/" + encodeURIComponent(url) + "?token=" + encodeURIComponent(serverToken);
|
||||
}
|
||||
else {
|
||||
return url => url;
|
||||
}
|
||||
}(window.DHT_SERVER_TOKEN);
|
||||
|
||||
const getEmoji = function(name, id, extension) {
|
||||
const tag = ":" + name + ":";
|
||||
return "<img src='" + fileUrlProcessor("https://cdn.discordapp.com/emojis/" + id + "." + extension) + "' alt='" + tag + "' title='" + tag + "' class='emoji'>";
|
||||
};
|
||||
|
||||
const processMessageContents = function(contents) {
|
||||
let processed = dom.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1"));
|
||||
let processed = DOM.escapeHTML(contents.replace(regex.formatUrlNoEmbed, "$1"));
|
||||
|
||||
if (settings.enableFormatting) {
|
||||
if (SETTINGS.enableFormatting) {
|
||||
const escapeHtmlMatch = (full, match) => "&#" + match.charCodeAt(0) + ";";
|
||||
|
||||
processed = processed
|
||||
@@ -74,33 +54,29 @@ export default (function() {
|
||||
.replace(regex.formatStrike, "<s>$1</s>");
|
||||
}
|
||||
|
||||
const animatedEmojiExtension = settings.enableAnimatedEmoji ? "gif" : "webp";
|
||||
const animatedEmojiExtension = SETTINGS.enableAnimatedEmoji ? "gif" : "png";
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
processed = processed
|
||||
.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
|
||||
.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + state.getChannelName(match) + "</span>")
|
||||
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='" + state.getUserName(match) + "'>@" + state.getUserDisplayName(match) + "</span>")
|
||||
.replace(regex.customEmojiStatic, (full, m1, m2) => getEmoji(m1, m2, "webp"))
|
||||
.replace(regex.customEmojiAnimated, (full, m1, m2) => getEmoji(m1, m2, animatedEmojiExtension));
|
||||
.replace(regex.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + STATE.getChannelName(match) + "</span>")
|
||||
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='#" + (STATE.getUserTag(match) || "????") + "'>@" + STATE.getUserName(match) + "</span>")
|
||||
.replace(regex.customEmojiStatic, "<img src='https://cdn.discordapp.com/emojis/$2.png' alt=':$1:' title=':$1:' class='emoji'>")
|
||||
.replace(regex.customEmojiAnimated, "<img src='https://cdn.discordapp.com/emojis/$2." + animatedEmojiExtension + "' alt=':$1:' title=':$1:' class='emoji'>");
|
||||
|
||||
return "<p>" + processed + "</p>";
|
||||
};
|
||||
|
||||
const getAvatarUrlObject = function(avatar) {
|
||||
return { url: fileUrlProcessor("https://cdn.discordapp.com/avatars/" + avatar.id + "/" + avatar.path + ".webp") };
|
||||
};
|
||||
|
||||
const getImageEmbed = function(url, image) {
|
||||
if (!settings.enableImagePreviews) {
|
||||
if (!SETTINGS.enableImagePreviews) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (image.width && image.height) {
|
||||
return templateEmbedImageWithSize.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url), width: image.width, height: image.height });
|
||||
return templateEmbedImageWithSize.apply({ url, src: image.url, width: image.width, height: image.height });
|
||||
}
|
||||
else {
|
||||
return templateEmbedImage.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url) });
|
||||
return templateEmbedImage.apply({ url, src: image.url });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,36 +88,36 @@ export default (function() {
|
||||
|
||||
return {
|
||||
setup() {
|
||||
templateChannelServer = new template([
|
||||
"<div class='channel' data-channel='{id}'>",
|
||||
templateChannelServer = new TEMPLATE([
|
||||
"<div data-channel='{id}'>",
|
||||
"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>",
|
||||
"<span class='server'>{server.name} ({server.type})</span>",
|
||||
"</div>"
|
||||
].join(""));
|
||||
|
||||
templateChannelPrivate = new template([
|
||||
"<div class='channel' data-channel='{id}'>",
|
||||
templateChannelPrivate = new TEMPLATE([
|
||||
"<div data-channel='{id}'>",
|
||||
"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>",
|
||||
"<span class='server'>({server.type})</span>",
|
||||
"</div>"
|
||||
].join(""));
|
||||
|
||||
templateMessageNoAvatar = new template([
|
||||
templateMessageNoAvatar = new TEMPLATE([
|
||||
"<div>",
|
||||
"<div class='reply-message'>{reply}</div>",
|
||||
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
||||
"{reactions}",
|
||||
"</div>"
|
||||
].join(""));
|
||||
|
||||
templateMessageWithAvatar = new template([
|
||||
templateMessageWithAvatar = new TEMPLATE([
|
||||
"<div>",
|
||||
"<div class='reply-message reply-message-with-avatar'>{reply}</div>",
|
||||
"<div class='avatar-wrapper'>",
|
||||
"<div class='avatar'>{avatar}</div>",
|
||||
"<div>",
|
||||
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
||||
"{reactions}",
|
||||
"</div>",
|
||||
@@ -149,52 +125,50 @@ export default (function() {
|
||||
"</div>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateUserAvatar = new template([
|
||||
"<img src='{url}' alt=''>"
|
||||
templateUserAvatar = new TEMPLATE([
|
||||
"<img src='https://cdn.discordapp.com/avatars/{id}/{path}.webp?size=128' alt=''>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateAttachmentDownload = new template([
|
||||
templateAttachmentDownload = new TEMPLATE([
|
||||
"<a href='{url}' class='embed download'>Download {name}</a>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateEmbedImage = new template([
|
||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>"
|
||||
templateEmbedImage = new TEMPLATE([
|
||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' alt='' onload='DISCORD.handleImageLoad(this)' onerror='DISCORD.handleImageLoadError(this)'></a><br>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateEmbedImageWithSize = new template([
|
||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>"
|
||||
templateEmbedImageWithSize = new TEMPLATE([
|
||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' width='{width}' height='{height}' alt='' onload='DISCORD.handleImageLoad(this)' onerror='DISCORD.handleImageLoadError(this)'></a><br>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateEmbedRich = new template([
|
||||
templateEmbedRich = new TEMPLATE([
|
||||
"<div class='embed download'><a href='{url}' class='title'>{title}</a><p class='desc'>{description}</p></div>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateEmbedRichNoDescription = new template([
|
||||
templateEmbedRichNoDescription = new TEMPLATE([
|
||||
"<div class='embed download'><a href='{url}' class='title'>{title}</a></div>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateEmbedUrl = new template([
|
||||
templateEmbedUrl = new TEMPLATE([
|
||||
"<a href='{url}' class='embed download'>{url}</a>"
|
||||
].join(""));
|
||||
|
||||
templateEmbedUnsupported = new template([
|
||||
templateEmbedUnsupported = new TEMPLATE([
|
||||
"<div class='embed download'><p>(Unsupported embed)</p></div>"
|
||||
].join(""));
|
||||
|
||||
templateReaction = new template([
|
||||
templateReaction = new TEMPLATE([
|
||||
"<span class='reaction-wrapper'><span class='reaction-emoji'>{n}</span><span class='count'>{c}</span></span>"
|
||||
].join(""));
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
templateReactionCustom = new template([
|
||||
"<span class='reaction-wrapper'><img src='{url}' alt=':{n}:' title=':{n}:' class='reaction-emoji-custom'><span class='count'>{c}</span></span>"
|
||||
templateReactionCustom = new TEMPLATE([
|
||||
"<span class='reaction-wrapper'><img src='https://cdn.discordapp.com/emojis/{id}.{ext}' alt=':{n}:' title=':{n}:' class='reaction-emoji-custom'><span class='count'>{c}</span></span>"
|
||||
].join(""));
|
||||
},
|
||||
|
||||
@@ -210,7 +184,7 @@ export default (function() {
|
||||
},
|
||||
|
||||
isImageAttachment(attachment) {
|
||||
const url = dom.tryParseUrl(attachment.url);
|
||||
const url = DOM.tryParseUrl(attachment.url);
|
||||
return url != null && isImageUrl(url);
|
||||
},
|
||||
|
||||
@@ -223,15 +197,15 @@ export default (function() {
|
||||
},
|
||||
|
||||
getMessageHTML(message) { // noinspection FunctionWithInconsistentReturnsJS
|
||||
return (settings.enableUserAvatars ? templateMessageWithAvatar : templateMessageNoAvatar).apply(message, (property, value) => {
|
||||
return (SETTINGS.enableUserAvatars ? templateMessageWithAvatar : templateMessageNoAvatar).apply(message, (property, value) => {
|
||||
if (property === "avatar") {
|
||||
return value ? templateUserAvatar.apply(getAvatarUrlObject(value)) : "";
|
||||
return value ? templateUserAvatar.apply(value) : "";
|
||||
}
|
||||
else if (property === "user.displayName") {
|
||||
return value ? value : message.user.name;
|
||||
else if (property === "user.tag") {
|
||||
return value ? value : "????";
|
||||
}
|
||||
else if (property === "timestamp") {
|
||||
return dom.getHumanReadableTime(value);
|
||||
return DOM.getHumanReadableTime(value);
|
||||
}
|
||||
else if (property === "contents") {
|
||||
return value && value.length > 0 ? processMessageContents(value) : "";
|
||||
@@ -268,32 +242,30 @@ export default (function() {
|
||||
}
|
||||
|
||||
return value.map(attachment => {
|
||||
const url = fileUrlProcessor(attachment.url);
|
||||
|
||||
if (!discord.isImageAttachment(attachment) || !settings.enableImagePreviews) {
|
||||
return templateAttachmentDownload.apply({ url, name: attachment.name });
|
||||
if (!DISCORD.isImageAttachment(attachment) || !SETTINGS.enableImagePreviews) {
|
||||
return templateAttachmentDownload.apply(attachment);
|
||||
}
|
||||
else if ("width" in attachment && "height" in attachment) {
|
||||
return templateEmbedImageWithSize.apply({ url, src: url, width: attachment.width, height: attachment.height });
|
||||
return templateEmbedImageWithSize.apply({ url: attachment.url, src: attachment.url, width: attachment.width, height: attachment.height });
|
||||
}
|
||||
else {
|
||||
return templateEmbedImage.apply({ url, src: url });
|
||||
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
|
||||
}
|
||||
}).join("");
|
||||
}
|
||||
else if (property === "edit") {
|
||||
return value ? "<span class='info edited'>Edited " + dom.getHumanReadableTime(value) + "</span>" : "";
|
||||
return value ? "<span class='info edited'>Edited " + DOM.getHumanReadableTime(value) + "</span>" : "";
|
||||
}
|
||||
else if (property === "jump") {
|
||||
return state.hasActiveFilter ? "<span class='info jump' data-jump='" + value + "'>Jump to message</span>" : "";
|
||||
return STATE.hasActiveFilter ? "<span class='info jump' data-jump='" + value + "'>Jump to message</span>" : "";
|
||||
}
|
||||
else if (property === "reply") {
|
||||
if (!value) {
|
||||
return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : "";
|
||||
}
|
||||
|
||||
const user = "<span class='reply-username' title='" + value.user.name + "'>" + (value.user.displayName ?? value.user.name) + "</span>";
|
||||
const avatar = settings.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(getAvatarUrlObject(value.avatar)) + "</span>" : "";
|
||||
const user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>";
|
||||
const avatar = SETTINGS.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(value.avatar) + "</span>" : "";
|
||||
const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
|
||||
|
||||
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
|
||||
@@ -305,10 +277,9 @@ export default (function() {
|
||||
|
||||
return "<div class='reactions'>" + value.map(reaction => {
|
||||
if ("id" in reaction){
|
||||
const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp";
|
||||
const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
return templateReactionCustom.apply({ url, n: reaction.n, c: reaction.c });
|
||||
// noinspection JSUnusedGlobalSymbols, JSUnresolvedVariable
|
||||
reaction.ext = reaction.a && SETTINGS.enableAnimatedEmoji ? "gif" : "png";
|
||||
return templateReactionCustom.apply(reaction);
|
||||
}
|
||||
else {
|
||||
return templateReaction.apply(reaction);
|
@@ -8,12 +8,9 @@ const HTML_ENTITY_MAP = {
|
||||
|
||||
const HTML_ENTITY_REGEX = /[&<>"']/g;
|
||||
|
||||
export default class {
|
||||
class DOM {
|
||||
/**
|
||||
* Returns a child element by its ID. Parent defaults to the entire document.
|
||||
* @param {string} id
|
||||
* @param {HTMLElement} [parent]
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
static id(id, parent) {
|
||||
return (parent || document).getElementById(id);
|
||||
@@ -21,9 +18,6 @@ export default class {
|
||||
|
||||
/**
|
||||
* Returns an array of all child elements containing the specified class. Parent defaults to the entire document.
|
||||
* @param {string} cls
|
||||
* @param {HTMLElement} [parent]
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
static cls(cls, parent) {
|
||||
return Array.prototype.slice.call((parent || document).getElementsByClassName(cls));
|
||||
@@ -31,9 +25,6 @@ export default class {
|
||||
|
||||
/**
|
||||
* Returns an array of all child elements that have the specified tag. Parent defaults to the entire document.
|
||||
* @param {string} tag
|
||||
* @param {HTMLElement} [parent]
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
static tag(tag, parent) {
|
||||
return Array.prototype.slice.call((parent || document).getElementsByTagName(tag));
|
||||
@@ -41,9 +32,6 @@ export default class {
|
||||
|
||||
/**
|
||||
* Returns the first child element containing the specified class. Parent defaults to the entire document.
|
||||
* @param {string} cls
|
||||
* @param {HTMLElement} [parent]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
static fcls(cls, parent) {
|
||||
return (parent || document).getElementsByClassName(cls)[0];
|
@@ -1,16 +1,10 @@
|
||||
import dom from "./dom.mjs";
|
||||
import discord from "./discord.mjs";
|
||||
import settings from "./settings.mjs";
|
||||
import state from "./state.mjs";
|
||||
|
||||
export default (function() {
|
||||
const GUI = (function() {
|
||||
let eventOnOptMessagesPerPageChanged;
|
||||
let eventOnOptMessageFilterChanged;
|
||||
let eventOnNavButtonClicked;
|
||||
|
||||
const getActiveFilter = function() {
|
||||
/** @type HTMLSelectElement */
|
||||
const active = dom.fcls("active", dom.id("opt-filter-list"));
|
||||
const active = DOM.fcls("active", DOM.id("opt-filter-list"));
|
||||
|
||||
return active && active.value !== "" ? {
|
||||
"type": active.getAttribute("data-filter-type"),
|
||||
@@ -23,12 +17,12 @@ export default (function() {
|
||||
};
|
||||
|
||||
const showModal = function(width, html) {
|
||||
const dialog = dom.id("dialog");
|
||||
const dialog = DOM.id("dialog");
|
||||
dialog.innerHTML = html;
|
||||
dialog.style.width = width + "px";
|
||||
dialog.style.marginLeft = (-width / 2) + "px";
|
||||
|
||||
dom.id("modal").classList.add("visible");
|
||||
DOM.id("modal").classList.add("visible");
|
||||
return dialog;
|
||||
};
|
||||
|
||||
@@ -44,9 +38,9 @@ export default (function() {
|
||||
<label><input id='dht-cfg-animemoji' type='checkbox'> Animated Emoji</label><br>`);
|
||||
|
||||
const setupCheckBox = function(id, settingName) {
|
||||
const ele = dom.id(id);
|
||||
ele.checked = settings[settingName];
|
||||
ele.addEventListener("change", () => settings[settingName] = ele.checked);
|
||||
const ele = DOM.id(id);
|
||||
ele.checked = SETTINGS[settingName];
|
||||
ele.addEventListener("change", () => SETTINGS[settingName] = ele.checked);
|
||||
};
|
||||
|
||||
setupCheckBox("dht-cfg-imgpreviews", "enableImagePreviews");
|
||||
@@ -71,20 +65,20 @@ export default (function() {
|
||||
// ---------
|
||||
|
||||
setup() {
|
||||
const inputMessageFilter = dom.id("opt-messages-filter");
|
||||
const containerFilterList = dom.id("opt-filter-list");
|
||||
const inputMessageFilter = DOM.id("opt-messages-filter");
|
||||
const containerFilterList = DOM.id("opt-filter-list");
|
||||
|
||||
const resetActiveFilter = function() {
|
||||
inputMessageFilter.value = "";
|
||||
inputMessageFilter.dispatchEvent(new Event("change"));
|
||||
|
||||
dom.id("opt-filter-contents").value = "";
|
||||
DOM.id("opt-filter-contents").value = "";
|
||||
};
|
||||
|
||||
inputMessageFilter.value = ""; // required to prevent browsers from remembering old value
|
||||
|
||||
inputMessageFilter.addEventListener("change", () => {
|
||||
dom.cls("active", containerFilterList).forEach(ele => ele.classList.remove("active"));
|
||||
DOM.cls("active", containerFilterList).forEach(ele => ele.classList.remove("active"));
|
||||
|
||||
if (inputMessageFilter.value) {
|
||||
containerFilterList.querySelector("[data-filter-type='" + inputMessageFilter.value + "']").classList.add("active");
|
||||
@@ -97,11 +91,11 @@ export default (function() {
|
||||
ele.addEventListener(ele.tagName === "SELECT" ? "change" : "input", () => triggerFilterChanged());
|
||||
});
|
||||
|
||||
dom.id("opt-messages-per-page").addEventListener("change", () => {
|
||||
DOM.id("opt-messages-per-page").addEventListener("change", () => {
|
||||
eventOnOptMessagesPerPageChanged && eventOnOptMessagesPerPageChanged();
|
||||
});
|
||||
|
||||
dom.tag("button", dom.fcls("nav")).forEach(button => {
|
||||
DOM.tag("button", DOM.fcls("nav")).forEach(button => {
|
||||
button.disabled = true;
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
@@ -109,34 +103,34 @@ export default (function() {
|
||||
});
|
||||
});
|
||||
|
||||
dom.id("btn-settings").addEventListener("click", () => {
|
||||
DOM.id("btn-settings").addEventListener("click", () => {
|
||||
showSettingsModal();
|
||||
});
|
||||
|
||||
dom.id("btn-about").addEventListener("click", () => {
|
||||
DOM.id("btn-about").addEventListener("click", () => {
|
||||
showInfoModal();
|
||||
});
|
||||
|
||||
dom.id("messages").addEventListener("click", e => {
|
||||
DOM.id("messages").addEventListener("click", e => {
|
||||
const jump = e.target.getAttribute("data-jump");
|
||||
|
||||
if (jump) {
|
||||
resetActiveFilter();
|
||||
|
||||
const index = state.navigateToMessage(jump);
|
||||
const index = STATE.navigateToMessage(jump);
|
||||
|
||||
if (index === -1) {
|
||||
alert("Message not found.");
|
||||
}
|
||||
else {
|
||||
dom.id("messages").children[index].scrollIntoView();
|
||||
DOM.id("messages").children[index].scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dom.id("overlay").addEventListener("click", () => {
|
||||
dom.id("modal").classList.remove("visible");
|
||||
dom.id("dialog").innerHTML = "";
|
||||
DOM.id("overlay").addEventListener("click", () => {
|
||||
DOM.id("modal").classList.remove("visible");
|
||||
DOM.id("dialog").innerHTML = "";
|
||||
});
|
||||
},
|
||||
|
||||
@@ -174,19 +168,19 @@ export default (function() {
|
||||
*/
|
||||
getOptionMessagesPerPage() {
|
||||
/** @type HTMLInputElement */
|
||||
const messagesPerPage = dom.id("opt-messages-per-page");
|
||||
const messagesPerPage = DOM.id("opt-messages-per-page");
|
||||
return parseInt(messagesPerPage.value, 10);
|
||||
},
|
||||
|
||||
updateNavigation(currentPage, totalPages) {
|
||||
dom.id("nav-page-current").innerHTML = currentPage;
|
||||
dom.id("nav-page-total").innerHTML = totalPages || "?";
|
||||
DOM.id("nav-page-current").innerHTML = currentPage;
|
||||
DOM.id("nav-page-total").innerHTML = totalPages || "?";
|
||||
|
||||
dom.id("nav-first").disabled = currentPage === 1;
|
||||
dom.id("nav-prev").disabled = currentPage === 1;
|
||||
dom.id("nav-pick").disabled = (totalPages || 0) <= 1;
|
||||
dom.id("nav-next").disabled = currentPage === (totalPages || 1);
|
||||
dom.id("nav-last").disabled = currentPage === (totalPages || 1);
|
||||
DOM.id("nav-first").disabled = currentPage === 1;
|
||||
DOM.id("nav-prev").disabled = currentPage === 1;
|
||||
DOM.id("nav-pick").disabled = (totalPages || 0) <= 1;
|
||||
DOM.id("nav-next").disabled = currentPage === (totalPages || 1);
|
||||
DOM.id("nav-last").disabled = currentPage === (totalPages || 1);
|
||||
},
|
||||
|
||||
// --------------
|
||||
@@ -197,7 +191,7 @@ export default (function() {
|
||||
* Updates the channel list and sets up their click events. The callback is triggered whenever a channel is selected, and takes the channel ID as its argument.
|
||||
*/
|
||||
updateChannelList(channels, selected, callback) {
|
||||
const eleChannels = dom.id("channels");
|
||||
const eleChannels = DOM.id("channels");
|
||||
|
||||
if (!channels) {
|
||||
eleChannels.innerHTML = "";
|
||||
@@ -207,11 +201,11 @@ export default (function() {
|
||||
channels = channels.filter(channel => channel.msgcount > 0);
|
||||
}
|
||||
|
||||
eleChannels.innerHTML = channels.map(channel => discord.getChannelHTML(channel)).join("");
|
||||
eleChannels.innerHTML = channels.map(channel => DISCORD.getChannelHTML(channel)).join("");
|
||||
|
||||
Array.prototype.forEach.call(eleChannels.children, ele => {
|
||||
ele.addEventListener("click", () => {
|
||||
const currentChannel = dom.fcls("active", eleChannels);
|
||||
const currentChannel = DOM.fcls("active", eleChannels);
|
||||
|
||||
if (currentChannel) {
|
||||
currentChannel.classList.remove("active");
|
||||
@@ -230,12 +224,12 @@ export default (function() {
|
||||
},
|
||||
|
||||
updateMessageList(messages) {
|
||||
dom.id("messages").innerHTML = messages ? messages.map(message => discord.getMessageHTML(message)).join("") : "";
|
||||
DOM.id("messages").innerHTML = messages ? messages.map(message => DISCORD.getMessageHTML(message)).join("") : "";
|
||||
},
|
||||
|
||||
updateUserList(users) {
|
||||
/** @type HTMLSelectElement */
|
||||
const eleSelect = dom.id("opt-filter-user");
|
||||
const eleSelect = DOM.id("opt-filter-user");
|
||||
|
||||
while (eleSelect.length > 1) {
|
||||
eleSelect.remove(1);
|
||||
@@ -243,11 +237,10 @@ export default (function() {
|
||||
|
||||
const options = [];
|
||||
|
||||
for (const id of Object.keys(users)) {
|
||||
const user = users[id];
|
||||
for (const key of Object.keys(users)) {
|
||||
const option = document.createElement("option");
|
||||
option.value = id;
|
||||
option.text = user.displayName ? `${user.displayName} (${user.name})` : user.name;
|
||||
option.value = key;
|
||||
option.text = users[key].name;
|
||||
options.push(option);
|
||||
}
|
||||
|
||||
@@ -256,7 +249,7 @@ export default (function() {
|
||||
},
|
||||
|
||||
scrollMessagesToTop() {
|
||||
dom.id("messages").scrollTop = 0;
|
||||
DOM.id("messages").scrollTop = 0;
|
||||
}
|
||||
};
|
||||
})();
|
@@ -1,35 +0,0 @@
|
||||
// https://gist.github.com/MattiasBuelens/496fc1d37adb50a733edd43853f2f60e/088f061ab79b296f29225467ae9ba86ff990195d
|
||||
|
||||
ReadableStream.prototype.values ??= function({ preventCancel = false } = {}) {
|
||||
const reader = this.getReader();
|
||||
return {
|
||||
async next() {
|
||||
try {
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
reader.releaseLock();
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async return(value) {
|
||||
if (!preventCancel) {
|
||||
const cancelPromise = reader.cancel(value);
|
||||
reader.releaseLock();
|
||||
await cancelPromise;
|
||||
}
|
||||
else {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return { done: true, value };
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
ReadableStream.prototype[Symbol.asyncIterator] ??= ReadableStream.prototype.values;
|
@@ -1,16 +1,16 @@
|
||||
import discord from "./discord.mjs";
|
||||
const PROCESSOR = {};
|
||||
|
||||
// ------------------------
|
||||
// Global filter generators
|
||||
// ------------------------
|
||||
|
||||
const filter = {
|
||||
byUser: ((user) => message => message.u === user),
|
||||
PROCESSOR.FILTER = {
|
||||
byUser: ((userindex) => message => message.u === userindex),
|
||||
byTime: ((timeStart, timeEnd) => message => message.t >= timeStart && message.t <= timeEnd),
|
||||
byContents: ((substr) => message => ("m" in message ? message.m : "").indexOf(substr) !== -1),
|
||||
byRegex: ((regex) => message => regex.test("m" in message ? message.m : "")),
|
||||
withImages: (() => message => (message.e && message.e.some(embed => embed.type === "image")) || (message.a && message.a.some(discord.isImageAttachment))),
|
||||
withDownloads: (() => message => message.a && message.a.some(attachment => !discord.isImageAttachment(attachment))),
|
||||
withImages: (() => message => (message.e && message.e.some(embed => embed.type === "image")) || (message.a && message.a.some(DISCORD.isImageAttachment))),
|
||||
withDownloads: (() => message => message.a && message.a.some(attachment => !DISCORD.isImageAttachment(attachment))),
|
||||
withEmbeds: (() => message => message.e && message.e.length > 0),
|
||||
withAttachments: (() => message => message.a && message.a.length > 0),
|
||||
isEdited: (() => message => ("te" in message) ? message.te : (message.f & 1) === 1)
|
||||
@@ -20,7 +20,7 @@ const filter = {
|
||||
// Global sorters
|
||||
// --------------
|
||||
|
||||
const sorter = {
|
||||
PROCESSOR.SORTER = {
|
||||
oldestToNewest: (key1, key2) => {
|
||||
if (key1.length === key2.length) {
|
||||
return key1 > key2 ? 1 : key1 < key2 ? -1 : 0;
|
||||
@@ -39,8 +39,3 @@ const sorter = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
FILTER: filter,
|
||||
SORTER: sorter
|
||||
};
|
@@ -1,4 +1,4 @@
|
||||
export default (function() {
|
||||
const SETTINGS = (function() {
|
||||
/**
|
||||
* @type {{}}
|
||||
* @property {Function} onSettingsChanged
|
@@ -1,12 +1,10 @@
|
||||
import settings from "./settings.mjs";
|
||||
import processor from "./processor.mjs";
|
||||
|
||||
// noinspection FunctionWithInconsistentReturnsJS
|
||||
export default (function() {
|
||||
const STATE = (function() {
|
||||
/**
|
||||
* @type {{}}
|
||||
* @property {{}} users
|
||||
* @property {{}} servers
|
||||
* @property {String[]} userindex
|
||||
* @property {{}[]} servers
|
||||
* @property {{}} channels
|
||||
*/
|
||||
let loadedFileMeta;
|
||||
@@ -19,16 +17,20 @@ export default (function() {
|
||||
let currentPage;
|
||||
let messagesPerPage;
|
||||
|
||||
const getUser = function(id) {
|
||||
return loadedFileMeta.users[id] || { "name": "<unknown>" };
|
||||
const getUser = function(index) {
|
||||
return loadedFileMeta.users[loadedFileMeta.userindex[index]] || { "name": "<unknown>" };
|
||||
};
|
||||
|
||||
const getUserId = function(index) {
|
||||
return loadedFileMeta.userindex[index];
|
||||
};
|
||||
|
||||
const getUserList = function() {
|
||||
return loadedFileMeta ? loadedFileMeta.users : [];
|
||||
};
|
||||
|
||||
const getServer = function(id) {
|
||||
return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" };
|
||||
const getServer = function(index) {
|
||||
return loadedFileMeta.servers[index] || { "name": "<unknown>", "type": "unknown" };
|
||||
};
|
||||
|
||||
const generateChannelHierarchy = function() {
|
||||
@@ -171,7 +173,7 @@ export default (function() {
|
||||
};
|
||||
|
||||
const getMessageChannel = function(id) {
|
||||
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
|
||||
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
|
||||
if (id in messages) {
|
||||
return channel;
|
||||
}
|
||||
@@ -180,15 +182,32 @@ export default (function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
const getMessageList = function() {
|
||||
const getMessageList = async function(abortSignal) {
|
||||
if (!loadedMessages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const messages = getMessages(selectedChannel);
|
||||
const startIndex = messagesPerPage * (root.getCurrentPage() - 1);
|
||||
const slicedMessages = loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage);
|
||||
|
||||
return loadedMessages.slice(startIndex, !messagesPerPage ? undefined : startIndex + messagesPerPage).map(key => {
|
||||
let messageTexts = null;
|
||||
|
||||
if (window.DHT_SERVER_URL !== null) {
|
||||
const messageIds = new Set(slicedMessages);
|
||||
|
||||
for (const key of slicedMessages) {
|
||||
const message = messages[key];
|
||||
|
||||
if ("r" in message) {
|
||||
messageIds.add(message.r);
|
||||
}
|
||||
}
|
||||
|
||||
messageTexts = await getMessageTextsFromServer(messageIds, abortSignal);
|
||||
}
|
||||
|
||||
return slicedMessages.map(key => {
|
||||
/**
|
||||
* @type {{}}
|
||||
* @property {Number} u
|
||||
@@ -202,7 +221,7 @@ export default (function() {
|
||||
*/
|
||||
const message = messages[key];
|
||||
const user = getUser(message.u);
|
||||
const avatar = user.avatar ? { id: message.u, path: user.avatar } : null;
|
||||
const avatar = user.avatar ? { id: getUserId(message.u), path: user.avatar } : null;
|
||||
|
||||
const obj = {
|
||||
user,
|
||||
@@ -214,6 +233,9 @@ export default (function() {
|
||||
if ("m" in message) {
|
||||
obj["contents"] = message.m;
|
||||
}
|
||||
else if (messageTexts && key in messageTexts) {
|
||||
obj["contents"] = messageTexts[key];
|
||||
}
|
||||
|
||||
if ("e" in message) {
|
||||
obj["embeds"] = message.e.map(embed => JSON.parse(embed));
|
||||
@@ -228,15 +250,16 @@ export default (function() {
|
||||
}
|
||||
|
||||
if ("r" in message) {
|
||||
const replyMessage = getMessageById(message.r);
|
||||
const replyId = message.r;
|
||||
const replyMessage = getMessageById(replyId);
|
||||
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
|
||||
const replyAvatar = replyUser && replyUser.avatar ? { id: replyMessage.u, path: replyUser.avatar } : null;
|
||||
const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(replyMessage.u), path: replyUser.avatar } : null;
|
||||
|
||||
obj["reply"] = replyMessage ? {
|
||||
"id": message.r,
|
||||
"id": replyId,
|
||||
"user": replyUser,
|
||||
"avatar": replyAvatar,
|
||||
"contents": replyMessage.m
|
||||
"contents": messageTexts != null && replyId in messageTexts ? messageTexts[replyId] : replyMessage.m,
|
||||
} : null;
|
||||
}
|
||||
|
||||
@@ -248,9 +271,35 @@ export default (function() {
|
||||
});
|
||||
};
|
||||
|
||||
const getMessageTextsFromServer = async function(messageIds, abortSignal) {
|
||||
let idParams = "";
|
||||
|
||||
for (const messageId of messageIds) {
|
||||
idParams += "id=" + encodeURIComponent(messageId) + "&";
|
||||
}
|
||||
|
||||
const response = await fetch(DHT_SERVER_URL + "/get-messages?" + idParams + "token=" + encodeURIComponent(DHT_SERVER_TOKEN), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal: abortSignal
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
return response.json();
|
||||
}
|
||||
else {
|
||||
throw new Error("Server returned status " + response.status + " " + response.statusText);
|
||||
}
|
||||
};
|
||||
|
||||
let eventOnUsersRefreshed;
|
||||
let eventOnChannelsRefreshed;
|
||||
let eventOnMessagesRefreshed;
|
||||
let messageLoaderAborter = null;
|
||||
|
||||
const triggerUsersRefreshed = function() {
|
||||
eventOnUsersRefreshed && eventOnUsersRefreshed(getUserList());
|
||||
@@ -261,7 +310,22 @@ export default (function() {
|
||||
};
|
||||
|
||||
const triggerMessagesRefreshed = function() {
|
||||
eventOnMessagesRefreshed && eventOnMessagesRefreshed(getMessageList());
|
||||
if (!eventOnMessagesRefreshed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageLoaderAborter != null) {
|
||||
messageLoaderAborter.abort();
|
||||
}
|
||||
|
||||
const aborter = new AbortController();
|
||||
messageLoaderAborter = aborter;
|
||||
|
||||
getMessageList(aborter.signal).then(eventOnMessagesRefreshed).finally(() => {
|
||||
if (messageLoaderAborter === aborter) {
|
||||
messageLoaderAborter = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getFilteredMessageKeys = function(channel) {
|
||||
@@ -288,17 +352,20 @@ export default (function() {
|
||||
eventOnUsersRefreshed = callback;
|
||||
},
|
||||
|
||||
uploadFile(meta, data) {
|
||||
/**
|
||||
* @param {{ meta, data }} file
|
||||
*/
|
||||
uploadFile(file) {
|
||||
if (loadedFileMeta != null) {
|
||||
throw "A file is already loaded!";
|
||||
}
|
||||
|
||||
if (typeof meta !== "object" || typeof data !== "object") {
|
||||
if (!file || typeof file.meta !== "object" || typeof file.data !== "object") {
|
||||
throw "Invalid file format!";
|
||||
}
|
||||
|
||||
loadedFileMeta = meta;
|
||||
loadedFileData = data;
|
||||
loadedFileMeta = file.meta;
|
||||
loadedFileData = file.data;
|
||||
loadedMessages = null;
|
||||
|
||||
selectedChannel = null;
|
||||
@@ -308,7 +375,7 @@ export default (function() {
|
||||
triggerChannelsRefreshed();
|
||||
triggerMessagesRefreshed();
|
||||
|
||||
settings.onSettingsChanged(() => triggerMessagesRefreshed());
|
||||
SETTINGS.onSettingsChanged(() => triggerMessagesRefreshed());
|
||||
},
|
||||
|
||||
getChannelName(channel) {
|
||||
@@ -316,21 +383,21 @@ export default (function() {
|
||||
return (channelObj && channelObj.name) || channel;
|
||||
},
|
||||
|
||||
getUserTag(user) {
|
||||
const userObj = loadedFileMeta.users[user];
|
||||
return (userObj && userObj.tag) || "????";
|
||||
},
|
||||
|
||||
getUserName(user) {
|
||||
const userObj = loadedFileMeta.users[user];
|
||||
return (userObj && userObj.name) || user;
|
||||
},
|
||||
|
||||
getUserDisplayName(user) {
|
||||
const userObj = loadedFileMeta.users[user];
|
||||
return (userObj && (userObj.displayName || userObj.name)) || user;
|
||||
},
|
||||
|
||||
selectChannel(channel) {
|
||||
currentPage = 1;
|
||||
selectedChannel = channel;
|
||||
|
||||
loadedMessages = getFilteredMessageKeys(channel).sort(processor.SORTER.oldestToNewest);
|
||||
loadedMessages = getFilteredMessageKeys(channel).sort(PROCESSOR.SORTER.oldestToNewest);
|
||||
triggerMessagesRefreshed();
|
||||
},
|
||||
|
||||
@@ -411,23 +478,23 @@ export default (function() {
|
||||
setActiveFilter(filter) {
|
||||
switch (filter ? filter.type : "") {
|
||||
case "user":
|
||||
filterFunction = processor.FILTER.byUser(filter.value);
|
||||
filterFunction = PROCESSOR.FILTER.byUser(loadedFileMeta.userindex.indexOf(filter.value));
|
||||
break;
|
||||
|
||||
case "contents":
|
||||
filterFunction = processor.FILTER.byContents(filter.value);
|
||||
filterFunction = PROCESSOR.FILTER.byContents(filter.value);
|
||||
break;
|
||||
|
||||
case "withimages":
|
||||
filterFunction = processor.FILTER.withImages();
|
||||
filterFunction = PROCESSOR.FILTER.withImages();
|
||||
break;
|
||||
|
||||
case "withdownloads":
|
||||
filterFunction = processor.FILTER.withDownloads();
|
||||
filterFunction = PROCESSOR.FILTER.withDownloads();
|
||||
break;
|
||||
|
||||
case "edited":
|
||||
filterFunction = processor.FILTER.isEdited();
|
||||
filterFunction = PROCESSOR.FILTER.isEdited();
|
||||
break;
|
||||
|
||||
default:
|
@@ -1,8 +1,6 @@
|
||||
import dom from "./dom.mjs";
|
||||
|
||||
const TEMPLATE_REGEX = /{([^{}]+?)}/g;
|
||||
|
||||
export default class {
|
||||
class TEMPLATE {
|
||||
constructor(contents) {
|
||||
this.contents = contents;
|
||||
};
|
||||
@@ -13,10 +11,10 @@ export default class {
|
||||
|
||||
if (processor) {
|
||||
const updated = processor(match, value);
|
||||
return typeof updated === "undefined" ? dom.escapeHTML(value) : updated;
|
||||
return typeof updated === "undefined" ? DOM.escapeHTML(value) : updated;
|
||||
}
|
||||
|
||||
return dom.escapeHTML(value);
|
||||
return DOM.escapeHTML(value);
|
||||
});
|
||||
}
|
||||
}
|
@@ -3,24 +3,18 @@
|
||||
min-width: 215px;
|
||||
max-width: 300px;
|
||||
overflow-y: auto;
|
||||
color: #eee;
|
||||
background-color: #1c1e22;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#channels > div.loading {
|
||||
margin: 0 auto;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
#channels > div.channel {
|
||||
#channels > div {
|
||||
cursor: pointer;
|
||||
padding: 10px 12px;
|
||||
color: #eee;
|
||||
font-size: 15px;
|
||||
border-bottom: 1px solid #333333;
|
||||
}
|
||||
|
||||
#channels > div.channel:hover, #channels > div.channel.active {
|
||||
#channels > div:hover, #channels > div.active {
|
||||
background-color: #282b30;
|
||||
}
|
||||
|
||||
|
@@ -11,20 +11,3 @@ body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: relative;
|
||||
--loading-backdrop: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
background: var(--loading-backdrop)
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300' preserveAspectRatio='xMidYMid'%3E %3Ccircle cx='150' cy='150' fill='none' stroke='%237983f5' stroke-width='8' r='42' stroke-dasharray='198 68'%3E %3CanimateTransform attributeName='transform' type='rotate' repeatCount='indefinite' dur='1.25s' values='0 150 150;360 150 150' keyTimes='0;1' /%3E %3C/circle%3E %3C/svg%3E")
|
||||
no-repeat center center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
@@ -1,16 +1,15 @@
|
||||
#menu {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-color: #17181c;
|
||||
border-bottom: 1px dotted #5d626b;
|
||||
}
|
||||
|
||||
#menu .splitter {
|
||||
flex: 0 0 1px;
|
||||
margin: 9px 1px;
|
||||
width: 1px;
|
||||
margin: 9px 4px;
|
||||
background-color: #5d626b;
|
||||
}
|
||||
|
||||
@@ -24,8 +23,7 @@
|
||||
}
|
||||
|
||||
#menu button, #menu select, #menu input[type="text"] {
|
||||
height: 31px;
|
||||
padding: 0 10px;
|
||||
margin: 8px;
|
||||
background-color: #7289da;
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
|
||||
@@ -33,25 +31,28 @@
|
||||
|
||||
#menu button {
|
||||
font-size: 17px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#menu select {
|
||||
font-size: 14px;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#menu input[type="text"] {
|
||||
font-size: 14px;
|
||||
padding: 7px 12px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#menu .nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
#menu .nav > button {
|
||||
@@ -65,7 +66,7 @@
|
||||
}
|
||||
|
||||
#menu .nav > button, #menu .nav > p {
|
||||
margin: 0 1px;
|
||||
margin: 8px 1px;
|
||||
}
|
||||
|
||||
#opt-filter-list > select, #opt-filter-list > input {
|
||||
@@ -75,7 +76,3 @@
|
||||
#opt-filter-list > .active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#btn-about {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
@@ -107,11 +107,23 @@
|
||||
}
|
||||
|
||||
.message .thumbnail {
|
||||
--loading-backdrop: rgba(0, 0, 0, 0.75);
|
||||
position: relative;
|
||||
max-width: calc(100% - 20px);
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.message .thumbnail.loading::after {
|
||||
content: "";
|
||||
background: rgba(0, 0, 0, 0.75)
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300' preserveAspectRatio='xMidYMid'%3E %3Ccircle cx='150' cy='150' fill='none' stroke='%237983f5' stroke-width='8' r='42' stroke-dasharray='198 68'%3E %3CanimateTransform attributeName='transform' type='rotate' repeatCount='indefinite' dur='1.25s' values='0 150 150;360 150 150' keyTimes='0;1' /%3E %3C/circle%3E %3C/svg%3E")
|
||||
no-repeat center center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message .thumbnail img {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
|
@@ -1,19 +1,15 @@
|
||||
namespace DHT.Server.Data.Aggregations;
|
||||
|
||||
public sealed class DownloadStatusStatistics {
|
||||
public int PendingCount { get; internal init; }
|
||||
public ulong PendingTotalSize { get; internal init; }
|
||||
public int PendingWithUnknownSizeCount { get; internal init; }
|
||||
public int EnqueuedCount { get; internal set; }
|
||||
public ulong EnqueuedSize { get; internal set; }
|
||||
|
||||
public int SuccessfulCount { get; internal init; }
|
||||
public ulong SuccessfulTotalSize { get; internal init; }
|
||||
public int SuccessfulWithUnknownSizeCount { get; internal init; }
|
||||
public int SuccessfulCount { get; internal set; }
|
||||
public ulong SuccessfulSize { get; internal set; }
|
||||
|
||||
public int FailedCount { get; internal init; }
|
||||
public ulong FailedTotalSize { get; internal init; }
|
||||
public int FailedWithUnknownSizeCount { get; internal init; }
|
||||
|
||||
public int SkippedCount { get; internal init; }
|
||||
public ulong SkippedTotalSize { get; internal init; }
|
||||
public int SkippedWithUnknownSizeCount { get; internal init; }
|
||||
public int FailedCount { get; internal set; }
|
||||
public ulong FailedSize { get; internal set; }
|
||||
|
||||
public int SkippedCount { get; internal set; }
|
||||
public ulong SkippedSize { get; internal set; }
|
||||
}
|
||||
|
@@ -1,17 +1,33 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using DHT.Server.Download;
|
||||
|
||||
namespace DHT.Server.Data;
|
||||
|
||||
public sealed class Download {
|
||||
public readonly struct Download {
|
||||
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(DownloadItem item, HttpStatusCode? statusCode, ulong size) {
|
||||
return new Download(item.NormalizedUrl, item.DownloadUrl, statusCode.HasValue ? (DownloadStatus) (int) statusCode : DownloadStatus.GenericError, size);
|
||||
}
|
||||
|
||||
public string NormalizedUrl { get; }
|
||||
public string DownloadUrl { get; }
|
||||
public DownloadStatus Status { get; }
|
||||
public string? Type { get; }
|
||||
public ulong? Size { get; }
|
||||
public ulong Size { get; }
|
||||
public byte[]? Data { get; }
|
||||
|
||||
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) {
|
||||
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, ulong size, byte[]? data = null) {
|
||||
NormalizedUrl = normalizedUrl;
|
||||
DownloadUrl = downloadUrl;
|
||||
Status = status;
|
||||
Type = type;
|
||||
Size = size;
|
||||
Data = data;
|
||||
}
|
||||
|
||||
internal Download WithData(byte[] data) {
|
||||
return new Download(NormalizedUrl, DownloadUrl, Status, Size, data);
|
||||
}
|
||||
}
|
||||
|
@@ -6,9 +6,7 @@ namespace DHT.Server.Data;
|
||||
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
||||
/// </summary>
|
||||
public enum DownloadStatus {
|
||||
Pending = 0,
|
||||
Enqueued = 0,
|
||||
GenericError = 1,
|
||||
Downloading = 2,
|
||||
LastCustomCode = 99,
|
||||
Success = HttpStatusCode.OK
|
||||
}
|
||||
|
6
app/Server/Data/DownloadedAttachment.cs
Normal file
6
app/Server/Data/DownloadedAttachment.cs
Normal 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; }
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
namespace DHT.Server.Data.Embeds;
|
||||
|
||||
sealed class DiscordEmbedJson {
|
||||
public string? Type { get; set; }
|
||||
public string? Url { get; set; }
|
||||
|
||||
public JsonImage? Image { get; set; }
|
||||
public JsonImage? Thumbnail { get; set; }
|
||||
public JsonImage? Video { get; set; }
|
||||
|
||||
public sealed class JsonImage {
|
||||
public string? Url { get; set; }
|
||||
public string? ProxyUrl { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
}
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DHT.Server.Data.Embeds;
|
||||
|
||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
|
||||
[JsonSerializable(typeof(DiscordEmbedJson))]
|
||||
sealed partial class DiscordEmbedJsonContext : JsonSerializerContext;
|
15
app/Server/Data/Filters/AttachmentFilter.cs
Normal file
15
app/Server/Data/Filters/AttachmentFilter.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace DHT.Server.Data.Filters;
|
||||
|
||||
public sealed class AttachmentFilter {
|
||||
public ulong? MaxBytes { get; set; } = null;
|
||||
|
||||
public DownloadItemRules? DownloadItemRule { get; set; } = null;
|
||||
|
||||
public bool IsEmpty => MaxBytes == null &&
|
||||
DownloadItemRule == null;
|
||||
|
||||
public enum DownloadItemRules {
|
||||
OnlyNotPresent,
|
||||
OnlyPresent
|
||||
}
|
||||
}
|
@@ -3,10 +3,8 @@ using System.Collections.Generic;
|
||||
namespace DHT.Server.Data.Filters;
|
||||
|
||||
public sealed class DownloadItemFilter {
|
||||
public HashSet<DownloadStatus>? IncludeStatuses { get; set; } = null;
|
||||
public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
|
||||
|
||||
public ulong? MaxBytes { get; set; } = null;
|
||||
public HashSet<DownloadStatus>? IncludeStatuses { get; init; } = null;
|
||||
public HashSet<DownloadStatus>? ExcludeStatuses { get; init; } = null;
|
||||
|
||||
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null && MaxBytes == null;
|
||||
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null;
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ public readonly struct Message {
|
||||
public long Timestamp { get; init; }
|
||||
public long? EditTimestamp { get; init; }
|
||||
public ulong? RepliedToId { get; init; }
|
||||
public ImmutableList<Attachment> Attachments { get; init; }
|
||||
public ImmutableList<Embed> Embeds { get; init; }
|
||||
public ImmutableList<Reaction> Reactions { get; init; }
|
||||
public ImmutableArray<Attachment> Attachments { get; init; }
|
||||
public ImmutableArray<Embed> Embeds { get; init; }
|
||||
public ImmutableArray<Reaction> Reactions { get; init; }
|
||||
}
|
||||
|
@@ -1,60 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DHT.Server.Data.Settings;
|
||||
|
||||
public static class SettingsKey {
|
||||
public static Bool DownloadsAutoStart { get; } = new ("downloads_auto_start");
|
||||
public static Bool DownloadsLimitSize { get; } = new ("downloads_limit_size");
|
||||
public static UnsignedLong DownloadsMaximumSize { get; } = new ("downloads_maximum_size");
|
||||
public static String DownloadsMaximumSizeUnit { get; } = new ("downloads_maximum_size_unit");
|
||||
|
||||
public sealed class String(string key) : SettingsKey<string>(key) {
|
||||
internal override bool FromString(string value, out string result) {
|
||||
result = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal override string ToString(string value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Bool(string key) : SettingsKey<bool>(key) {
|
||||
internal override bool FromString(string value, out bool result) {
|
||||
switch (value) {
|
||||
case "1":
|
||||
result = true;
|
||||
return true;
|
||||
|
||||
case "0":
|
||||
result = false;
|
||||
return true;
|
||||
|
||||
default:
|
||||
result = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal override string ToString(bool value) {
|
||||
return value ? "1" : "0";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UnsignedLong(string key) : SettingsKey<ulong>(key) {
|
||||
internal override bool FromString(string value, out ulong result) {
|
||||
return ulong.TryParse(value, out result);
|
||||
}
|
||||
|
||||
internal override string ToString(ulong value) {
|
||||
return value.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class SettingsKey<T>(string key) {
|
||||
internal string Key => key;
|
||||
|
||||
internal abstract bool FromString(string value, [NotNullWhen(true)] out T result);
|
||||
internal abstract string ToString(T value);
|
||||
}
|
@@ -3,7 +3,6 @@ namespace DHT.Server.Data;
|
||||
public readonly struct User {
|
||||
public ulong Id { get; init; }
|
||||
public string Name { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public string? AvatarUrl { get; init; }
|
||||
public string? Discriminator { get; init; }
|
||||
}
|
||||
|
@@ -1,34 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Data;
|
||||
|
||||
namespace DHT.Server.Database;
|
||||
|
||||
public static class DatabaseExtensions {
|
||||
public static async Task AddFrom(this IDatabaseFile target, IDatabaseFile source) {
|
||||
await target.Users.Add(await source.Users.Get().ToListAsync());
|
||||
await target.Servers.Add(await source.Servers.Get().ToListAsync());
|
||||
await target.Channels.Add(await source.Channels.Get().ToListAsync());
|
||||
public static void AddFrom(this IDatabaseFile target, IDatabaseFile source) {
|
||||
target.AddServers(source.GetAllServers());
|
||||
target.AddChannels(source.GetAllChannels());
|
||||
target.AddUsers(source.GetAllUsers().ToArray());
|
||||
target.AddMessages(source.GetMessages().ToArray());
|
||||
|
||||
const int MessageBatchSize = 100;
|
||||
List<Message> batchedMessages = new (MessageBatchSize);
|
||||
|
||||
await foreach (var message in source.Messages.Get()) {
|
||||
batchedMessages.Add(message);
|
||||
|
||||
if (batchedMessages.Count >= MessageBatchSize) {
|
||||
await target.Messages.Add(batchedMessages);
|
||||
batchedMessages.Clear();
|
||||
}
|
||||
foreach (var download in source.GetDownloadsWithoutData()) {
|
||||
target.AddDownload(download.Status == DownloadStatus.Success ? source.GetDownloadWithData(download) : download);
|
||||
}
|
||||
|
||||
await target.Messages.Add(batchedMessages);
|
||||
}
|
||||
|
||||
await foreach (var download in source.Downloads.Get()) {
|
||||
if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) {
|
||||
await target.Downloads.AddDownload(download, stream: null);
|
||||
}
|
||||
internal static void AddServers(this IDatabaseFile target, IEnumerable<Data.Server> servers) {
|
||||
foreach (var server in servers) {
|
||||
target.AddServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void AddChannels(this IDatabaseFile target, IEnumerable<Channel> channels) {
|
||||
foreach (var channel in channels) {
|
||||
target.AddChannel(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
46
app/Server/Database/DatabaseStatistics.cs
Normal file
46
app/Server/Database/DatabaseStatistics.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using DHT.Utils.Models;
|
||||
|
||||
namespace DHT.Server.Database;
|
||||
|
||||
/// <summary>
|
||||
/// A live view of database statistics.
|
||||
/// Some of the totals are computed asynchronously and may not reflect the most recent version of the database, or may not be available at all until computed for the first time.
|
||||
/// </summary>
|
||||
public sealed class DatabaseStatistics : BaseModel {
|
||||
private long totalServers;
|
||||
private long totalChannels;
|
||||
private long totalUsers;
|
||||
private long? totalMessages;
|
||||
private long? totalAttachments;
|
||||
private long? totalDownloads;
|
||||
|
||||
public long TotalServers {
|
||||
get => totalServers;
|
||||
internal set => Change(ref totalServers, value);
|
||||
}
|
||||
|
||||
public long TotalChannels {
|
||||
get => totalChannels;
|
||||
internal set => Change(ref totalChannels, value);
|
||||
}
|
||||
|
||||
public long TotalUsers {
|
||||
get => totalUsers;
|
||||
internal set => Change(ref totalUsers, value);
|
||||
}
|
||||
|
||||
public long? TotalMessages {
|
||||
get => totalMessages;
|
||||
internal set => Change(ref totalMessages, value);
|
||||
}
|
||||
|
||||
public long? TotalAttachments {
|
||||
get => totalAttachments;
|
||||
internal set => Change(ref totalAttachments, value);
|
||||
}
|
||||
|
||||
public long? TotalDownloads {
|
||||
get => totalDownloads;
|
||||
internal set => Change(ref totalDownloads, value);
|
||||
}
|
||||
}
|
11
app/Server/Database/DatabaseStatisticsSnapshot.cs
Normal file
11
app/Server/Database/DatabaseStatisticsSnapshot.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace DHT.Server.Database;
|
||||
|
||||
/// <summary>
|
||||
/// A complete snapshot of database statistics at a particular point in time.
|
||||
/// </summary>
|
||||
public readonly struct DatabaseStatisticsSnapshot {
|
||||
public long TotalServers { get; internal init; }
|
||||
public long TotalChannels { get; internal init; }
|
||||
public long TotalUsers { get; internal init; }
|
||||
public long TotalMessages { get; internal init; }
|
||||
}
|
@@ -1,29 +1,90 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using DHT.Server.Database.Repositories;
|
||||
using DHT.Server.Data;
|
||||
using DHT.Server.Data.Aggregations;
|
||||
using DHT.Server.Data.Filters;
|
||||
using DHT.Server.Download;
|
||||
|
||||
namespace DHT.Server.Database;
|
||||
|
||||
[SuppressMessage("ReSharper", "ArrangeObjectCreationWhenTypeNotEvident")]
|
||||
sealed class DummyDatabaseFile : IDatabaseFile {
|
||||
public static DummyDatabaseFile Instance { get; } = new ();
|
||||
public sealed class DummyDatabaseFile : IDatabaseFile {
|
||||
public static DummyDatabaseFile Instance { get; } = new();
|
||||
|
||||
public string Path => "";
|
||||
|
||||
public ISettingsRepository Settings { get; } = new ISettingsRepository.Dummy();
|
||||
public IUserRepository Users { get; } = new IUserRepository.Dummy();
|
||||
public IServerRepository Servers { get; } = new IServerRepository.Dummy();
|
||||
public IChannelRepository Channels { get; } = new IChannelRepository.Dummy();
|
||||
public IMessageRepository Messages { get; } = new IMessageRepository.Dummy();
|
||||
public IDownloadRepository Downloads { get; } = new IDownloadRepository.Dummy();
|
||||
|
||||
public DatabaseStatistics Statistics { get; } = new();
|
||||
|
||||
private DummyDatabaseFile() {}
|
||||
|
||||
public Task Vacuum() {
|
||||
return Task.CompletedTask;
|
||||
public DatabaseStatisticsSnapshot SnapshotStatistics() {
|
||||
return new();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() {
|
||||
return ValueTask.CompletedTask;
|
||||
public void AddServer(Data.Server server) {}
|
||||
|
||||
public List<Data.Server> GetAllServers() {
|
||||
return new();
|
||||
}
|
||||
|
||||
public void AddChannel(Channel channel) {}
|
||||
|
||||
public List<Channel> GetAllChannels() {
|
||||
return new();
|
||||
}
|
||||
|
||||
public void AddUsers(User[] users) {}
|
||||
|
||||
public List<User> GetAllUsers() {
|
||||
return new();
|
||||
}
|
||||
|
||||
public void AddMessages(Message[] messages) {}
|
||||
|
||||
public int CountMessages(MessageFilter? filter = null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public List<Message> GetMessages(MessageFilter? filter = null, bool includeText = true) {
|
||||
return new();
|
||||
}
|
||||
|
||||
public HashSet<ulong> GetMessageIds(MessageFilter? filter = null) {
|
||||
return new();
|
||||
}
|
||||
|
||||
public void RemoveMessages(MessageFilter filter, FilterRemovalMode mode) {}
|
||||
|
||||
public int CountAttachments(AttachmentFilter? filter = null) {
|
||||
return new();
|
||||
}
|
||||
|
||||
public List<Data.Download> GetDownloadsWithoutData() {
|
||||
return new();
|
||||
}
|
||||
|
||||
public Data.Download GetDownloadWithData(Data.Download download) {
|
||||
return download;
|
||||
}
|
||||
|
||||
public DownloadedAttachment? GetDownloadedAttachment(string url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public void AddDownload(Data.Download download) {}
|
||||
|
||||
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {}
|
||||
|
||||
public List<DownloadItem> GetEnqueuedDownloadItems(int count) {
|
||||
return new();
|
||||
}
|
||||
|
||||
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {}
|
||||
|
||||
public DownloadStatusStatistics GetDownloadStatusStatistics() {
|
||||
return new();
|
||||
}
|
||||
|
||||
public void Vacuum() {}
|
||||
|
||||
public void Dispose() {}
|
||||
}
|
||||
|
@@ -5,9 +5,9 @@ namespace DHT.Server.Database.Exceptions;
|
||||
|
||||
public sealed class DatabaseTooNewException : Exception {
|
||||
public int DatabaseVersion { get; }
|
||||
public int CurrentVersion => SqliteSchema.Version;
|
||||
public int CurrentVersion => Schema.Version;
|
||||
|
||||
internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + SqliteSchema.Version) {
|
||||
internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + Schema.Version) {
|
||||
this.DatabaseVersion = databaseVersion;
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,3 @@
|
||||
namespace DHT.Server.Database.Export;
|
||||
namespace DHT.Server.Database.Export;
|
||||
|
||||
readonly record struct Snowflake(ulong Id) {
|
||||
public static implicit operator Snowflake(ulong id) => new (id);
|
||||
}
|
||||
readonly record struct Snowflake(ulong Id);
|
||||
|
@@ -0,0 +1,9 @@
|
||||
using DHT.Server.Data;
|
||||
|
||||
namespace DHT.Server.Database.Export.Strategy;
|
||||
|
||||
public interface IViewerExportStrategy {
|
||||
bool IncludeMessageText { get; }
|
||||
string ProcessViewerTemplate(string template);
|
||||
string GetAttachmentUrl(Attachment attachment);
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using DHT.Server.Data;
|
||||
|
||||
namespace DHT.Server.Database.Export.Strategy;
|
||||
|
||||
public sealed class LiveViewerExportStrategy : IViewerExportStrategy {
|
||||
private readonly string safePort;
|
||||
private readonly string safeToken;
|
||||
|
||||
public LiveViewerExportStrategy(ushort port, string token) {
|
||||
this.safePort = port.ToString();
|
||||
this.safeToken = WebUtility.UrlEncode(token);
|
||||
}
|
||||
|
||||
public bool IncludeMessageText => false;
|
||||
|
||||
public string ProcessViewerTemplate(string template) {
|
||||
return template.Replace("/*[SERVER_URL]*/", "http://127.0.0.1:" + safePort)
|
||||
.Replace("/*[SERVER_TOKEN]*/", WebUtility.UrlEncode(safeToken));
|
||||
}
|
||||
|
||||
public string GetAttachmentUrl(Attachment attachment) {
|
||||
return "http://127.0.0.1:" + safePort + "/get-attachment/" + WebUtility.UrlEncode(attachment.NormalizedUrl) + "?token=" + safeToken;
|
||||
}
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
using DHT.Server.Data;
|
||||
|
||||
namespace DHT.Server.Database.Export.Strategy;
|
||||
|
||||
public sealed class StandaloneViewerExportStrategy : IViewerExportStrategy {
|
||||
public static StandaloneViewerExportStrategy Instance { get; } = new ();
|
||||
|
||||
private StandaloneViewerExportStrategy() {}
|
||||
|
||||
public bool IncludeMessageText => true;
|
||||
|
||||
public string ProcessViewerTemplate(string template) {
|
||||
return template.Replace("\"/*[SERVER_URL]*/\"", "null")
|
||||
.Replace("\"/*[SERVER_TOKEN]*/\"", "null");
|
||||
}
|
||||
|
||||
public string GetAttachmentUrl(Attachment attachment) {
|
||||
// The normalized URL will not load files from Discord CDN once the time limit is enforced.
|
||||
|
||||
// The downloaded URL would work, but only for a limited time, so it is better for the links to not work
|
||||
// rather than give users a false sense of security.
|
||||
|
||||
return attachment.NormalizedUrl;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user