mirror of
https://github.com/chylex/Discord-History-Tracker.git
synced 2025-09-15 19:32:09 +02:00
Compare commits
40 Commits
e0f359c15b
...
v42.1
Author | SHA1 | Date | |
---|---|---|---|
c3d4fa5532
|
|||
a6225b9721
|
|||
943163473a
|
|||
fa00df10d8
|
|||
f54465e5fe
|
|||
2d55ca4013
|
|||
0072e025f4
|
|||
383ee5c90e
|
|||
2b1c178f49
|
|||
16d7e5a6f8
|
|||
052d24d1e7
|
|||
b058f1fb77
|
|||
d4da64a5ed
|
|||
8de309a6c4
|
|||
de8d6a1e11
|
|||
d79e6f53b4
|
|||
70c04fc986
|
|||
c8d8d95daa
|
|||
daafdbbfaf
|
|||
07615de87a
|
|||
7fdc19880e
|
|||
67b9c12843
|
|||
9030a2f010
|
|||
a6dad6b4c7
|
|||
72b8fb7c14
|
|||
7173dc6cfc
|
|||
2c1e5a7603
|
|||
4929a19397
|
|||
c5f77872fe
|
|||
c9e50e1a80
|
|||
ef3e34066a
|
|||
37374eeb18
|
|||
23ddb45a0d
|
|||
9904a711f7
|
|||
d5720c8758
|
|||
89161e14b1
|
|||
9d208b026c
|
|||
119649ef9b
|
|||
0bc6232da7
|
|||
de266473c5
|
@@ -9,15 +9,15 @@
|
|||||||
<entry key="Desktop/Dialogs/Progress/ProgressDialog.axaml" value="Desktop/Desktop.csproj" />
|
<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/Dialogs/TextBox/TextBoxDialog.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/AboutWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/AttachmentFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
<entry key="Desktop/Main/Controls/DownloadItemFilterPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/MessageFilterPanel.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/ServerConfigurationPanel.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Controls/StatusBar.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/MainWindow.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Pages/AdvancedPage.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/DatabasePage.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Pages/DebugPage.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/TrackingPage.axaml" value="Desktop/Desktop.csproj" />
|
||||||
<entry key="Desktop/Main/Pages/ViewerPage.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" />
|
<entry key="Desktop/Main/Screens/MainContentScreen.axaml" value="Desktop/Desktop.csproj" />
|
||||||
@@ -25,4 +25,4 @@
|
|||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
@@ -13,10 +13,10 @@
|
|||||||
<CssCodeStyleSettings>
|
<CssCodeStyleSettings>
|
||||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||||
</CssCodeStyleSettings>
|
</CssCodeStyleSettings>
|
||||||
<DB2CodeStyleSettings version="6">
|
<DB2CodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</DB2CodeStyleSettings>
|
</DB2CodeStyleSettings>
|
||||||
<DerbyCodeStyleSettings version="6">
|
<DerbyCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</DerbyCodeStyleSettings>
|
</DerbyCodeStyleSettings>
|
||||||
<GoCodeStyleSettings>
|
<GoCodeStyleSettings>
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
<option name="WRAP_FUNC_PARAMS" value="5" />
|
<option name="WRAP_FUNC_PARAMS" value="5" />
|
||||||
<option name="WRAP_FUNC_RESULT" value="5" />
|
<option name="WRAP_FUNC_RESULT" value="5" />
|
||||||
</GoCodeStyleSettings>
|
</GoCodeStyleSettings>
|
||||||
<H2CodeStyleSettings version="6">
|
<H2CodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</H2CodeStyleSettings>
|
</H2CodeStyleSettings>
|
||||||
<HSQLCodeStyleSettings version="6">
|
<HSQLCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</HSQLCodeStyleSettings>
|
</HSQLCodeStyleSettings>
|
||||||
<HTMLCodeStyleSettings>
|
<HTMLCodeStyleSettings>
|
||||||
@@ -85,10 +85,10 @@
|
|||||||
<LessCodeStyleSettings>
|
<LessCodeStyleSettings>
|
||||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||||
</LessCodeStyleSettings>
|
</LessCodeStyleSettings>
|
||||||
<MSSQLCodeStyleSettings version="6">
|
<MSSQLCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</MSSQLCodeStyleSettings>
|
</MSSQLCodeStyleSettings>
|
||||||
<MySQLCodeStyleSettings version="6">
|
<MySQLCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</MySQLCodeStyleSettings>
|
</MySQLCodeStyleSettings>
|
||||||
<Objective-C>
|
<Objective-C>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<option name="SPACE_BEFORE_INIT_LIST" value="true" />
|
<option name="SPACE_BEFORE_INIT_LIST" value="true" />
|
||||||
<option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" />
|
<option name="SPACE_AFTER_DICTIONARY_LITERAL_COLON" value="false" />
|
||||||
</Objective-C>
|
</Objective-C>
|
||||||
<OracleCodeStyleSettings version="6">
|
<OracleCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</OracleCodeStyleSettings>
|
</OracleCodeStyleSettings>
|
||||||
<PHPCodeStyleSettings>
|
<PHPCodeStyleSettings>
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
<option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" />
|
<option name="NEW_LINE_AFTER_PHP_OPENING_TAG" value="true" />
|
||||||
<option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" />
|
<option name="SPACE_AROUND_ASSIGNMENT_IN_DECLARE" value="true" />
|
||||||
</PHPCodeStyleSettings>
|
</PHPCodeStyleSettings>
|
||||||
<PostgresCodeStyleSettings version="6">
|
<PostgresCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</PostgresCodeStyleSettings>
|
</PostgresCodeStyleSettings>
|
||||||
<Properties>
|
<Properties>
|
||||||
@@ -143,13 +143,16 @@
|
|||||||
<option name="INDENT_WHEN_CASES" value="true" />
|
<option name="INDENT_WHEN_CASES" value="true" />
|
||||||
<option name="CHAIN_CALLS_ALIGNMENT" value="2" />
|
<option name="CHAIN_CALLS_ALIGNMENT" value="2" />
|
||||||
</Ruby>
|
</Ruby>
|
||||||
<SQLiteCodeStyleSettings version="6">
|
<SQLiteCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</SQLiteCodeStyleSettings>
|
</SQLiteCodeStyleSettings>
|
||||||
<ScssCodeStyleSettings>
|
<ScssCodeStyleSettings>
|
||||||
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
<option name="HEX_COLOR_LOWER_CASE" value="true" />
|
||||||
</ScssCodeStyleSettings>
|
</ScssCodeStyleSettings>
|
||||||
<SqlCodeStyleSettings version="6">
|
<Shell_Script>
|
||||||
|
<option name="KEEP_COLUMN_ALIGNMENT_PADDING" value="true" />
|
||||||
|
</Shell_Script>
|
||||||
|
<SqlCodeStyleSettings version="7">
|
||||||
<option name="KEYWORD_CASE" value="2" />
|
<option name="KEYWORD_CASE" value="2" />
|
||||||
<option name="TYPE_CASE" value="2" />
|
<option name="TYPE_CASE" value="2" />
|
||||||
<option name="CUSTOM_TYPE_CASE" value="2" />
|
<option name="CUSTOM_TYPE_CASE" value="2" />
|
||||||
@@ -173,9 +176,10 @@
|
|||||||
<option name="CONSTRAINT_WRAP_3" value="true" />
|
<option name="CONSTRAINT_WRAP_3" value="true" />
|
||||||
<option name="CONSTRAINT_WRAP_4" value="true" />
|
<option name="CONSTRAINT_WRAP_4" value="true" />
|
||||||
<option name="VIEW_INDENT_QUERY" 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" />
|
<option name="EXPR_CASE_WHEN_WRAP" value="false" />
|
||||||
</SqlCodeStyleSettings>
|
</SqlCodeStyleSettings>
|
||||||
<SybaseCodeStyleSettings version="6">
|
<SybaseCodeStyleSettings version="7">
|
||||||
<option name="USE_GENERIC_STYLE" value="true" />
|
<option name="USE_GENERIC_STYLE" value="true" />
|
||||||
</SybaseCodeStyleSettings>
|
</SybaseCodeStyleSettings>
|
||||||
<TypeScriptCodeStyleSettings version="0">
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
@@ -203,6 +207,11 @@
|
|||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Gherkin">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="Groovy">
|
<codeStyleSettings language="Groovy">
|
||||||
<option name="ELSE_ON_NEW_LINE" value="true" />
|
<option name="ELSE_ON_NEW_LINE" value="true" />
|
||||||
<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true" />
|
<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true" />
|
||||||
@@ -282,6 +291,13 @@
|
|||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</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">
|
<codeStyleSettings language="ObjectiveC">
|
||||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
@@ -342,7 +358,6 @@
|
|||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="Rust">
|
<codeStyleSettings language="Rust">
|
||||||
<option name="RIGHT_MARGIN" value="140" />
|
|
||||||
<option name="ALIGN_MULTILINE_CHAINED_METHODS" value="true" />
|
<option name="ALIGN_MULTILINE_CHAINED_METHODS" value="true" />
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
@@ -357,6 +372,7 @@
|
|||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="SCSS">
|
<codeStyleSettings language="SCSS">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
@@ -367,13 +383,6 @@
|
|||||||
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</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">
|
<codeStyleSettings language="TOML">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
@@ -427,6 +436,9 @@
|
|||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="liquid">
|
<codeStyleSettings language="liquid">
|
||||||
<indentOptions>
|
<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" />
|
<option name="KEEP_INDENTS_ON_EMPTY_LINES" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
@@ -442,4 +454,4 @@
|
|||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
@@ -91,7 +91,6 @@
|
|||||||
<inspection_tool class="ComparatorNotSerializable" enabled="true" level="WARNING" enabled_by_default="true" />
|
<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="CompareToUsesNonFinalVariable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="ComparisonOfShortAndChar" 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="ConditionSignal" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="ConditionalExpression" enabled="true" level="INFORMATION" enabled_by_default="true">
|
<inspection_tool class="ConditionalExpression" enabled="true" level="INFORMATION" enabled_by_default="true">
|
||||||
<option name="ignoreSimpleAssignmentsAndReturns" value="true" />
|
<option name="ignoreSimpleAssignmentsAndReturns" value="true" />
|
||||||
@@ -362,7 +361,7 @@
|
|||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="LocalVariableDeclarationSideOnly" enabled="true" level="ERROR" enabled_by_default="true" />
|
<inspection_tool class="LocalVariableDeclarationSideOnly" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||||
<inspection_tool class="LocalVariableNamingConventionJS" enabled="true" level="WARNING" 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-z]*" />
|
<option name="m_regex" value="[a-z][A-Za-z0-9]*" />
|
||||||
<option name="m_minLength" value="0" />
|
<option name="m_minLength" value="0" />
|
||||||
<option name="m_maxLength" value="99" />
|
<option name="m_maxLength" value="99" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
|
@@ -1,22 +0,0 @@
|
|||||||
<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,29 +1,31 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop;
|
namespace DHT.Desktop;
|
||||||
|
|
||||||
sealed class Arguments {
|
sealed class Arguments {
|
||||||
private static readonly Log Log = Log.ForType<Arguments>();
|
private static readonly Log Log = Log.ForType<Arguments>();
|
||||||
|
|
||||||
private const int FirstArgument = 1;
|
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 bool Console { get; }
|
||||||
public string? DatabaseFile { get; }
|
public string? DatabaseFile { get; }
|
||||||
public ushort? ServerPort { get; }
|
public ushort? ServerPort { get; }
|
||||||
public string? ServerToken { get; }
|
public string? ServerToken { get; }
|
||||||
|
public byte? ConcurrentDownloads { get; }
|
||||||
|
|
||||||
public Arguments(string[] args) {
|
public Arguments(IReadOnlyList<string> args) {
|
||||||
for (int i = FirstArgument; i < args.Length; i++) {
|
for (int i = FirstArgument; i < args.Count; i++) {
|
||||||
string key = args[i];
|
string key = args[i];
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "-debug":
|
case "-debug":
|
||||||
Log.IsDebugEnabled = true;
|
Log.IsDebugEnabled = true;
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
case "-console":
|
case "-console":
|
||||||
Console = true;
|
Console = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -35,7 +37,7 @@ sealed class Arguments {
|
|||||||
value = key;
|
value = key;
|
||||||
key = "-db";
|
key = "-db";
|
||||||
}
|
}
|
||||||
else if (i >= args.Length - 1) {
|
else if (i >= args.Count - 1) {
|
||||||
Log.Warn("Missing value for command line argument: " + key);
|
Log.Warn("Missing value for command line argument: " + key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -49,11 +51,11 @@ sealed class Arguments {
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
case "-port": {
|
case "-port": {
|
||||||
if (ushort.TryParse(value, out var port)) {
|
if (!ushort.TryParse(value, out var port)) {
|
||||||
ServerPort = port;
|
Log.Warn("Invalid port number: " + value);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Log.Warn("Invalid port number: " + value);
|
ServerPort = port;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -62,6 +64,20 @@ sealed class Arguments {
|
|||||||
case "-token":
|
case "-token":
|
||||||
ServerToken = value;
|
ServerToken = value;
|
||||||
continue;
|
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:
|
default:
|
||||||
Log.Warn("Unknown command line argument: " + key);
|
Log.Warn("Unknown command line argument: " + key);
|
||||||
|
@@ -19,17 +19,17 @@ sealed class BytesValueConverter : IValueConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly Unit[] Units = {
|
private static readonly Unit[] Units = [
|
||||||
new ("B", decimalPlaces: 0),
|
new Unit("B", decimalPlaces: 0),
|
||||||
new ("kB", decimalPlaces: 0),
|
new Unit("kB", decimalPlaces: 0),
|
||||||
new ("MB", decimalPlaces: 1),
|
new Unit("MB", decimalPlaces: 1),
|
||||||
new ("GB", decimalPlaces: 1),
|
new Unit("GB", decimalPlaces: 1),
|
||||||
new ("TB", decimalPlaces: 1)
|
new Unit("TB", decimalPlaces: 1)
|
||||||
};
|
];
|
||||||
|
|
||||||
private const int Scale = 1000;
|
private const int Scale = 1000;
|
||||||
|
|
||||||
private static string Convert(ulong size) {
|
public static string Convert(ulong size) {
|
||||||
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
int power = size == 0L ? 0 : (int) Math.Log(size, Scale);
|
||||||
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
int unit = power >= Units.Length ? Units.Length - 1 : power;
|
||||||
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
return Units[unit].Format(unit == 0 ? size : size / Math.Pow(Scale, unit));
|
||||||
|
@@ -1,17 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Threading;
|
|
||||||
using DHT.Desktop.Dialogs.File;
|
using DHT.Desktop.Dialogs.File;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Exceptions;
|
using DHT.Server.Database.Exceptions;
|
||||||
using DHT.Server.Database.Sqlite;
|
using DHT.Server.Database.Sqlite;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Desktop.Common;
|
namespace DHT.Desktop.Common;
|
||||||
@@ -45,15 +43,10 @@ static class DatabaseGui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
|
||||||
var prevSynchronizationContext = SynchronizationContext.Current;
|
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
|
||||||
var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
|
||||||
SynchronizationContext.SetSynchronizationContext(prevSynchronizationContext);
|
|
||||||
|
|
||||||
IDatabaseFile? file = null;
|
IDatabaseFile? file = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler);
|
file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks);
|
||||||
} catch (InvalidDatabaseVersionException ex) {
|
} catch (InvalidDatabaseVersionException ex) {
|
||||||
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
|
||||||
} catch (DatabaseTooNewException ex) {
|
} catch (DatabaseTooNewException ex) {
|
||||||
|
@@ -23,6 +23,7 @@
|
|||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
|
||||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -31,10 +32,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\Version.cs" Link="Version.cs" />
|
<Compile Include="..\Version.cs" Link="Version.cs" />
|
||||||
<Compile Update="Dialogs\TextBox\TextBoxDialog.axaml.cs">
|
|
||||||
<DependentUpon>CheckBoxDialog.axaml</DependentUpon>
|
|
||||||
<SubType>Code</SubType>
|
|
||||||
</Compile>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -42,11 +39,6 @@
|
|||||||
<EmbeddedResource Include="Resources/tracker-loader.js">
|
<EmbeddedResource Include="Resources/tracker-loader.js">
|
||||||
<LogicalName>tracker-loader.js</LogicalName>
|
<LogicalName>tracker-loader.js</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
<EmbeddedResource Include="../Resources/Viewer/**">
|
|
||||||
<LogicalName>Viewer\%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
|
||||||
<Link>Resources/Viewer/%(RecursiveDir)%(Filename)%(Extension)</Link>
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@@ -38,7 +38,7 @@
|
|||||||
<ItemsRepeater ItemsSource="{Binding Items}">
|
<ItemsRepeater ItemsSource="{Binding Items}">
|
||||||
<ItemsRepeater.ItemTemplate>
|
<ItemsRepeater.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<CheckBox IsChecked="{Binding Checked}">
|
<CheckBox IsChecked="{Binding IsChecked}">
|
||||||
<Label>
|
<Label>
|
||||||
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding Title}" TextWrapping="Wrap" />
|
||||||
</Label>
|
</Label>
|
||||||
|
@@ -2,11 +2,11 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DHT.Utils.Models;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
|
|
||||||
class CheckBoxDialogModel : BaseModel {
|
class CheckBoxDialogModel : ObservableObject {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
private IReadOnlyList<CheckBoxItem> items = Array.Empty<CheckBoxItem>();
|
||||||
@@ -29,8 +29,8 @@ class CheckBoxDialogModel : BaseModel {
|
|||||||
|
|
||||||
private bool pauseCheckEvents = false;
|
private bool pauseCheckEvents = false;
|
||||||
|
|
||||||
public bool AreAllSelected => Items.All(static item => item.Checked);
|
public bool AreAllSelected => Items.All(static item => item.IsChecked);
|
||||||
public bool AreNoneSelected => Items.All(static item => !item.Checked);
|
public bool AreNoneSelected => Items.All(static item => !item.IsChecked);
|
||||||
|
|
||||||
public void SelectAll() => SetAllChecked(true);
|
public void SelectAll() => SetAllChecked(true);
|
||||||
public void SelectNone() => SetAllChecked(false);
|
public void SelectNone() => SetAllChecked(false);
|
||||||
@@ -39,7 +39,7 @@ class CheckBoxDialogModel : BaseModel {
|
|||||||
pauseCheckEvents = true;
|
pauseCheckEvents = true;
|
||||||
|
|
||||||
foreach (var item in Items) {
|
foreach (var item in Items) {
|
||||||
item.Checked = isChecked;
|
item.IsChecked = isChecked;
|
||||||
}
|
}
|
||||||
|
|
||||||
pauseCheckEvents = false;
|
pauseCheckEvents = false;
|
||||||
@@ -52,16 +52,16 @@ class CheckBoxDialogModel : BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.Checked)) {
|
if (!pauseCheckEvents && e.PropertyName == nameof(CheckBoxItem.IsChecked)) {
|
||||||
UpdateBulkButtons();
|
UpdateBulkButtons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
sealed class CheckBoxDialogModel<T> : CheckBoxDialogModel {
|
||||||
public new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
private new IReadOnlyList<CheckBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.Checked);
|
public IEnumerable<CheckBoxItem<T>> SelectedItems => Items.Where(static item => item.IsChecked);
|
||||||
|
|
||||||
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
public CheckBoxDialogModel(IEnumerable<CheckBoxItem<T>> items) {
|
||||||
this.Items = new List<CheckBoxItem<T>>(items);
|
this.Items = new List<CheckBoxItem<T>>(items);
|
||||||
|
@@ -1,17 +1,13 @@
|
|||||||
using DHT.Utils.Models;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.CheckBox;
|
namespace DHT.Desktop.Dialogs.CheckBox;
|
||||||
|
|
||||||
class CheckBoxItem : BaseModel {
|
partial class CheckBoxItem : ObservableObject {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public object? Item { get; init; } = null;
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool isChecked = false;
|
private bool isChecked = false;
|
||||||
|
|
||||||
public bool Checked {
|
|
||||||
get => isChecked;
|
|
||||||
set => Change(ref isChecked, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CheckBoxItem<T> : CheckBoxItem {
|
sealed class CheckBoxItem<T> : CheckBoxItem {
|
||||||
|
@@ -4,11 +4,10 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress;
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
sealed class ProgressDialogModel : BaseModel {
|
sealed class ProgressDialogModel {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
|
|
||||||
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
|
||||||
|
@@ -1,18 +1,12 @@
|
|||||||
using DHT.Utils.Models;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.Progress;
|
namespace DHT.Desktop.Dialogs.Progress;
|
||||||
|
|
||||||
sealed class ProgressItem : BaseModel {
|
sealed partial class ProgressItem : ObservableObject {
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Opacity))]
|
||||||
private bool isVisible = false;
|
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;
|
public double Opacity => IsVisible ? 1.0 : 0.0;
|
||||||
|
|
||||||
private string message = "";
|
private string message = "";
|
||||||
@@ -20,29 +14,17 @@ sealed class ProgressItem : BaseModel {
|
|||||||
public string Message {
|
public string Message {
|
||||||
get => message;
|
get => message;
|
||||||
set {
|
set {
|
||||||
Change(ref message, value);
|
SetProperty(ref message, value);
|
||||||
IsVisible = !string.IsNullOrEmpty(value);
|
IsVisible = !string.IsNullOrEmpty(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private string items = "";
|
private string items = "";
|
||||||
|
|
||||||
public string Items {
|
[ObservableProperty]
|
||||||
get => items;
|
|
||||||
set => Change(ref items, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int progress = 0;
|
private int progress = 0;
|
||||||
|
|
||||||
public int Progress {
|
[ObservableProperty]
|
||||||
get => progress;
|
|
||||||
set => Change(ref progress, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool isIndeterminate;
|
private bool isIndeterminate;
|
||||||
|
|
||||||
public bool IsIndeterminate {
|
|
||||||
get => isIndeterminate;
|
|
||||||
set => Change(ref isIndeterminate, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -2,11 +2,11 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DHT.Utils.Models;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox;
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
class TextBoxDialogModel : BaseModel {
|
class TextBoxDialogModel : ObservableObject {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public string Description { get; init; } = "";
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class TextBoxDialogModel : BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
sealed class TextBoxDialogModel<T> : TextBoxDialogModel {
|
||||||
public new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
private new IReadOnlyList<TextBoxItem<T>> Items { get; }
|
||||||
|
|
||||||
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
public IEnumerable<TextBoxItem<T>> ValidItems => Items.Where(static item => item.IsValid);
|
||||||
|
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using DHT.Utils.Models;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace DHT.Desktop.Dialogs.TextBox;
|
namespace DHT.Desktop.Dialogs.TextBox;
|
||||||
|
|
||||||
class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
class TextBoxItem : ObservableObject, INotifyDataErrorInfo {
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public object? Item { get; init; } = null;
|
public object? Item { get; init; } = null;
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class TextBoxItem : BaseModel, INotifyDataErrorInfo {
|
|||||||
public string Value {
|
public string Value {
|
||||||
get => this.value;
|
get => this.value;
|
||||||
set {
|
set {
|
||||||
Change(ref this.value, value);
|
SetProperty(ref this.value, value);
|
||||||
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -39,7 +39,7 @@ static class DiscordAppSettings {
|
|||||||
|
|
||||||
public static async Task<bool?> AreDevToolsEnabled() {
|
public static async Task<bool?> AreDevToolsEnabled() {
|
||||||
try {
|
try {
|
||||||
var settingsJson = await ReadSettingsJson().ConfigureAwait(false);
|
var settingsJson = await ReadSettingsJson();
|
||||||
return AreDevToolsEnabled(settingsJson);
|
return AreDevToolsEnabled(settingsJson);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.Error("Cannot read settings file.");
|
Log.Error("Cannot read settings file.");
|
||||||
|
@@ -5,4 +5,4 @@ namespace DHT.Desktop.Discord;
|
|||||||
|
|
||||||
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
|
||||||
[JsonSerializable(typeof(JsonObject))]
|
[JsonSerializable(typeof(JsonObject))]
|
||||||
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {}
|
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext;
|
||||||
|
@@ -1,131 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Desktop.Common;
|
|
||||||
using DHT.Server;
|
|
||||||
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 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; } = "";
|
|
||||||
|
|
||||||
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 State state;
|
|
||||||
private readonly string verb;
|
|
||||||
|
|
||||||
private readonly RestartableTask<long> matchingAttachmentCountTask;
|
|
||||||
private long? matchingAttachmentCount;
|
|
||||||
private long? totalAttachmentCount;
|
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
|
||||||
public AttachmentFilterPanelModel() : this(State.Dummy) {}
|
|
||||||
|
|
||||||
public AttachmentFilterPanelModel(State state, string verb = "Matches") {
|
|
||||||
this.state = state;
|
|
||||||
this.verb = verb;
|
|
||||||
|
|
||||||
this.matchingAttachmentCountTask = new RestartableTask<long>(SetAttachmentCounts, TaskScheduler.FromCurrentSynchronizationContext());
|
|
||||||
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
|
||||||
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
state.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 = state.Db.Statistics.TotalAttachments;
|
|
||||||
UpdateFilterStatistics();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
|
||||||
var filter = CreateFilter();
|
|
||||||
if (filter.IsEmpty) {
|
|
||||||
matchingAttachmentCountTask.Cancel();
|
|
||||||
matchingAttachmentCount = totalAttachmentCount;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
matchingAttachmentCount = null;
|
|
||||||
UpdateFilterStatisticsText();
|
|
||||||
matchingAttachmentCountTask.Restart(cancellationToken => state.Db.Downloads.CountAttachments(filter, cancellationToken));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -4,11 +4,11 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"
|
x:Class="DHT.Desktop.Main.Controls.DownloadItemFilterPanel"
|
||||||
x:DataType="controls:AttachmentFilterPanelModel">
|
x:DataType="controls:DownloadItemFilterPanelModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<controls:AttachmentFilterPanelModel />
|
<controls:DownloadItemFilterPanelModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
@@ -4,8 +4,8 @@ using Avalonia.Controls;
|
|||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class AttachmentFilterPanel : UserControl {
|
public sealed partial class DownloadItemFilterPanel : UserControl {
|
||||||
public AttachmentFilterPanel() {
|
public DownloadItemFilterPanel() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
124
app/Desktop/Main/Controls/DownloadItemFilterPanelModel.cs
Normal file
124
app/Desktop/Main/Controls/DownloadItemFilterPanelModel.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
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.Utils.Tasks;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
|
sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposable {
|
||||||
|
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 = 0L;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private Unit maximumSizeUnit = AllUnits[0];
|
||||||
|
|
||||||
|
public IEnumerable<Unit> Units => AllUnits;
|
||||||
|
|
||||||
|
private readonly State state;
|
||||||
|
private readonly string verb;
|
||||||
|
|
||||||
|
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.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged);
|
||||||
|
|
||||||
|
UpdateFilterStatistics();
|
||||||
|
|
||||||
|
PropertyChanged += OnPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
downloadItemCountTask.Cancel();
|
||||||
|
downloadItemCountSubscription.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
|
if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) {
|
||||||
|
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,9 +2,12 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.CheckBox;
|
using DHT.Desktop.Dialogs.CheckBox;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
@@ -12,13 +15,11 @@ using DHT.Desktop.Dialogs.Progress;
|
|||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
sealed partial class MessageFilterPanelModel : ObservableObject, IDisposable {
|
||||||
private static readonly HashSet<string> FilterProperties = [
|
private static readonly HashSet<string> FilterProperties = [
|
||||||
nameof(FilterByDate),
|
nameof(FilterByDate),
|
||||||
nameof(StartDate),
|
nameof(StartDate),
|
||||||
@@ -35,70 +36,48 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
|
|
||||||
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
public bool HasAnyFilters => FilterByDate || FilterByChannel || FilterByUser;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool filterByDate = false;
|
private bool filterByDate = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private DateTime? startDate = null;
|
private DateTime? startDate = null;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private DateTime? endDate = null;
|
private DateTime? endDate = null;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool filterByChannel = false;
|
private bool filterByChannel = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private HashSet<ulong>? includedChannels = null;
|
private HashSet<ulong>? includedChannels = null;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool filterByUser = false;
|
private bool filterByUser = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private HashSet<ulong>? includedUsers = null;
|
private HashSet<ulong>? includedUsers = null;
|
||||||
|
|
||||||
public bool FilterByDate {
|
[ObservableProperty]
|
||||||
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;
|
|
||||||
set => Change(ref includedChannels, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool FilterByUser {
|
|
||||||
get => filterByUser;
|
|
||||||
set => Change(ref filterByUser, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public HashSet<ulong>? IncludedUsers {
|
|
||||||
get => includedUsers;
|
|
||||||
set => Change(ref includedUsers, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string channelFilterLabel = "";
|
private string channelFilterLabel = "";
|
||||||
|
|
||||||
public string ChannelFilterLabel {
|
[ObservableProperty]
|
||||||
get => channelFilterLabel;
|
|
||||||
set => Change(ref channelFilterLabel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string userFilterLabel = "";
|
private string userFilterLabel = "";
|
||||||
|
|
||||||
public string UserFilterLabel {
|
|
||||||
get => userFilterLabel;
|
|
||||||
set => Change(ref userFilterLabel, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
private readonly string verb;
|
private readonly string verb;
|
||||||
|
|
||||||
private readonly RestartableTask<long> exportedMessageCountTask;
|
private readonly RestartableTask<long> exportedMessageCountTask;
|
||||||
private long? exportedMessageCount;
|
private long? exportedMessageCount;
|
||||||
|
|
||||||
|
private readonly IDisposable messageCountSubscription;
|
||||||
private long? totalMessageCount;
|
private long? totalMessageCount;
|
||||||
|
|
||||||
|
private readonly IDisposable channelCountSubscription;
|
||||||
|
private long? totalChannelCount;
|
||||||
|
|
||||||
|
private readonly IDisposable userCountSubscription;
|
||||||
|
private long? totalUserCount;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
public MessageFilterPanelModel() : this(null!, State.Dummy) {}
|
||||||
@@ -109,18 +88,24 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
this.verb = verb;
|
this.verb = verb;
|
||||||
|
|
||||||
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
this.exportedMessageCountTask = new RestartableTask<long>(SetExportedMessageCount, TaskScheduler.FromCurrentSynchronizationContext());
|
||||||
|
|
||||||
|
this.messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnMessageCountChanged);
|
||||||
|
this.channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnChannelCountChanged);
|
||||||
|
this.userCountSubscription = state.Db.Users.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnUserCountChanged);
|
||||||
|
|
||||||
UpdateFilterStatistics();
|
UpdateFilterStatistics();
|
||||||
UpdateChannelFilterLabel();
|
UpdateChannelFilterLabel();
|
||||||
UpdateUserFilterLabel();
|
UpdateUserFilterLabel();
|
||||||
|
|
||||||
PropertyChanged += OnPropertyChanged;
|
PropertyChanged += OnPropertyChanged;
|
||||||
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
exportedMessageCountTask.Cancel();
|
exportedMessageCountTask.Cancel();
|
||||||
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
|
messageCountSubscription.Dispose();
|
||||||
|
channelCountSubscription.Dispose();
|
||||||
|
userCountSubscription.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
|
||||||
@@ -137,29 +122,41 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
private void OnMessageCountChanged(long newMessageCount) {
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
|
totalMessageCount = newMessageCount;
|
||||||
totalMessageCount = state.Db.Statistics.TotalMessages;
|
UpdateFilterStatistics();
|
||||||
UpdateFilterStatistics();
|
}
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
|
private void OnChannelCountChanged(long newChannelCount) {
|
||||||
UpdateChannelFilterLabel();
|
totalChannelCount = newChannelCount;
|
||||||
}
|
UpdateChannelFilterLabel();
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalUsers)) {
|
}
|
||||||
UpdateUserFilterLabel();
|
|
||||||
}
|
private void OnUserCountChanged(long newUserCount) {
|
||||||
|
totalUserCount = newUserCount;
|
||||||
|
UpdateUserFilterLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateChannelFilterLabel() {
|
private void UpdateChannelFilterLabel() {
|
||||||
long total = state.Db.Statistics.TotalChannels;
|
if (totalChannelCount.HasValue) {
|
||||||
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
|
long total = totalChannelCount.Value;
|
||||||
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
long included = FilterByChannel && IncludedChannels != null ? IncludedChannels.Count : total;
|
||||||
|
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ChannelFilterLabel = "Loading...";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateUserFilterLabel() {
|
private void UpdateUserFilterLabel() {
|
||||||
long total = state.Db.Statistics.TotalUsers;
|
if (totalUserCount.HasValue) {
|
||||||
long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total;
|
long total = totalUserCount.Value;
|
||||||
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
long included = FilterByUser && IncludedUsers != null ? IncludedUsers.Count : total;
|
||||||
|
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
UserFilterLabel = "Loading...";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateFilterStatistics() {
|
private void UpdateFilterStatistics() {
|
||||||
@@ -224,13 +221,13 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
|
|
||||||
items.Add(new CheckBoxItem<ulong>(channelId) {
|
items.Add(new CheckBoxItem<ulong>(channelId) {
|
||||||
Title = title,
|
Title = title,
|
||||||
Checked = IncludedChannels == null || IncludedChannels.Contains(channelId)
|
IsChecked = IncludedChannels == null || IncludedChannels.Contains(channelId)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
const string Title = "Included Channels";
|
const string Title = "Included Channels";
|
||||||
|
|
||||||
List<CheckBoxItem<ulong>> items;
|
List<CheckBoxItem<ulong>> items;
|
||||||
@@ -252,12 +249,9 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
var checkBoxItems = new List<CheckBoxItem<ulong>>();
|
||||||
|
|
||||||
await foreach (var user in state.Db.Users.Get()) {
|
await foreach (var user in state.Db.Users.Get()) {
|
||||||
var name = user.Name;
|
|
||||||
var discriminator = user.Discriminator;
|
|
||||||
|
|
||||||
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
checkBoxItems.Add(new CheckBoxItem<ulong>(user.Id) {
|
||||||
Title = discriminator == null ? name : name + " #" + discriminator,
|
Title = user.DisplayName == null ? user.Name : $"{user.DisplayName} ({user.Name})",
|
||||||
Checked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
IsChecked = IncludedUsers == null || IncludedUsers.Contains(user.Id)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +259,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const string Title = "Included Users";
|
const string Title = "Included Users";
|
||||||
|
|
||||||
List<CheckBoxItem<ulong>> items;
|
List<CheckBoxItem<ulong>> items;
|
||||||
try {
|
try {
|
||||||
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
items = await ProgressDialog.ShowIndeterminate(window, Title, "Loading users...", PrepareUserItems);
|
||||||
|
@@ -2,47 +2,31 @@ using System;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
|
sealed partial class ServerConfigurationPanelModel : ObservableObject, IDisposable {
|
||||||
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
|
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||||
private string inputPort;
|
private string inputPort;
|
||||||
|
|
||||||
public string InputPort {
|
[ObservableProperty]
|
||||||
get => inputPort;
|
[NotifyPropertyChangedFor(nameof(HasMadeChanges))]
|
||||||
set {
|
|
||||||
Change(ref inputPort, value);
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string inputToken;
|
private string inputToken;
|
||||||
|
|
||||||
public string InputToken {
|
|
||||||
get => inputToken;
|
|
||||||
set {
|
|
||||||
Change(ref inputToken, value);
|
|
||||||
OnPropertyChanged(nameof(HasMadeChanges));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private bool isToggleServerButtonEnabled = true;
|
private bool isToggleServerButtonEnabled = true;
|
||||||
|
|
||||||
public bool IsToggleServerButtonEnabled {
|
|
||||||
get => isToggleServerButtonEnabled;
|
|
||||||
set => Change(ref isToggleServerButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
@@ -45,17 +45,17 @@
|
|||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Servers</TextBlock>
|
<TextBlock Classes="label">Servers</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding ServerCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Channels</TextBlock>
|
<TextBlock Classes="label">Channels</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding ChannelCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Rectangle />
|
<Rectangle />
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
<TextBlock Classes="label">Messages</TextBlock>
|
<TextBlock Classes="label">Messages</TextBlock>
|
||||||
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
<TextBlock Classes="value" Text="{Binding MessageCount, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
@@ -1,15 +1,25 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Server.Service;
|
using DHT.Server.Service;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Controls;
|
namespace DHT.Desktop.Main.Controls;
|
||||||
|
|
||||||
sealed class StatusBarModel : BaseModel, IDisposable {
|
sealed partial class StatusBarModel : ObservableObject, IDisposable {
|
||||||
public DatabaseStatistics DatabaseStatistics { get; }
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private long? serverCount;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private long? channelCount;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private long? messageCount;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ServerStatusText))]
|
||||||
private ServerManager.Status serverStatus;
|
private ServerManager.Status serverStatus;
|
||||||
|
|
||||||
public string ServerStatusText => serverStatus switch {
|
public string ServerStatusText => serverStatus switch {
|
||||||
@@ -21,26 +31,33 @@ sealed class StatusBarModel : BaseModel, IDisposable {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly State state;
|
private readonly State state;
|
||||||
|
private readonly IDisposable serverCountSubscription;
|
||||||
|
private readonly IDisposable channelCountSubscription;
|
||||||
|
private readonly IDisposable messageCountSubscription;
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
[Obsolete("Designer")]
|
||||||
public StatusBarModel() : this(State.Dummy) {}
|
public StatusBarModel() : this(State.Dummy) {}
|
||||||
|
|
||||||
public StatusBarModel(State state) {
|
public StatusBarModel(State state) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.DatabaseStatistics = state.Db.Statistics;
|
|
||||||
|
serverCountSubscription = state.Db.Servers.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newServerCount => ServerCount = newServerCount);
|
||||||
|
channelCountSubscription = state.Db.Channels.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newChannelCount => ChannelCount = newChannelCount);
|
||||||
|
messageCountSubscription = state.Db.Messages.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(newMessageCount => MessageCount = newMessageCount);
|
||||||
|
|
||||||
state.Server.StatusChanged += OnServerStatusChanged;
|
state.Server.StatusChanged += OnServerStatusChanged;
|
||||||
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
|
serverCountSubscription.Dispose();
|
||||||
|
channelCountSubscription.Dispose();
|
||||||
|
messageCountSubscription.Dispose();
|
||||||
|
|
||||||
state.Server.StatusChanged -= OnServerStatusChanged;
|
state.Server.StatusChanged -= OnServerStatusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
|
||||||
Dispatcher.UIThread.InvokeAsync(() => {
|
Dispatcher.UIThread.InvokeAsync(() => ServerStatus = e);
|
||||||
serverStatus = e;
|
|
||||||
OnPropertyChanged(nameof(ServerStatusText));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,7 +8,7 @@
|
|||||||
x:DataType="main:MainWindowModel"
|
x:DataType="main:MainWindowModel"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
|
||||||
Width="800" Height="500"
|
Width="820" Height="520"
|
||||||
MinWidth="520" MinHeight="300"
|
MinWidth="520" MinHeight="300"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
Closing="OnClosing">
|
Closing="OnClosing">
|
||||||
|
@@ -1,9 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using DHT.Desktop.Main.Pages;
|
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
@@ -43,13 +41,5 @@ public sealed partial class MainWindow : Window {
|
|||||||
Log.Error("Caught exception while disposing window: " + ex);
|
Log.Error("Caught exception while disposing window: " + ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {
|
|
||||||
try {
|
|
||||||
File.Delete(temporaryFile);
|
|
||||||
} catch (Exception) {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,24 +3,26 @@ using System.IO;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Main.Screens;
|
using DHT.Desktop.Main.Screens;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main;
|
namespace DHT.Desktop.Main;
|
||||||
|
|
||||||
sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable {
|
||||||
private const string DefaultTitle = "Discord History Tracker";
|
private const string DefaultTitle = "Discord History Tracker";
|
||||||
|
|
||||||
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
private static readonly Log Log = Log.ForType<MainWindowModel>();
|
||||||
|
|
||||||
public string Title { get; private set; } = DefaultTitle;
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private string title = DefaultTitle;
|
||||||
|
|
||||||
public UserControl CurrentScreen { get; private set; }
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
private UserControl currentScreen;
|
||||||
|
|
||||||
private readonly WelcomeScreen welcomeScreen;
|
private readonly WelcomeScreen welcomeScreen;
|
||||||
private readonly WelcomeScreenModel welcomeScreenModel;
|
private readonly WelcomeScreenModel welcomeScreenModel;
|
||||||
@@ -28,6 +30,7 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
private MainContentScreenModel? mainContentScreenModel;
|
private MainContentScreenModel? mainContentScreenModel;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
private readonly int? concurrentDownloads;
|
||||||
|
|
||||||
private State? state;
|
private State? state;
|
||||||
|
|
||||||
@@ -41,7 +44,7 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
|
|
||||||
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
welcomeScreen = new WelcomeScreen { DataContext = welcomeScreenModel };
|
||||||
CurrentScreen = welcomeScreen;
|
currentScreen = welcomeScreen;
|
||||||
|
|
||||||
var dbFile = args.DatabaseFile;
|
var dbFile = args.DatabaseFile;
|
||||||
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
if (!string.IsNullOrWhiteSpace(dbFile)) {
|
||||||
@@ -71,6 +74,8 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
if (args.ServerToken != null) {
|
if (args.ServerToken != null) {
|
||||||
ServerConfiguration.Token = args.ServerToken;
|
ServerConfiguration.Token = args.ServerToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
concurrentDownloads = args.ConcurrentDownloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
private async void OnDatabaseSelected(object? sender, IDatabaseFile db) {
|
||||||
@@ -78,7 +83,7 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
|
|
||||||
await DisposeState();
|
await DisposeState();
|
||||||
|
|
||||||
state = new State(db);
|
state = new State(db, concurrentDownloads);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
|
||||||
@@ -93,9 +98,6 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
|
||||||
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Title));
|
|
||||||
OnPropertyChanged(nameof(CurrentScreen));
|
|
||||||
|
|
||||||
window.Focus();
|
window.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +114,6 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
|
|||||||
CurrentScreen = welcomeScreen;
|
CurrentScreen = welcomeScreen;
|
||||||
|
|
||||||
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
welcomeScreenModel.DatabaseSelected += OnDatabaseSelected;
|
||||||
|
|
||||||
OnPropertyChanged(nameof(Title));
|
|
||||||
OnPropertyChanged(nameof(CurrentScreen));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DisposeState() {
|
private async Task DisposeState() {
|
||||||
|
@@ -5,11 +5,10 @@ using DHT.Desktop.Dialogs.Message;
|
|||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class AdvancedPageModel : BaseModel, IDisposable {
|
sealed class AdvancedPageModel : IDisposable {
|
||||||
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
public ServerConfigurationPanelModel ServerConfigurationModel { get; }
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
@@ -1,249 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Reactive.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.ReactiveUI;
|
|
||||||
using DHT.Desktop.Common;
|
|
||||||
using DHT.Desktop.Main.Controls;
|
|
||||||
using DHT.Server;
|
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Data.Aggregations;
|
|
||||||
using DHT.Server.Data.Filters;
|
|
||||||
using DHT.Server.Database;
|
|
||||||
using DHT.Utils.Logging;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
using DHT.Utils.Tasks;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
|
||||||
|
|
||||||
sealed class AttachmentsPageModel : BaseModel, IDisposable {
|
|
||||||
private static readonly Log Log = Log.ForType<AttachmentsPageModel>();
|
|
||||||
|
|
||||||
private static readonly DownloadItemFilter EnqueuedItemFilter = new () {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
|
||||||
DownloadStatus.Enqueued,
|
|
||||||
DownloadStatus.Downloading
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private bool isToggleDownloadButtonEnabled = true;
|
|
||||||
|
|
||||||
public bool IsToggleDownloadButtonEnabled {
|
|
||||||
get => isToggleDownloadButtonEnabled;
|
|
||||||
set => Change(ref isToggleDownloadButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
|
|
||||||
|
|
||||||
private bool isRetryingFailedDownloads = false;
|
|
||||||
|
|
||||||
public bool IsRetryingFailedDownloads {
|
|
||||||
get => isRetryingFailedDownloads;
|
|
||||||
set {
|
|
||||||
isRetryingFailedDownloads = value;
|
|
||||||
OnPropertyChanged(nameof(IsRetryFailedOnDownloadsButtonEnabled));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsRetryFailedOnDownloadsButtonEnabled => !IsRetryingFailedDownloads && HasFailedDownloads;
|
|
||||||
|
|
||||||
public string DownloadMessage { get; set; } = "";
|
|
||||||
public double DownloadProgress => totalItemsToDownloadCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / totalItemsToDownloadCount.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 => [
|
|
||||||
statisticsEnqueued,
|
|
||||||
statisticsDownloaded,
|
|
||||||
statisticsFailed,
|
|
||||||
statisticsSkipped
|
|
||||||
];
|
|
||||||
|
|
||||||
public bool IsDownloading => state.Downloader.IsDownloading;
|
|
||||||
public bool HasFailedDownloads => statisticsFailed.Items > 0;
|
|
||||||
|
|
||||||
private readonly State state;
|
|
||||||
private readonly ThrottledTask<int> enqueueDownloadItemsTask;
|
|
||||||
private readonly ThrottledTask<DownloadStatusStatistics> downloadStatisticsTask;
|
|
||||||
|
|
||||||
private IDisposable? finishedItemsSubscription;
|
|
||||||
private int doneItemsCount;
|
|
||||||
private int totalEnqueuedItemCount;
|
|
||||||
private int? totalItemsToDownloadCount;
|
|
||||||
|
|
||||||
public AttachmentsPageModel() : this(State.Dummy) {}
|
|
||||||
|
|
||||||
public AttachmentsPageModel(State state) {
|
|
||||||
this.state = state;
|
|
||||||
|
|
||||||
FilterModel = new AttachmentFilterPanelModel(state);
|
|
||||||
|
|
||||||
enqueueDownloadItemsTask = new ThrottledTask<int>(OnItemsEnqueued, TaskScheduler.FromCurrentSynchronizationContext());
|
|
||||||
downloadStatisticsTask = new ThrottledTask<DownloadStatusStatistics>(UpdateStatistics, TaskScheduler.FromCurrentSynchronizationContext());
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
|
|
||||||
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
|
|
||||||
enqueueDownloadItemsTask.Dispose();
|
|
||||||
downloadStatisticsTask.Dispose();
|
|
||||||
finishedItemsSubscription?.Dispose();
|
|
||||||
FilterModel.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
|
|
||||||
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
|
|
||||||
if (IsDownloading) {
|
|
||||||
EnqueueDownloadItemsLater();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (e.PropertyName == nameof(DatabaseStatistics.TotalDownloads)) {
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task EnqueueDownloadItems() {
|
|
||||||
OnItemsEnqueued(await state.Db.Downloads.EnqueueDownloadItems(CreateAttachmentFilter()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnqueueDownloadItemsLater() {
|
|
||||||
var filter = CreateAttachmentFilter();
|
|
||||||
enqueueDownloadItemsTask.Post(cancellationToken => state.Db.Downloads.EnqueueDownloadItems(filter, cancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnItemsEnqueued(int itemCount) {
|
|
||||||
totalEnqueuedItemCount += itemCount;
|
|
||||||
totalItemsToDownloadCount = totalEnqueuedItemCount;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
}
|
|
||||||
|
|
||||||
private AttachmentFilter CreateAttachmentFilter() {
|
|
||||||
var filter = FilterModel.CreateFilter();
|
|
||||||
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task OnClickToggleDownload() {
|
|
||||||
IsToggleDownloadButtonEnabled = false;
|
|
||||||
|
|
||||||
if (IsDownloading) {
|
|
||||||
await state.Downloader.Stop();
|
|
||||||
|
|
||||||
finishedItemsSubscription?.Dispose();
|
|
||||||
finishedItemsSubscription = null;
|
|
||||||
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
|
|
||||||
await state.Db.Downloads.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
|
|
||||||
|
|
||||||
doneItemsCount = 0;
|
|
||||||
totalEnqueuedItemCount = 0;
|
|
||||||
totalItemsToDownloadCount = null;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
var finishedItems = await state.Downloader.Start();
|
|
||||||
|
|
||||||
finishedItemsSubscription = finishedItems.Select(static _ => true)
|
|
||||||
.Buffer(TimeSpan.FromMilliseconds(100))
|
|
||||||
.Select(static items => items.Count)
|
|
||||||
.Where(static items => items > 0)
|
|
||||||
.ObserveOn(AvaloniaScheduler.Instance)
|
|
||||||
.Subscribe(OnItemsFinished);
|
|
||||||
|
|
||||||
await EnqueueDownloadItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
|
||||||
OnPropertyChanged(nameof(IsDownloading));
|
|
||||||
IsToggleDownloadButtonEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnItemsFinished(int finishedItemCount) {
|
|
||||||
doneItemsCount += finishedItemCount;
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
RecomputeDownloadStatistics();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task OnClickRetryFailedDownloads() {
|
|
||||||
IsRetryingFailedDownloads = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
var allExceptFailedFilter = new DownloadItemFilter {
|
|
||||||
IncludeStatuses = new HashSet<DownloadStatus> {
|
|
||||||
DownloadStatus.Enqueued,
|
|
||||||
DownloadStatus.Downloading,
|
|
||||||
DownloadStatus.Success
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await state.Db.Downloads.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
|
|
||||||
|
|
||||||
if (IsDownloading) {
|
|
||||||
await EnqueueDownloadItems();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.Error(e);
|
|
||||||
} finally {
|
|
||||||
IsRetryingFailedDownloads = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecomputeDownloadStatistics() {
|
|
||||||
downloadStatisticsTask.Post(state.Db.Downloads.GetStatistics);
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
OnPropertyChanged(nameof(IsRetryFailedOnDownloadsButtonEnabled));
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateDownloadMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateDownloadMessage() {
|
|
||||||
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (totalItemsToDownloadCount?.Format() ?? "?") : "";
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(DownloadMessage));
|
|
||||||
OnPropertyChanged(nameof(DownloadProgress));
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class StatisticsRow {
|
|
||||||
public string State { get; }
|
|
||||||
public int Items { get; set; }
|
|
||||||
public ulong? Size { get; set; }
|
|
||||||
|
|
||||||
public StatisticsRow(string state) {
|
|
||||||
State = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -18,13 +18,12 @@ using DHT.Server;
|
|||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Import;
|
using DHT.Server.Database.Import;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class DatabasePageModel : BaseModel {
|
sealed class DatabasePageModel {
|
||||||
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
private static readonly Log Log = Log.ForType<DatabasePageModel>();
|
||||||
|
|
||||||
public IDatabaseFile Db { get; }
|
public IDatabaseFile Db { get; }
|
||||||
@@ -81,7 +80,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
|
|
||||||
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
|
||||||
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
|
||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
|
||||||
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
await target.AddFrom(db);
|
await target.AddFrom(db);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
db.Dispose();
|
await db.DisposeAsync();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -102,7 +101,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
private readonly ProgressDialog dialog;
|
private readonly ProgressDialog dialog;
|
||||||
private readonly int total;
|
private readonly int total;
|
||||||
private bool? decision;
|
private bool? decision;
|
||||||
|
|
||||||
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
|
||||||
this.total = total;
|
this.total = total;
|
||||||
this.dialog = dialog;
|
this.dialog = dialog;
|
||||||
@@ -150,7 +149,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
|
|
||||||
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
await PerformImport(target, paths, dialog, callback, "Legacy Archive Import", "Legacy Archive Error", "archive file", async path => {
|
||||||
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
await using var jsonStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
return await LegacyArchiveImport.Read(jsonStream, target, fakeSnowflake, async servers => {
|
||||||
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
SynchronizationContext? prevSyncContext = SynchronizationContext.Current;
|
||||||
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
|
||||||
@@ -165,7 +164,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
static bool IsValidSnowflake(string value) {
|
static bool IsValidSnowflake(string value) {
|
||||||
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
return string.IsNullOrEmpty(value) || ulong.TryParse(value, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
var items = new List<TextBoxItem<DHT.Server.Data.Server>>();
|
||||||
|
|
||||||
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
foreach (var server in servers.OrderBy(static server => server.Type).ThenBy(static server => server.Name)) {
|
||||||
@@ -194,7 +193,7 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
|
|
||||||
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
private static async Task PerformImport(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback, string neutralDialogTitle, string errorDialogTitle, string itemName, Func<string, Task<bool>> performImport) {
|
||||||
int total = paths.Length;
|
int total = paths.Length;
|
||||||
var oldStatistics = await target.SnapshotStatistics();
|
var oldStatistics = await DatabaseStatistics.Take(target);
|
||||||
|
|
||||||
int successful = 0;
|
int successful = 0;
|
||||||
int finished = 0;
|
int finished = 0;
|
||||||
@@ -225,15 +224,26 @@ sealed class DatabasePageModel : BaseModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newStatistics = await target.SnapshotStatistics();
|
var newStatistics = await DatabaseStatistics.Take(target);
|
||||||
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
await Dialog.ShowOk(dialog, neutralDialogTitle, GetImportDialogMessage(oldStatistics, newStatistics, successful, total, itemName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetImportDialogMessage(DatabaseStatisticsSnapshot oldStatistics, DatabaseStatisticsSnapshot newStatistics, int successfulItems, int totalItems, string itemName) {
|
private sealed record DatabaseStatistics(long ServerCount, long ChannelCount, long UserCount, long MessageCount) {
|
||||||
long newServers = newStatistics.TotalServers - oldStatistics.TotalServers;
|
public static async Task<DatabaseStatistics> Take(IDatabaseFile db) {
|
||||||
long newChannels = newStatistics.TotalChannels - oldStatistics.TotalChannels;
|
return new DatabaseStatistics(
|
||||||
long newUsers = newStatistics.TotalUsers - oldStatistics.TotalUsers;
|
await db.Servers.Count(),
|
||||||
long newMessages = newStatistics.TotalMessages - oldStatistics.TotalMessages;
|
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;
|
||||||
|
|
||||||
StringBuilder message = new StringBuilder();
|
StringBuilder message = new StringBuilder();
|
||||||
message.Append("Processed ");
|
message.Append("Processed ");
|
||||||
|
@@ -9,168 +9,166 @@ using DHT.Desktop.Dialogs.Progress;
|
|||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Service;
|
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";
|
|
||||||
|
|
||||||
private readonly Window window;
|
sealed class DebugPageModel {
|
||||||
private readonly State state;
|
public string GenerateChannels { get; set; } = "0";
|
||||||
|
public string GenerateUsers { get; set; } = "0";
|
||||||
|
public string GenerateMessages { get; set; } = "0";
|
||||||
|
|
||||||
[Obsolete("Designer")]
|
private readonly Window window;
|
||||||
public DebugPageModel() : this(null!, State.Dummy) {}
|
private readonly State state;
|
||||||
|
|
||||||
public DebugPageModel(Window window, State state) {
|
[Obsolete("Designer")]
|
||||||
this.window = window;
|
public DebugPageModel() : this(null!, State.Dummy) {}
|
||||||
this.state = state;
|
|
||||||
|
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 async void OnClickAddRandomDataToDatabase() {
|
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
||||||
if (!int.TryParse(GenerateChannels, out int channels) || channels < 1) {
|
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of channels must be at least 1!");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!int.TryParse(GenerateUsers, out int users) || users < 1) {
|
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of users must be at least 1!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
|
||||||
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int BatchSize = 500;
|
if (!int.TryParse(GenerateMessages, out int messages) || messages < 1) {
|
||||||
|
await Dialog.ShowOk(window, "Generate Random Data", "Amount of messages must be at least 1!");
|
||||||
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
return;
|
||||||
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"),
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
await state.Db.Messages.Add(messages);
|
|
||||||
|
|
||||||
messageCount -= BatchSize;
|
|
||||||
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ulong RandomId(Random rand) {
|
await ProgressDialog.Show(window, "Generating Random Data", async (_, callback) => await GenerateRandomData(channels, users, messages, callback));
|
||||||
ulong h = unchecked((ulong) rand.Next());
|
}
|
||||||
ulong l = unchecked((ulong) rand.Next());
|
|
||||||
return (h << 32) | l;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string RandomName(string prefix) {
|
private const int BatchSize = 500;
|
||||||
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
private async Task GenerateRandomData(int channelCount, int userCount, int messageCount, IProgressCallback callback) {
|
||||||
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
int batchCount = (messageCount + BatchSize - 1) / BatchSize;
|
||||||
}
|
await callback.Update("Adding messages in batches of " + BatchSize, 0, batchCount);
|
||||||
|
|
||||||
private static readonly string[] RandomWords = {
|
var rand = new Random();
|
||||||
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
var server = new DHT.Server.Data.Server {
|
||||||
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
Id = RandomId(rand),
|
||||||
"cabbage", "cacao", "cantaloupe", "caper", "carambola", "carrot", "cauliflower", "celery", "chard", "cherry", "chokeberry", "citron", "clementine", "coconut", "corn", "crabapple", "cranberry", "cucumber", "currant",
|
Name = RandomName("s"),
|
||||||
"daikon", "date", "dewberry", "durian",
|
Type = ServerType.Server,
|
||||||
"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) {
|
var channels = Enumerable.Range(0, channelCount).Select(i => new Channel {
|
||||||
int wordCount = 1 + (int) Math.Floor(maxWords * Math.Pow(rand.NextDouble(), 3));
|
Id = RandomId(rand),
|
||||||
return string.Join(' ', Enumerable.Range(0, wordCount).Select(_ => RandomWords[rand.Next(RandomWords.Length)]));
|
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,
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
await state.Db.Messages.Add(messages);
|
||||||
|
|
||||||
|
messageCount -= BatchSize;
|
||||||
|
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ulong RandomId(Random rand) {
|
||||||
|
ulong h = unchecked((ulong) rand.Next());
|
||||||
|
ulong l = unchecked((ulong) rand.Next());
|
||||||
|
return (h << 32) | l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RandomName(string prefix) {
|
||||||
|
return prefix + "-" + ServerUtils.GenerateRandomToken(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T RandomBiasedIndex<T>(Random rand, T[] options) {
|
||||||
|
return options[(int) Math.Floor(options.Length * rand.NextDouble() * rand.NextDouble())];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] RandomWords = [
|
||||||
|
"apple", "apricot", "artichoke", "arugula", "asparagus", "avocado",
|
||||||
|
"banana", "bean", "beechnut", "beet", "blackberry", "blackcurrant", "blueberry", "boysenberry", "bramble", "broccoli",
|
||||||
|
"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)]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
using DHT.Utils.Models;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages {
|
sealed class DebugPageModel {
|
||||||
sealed class DebugPageModel : BaseModel {
|
public string GenerateChannels { get; set; } = "0";
|
||||||
public string GenerateChannels { get; set; } = "0";
|
public string GenerateUsers { get; set; } = "0";
|
||||||
public string GenerateUsers { get; set; } = "0";
|
public string GenerateMessages { get; set; } = "0";
|
||||||
public string GenerateMessages { get; set; } = "0";
|
|
||||||
|
|
||||||
public void OnClickAddRandomDataToDatabase() {}
|
public void OnClickAddRandomDataToDatabase() {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
@@ -5,11 +5,11 @@
|
|||||||
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
|
||||||
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
|
x:Class="DHT.Desktop.Main.Pages.DownloadsPage"
|
||||||
x:DataType="pages:AttachmentsPageModel">
|
x:DataType="pages:DownloadsPageModel">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<pages:AttachmentsPageModel />
|
<pages:DownloadsPageModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
@@ -31,19 +31,15 @@
|
|||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<StackPanel Orientation="Vertical" Spacing="20">
|
<StackPanel Orientation="Vertical" Spacing="20">
|
||||||
<DockPanel>
|
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" />
|
||||||
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
|
<controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" />
|
||||||
<TextBlock Text="{Binding DownloadMessage}" MinWidth="100" Margin="10 0 0 0" VerticalAlignment="Center" TextAlignment="Right" 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">
|
<StackPanel Orientation="Vertical" Spacing="12">
|
||||||
<Expander Header="Download Status" IsExpanded="True">
|
<Expander Header="Download Status" IsExpanded="True">
|
||||||
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
|
<DataGridTextColumn Header="State" Binding="{Binding State, Mode=OneWay}" Width="*" />
|
||||||
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
<DataGridTextColumn Header="Files" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
|
||||||
<DataGridTextColumn Header="Size" Binding="{Binding Size, Mode=OneWay, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
|
<DataGridTextColumn Header="Size" Binding="{Binding SizeText, Mode=OneWay}" Width="*" CellStyleClasses="right" />
|
||||||
</DataGrid.Columns>
|
</DataGrid.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</Expander>
|
</Expander>
|
@@ -4,8 +4,8 @@ using Avalonia.Controls;
|
|||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
|
||||||
public sealed partial class AttachmentsPage : UserControl {
|
public sealed partial class DownloadsPage : UserControl {
|
||||||
public AttachmentsPage() {
|
public DownloadsPage() {
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
}
|
186
app/Desktop/Main/Pages/DownloadsPageModel.cs
Normal file
186
app/Desktop/Main/Pages/DownloadsPageModel.cs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
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.Download;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
|
sealed partial class DownloadsPageModel : ObservableObject, IDisposable {
|
||||||
|
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 void Dispose() {
|
||||||
|
finishedItemsSubscription?.Dispose();
|
||||||
|
|
||||||
|
downloadItemCountSubscription.Dispose();
|
||||||
|
downloadStatisticsTask.Dispose();
|
||||||
|
|
||||||
|
FilterModel.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDownloadCountChanged(long newDownloadCount) {
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnClickToggleDownload() {
|
||||||
|
IsToggleDownloadButtonEnabled = false;
|
||||||
|
|
||||||
|
if (IsDownloading) {
|
||||||
|
await state.Downloader.Stop();
|
||||||
|
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||||
|
|
||||||
|
finishedItemsSubscription?.Dispose();
|
||||||
|
finishedItemsSubscription = null;
|
||||||
|
|
||||||
|
currentDownloadFilter = null;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await state.Db.Downloads.MoveDownloadingItemsBackToQueue();
|
||||||
|
|
||||||
|
var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter());
|
||||||
|
finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecomputeDownloadStatistics();
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(ToggleDownloadButtonText));
|
||||||
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
|
IsToggleDownloadButtonEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -3,33 +3,25 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Discord;
|
using DHT.Desktop.Discord;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Utils.Models;
|
|
||||||
using static DHT.Desktop.Program;
|
using static DHT.Desktop.Program;
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class TrackingPageModel : BaseModel {
|
sealed partial class TrackingPageModel : ObservableObject {
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private bool isCopyTrackingScriptButtonEnabled = true;
|
private bool isCopyTrackingScriptButtonEnabled = true;
|
||||||
|
|
||||||
public bool IsCopyTrackingScriptButtonEnabled {
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
get => isCopyTrackingScriptButtonEnabled;
|
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||||
set => Change(ref isCopyTrackingScriptButtonEnabled, value);
|
private bool? areDevToolsEnabled = null;
|
||||||
}
|
|
||||||
|
|
||||||
private bool? areDevToolsEnabled;
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ToggleAppDevToolsButtonText))]
|
||||||
private bool? AreDevToolsEnabled {
|
private bool isToggleAppDevToolsButtonEnabled = false;
|
||||||
get => areDevToolsEnabled;
|
|
||||||
set {
|
|
||||||
Change(ref areDevToolsEnabled, value);
|
|
||||||
OnPropertyChanged(nameof(ToggleAppDevToolsButtonText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsToggleAppDevToolsButtonEnabled { get; private set; } = false;
|
|
||||||
|
|
||||||
public string ToggleAppDevToolsButtonText {
|
public string ToggleAppDevToolsButtonText {
|
||||||
get {
|
get {
|
||||||
@@ -86,17 +78,15 @@ sealed class TrackingPageModel : BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task InitializeDevToolsToggle() {
|
private async Task InitializeDevToolsToggle() {
|
||||||
bool? devToolsEnabled = await DiscordAppSettings.AreDevToolsEnabled();
|
bool? devToolsEnabled = await Task.Run(DiscordAppSettings.AreDevToolsEnabled);
|
||||||
|
|
||||||
if (devToolsEnabled.HasValue) {
|
if (devToolsEnabled.HasValue) {
|
||||||
IsToggleAppDevToolsButtonEnabled = true;
|
|
||||||
AreDevToolsEnabled = devToolsEnabled.Value;
|
AreDevToolsEnabled = devToolsEnabled.Value;
|
||||||
|
IsToggleAppDevToolsButtonEnabled = true;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
IsToggleAppDevToolsButtonEnabled = false;
|
IsToggleAppDevToolsButtonEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(IsToggleAppDevToolsButtonEnabled));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnClickToggleAppDevTools() {
|
public async Task OnClickToggleAppDevTools() {
|
||||||
|
@@ -21,7 +21,6 @@
|
|||||||
<StackPanel Orientation="Vertical" Spacing="20">
|
<StackPanel Orientation="Vertical" Spacing="20">
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
||||||
<Button Command="{Binding OnClickOpenViewer}" Margin="0 0 5 0">Open Viewer</Button>
|
<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>
|
</StackPanel>
|
||||||
<controls:MessageFilterPanel DataContext="{Binding FilterModel}" />
|
<controls:MessageFilterPanel DataContext="{Binding FilterModel}" />
|
||||||
<Expander Header="Database Tools">
|
<Expander Header="Database Tools">
|
||||||
|
@@ -1,45 +1,28 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.File;
|
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Desktop.Main.Controls;
|
using DHT.Desktop.Main.Controls;
|
||||||
using DHT.Desktop.Server;
|
using DHT.Desktop.Server;
|
||||||
using DHT.Server;
|
using DHT.Server;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database.Export;
|
using DHT.Server.Service.Viewer;
|
||||||
using DHT.Server.Database.Export.Strategy;
|
|
||||||
using DHT.Utils.Models;
|
|
||||||
using static DHT.Desktop.Program;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Pages;
|
namespace DHT.Desktop.Main.Pages;
|
||||||
|
|
||||||
sealed class ViewerPageModel : BaseModel, IDisposable {
|
sealed partial class ViewerPageModel : ObservableObject, IDisposable {
|
||||||
public static readonly ConcurrentBag<string> TemporaryFiles = [];
|
|
||||||
|
|
||||||
private static readonly FilePickerFileType[] ViewerFileTypes = [
|
|
||||||
FileDialogs.CreateFilter("Discord History Viewer", ["html"])
|
|
||||||
];
|
|
||||||
|
|
||||||
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
public bool DatabaseToolFilterModeKeep { get; set; } = true;
|
||||||
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
public bool DatabaseToolFilterModeRemove { get; set; } = false;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
private bool hasFilters = false;
|
private bool hasFilters = false;
|
||||||
|
|
||||||
public bool HasFilters {
|
|
||||||
get => hasFilters;
|
|
||||||
set => Change(ref hasFilters, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public MessageFilterPanelModel FilterModel { get; }
|
public MessageFilterPanelModel FilterModel { get; }
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
@@ -66,93 +49,18 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
|
|||||||
|
|
||||||
public async void OnClickOpenViewer() {
|
public async void OnClickOpenViewer() {
|
||||||
try {
|
try {
|
||||||
var fullPath = await PrepareTemporaryViewerFile();
|
string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
|
||||||
var strategy = new LiveViewerExportStrategy(ServerConfiguration.Port, ServerConfiguration.Token);
|
string serverToken = ServerConfiguration.Token;
|
||||||
|
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
|
||||||
await WriteViewerFile(fullPath, strategy);
|
|
||||||
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
|
|
||||||
} catch (Exception e) {
|
|
||||||
await Dialog.ShowOk(window, "Open Viewer", "Could not save viewer: " + e.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> PrepareTemporaryViewerFile() {
|
|
||||||
return await Task.Run(() => {
|
|
||||||
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
|
|
||||||
string filenameBase = Path.GetFileNameWithoutExtension(state.Db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
|
|
||||||
string fullPath = Path.Combine(rootPath, filenameBase + ".html");
|
|
||||||
|
|
||||||
int counter = 0;
|
Process.Start(new ProcessStartInfo(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId)) {
|
||||||
|
UseShellExecute = true
|
||||||
while (File.Exists(fullPath)) {
|
});
|
||||||
++counter;
|
|
||||||
fullPath = Path.Combine(rootPath, filenameBase + "-" + counter + ".html");
|
|
||||||
}
|
|
||||||
|
|
||||||
TemporaryFiles.Add(fullPath);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(rootPath);
|
|
||||||
|
|
||||||
return fullPath;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void OnClickSaveViewer() {
|
|
||||||
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
|
|
||||||
Title = "Save Viewer",
|
|
||||||
FileTypeChoices = ViewerFileTypes,
|
|
||||||
SuggestedFileName = Path.GetFileNameWithoutExtension(state.Db.Path) + ".html",
|
|
||||||
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(state.Db.Path)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (path == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await WriteViewerFile(path, StandaloneViewerExportStrategy.Instance);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
await Dialog.ShowOk(window, "Save Viewer", "Could not save viewer: " + e.Message);
|
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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'));
|
|
||||||
|
|
||||||
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, state.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 Task OnClickApplyFiltersToDatabase() {
|
public async Task OnClickApplyFiltersToDatabase() {
|
||||||
var filter = FilterModel.CreateFilter();
|
var filter = FilterModel.CreateFilter();
|
||||||
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
var messageCount = await ProgressDialog.ShowIndeterminate(window, "Apply Filters", "Counting matching messages...", _ => state.Db.Messages.Count(filter));
|
||||||
|
@@ -74,7 +74,7 @@
|
|||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Border Classes="statusBar" DockPanel.Dock="Bottom">
|
<Border Classes="statusBar" DockPanel.Dock="Bottom">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Attachments</TextBlock>
|
<TextBlock Classes="invisibleTabItem" DockPanel.Dock="Left">Downloads</TextBlock>
|
||||||
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
|
<controls:StatusBar DataContext="{Binding StatusBarModel}" DockPanel.Dock="Right" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -94,9 +94,9 @@
|
|||||||
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
<ContentPresenter Content="{Binding TrackingPage}" Classes="page" />
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem x:Name="TabAttachments" Header="Attachments" Grid.Row="2">
|
<TabItem x:Name="TabDownloads" Header="Downloads" Grid.Row="2">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<ContentPresenter Content="{Binding AttachmentsPage}" Classes="page" />
|
<ContentPresenter Content="{Binding DownloadsPage}" Classes="page" />
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
|
<TabItem x:Name="TabViewer" Header="Viewer" Grid.Row="3">
|
||||||
|
@@ -13,8 +13,8 @@ sealed class MainContentScreenModel : IDisposable {
|
|||||||
public TrackingPage TrackingPage { get; }
|
public TrackingPage TrackingPage { get; }
|
||||||
private TrackingPageModel TrackingPageModel { get; }
|
private TrackingPageModel TrackingPageModel { get; }
|
||||||
|
|
||||||
public AttachmentsPage AttachmentsPage { get; }
|
public DownloadsPage DownloadsPage { get; }
|
||||||
private AttachmentsPageModel AttachmentsPageModel { get; }
|
private DownloadsPageModel DownloadsPageModel { get; }
|
||||||
|
|
||||||
public ViewerPage ViewerPage { get; }
|
public ViewerPage ViewerPage { get; }
|
||||||
private ViewerPageModel ViewerPageModel { get; }
|
private ViewerPageModel ViewerPageModel { get; }
|
||||||
@@ -52,8 +52,8 @@ sealed class MainContentScreenModel : IDisposable {
|
|||||||
TrackingPageModel = new TrackingPageModel(window);
|
TrackingPageModel = new TrackingPageModel(window);
|
||||||
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
|
||||||
|
|
||||||
AttachmentsPageModel = new AttachmentsPageModel(state);
|
DownloadsPageModel = new DownloadsPageModel(state);
|
||||||
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
|
DownloadsPage = new DownloadsPage { DataContext = DownloadsPageModel };
|
||||||
|
|
||||||
ViewerPageModel = new ViewerPageModel(window, state);
|
ViewerPageModel = new ViewerPageModel(window, state);
|
||||||
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
|
||||||
@@ -72,7 +72,7 @@ sealed class MainContentScreenModel : IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
AttachmentsPageModel.Dispose();
|
DownloadsPageModel.Dispose();
|
||||||
ViewerPageModel.Dispose();
|
ViewerPageModel.Dispose();
|
||||||
AdvancedPageModel.Dispose();
|
AdvancedPageModel.Dispose();
|
||||||
StatusBarModel.Dispose();
|
StatusBarModel.Dispose();
|
||||||
|
@@ -3,25 +3,21 @@ using System.Collections.Generic;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DHT.Desktop.Common;
|
using DHT.Desktop.Common;
|
||||||
using DHT.Desktop.Dialogs.Message;
|
using DHT.Desktop.Dialogs.Message;
|
||||||
using DHT.Desktop.Dialogs.Progress;
|
using DHT.Desktop.Dialogs.Progress;
|
||||||
using DHT.Server.Database;
|
using DHT.Server.Database;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Schema;
|
||||||
using DHT.Utils.Models;
|
|
||||||
|
|
||||||
namespace DHT.Desktop.Main.Screens;
|
namespace DHT.Desktop.Main.Screens;
|
||||||
|
|
||||||
sealed class WelcomeScreenModel : BaseModel {
|
sealed partial class WelcomeScreenModel : ObservableObject {
|
||||||
public string Version => Program.Version;
|
public string Version => Program.Version;
|
||||||
|
|
||||||
|
[ObservableProperty(Setter = Access.Private)]
|
||||||
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
private bool isOpenOrCreateDatabaseButtonEnabled = true;
|
||||||
|
|
||||||
public bool IsOpenOrCreateDatabaseButtonEnabled {
|
|
||||||
get => isOpenOrCreateDatabaseButtonEnabled;
|
|
||||||
set => Change(ref isOpenOrCreateDatabaseButtonEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
public event EventHandler<IDatabaseFile>? DatabaseSelected;
|
||||||
|
|
||||||
private readonly Window window;
|
private readonly Window window;
|
||||||
|
6
app/NuGet.Config
Normal file
6
app/NuGet.Config
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?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,6 +9,8 @@ items:
|
|||||||
pattern: "^[0-9]+$"
|
pattern: "^[0-9]+$"
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
avatar:
|
avatar:
|
||||||
type: string
|
type: string
|
||||||
discriminator:
|
discriminator:
|
||||||
|
@@ -48,6 +48,16 @@ const STATE = (function() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDate = function(date) {
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// noinspection JSUnresolvedReference
|
||||||
|
return date.toDate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const trackingStateChangedListeners = [];
|
const trackingStateChangedListeners = [];
|
||||||
let isTracking = false;
|
let isTracking = false;
|
||||||
|
|
||||||
@@ -59,6 +69,7 @@ const STATE = (function() {
|
|||||||
* @property {String} id
|
* @property {String} id
|
||||||
* @property {String} username
|
* @property {String} username
|
||||||
* @property {String} discriminator
|
* @property {String} discriminator
|
||||||
|
* @property {String} [globalName]
|
||||||
* @property {String} [avatar]
|
* @property {String} [avatar]
|
||||||
* @property {Boolean} [bot]
|
* @property {Boolean} [bot]
|
||||||
*/
|
*/
|
||||||
@@ -69,8 +80,8 @@ const STATE = (function() {
|
|||||||
* @property {String} channel_id
|
* @property {String} channel_id
|
||||||
* @property {DiscordUser} author
|
* @property {DiscordUser} author
|
||||||
* @property {String} content
|
* @property {String} content
|
||||||
* @property {Timestamp} timestamp
|
* @property {Date} timestamp
|
||||||
* @property {Timestamp|null} editedTimestamp
|
* @property {Date|null} editedTimestamp
|
||||||
* @property {DiscordAttachment[]} attachments
|
* @property {DiscordAttachment[]} attachments
|
||||||
* @property {Object[]} embeds
|
* @property {Object[]} embeds
|
||||||
* @property {DiscordMessageReaction[]} [reactions]
|
* @property {DiscordMessageReaction[]} [reactions]
|
||||||
@@ -106,11 +117,6 @@ const STATE = (function() {
|
|||||||
* @property {Boolean} animated
|
* @property {Boolean} animated
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* @name Timestamp
|
|
||||||
* @property {Function} toDate
|
|
||||||
*/
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setup(port, token) {
|
setup(port, token) {
|
||||||
serverPort = port;
|
serverPort = port;
|
||||||
@@ -195,6 +201,10 @@ const STATE = (function() {
|
|||||||
name: user.username
|
name: user.username
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (user.globalName) {
|
||||||
|
obj.displayName = user.globalName;
|
||||||
|
}
|
||||||
|
|
||||||
if (user.avatar) {
|
if (user.avatar) {
|
||||||
obj.avatar = user.avatar;
|
obj.avatar = user.avatar;
|
||||||
}
|
}
|
||||||
@@ -223,12 +233,12 @@ const STATE = (function() {
|
|||||||
sender: msg.author.id,
|
sender: msg.author.id,
|
||||||
channel: msg.channel_id,
|
channel: msg.channel_id,
|
||||||
text: msg.content,
|
text: msg.content,
|
||||||
timestamp: msg.timestamp.toDate().getTime()
|
timestamp: getDate(msg.timestamp).getTime()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (msg.editedTimestamp !== null) {
|
if (msg.editedTimestamp !== null) {
|
||||||
// noinspection JSUnusedGlobalSymbols
|
// noinspection JSUnusedGlobalSymbols
|
||||||
obj.editTimestamp = msg.editedTimestamp.toDate().getTime();
|
obj.editTimestamp = getDate(msg.editedTimestamp).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.messageReference !== null) {
|
if (msg.messageReference !== null) {
|
||||||
|
@@ -2,20 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
|
||||||
<title>Discord Offline History</title>
|
<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">
|
<script type="text/javascript">
|
||||||
window.DHT_EMBEDDED = "/*[ARCHIVE]*/";
|
const query = new URLSearchParams(location.search);
|
||||||
/*[JS]*/
|
window.DHT_SERVER_TOKEN = query.get("token");
|
||||||
|
window.DHT_SERVER_SESSION = query.get("session");
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<script type="module" src="scripts/bootstrap.mjs"></script>
|
||||||
/*[CSS]*/
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="menu">
|
<div id="menu">
|
||||||
<button id="btn-settings">Settings</button>
|
<button id="btn-settings">Settings</button>
|
||||||
|
|
||||||
|
<div class="splitter"></div>
|
||||||
|
|
||||||
<div> <!-- needed to stop the select from messing up -->
|
<div> <!-- needed to stop the select from messing up -->
|
||||||
<select id="opt-messages-per-page">
|
<select id="opt-messages-per-page">
|
||||||
<option value="50">50 messages per page </option>
|
<option value="50">50 messages per page </option>
|
||||||
@@ -64,7 +75,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="channels"></div>
|
<div id="channels">
|
||||||
|
<div class="loading"></div>
|
||||||
|
</div>
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
39
app/Resources/Viewer/scripts/bootstrap.js
vendored
39
app/Resources/Viewer/scripts/bootstrap.js
vendored
@@ -1,39 +0,0 @@
|
|||||||
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.");
|
|
||||||
}
|
|
||||||
});
|
|
111
app/Resources/Viewer/scripts/bootstrap.mjs
Normal file
111
app/Resources/Viewer/scripts/bootstrap.mjs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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,4 +1,10 @@
|
|||||||
const DISCORD = (function() {
|
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 regex = {
|
const regex = {
|
||||||
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
formatBold: /\*\*([\s\S]+?)\*\*(?!\*)/g,
|
||||||
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
formatItalic1: /\*([\s\S]+?)\*(?!\*)/g,
|
||||||
@@ -35,10 +41,24 @@ const DISCORD = (function() {
|
|||||||
let templateReaction;
|
let templateReaction;
|
||||||
let templateReactionCustom;
|
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) {
|
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) + ";";
|
const escapeHtmlMatch = (full, match) => "&#" + match.charCodeAt(0) + ";";
|
||||||
|
|
||||||
processed = processed
|
processed = processed
|
||||||
@@ -54,29 +74,33 @@ const DISCORD = (function() {
|
|||||||
.replace(regex.formatStrike, "<s>$1</s>");
|
.replace(regex.formatStrike, "<s>$1</s>");
|
||||||
}
|
}
|
||||||
|
|
||||||
const animatedEmojiExtension = SETTINGS.enableAnimatedEmoji ? "gif" : "png";
|
const animatedEmojiExtension = settings.enableAnimatedEmoji ? "gif" : "webp";
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
processed = processed
|
processed = processed
|
||||||
.replace(regex.formatUrl, "<a href='$1' target='_blank' rel='noreferrer'>$1</a>")
|
.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.mentionChannel, (full, match) => "<span class='link mention-chat'>#" + state.getChannelName(match) + "</span>")
|
||||||
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='#" + (STATE.getUserTag(match) || "????") + "'>@" + STATE.getUserName(match) + "</span>")
|
.replace(regex.mentionUser, (full, match) => "<span class='link mention-user' title='" + state.getUserName(match) + "'>@" + state.getUserDisplayName(match) + "</span>")
|
||||||
.replace(regex.customEmojiStatic, "<img src='https://cdn.discordapp.com/emojis/$2.png' alt=':$1:' title=':$1:' class='emoji'>")
|
.replace(regex.customEmojiStatic, (full, m1, m2) => getEmoji(m1, m2, "webp"))
|
||||||
.replace(regex.customEmojiAnimated, "<img src='https://cdn.discordapp.com/emojis/$2." + animatedEmojiExtension + "' alt=':$1:' title=':$1:' class='emoji'>");
|
.replace(regex.customEmojiAnimated, (full, m1, m2) => getEmoji(m1, m2, animatedEmojiExtension));
|
||||||
|
|
||||||
return "<p>" + processed + "</p>";
|
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) {
|
const getImageEmbed = function(url, image) {
|
||||||
if (!SETTINGS.enableImagePreviews) {
|
if (!settings.enableImagePreviews) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (image.width && image.height) {
|
if (image.width && image.height) {
|
||||||
return templateEmbedImageWithSize.apply({ url, src: image.url, width: image.width, height: image.height });
|
return templateEmbedImageWithSize.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url), width: image.width, height: image.height });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return templateEmbedImage.apply({ url, src: image.url });
|
return templateEmbedImage.apply({ url: fileUrlProcessor(url), src: fileUrlProcessor(image.url) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,36 +112,36 @@ const DISCORD = (function() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
setup() {
|
setup() {
|
||||||
templateChannelServer = new TEMPLATE([
|
templateChannelServer = new template([
|
||||||
"<div data-channel='{id}'>",
|
"<div class='channel' data-channel='{id}'>",
|
||||||
"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>",
|
"<div class='info' title='{topic}'><strong class='name'>#{name}</strong>{nsfw}<span class='tag'>{msgcount}</span></div>",
|
||||||
"<span class='server'>{server.name} ({server.type})</span>",
|
"<span class='server'>{server.name} ({server.type})</span>",
|
||||||
"</div>"
|
"</div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateChannelPrivate = new TEMPLATE([
|
templateChannelPrivate = new template([
|
||||||
"<div data-channel='{id}'>",
|
"<div class='channel' data-channel='{id}'>",
|
||||||
"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>",
|
"<div class='info'><strong class='name'>{name}</strong><span class='tag'>{msgcount}</span></div>",
|
||||||
"<span class='server'>({server.type})</span>",
|
"<span class='server'>({server.type})</span>",
|
||||||
"</div>"
|
"</div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateMessageNoAvatar = new TEMPLATE([
|
templateMessageNoAvatar = new template([
|
||||||
"<div>",
|
"<div>",
|
||||||
"<div class='reply-message'>{reply}</div>",
|
"<div class='reply-message'>{reply}</div>",
|
||||||
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||||
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
||||||
"{reactions}",
|
"{reactions}",
|
||||||
"</div>"
|
"</div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateMessageWithAvatar = new TEMPLATE([
|
templateMessageWithAvatar = new template([
|
||||||
"<div>",
|
"<div>",
|
||||||
"<div class='reply-message reply-message-with-avatar'>{reply}</div>",
|
"<div class='reply-message reply-message-with-avatar'>{reply}</div>",
|
||||||
"<div class='avatar-wrapper'>",
|
"<div class='avatar-wrapper'>",
|
||||||
"<div class='avatar'>{avatar}</div>",
|
"<div class='avatar'>{avatar}</div>",
|
||||||
"<div>",
|
"<div>",
|
||||||
"<h2><strong class='username' title='#{user.tag}'>{user.name}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
"<h2><strong class='username' title='{user.name}'>{user.displayName}</strong><span class='info time'>{timestamp}</span>{edit}{jump}</h2>",
|
||||||
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
"<div class='message'>{contents}{embeds}{attachments}</div>",
|
||||||
"{reactions}",
|
"{reactions}",
|
||||||
"</div>",
|
"</div>",
|
||||||
@@ -125,50 +149,52 @@ const DISCORD = (function() {
|
|||||||
"</div>"
|
"</div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateUserAvatar = new TEMPLATE([
|
// noinspection HtmlUnknownTarget
|
||||||
"<img src='https://cdn.discordapp.com/avatars/{id}/{path}.webp?size=128' alt=''>"
|
templateUserAvatar = new template([
|
||||||
|
"<img src='{url}' alt=''>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateAttachmentDownload = new TEMPLATE([
|
templateAttachmentDownload = new template([
|
||||||
"<a href='{url}' class='embed download'>Download {name}</a>"
|
"<a href='{url}' class='embed download'>Download {name}</a>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedImage = new TEMPLATE([
|
templateEmbedImage = new template([
|
||||||
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' alt='' onload='DISCORD.handleImageLoad(this)' onerror='DISCORD.handleImageLoadError(this)'></a><br>"
|
"<a href='{url}' class='embed thumbnail loading'><img src='{src}' alt='' onload='window.DISCORD.handleImageLoad(this)' onerror='window.DISCORD.handleImageLoadError(this)'></a><br>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedImageWithSize = new TEMPLATE([
|
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>"
|
"<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>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// 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>"
|
"<div class='embed download'><a href='{url}' class='title'>{title}</a><p class='desc'>{description}</p></div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedRichNoDescription = new TEMPLATE([
|
templateEmbedRichNoDescription = new template([
|
||||||
"<div class='embed download'><a href='{url}' class='title'>{title}</a></div>"
|
"<div class='embed download'><a href='{url}' class='title'>{title}</a></div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
// noinspection HtmlUnknownTarget
|
// noinspection HtmlUnknownTarget
|
||||||
templateEmbedUrl = new TEMPLATE([
|
templateEmbedUrl = new template([
|
||||||
"<a href='{url}' class='embed download'>{url}</a>"
|
"<a href='{url}' class='embed download'>{url}</a>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateEmbedUnsupported = new TEMPLATE([
|
templateEmbedUnsupported = new template([
|
||||||
"<div class='embed download'><p>(Unsupported embed)</p></div>"
|
"<div class='embed download'><p>(Unsupported embed)</p></div>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateReaction = new TEMPLATE([
|
templateReaction = new template([
|
||||||
"<span class='reaction-wrapper'><span class='reaction-emoji'>{n}</span><span class='count'>{c}</span></span>"
|
"<span class='reaction-wrapper'><span class='reaction-emoji'>{n}</span><span class='count'>{c}</span></span>"
|
||||||
].join(""));
|
].join(""));
|
||||||
|
|
||||||
templateReactionCustom = new TEMPLATE([
|
// noinspection HtmlUnknownTarget
|
||||||
"<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>"
|
templateReactionCustom = new template([
|
||||||
|
"<span class='reaction-wrapper'><img src='{url}' alt=':{n}:' title=':{n}:' class='reaction-emoji-custom'><span class='count'>{c}</span></span>"
|
||||||
].join(""));
|
].join(""));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -184,7 +210,7 @@ const DISCORD = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
isImageAttachment(attachment) {
|
isImageAttachment(attachment) {
|
||||||
const url = DOM.tryParseUrl(attachment.url);
|
const url = dom.tryParseUrl(attachment.url);
|
||||||
return url != null && isImageUrl(url);
|
return url != null && isImageUrl(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -197,15 +223,15 @@ const DISCORD = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getMessageHTML(message) { // noinspection FunctionWithInconsistentReturnsJS
|
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") {
|
if (property === "avatar") {
|
||||||
return value ? templateUserAvatar.apply(value) : "";
|
return value ? templateUserAvatar.apply(getAvatarUrlObject(value)) : "";
|
||||||
}
|
}
|
||||||
else if (property === "user.tag") {
|
else if (property === "user.displayName") {
|
||||||
return value ? value : "????";
|
return value ? value : message.user.name;
|
||||||
}
|
}
|
||||||
else if (property === "timestamp") {
|
else if (property === "timestamp") {
|
||||||
return DOM.getHumanReadableTime(value);
|
return dom.getHumanReadableTime(value);
|
||||||
}
|
}
|
||||||
else if (property === "contents") {
|
else if (property === "contents") {
|
||||||
return value && value.length > 0 ? processMessageContents(value) : "";
|
return value && value.length > 0 ? processMessageContents(value) : "";
|
||||||
@@ -242,30 +268,32 @@ const DISCORD = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return value.map(attachment => {
|
return value.map(attachment => {
|
||||||
if (!DISCORD.isImageAttachment(attachment) || !SETTINGS.enableImagePreviews) {
|
const url = fileUrlProcessor(attachment.url);
|
||||||
return templateAttachmentDownload.apply(attachment);
|
|
||||||
|
if (!discord.isImageAttachment(attachment) || !settings.enableImagePreviews) {
|
||||||
|
return templateAttachmentDownload.apply({ url, name: attachment.name });
|
||||||
}
|
}
|
||||||
else if ("width" in attachment && "height" in attachment) {
|
else if ("width" in attachment && "height" in attachment) {
|
||||||
return templateEmbedImageWithSize.apply({ url: attachment.url, src: attachment.url, width: attachment.width, height: attachment.height });
|
return templateEmbedImageWithSize.apply({ url, src: url, width: attachment.width, height: attachment.height });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return templateEmbedImage.apply({ url: attachment.url, src: attachment.url });
|
return templateEmbedImage.apply({ url, src: url });
|
||||||
}
|
}
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
else if (property === "edit") {
|
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") {
|
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") {
|
else if (property === "reply") {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : "";
|
return value === null ? "<span class='reply-contents reply-missing'>(replies to an unknown message)</span>" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = "<span class='reply-username' title='#" + (value.user.tag ? value.user.tag : "????") + "'>" + value.user.name + "</span>";
|
const user = "<span class='reply-username' title='" + value.user.name + "'>" + (value.user.displayName ?? value.user.name) + "</span>";
|
||||||
const avatar = SETTINGS.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(value.avatar) + "</span>" : "";
|
const avatar = settings.enableUserAvatars && value.avatar ? "<span class='reply-avatar'>" + templateUserAvatar.apply(getAvatarUrlObject(value.avatar)) + "</span>" : "";
|
||||||
const contents = value.contents ? "<span class='reply-contents'>" + processMessageContents(value.contents) + "</span>" : "";
|
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;
|
return "<span class='jump' data-jump='" + value.id + "'>Jump to reply</span><span class='user'>" + avatar + user + "</span>" + contents;
|
||||||
@@ -277,9 +305,10 @@ const DISCORD = (function() {
|
|||||||
|
|
||||||
return "<div class='reactions'>" + value.map(reaction => {
|
return "<div class='reactions'>" + value.map(reaction => {
|
||||||
if ("id" in reaction){
|
if ("id" in reaction){
|
||||||
// noinspection JSUnusedGlobalSymbols, JSUnresolvedVariable
|
const ext = reaction.a && settings.enableAnimatedEmoji ? "gif" : "webp";
|
||||||
reaction.ext = reaction.a && SETTINGS.enableAnimatedEmoji ? "gif" : "png";
|
const url = fileUrlProcessor("https://cdn.discordapp.com/emojis/" + reaction.id + "." + ext);
|
||||||
return templateReactionCustom.apply(reaction);
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
return templateReactionCustom.apply({ url, n: reaction.n, c: reaction.c });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return templateReaction.apply(reaction);
|
return templateReaction.apply(reaction);
|
@@ -8,9 +8,12 @@ const HTML_ENTITY_MAP = {
|
|||||||
|
|
||||||
const HTML_ENTITY_REGEX = /[&<>"']/g;
|
const HTML_ENTITY_REGEX = /[&<>"']/g;
|
||||||
|
|
||||||
class DOM {
|
export default class {
|
||||||
/**
|
/**
|
||||||
* Returns a child element by its ID. Parent defaults to the entire document.
|
* 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) {
|
static id(id, parent) {
|
||||||
return (parent || document).getElementById(id);
|
return (parent || document).getElementById(id);
|
||||||
@@ -18,6 +21,9 @@ class DOM {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an array of all child elements containing the specified class. Parent defaults to the entire document.
|
* 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) {
|
static cls(cls, parent) {
|
||||||
return Array.prototype.slice.call((parent || document).getElementsByClassName(cls));
|
return Array.prototype.slice.call((parent || document).getElementsByClassName(cls));
|
||||||
@@ -25,6 +31,9 @@ class DOM {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an array of all child elements that have the specified tag. Parent defaults to the entire document.
|
* 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) {
|
static tag(tag, parent) {
|
||||||
return Array.prototype.slice.call((parent || document).getElementsByTagName(tag));
|
return Array.prototype.slice.call((parent || document).getElementsByTagName(tag));
|
||||||
@@ -32,6 +41,9 @@ class DOM {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first child element containing the specified class. Parent defaults to the entire document.
|
* 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) {
|
static fcls(cls, parent) {
|
||||||
return (parent || document).getElementsByClassName(cls)[0];
|
return (parent || document).getElementsByClassName(cls)[0];
|
@@ -1,10 +1,16 @@
|
|||||||
const GUI = (function() {
|
import dom from "./dom.mjs";
|
||||||
|
import discord from "./discord.mjs";
|
||||||
|
import settings from "./settings.mjs";
|
||||||
|
import state from "./state.mjs";
|
||||||
|
|
||||||
|
export default (function() {
|
||||||
let eventOnOptMessagesPerPageChanged;
|
let eventOnOptMessagesPerPageChanged;
|
||||||
let eventOnOptMessageFilterChanged;
|
let eventOnOptMessageFilterChanged;
|
||||||
let eventOnNavButtonClicked;
|
let eventOnNavButtonClicked;
|
||||||
|
|
||||||
const getActiveFilter = function() {
|
const getActiveFilter = function() {
|
||||||
const active = DOM.fcls("active", DOM.id("opt-filter-list"));
|
/** @type HTMLSelectElement */
|
||||||
|
const active = dom.fcls("active", dom.id("opt-filter-list"));
|
||||||
|
|
||||||
return active && active.value !== "" ? {
|
return active && active.value !== "" ? {
|
||||||
"type": active.getAttribute("data-filter-type"),
|
"type": active.getAttribute("data-filter-type"),
|
||||||
@@ -17,12 +23,12 @@ const GUI = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showModal = function(width, html) {
|
const showModal = function(width, html) {
|
||||||
const dialog = DOM.id("dialog");
|
const dialog = dom.id("dialog");
|
||||||
dialog.innerHTML = html;
|
dialog.innerHTML = html;
|
||||||
dialog.style.width = width + "px";
|
dialog.style.width = width + "px";
|
||||||
dialog.style.marginLeft = (-width / 2) + "px";
|
dialog.style.marginLeft = (-width / 2) + "px";
|
||||||
|
|
||||||
DOM.id("modal").classList.add("visible");
|
dom.id("modal").classList.add("visible");
|
||||||
return dialog;
|
return dialog;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,9 +44,9 @@ const GUI = (function() {
|
|||||||
<label><input id='dht-cfg-animemoji' type='checkbox'> Animated Emoji</label><br>`);
|
<label><input id='dht-cfg-animemoji' type='checkbox'> Animated Emoji</label><br>`);
|
||||||
|
|
||||||
const setupCheckBox = function(id, settingName) {
|
const setupCheckBox = function(id, settingName) {
|
||||||
const ele = DOM.id(id);
|
const ele = dom.id(id);
|
||||||
ele.checked = SETTINGS[settingName];
|
ele.checked = settings[settingName];
|
||||||
ele.addEventListener("change", () => SETTINGS[settingName] = ele.checked);
|
ele.addEventListener("change", () => settings[settingName] = ele.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
setupCheckBox("dht-cfg-imgpreviews", "enableImagePreviews");
|
setupCheckBox("dht-cfg-imgpreviews", "enableImagePreviews");
|
||||||
@@ -65,20 +71,20 @@ const GUI = (function() {
|
|||||||
// ---------
|
// ---------
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
const inputMessageFilter = DOM.id("opt-messages-filter");
|
const inputMessageFilter = dom.id("opt-messages-filter");
|
||||||
const containerFilterList = DOM.id("opt-filter-list");
|
const containerFilterList = dom.id("opt-filter-list");
|
||||||
|
|
||||||
const resetActiveFilter = function() {
|
const resetActiveFilter = function() {
|
||||||
inputMessageFilter.value = "";
|
inputMessageFilter.value = "";
|
||||||
inputMessageFilter.dispatchEvent(new Event("change"));
|
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.value = ""; // required to prevent browsers from remembering old value
|
||||||
|
|
||||||
inputMessageFilter.addEventListener("change", () => {
|
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) {
|
if (inputMessageFilter.value) {
|
||||||
containerFilterList.querySelector("[data-filter-type='" + inputMessageFilter.value + "']").classList.add("active");
|
containerFilterList.querySelector("[data-filter-type='" + inputMessageFilter.value + "']").classList.add("active");
|
||||||
@@ -91,11 +97,11 @@ const GUI = (function() {
|
|||||||
ele.addEventListener(ele.tagName === "SELECT" ? "change" : "input", () => triggerFilterChanged());
|
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();
|
eventOnOptMessagesPerPageChanged && eventOnOptMessagesPerPageChanged();
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.tag("button", DOM.fcls("nav")).forEach(button => {
|
dom.tag("button", dom.fcls("nav")).forEach(button => {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
@@ -103,34 +109,34 @@ const GUI = (function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("btn-settings").addEventListener("click", () => {
|
dom.id("btn-settings").addEventListener("click", () => {
|
||||||
showSettingsModal();
|
showSettingsModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("btn-about").addEventListener("click", () => {
|
dom.id("btn-about").addEventListener("click", () => {
|
||||||
showInfoModal();
|
showInfoModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("messages").addEventListener("click", e => {
|
dom.id("messages").addEventListener("click", e => {
|
||||||
const jump = e.target.getAttribute("data-jump");
|
const jump = e.target.getAttribute("data-jump");
|
||||||
|
|
||||||
if (jump) {
|
if (jump) {
|
||||||
resetActiveFilter();
|
resetActiveFilter();
|
||||||
|
|
||||||
const index = STATE.navigateToMessage(jump);
|
const index = state.navigateToMessage(jump);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
alert("Message not found.");
|
alert("Message not found.");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
DOM.id("messages").children[index].scrollIntoView();
|
dom.id("messages").children[index].scrollIntoView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
DOM.id("overlay").addEventListener("click", () => {
|
dom.id("overlay").addEventListener("click", () => {
|
||||||
DOM.id("modal").classList.remove("visible");
|
dom.id("modal").classList.remove("visible");
|
||||||
DOM.id("dialog").innerHTML = "";
|
dom.id("dialog").innerHTML = "";
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -168,19 +174,19 @@ const GUI = (function() {
|
|||||||
*/
|
*/
|
||||||
getOptionMessagesPerPage() {
|
getOptionMessagesPerPage() {
|
||||||
/** @type HTMLInputElement */
|
/** @type HTMLInputElement */
|
||||||
const messagesPerPage = DOM.id("opt-messages-per-page");
|
const messagesPerPage = dom.id("opt-messages-per-page");
|
||||||
return parseInt(messagesPerPage.value, 10);
|
return parseInt(messagesPerPage.value, 10);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNavigation(currentPage, totalPages) {
|
updateNavigation(currentPage, totalPages) {
|
||||||
DOM.id("nav-page-current").innerHTML = currentPage;
|
dom.id("nav-page-current").innerHTML = currentPage;
|
||||||
DOM.id("nav-page-total").innerHTML = totalPages || "?";
|
dom.id("nav-page-total").innerHTML = totalPages || "?";
|
||||||
|
|
||||||
DOM.id("nav-first").disabled = currentPage === 1;
|
dom.id("nav-first").disabled = currentPage === 1;
|
||||||
DOM.id("nav-prev").disabled = currentPage === 1;
|
dom.id("nav-prev").disabled = currentPage === 1;
|
||||||
DOM.id("nav-pick").disabled = (totalPages || 0) <= 1;
|
dom.id("nav-pick").disabled = (totalPages || 0) <= 1;
|
||||||
DOM.id("nav-next").disabled = currentPage === (totalPages || 1);
|
dom.id("nav-next").disabled = currentPage === (totalPages || 1);
|
||||||
DOM.id("nav-last").disabled = currentPage === (totalPages || 1);
|
dom.id("nav-last").disabled = currentPage === (totalPages || 1);
|
||||||
},
|
},
|
||||||
|
|
||||||
// --------------
|
// --------------
|
||||||
@@ -191,7 +197,7 @@ const GUI = (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.
|
* 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) {
|
updateChannelList(channels, selected, callback) {
|
||||||
const eleChannels = DOM.id("channels");
|
const eleChannels = dom.id("channels");
|
||||||
|
|
||||||
if (!channels) {
|
if (!channels) {
|
||||||
eleChannels.innerHTML = "";
|
eleChannels.innerHTML = "";
|
||||||
@@ -201,11 +207,11 @@ const GUI = (function() {
|
|||||||
channels = channels.filter(channel => channel.msgcount > 0);
|
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 => {
|
Array.prototype.forEach.call(eleChannels.children, ele => {
|
||||||
ele.addEventListener("click", () => {
|
ele.addEventListener("click", () => {
|
||||||
const currentChannel = DOM.fcls("active", eleChannels);
|
const currentChannel = dom.fcls("active", eleChannels);
|
||||||
|
|
||||||
if (currentChannel) {
|
if (currentChannel) {
|
||||||
currentChannel.classList.remove("active");
|
currentChannel.classList.remove("active");
|
||||||
@@ -224,12 +230,12 @@ const GUI = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateMessageList(messages) {
|
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) {
|
updateUserList(users) {
|
||||||
/** @type HTMLSelectElement */
|
/** @type HTMLSelectElement */
|
||||||
const eleSelect = DOM.id("opt-filter-user");
|
const eleSelect = dom.id("opt-filter-user");
|
||||||
|
|
||||||
while (eleSelect.length > 1) {
|
while (eleSelect.length > 1) {
|
||||||
eleSelect.remove(1);
|
eleSelect.remove(1);
|
||||||
@@ -237,10 +243,11 @@ const GUI = (function() {
|
|||||||
|
|
||||||
const options = [];
|
const options = [];
|
||||||
|
|
||||||
for (const key of Object.keys(users)) {
|
for (const id of Object.keys(users)) {
|
||||||
|
const user = users[id];
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = key;
|
option.value = id;
|
||||||
option.text = users[key].name;
|
option.text = user.displayName ? `${user.displayName} (${user.name})` : user.name;
|
||||||
options.push(option);
|
options.push(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +256,7 @@ const GUI = (function() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
scrollMessagesToTop() {
|
scrollMessagesToTop() {
|
||||||
DOM.id("messages").scrollTop = 0;
|
dom.id("messages").scrollTop = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
35
app/Resources/Viewer/scripts/polyfills.mjs
Normal file
35
app/Resources/Viewer/scripts/polyfills.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// https://gist.github.com/MattiasBuelens/496fc1d37adb50a733edd43853f2f60e/088f061ab79b296f29225467ae9ba86ff990195d
|
||||||
|
|
||||||
|
ReadableStream.prototype.values ??= function({ preventCancel = false } = {}) {
|
||||||
|
const reader = this.getReader();
|
||||||
|
return {
|
||||||
|
async next() {
|
||||||
|
try {
|
||||||
|
const result = await reader.read();
|
||||||
|
if (result.done) {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
reader.releaseLock();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async return(value) {
|
||||||
|
if (!preventCancel) {
|
||||||
|
const cancelPromise = reader.cancel(value);
|
||||||
|
reader.releaseLock();
|
||||||
|
await cancelPromise;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
return { done: true, value };
|
||||||
|
},
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
ReadableStream.prototype[Symbol.asyncIterator] ??= ReadableStream.prototype.values;
|
@@ -1,16 +1,16 @@
|
|||||||
const PROCESSOR = {};
|
import discord from "./discord.mjs";
|
||||||
|
|
||||||
// ------------------------
|
// ------------------------
|
||||||
// Global filter generators
|
// Global filter generators
|
||||||
// ------------------------
|
// ------------------------
|
||||||
|
|
||||||
PROCESSOR.FILTER = {
|
const filter = {
|
||||||
byUser: ((userindex) => message => message.u === userindex),
|
byUser: ((user) => message => message.u === user),
|
||||||
byTime: ((timeStart, timeEnd) => message => message.t >= timeStart && message.t <= timeEnd),
|
byTime: ((timeStart, timeEnd) => message => message.t >= timeStart && message.t <= timeEnd),
|
||||||
byContents: ((substr) => message => ("m" in message ? message.m : "").indexOf(substr) !== -1),
|
byContents: ((substr) => message => ("m" in message ? message.m : "").indexOf(substr) !== -1),
|
||||||
byRegex: ((regex) => message => regex.test("m" in message ? message.m : "")),
|
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))),
|
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))),
|
withDownloads: (() => message => message.a && message.a.some(attachment => !discord.isImageAttachment(attachment))),
|
||||||
withEmbeds: (() => message => message.e && message.e.length > 0),
|
withEmbeds: (() => message => message.e && message.e.length > 0),
|
||||||
withAttachments: (() => message => message.a && message.a.length > 0),
|
withAttachments: (() => message => message.a && message.a.length > 0),
|
||||||
isEdited: (() => message => ("te" in message) ? message.te : (message.f & 1) === 1)
|
isEdited: (() => message => ("te" in message) ? message.te : (message.f & 1) === 1)
|
||||||
@@ -20,7 +20,7 @@ PROCESSOR.FILTER = {
|
|||||||
// Global sorters
|
// Global sorters
|
||||||
// --------------
|
// --------------
|
||||||
|
|
||||||
PROCESSOR.SORTER = {
|
const sorter = {
|
||||||
oldestToNewest: (key1, key2) => {
|
oldestToNewest: (key1, key2) => {
|
||||||
if (key1.length === key2.length) {
|
if (key1.length === key2.length) {
|
||||||
return key1 > key2 ? 1 : key1 < key2 ? -1 : 0;
|
return key1 > key2 ? 1 : key1 < key2 ? -1 : 0;
|
||||||
@@ -39,3 +39,8 @@ PROCESSOR.SORTER = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
FILTER: filter,
|
||||||
|
SORTER: sorter
|
||||||
|
};
|
@@ -1,4 +1,4 @@
|
|||||||
const SETTINGS = (function() {
|
export default (function() {
|
||||||
/**
|
/**
|
||||||
* @type {{}}
|
* @type {{}}
|
||||||
* @property {Function} onSettingsChanged
|
* @property {Function} onSettingsChanged
|
@@ -1,10 +1,12 @@
|
|||||||
|
import settings from "./settings.mjs";
|
||||||
|
import processor from "./processor.mjs";
|
||||||
|
|
||||||
// noinspection FunctionWithInconsistentReturnsJS
|
// noinspection FunctionWithInconsistentReturnsJS
|
||||||
const STATE = (function() {
|
export default (function() {
|
||||||
/**
|
/**
|
||||||
* @type {{}}
|
* @type {{}}
|
||||||
* @property {{}} users
|
* @property {{}} users
|
||||||
* @property {String[]} userindex
|
* @property {{}} servers
|
||||||
* @property {{}[]} servers
|
|
||||||
* @property {{}} channels
|
* @property {{}} channels
|
||||||
*/
|
*/
|
||||||
let loadedFileMeta;
|
let loadedFileMeta;
|
||||||
@@ -17,20 +19,16 @@ const STATE = (function() {
|
|||||||
let currentPage;
|
let currentPage;
|
||||||
let messagesPerPage;
|
let messagesPerPage;
|
||||||
|
|
||||||
const getUser = function(index) {
|
const getUser = function(id) {
|
||||||
return loadedFileMeta.users[loadedFileMeta.userindex[index]] || { "name": "<unknown>" };
|
return loadedFileMeta.users[id] || { "name": "<unknown>" };
|
||||||
};
|
|
||||||
|
|
||||||
const getUserId = function(index) {
|
|
||||||
return loadedFileMeta.userindex[index];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserList = function() {
|
const getUserList = function() {
|
||||||
return loadedFileMeta ? loadedFileMeta.users : [];
|
return loadedFileMeta ? loadedFileMeta.users : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getServer = function(index) {
|
const getServer = function(id) {
|
||||||
return loadedFileMeta.servers[index] || { "name": "<unknown>", "type": "unknown" };
|
return loadedFileMeta.servers[id] || { "name": "<unknown>", "type": "unknown" };
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateChannelHierarchy = function() {
|
const generateChannelHierarchy = function() {
|
||||||
@@ -173,7 +171,7 @@ const STATE = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getMessageChannel = function(id) {
|
const getMessageChannel = function(id) {
|
||||||
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
|
for (const [ channel, messages ] of Object.entries(loadedFileData)) {
|
||||||
if (id in messages) {
|
if (id in messages) {
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
@@ -204,7 +202,7 @@ const STATE = (function() {
|
|||||||
*/
|
*/
|
||||||
const message = messages[key];
|
const message = messages[key];
|
||||||
const user = getUser(message.u);
|
const user = getUser(message.u);
|
||||||
const avatar = user.avatar ? { id: getUserId(message.u), path: user.avatar } : null;
|
const avatar = user.avatar ? { id: message.u, path: user.avatar } : null;
|
||||||
|
|
||||||
const obj = {
|
const obj = {
|
||||||
user,
|
user,
|
||||||
@@ -232,7 +230,7 @@ const STATE = (function() {
|
|||||||
if ("r" in message) {
|
if ("r" in message) {
|
||||||
const replyMessage = getMessageById(message.r);
|
const replyMessage = getMessageById(message.r);
|
||||||
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
|
const replyUser = replyMessage ? getUser(replyMessage.u) : null;
|
||||||
const replyAvatar = replyUser && replyUser.avatar ? { id: getUserId(replyMessage.u), path: replyUser.avatar } : null;
|
const replyAvatar = replyUser && replyUser.avatar ? { id: replyMessage.u, path: replyUser.avatar } : null;
|
||||||
|
|
||||||
obj["reply"] = replyMessage ? {
|
obj["reply"] = replyMessage ? {
|
||||||
"id": message.r,
|
"id": message.r,
|
||||||
@@ -290,20 +288,17 @@ const STATE = (function() {
|
|||||||
eventOnUsersRefreshed = callback;
|
eventOnUsersRefreshed = callback;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
uploadFile(meta, data) {
|
||||||
* @param {{ meta, data }} file
|
|
||||||
*/
|
|
||||||
uploadFile(file) {
|
|
||||||
if (loadedFileMeta != null) {
|
if (loadedFileMeta != null) {
|
||||||
throw "A file is already loaded!";
|
throw "A file is already loaded!";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file || typeof file.meta !== "object" || typeof file.data !== "object") {
|
if (typeof meta !== "object" || typeof data !== "object") {
|
||||||
throw "Invalid file format!";
|
throw "Invalid file format!";
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedFileMeta = file.meta;
|
loadedFileMeta = meta;
|
||||||
loadedFileData = file.data;
|
loadedFileData = data;
|
||||||
loadedMessages = null;
|
loadedMessages = null;
|
||||||
|
|
||||||
selectedChannel = null;
|
selectedChannel = null;
|
||||||
@@ -313,7 +308,7 @@ const STATE = (function() {
|
|||||||
triggerChannelsRefreshed();
|
triggerChannelsRefreshed();
|
||||||
triggerMessagesRefreshed();
|
triggerMessagesRefreshed();
|
||||||
|
|
||||||
SETTINGS.onSettingsChanged(() => triggerMessagesRefreshed());
|
settings.onSettingsChanged(() => triggerMessagesRefreshed());
|
||||||
},
|
},
|
||||||
|
|
||||||
getChannelName(channel) {
|
getChannelName(channel) {
|
||||||
@@ -321,21 +316,21 @@ const STATE = (function() {
|
|||||||
return (channelObj && channelObj.name) || channel;
|
return (channelObj && channelObj.name) || channel;
|
||||||
},
|
},
|
||||||
|
|
||||||
getUserTag(user) {
|
|
||||||
const userObj = loadedFileMeta.users[user];
|
|
||||||
return (userObj && userObj.tag) || "????";
|
|
||||||
},
|
|
||||||
|
|
||||||
getUserName(user) {
|
getUserName(user) {
|
||||||
const userObj = loadedFileMeta.users[user];
|
const userObj = loadedFileMeta.users[user];
|
||||||
return (userObj && userObj.name) || user;
|
return (userObj && userObj.name) || user;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getUserDisplayName(user) {
|
||||||
|
const userObj = loadedFileMeta.users[user];
|
||||||
|
return (userObj && (userObj.displayName || userObj.name)) || user;
|
||||||
|
},
|
||||||
|
|
||||||
selectChannel(channel) {
|
selectChannel(channel) {
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
selectedChannel = channel;
|
selectedChannel = channel;
|
||||||
|
|
||||||
loadedMessages = getFilteredMessageKeys(channel).sort(PROCESSOR.SORTER.oldestToNewest);
|
loadedMessages = getFilteredMessageKeys(channel).sort(processor.SORTER.oldestToNewest);
|
||||||
triggerMessagesRefreshed();
|
triggerMessagesRefreshed();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -416,23 +411,23 @@ const STATE = (function() {
|
|||||||
setActiveFilter(filter) {
|
setActiveFilter(filter) {
|
||||||
switch (filter ? filter.type : "") {
|
switch (filter ? filter.type : "") {
|
||||||
case "user":
|
case "user":
|
||||||
filterFunction = PROCESSOR.FILTER.byUser(loadedFileMeta.userindex.indexOf(filter.value));
|
filterFunction = processor.FILTER.byUser(filter.value);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "contents":
|
case "contents":
|
||||||
filterFunction = PROCESSOR.FILTER.byContents(filter.value);
|
filterFunction = processor.FILTER.byContents(filter.value);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "withimages":
|
case "withimages":
|
||||||
filterFunction = PROCESSOR.FILTER.withImages();
|
filterFunction = processor.FILTER.withImages();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "withdownloads":
|
case "withdownloads":
|
||||||
filterFunction = PROCESSOR.FILTER.withDownloads();
|
filterFunction = processor.FILTER.withDownloads();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "edited":
|
case "edited":
|
||||||
filterFunction = PROCESSOR.FILTER.isEdited();
|
filterFunction = processor.FILTER.isEdited();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
@@ -1,6 +1,8 @@
|
|||||||
|
import dom from "./dom.mjs";
|
||||||
|
|
||||||
const TEMPLATE_REGEX = /{([^{}]+?)}/g;
|
const TEMPLATE_REGEX = /{([^{}]+?)}/g;
|
||||||
|
|
||||||
class TEMPLATE {
|
export default class {
|
||||||
constructor(contents) {
|
constructor(contents) {
|
||||||
this.contents = contents;
|
this.contents = contents;
|
||||||
};
|
};
|
||||||
@@ -11,10 +13,10 @@ class TEMPLATE {
|
|||||||
|
|
||||||
if (processor) {
|
if (processor) {
|
||||||
const updated = processor(match, value);
|
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,18 +3,24 @@
|
|||||||
min-width: 215px;
|
min-width: 215px;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
color: #eee;
|
||||||
background-color: #1c1e22;
|
background-color: #1c1e22;
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#channels > div {
|
#channels > div.loading {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#channels > div.channel {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
color: #eee;
|
|
||||||
font-size: 15px;
|
|
||||||
border-bottom: 1px solid #333333;
|
border-bottom: 1px solid #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
#channels > div:hover, #channels > div.active {
|
#channels > div.channel:hover, #channels > div.channel.active {
|
||||||
background-color: #282b30;
|
background-color: #282b30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -11,3 +11,20 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
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,15 +1,16 @@
|
|||||||
#menu {
|
#menu {
|
||||||
width: 100%;
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
background-color: #17181c;
|
background-color: #17181c;
|
||||||
border-bottom: 1px dotted #5d626b;
|
border-bottom: 1px dotted #5d626b;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .splitter {
|
#menu .splitter {
|
||||||
width: 1px;
|
flex: 0 0 1px;
|
||||||
margin: 9px 4px;
|
margin: 9px 1px;
|
||||||
background-color: #5d626b;
|
background-color: #5d626b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#menu button, #menu select, #menu input[type="text"] {
|
#menu button, #menu select, #menu input[type="text"] {
|
||||||
margin: 8px;
|
height: 31px;
|
||||||
|
padding: 0 10px;
|
||||||
background-color: #7289da;
|
background-color: #7289da;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.75);
|
||||||
@@ -31,28 +33,25 @@
|
|||||||
|
|
||||||
#menu button {
|
#menu button {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
padding: 0 12px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu select {
|
#menu select {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 6px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu input[type="text"] {
|
#menu input[type="text"] {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 7px 12px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav {
|
#menu .nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
margin: 0 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav > button {
|
#menu .nav > button {
|
||||||
@@ -66,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#menu .nav > button, #menu .nav > p {
|
#menu .nav > button, #menu .nav > p {
|
||||||
margin: 8px 1px;
|
margin: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#opt-filter-list > select, #opt-filter-list > input {
|
#opt-filter-list > select, #opt-filter-list > input {
|
||||||
@@ -76,3 +75,7 @@
|
|||||||
#opt-filter-list > .active {
|
#opt-filter-list > .active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btn-about {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@@ -107,23 +107,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message .thumbnail {
|
.message .thumbnail {
|
||||||
position: relative;
|
--loading-backdrop: rgba(0, 0, 0, 0.75);
|
||||||
max-width: calc(100% - 20px);
|
max-width: calc(100% - 20px);
|
||||||
max-height: 320px;
|
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 {
|
.message .thumbnail img {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
@@ -1,15 +1,19 @@
|
|||||||
namespace DHT.Server.Data.Aggregations;
|
namespace DHT.Server.Data.Aggregations;
|
||||||
|
|
||||||
public sealed class DownloadStatusStatistics {
|
public sealed class DownloadStatusStatistics {
|
||||||
public int EnqueuedCount { get; internal set; }
|
public int PendingCount { get; internal init; }
|
||||||
public ulong EnqueuedSize { get; internal set; }
|
public ulong PendingTotalSize { get; internal init; }
|
||||||
|
public int PendingWithUnknownSizeCount { get; internal init; }
|
||||||
|
|
||||||
public int SuccessfulCount { get; internal set; }
|
public int SuccessfulCount { get; internal init; }
|
||||||
public ulong SuccessfulSize { get; internal set; }
|
public ulong SuccessfulTotalSize { get; internal init; }
|
||||||
|
public int SuccessfulWithUnknownSizeCount { get; internal init; }
|
||||||
|
|
||||||
public int FailedCount { get; internal set; }
|
public int FailedCount { get; internal init; }
|
||||||
public ulong FailedSize { get; internal set; }
|
public ulong FailedTotalSize { get; internal init; }
|
||||||
|
public int FailedWithUnknownSizeCount { get; internal init; }
|
||||||
public int SkippedCount { get; internal set; }
|
|
||||||
public ulong SkippedSize { get; internal set; }
|
public int SkippedCount { get; internal init; }
|
||||||
|
public ulong SkippedTotalSize { get; internal init; }
|
||||||
|
public int SkippedWithUnknownSizeCount { get; internal init; }
|
||||||
}
|
}
|
||||||
|
@@ -1,33 +1,17 @@
|
|||||||
using System;
|
|
||||||
using System.Net;
|
|
||||||
using DHT.Server.Download;
|
|
||||||
|
|
||||||
namespace DHT.Server.Data;
|
namespace DHT.Server.Data;
|
||||||
|
|
||||||
public readonly struct Download {
|
public sealed class 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 NormalizedUrl { get; }
|
||||||
public string DownloadUrl { get; }
|
public string DownloadUrl { get; }
|
||||||
public DownloadStatus Status { get; }
|
public DownloadStatus Status { get; }
|
||||||
public ulong Size { get; }
|
public string? Type { get; }
|
||||||
public byte[]? Data { get; }
|
public ulong? Size { get; }
|
||||||
|
|
||||||
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, ulong size, byte[]? data = null) {
|
internal Download(string normalizedUrl, string downloadUrl, DownloadStatus status, string? type, ulong? size) {
|
||||||
NormalizedUrl = normalizedUrl;
|
NormalizedUrl = normalizedUrl;
|
||||||
DownloadUrl = downloadUrl;
|
DownloadUrl = downloadUrl;
|
||||||
Status = status;
|
Status = status;
|
||||||
|
Type = type;
|
||||||
Size = size;
|
Size = size;
|
||||||
Data = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal Download WithData(byte[] data) {
|
|
||||||
return new Download(NormalizedUrl, DownloadUrl, Status, Size, data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,8 +6,9 @@ namespace DHT.Server.Data;
|
|||||||
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
/// Extends <see cref="HttpStatusCode"/> with custom status codes in the range 0-99.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum DownloadStatus {
|
public enum DownloadStatus {
|
||||||
Enqueued = 0,
|
Pending = 0,
|
||||||
GenericError = 1,
|
GenericError = 1,
|
||||||
Downloading = 2,
|
Downloading = 2,
|
||||||
|
LastCustomCode = 99,
|
||||||
Success = HttpStatusCode.OK
|
Success = HttpStatusCode.OK
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
namespace DHT.Server.Data;
|
|
||||||
|
|
||||||
public readonly struct DownloadedAttachment {
|
|
||||||
public string? Type { get; internal init; }
|
|
||||||
public byte[] Data { get; internal init; }
|
|
||||||
}
|
|
17
app/Server/Data/Embeds/DiscordEmbedJson.cs
Normal file
17
app/Server/Data/Embeds/DiscordEmbedJson.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
7
app/Server/Data/Embeds/DiscordEmbedJsonContext.cs
Normal file
7
app/Server/Data/Embeds/DiscordEmbedJsonContext.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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;
|
@@ -1,15 +0,0 @@
|
|||||||
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,8 +3,10 @@ using System.Collections.Generic;
|
|||||||
namespace DHT.Server.Data.Filters;
|
namespace DHT.Server.Data.Filters;
|
||||||
|
|
||||||
public sealed class DownloadItemFilter {
|
public sealed class DownloadItemFilter {
|
||||||
public HashSet<DownloadStatus>? IncludeStatuses { get; init; } = null;
|
public HashSet<DownloadStatus>? IncludeStatuses { get; set; } = null;
|
||||||
public HashSet<DownloadStatus>? ExcludeStatuses { get; init; } = null;
|
public HashSet<DownloadStatus>? ExcludeStatuses { get; set; } = null;
|
||||||
|
|
||||||
|
public ulong? MaxBytes { get; set; } = null;
|
||||||
|
|
||||||
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null;
|
public bool IsEmpty => IncludeStatuses == null && ExcludeStatuses == null && MaxBytes == null;
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@ namespace DHT.Server.Data;
|
|||||||
public readonly struct User {
|
public readonly struct User {
|
||||||
public ulong Id { get; init; }
|
public ulong Id { get; init; }
|
||||||
public string Name { get; init; }
|
public string Name { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
public string? AvatarUrl { get; init; }
|
public string? AvatarUrl { get; init; }
|
||||||
public string? Discriminator { get; init; }
|
public string? Discriminator { get; init; }
|
||||||
}
|
}
|
||||||
|
@@ -25,8 +25,10 @@ public static class DatabaseExtensions {
|
|||||||
|
|
||||||
await target.Messages.Add(batchedMessages);
|
await target.Messages.Add(batchedMessages);
|
||||||
|
|
||||||
await foreach (var download in source.Downloads.GetWithoutData()) {
|
await foreach (var download in source.Downloads.Get()) {
|
||||||
await target.Downloads.AddDownload(download.Status == DownloadStatus.Success ? await source.Downloads.HydrateWithData(download) : download);
|
if (download.Status != DownloadStatus.Success || !await source.Downloads.GetDownloadData(download.NormalizedUrl, stream => target.Downloads.AddDownload(download, stream))) {
|
||||||
|
await target.Downloads.AddDownload(download, stream: null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,46 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,11 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
@@ -9,7 +9,6 @@ sealed class DummyDatabaseFile : IDatabaseFile {
|
|||||||
public static DummyDatabaseFile Instance { get; } = new ();
|
public static DummyDatabaseFile Instance { get; } = new ();
|
||||||
|
|
||||||
public string Path => "";
|
public string Path => "";
|
||||||
public DatabaseStatistics Statistics { get; } = new ();
|
|
||||||
|
|
||||||
public IUserRepository Users { get; } = new IUserRepository.Dummy();
|
public IUserRepository Users { get; } = new IUserRepository.Dummy();
|
||||||
public IServerRepository Servers { get; } = new IServerRepository.Dummy();
|
public IServerRepository Servers { get; } = new IServerRepository.Dummy();
|
||||||
@@ -19,13 +18,11 @@ sealed class DummyDatabaseFile : IDatabaseFile {
|
|||||||
|
|
||||||
private DummyDatabaseFile() {}
|
private DummyDatabaseFile() {}
|
||||||
|
|
||||||
public Task<DatabaseStatisticsSnapshot> SnapshotStatistics() {
|
|
||||||
return Task.FromResult(new DatabaseStatisticsSnapshot());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task Vacuum() {
|
public Task Vacuum() {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {}
|
public ValueTask DisposeAsync() {
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,9 +5,9 @@ namespace DHT.Server.Database.Exceptions;
|
|||||||
|
|
||||||
public sealed class DatabaseTooNewException : Exception {
|
public sealed class DatabaseTooNewException : Exception {
|
||||||
public int DatabaseVersion { get; }
|
public int DatabaseVersion { get; }
|
||||||
public int CurrentVersion => Schema.Version;
|
public int CurrentVersion => SqliteSchema.Version;
|
||||||
|
|
||||||
internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + Schema.Version) {
|
internal DatabaseTooNewException(int databaseVersion) : base("Database is too new: " + databaseVersion + " > " + SqliteSchema.Version) {
|
||||||
this.DatabaseVersion = databaseVersion;
|
this.DatabaseVersion = databaseVersion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
namespace DHT.Server.Database.Export;
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
readonly record struct Snowflake(ulong Id);
|
readonly record struct Snowflake(ulong Id) {
|
||||||
|
public static implicit operator Snowflake(ulong id) => new (id);
|
||||||
|
}
|
||||||
|
@@ -1,7 +0,0 @@
|
|||||||
using DHT.Server.Data;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Export.Strategy;
|
|
||||||
|
|
||||||
public interface IViewerExportStrategy {
|
|
||||||
string GetAttachmentUrl(Attachment attachment);
|
|
||||||
}
|
|
@@ -1,18 +0,0 @@
|
|||||||
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 string GetAttachmentUrl(Attachment attachment) {
|
|
||||||
return "http://127.0.0.1:" + safePort + "/get-attachment/" + WebUtility.UrlEncode(attachment.NormalizedUrl) + "?token=" + safeToken;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,18 +0,0 @@
|
|||||||
using DHT.Server.Data;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Export.Strategy;
|
|
||||||
|
|
||||||
public sealed class StandaloneViewerExportStrategy : IViewerExportStrategy {
|
|
||||||
public static StandaloneViewerExportStrategy Instance { get; } = new ();
|
|
||||||
|
|
||||||
private StandaloneViewerExportStrategy() {}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -3,14 +3,10 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace DHT.Server.Database.Export;
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
sealed class ViewerJson {
|
static class ViewerJson {
|
||||||
public required JsonMeta Meta { get; init; }
|
|
||||||
public required Dictionary<Snowflake, Dictionary<Snowflake, JsonMessage>> Data { get; init; }
|
|
||||||
|
|
||||||
public sealed class JsonMeta {
|
public sealed class JsonMeta {
|
||||||
public required Dictionary<Snowflake, JsonUser> Users { get; init; }
|
public required Dictionary<Snowflake, JsonUser> Users { get; init; }
|
||||||
public required List<Snowflake> Userindex { get; init; }
|
public required Dictionary<Snowflake, JsonServer> Servers { get; init; }
|
||||||
public required List<JsonServer> Servers { get; init; }
|
|
||||||
public required Dictionary<Snowflake, JsonChannel> Channels { get; init; }
|
public required Dictionary<Snowflake, JsonChannel> Channels { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,10 +14,10 @@ sealed class ViewerJson {
|
|||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Avatar { get; init; }
|
public string? DisplayName { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Tag { get; init; }
|
public string? Avatar { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonServer {
|
public sealed class JsonServer {
|
||||||
@@ -30,7 +26,7 @@ sealed class ViewerJson {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonChannel {
|
public sealed class JsonChannel {
|
||||||
public required int Server { get; init; }
|
public required Snowflake Server { get; init; }
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
@@ -47,7 +43,9 @@ sealed class ViewerJson {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed class JsonMessage {
|
public sealed class JsonMessage {
|
||||||
public required int U { get; init; }
|
public required Snowflake Id { get; init; }
|
||||||
|
public required Snowflake C { get; init; }
|
||||||
|
public required Snowflake U { get; init; }
|
||||||
public required long T { get; init; }
|
public required long T { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
@@ -2,118 +2,103 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database.Export.Strategy;
|
|
||||||
using DHT.Utils.Logging;
|
using DHT.Utils.Logging;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Export;
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
public static class ViewerJsonExport {
|
static class ViewerJsonExport {
|
||||||
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
|
private static readonly Log Log = Log.ForType(typeof(ViewerJsonExport));
|
||||||
|
|
||||||
public static async Task Generate(Stream stream, IViewerExportStrategy strategy, IDatabaseFile db, MessageFilter? filter = null) {
|
public static async Task GetMetadata(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) {
|
||||||
var perf = Log.Start();
|
var perf = Log.Start();
|
||||||
|
|
||||||
var includedUserIds = new HashSet<ulong>();
|
var includedChannels = new List<Channel>();
|
||||||
var includedChannelIds = new HashSet<ulong>();
|
|
||||||
var includedServerIds = new HashSet<ulong>();
|
var includedServerIds = new HashSet<ulong>();
|
||||||
|
|
||||||
var includedMessages = await db.Messages.Get(filter).ToListAsync();
|
var channelIdFilter = filter?.ChannelIds;
|
||||||
var includedChannels = new List<Channel>();
|
|
||||||
|
|
||||||
foreach (var message in includedMessages) {
|
await foreach (var channel in db.Channels.Get(cancellationToken)) {
|
||||||
includedUserIds.Add(message.Sender);
|
if (channelIdFilter == null || channelIdFilter.Contains(channel.Id)) {
|
||||||
includedChannelIds.Add(message.Channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
await foreach (var channel in db.Channels.Get()) {
|
|
||||||
if (includedChannelIds.Contains(channel.Id)) {
|
|
||||||
includedChannels.Add(channel);
|
includedChannels.Add(channel);
|
||||||
includedServerIds.Add(channel.Server);
|
includedServerIds.Add(channel.Server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (users, userIndex, userIndices) = await GenerateUserList(db, includedUserIds);
|
var users = await GenerateUserList(db, cancellationToken);
|
||||||
var (servers, serverIndices) = await GenerateServerList(db, includedServerIds);
|
var servers = await GenerateServerList(db, includedServerIds, cancellationToken);
|
||||||
var channels = GenerateChannelList(includedChannels, serverIndices);
|
var channels = GenerateChannelList(includedChannels);
|
||||||
|
|
||||||
|
var meta = new ViewerJson.JsonMeta {
|
||||||
|
Users = users,
|
||||||
|
Servers = servers,
|
||||||
|
Channels = channels
|
||||||
|
};
|
||||||
|
|
||||||
perf.Step("Collect database data");
|
perf.Step("Collect database data");
|
||||||
|
|
||||||
var value = new ViewerJson {
|
await JsonSerializer.SerializeAsync(stream, meta, ViewerJsonMetadataContext.Default.JsonMeta, cancellationToken);
|
||||||
Meta = new ViewerJson.JsonMeta {
|
|
||||||
Users = users,
|
|
||||||
Userindex = userIndex,
|
|
||||||
Servers = servers,
|
|
||||||
Channels = channels
|
|
||||||
},
|
|
||||||
Data = GenerateMessageList(includedMessages, userIndices, strategy)
|
|
||||||
};
|
|
||||||
|
|
||||||
perf.Step("Generate value object");
|
|
||||||
|
|
||||||
await JsonSerializer.SerializeAsync(stream, value, ViewerJsonContext.Default.ViewerJson);
|
|
||||||
|
|
||||||
perf.Step("Serialize to JSON");
|
perf.Step("Serialize to JSON");
|
||||||
perf.End();
|
perf.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(Dictionary<Snowflake, ViewerJson.JsonUser> Users, List<Snowflake> UserIndex, Dictionary<ulong, int> UserIndices)> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds) {
|
public static async Task GetMessages(Stream stream, IDatabaseFile db, MessageFilter? filter = null, CancellationToken cancellationToken = default) {
|
||||||
|
var perf = Log.Start();
|
||||||
|
|
||||||
|
ReadOnlyMemory<byte> newLine = "\n"u8.ToArray();
|
||||||
|
|
||||||
|
await foreach(var message in GenerateMessageList(db, filter, cancellationToken)) {
|
||||||
|
await JsonSerializer.SerializeAsync(stream, message, ViewerJsonMessageContext.Default.JsonMessage, cancellationToken);
|
||||||
|
await stream.WriteAsync(newLine, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
perf.Step("Generate and serialize messages to JSON");
|
||||||
|
perf.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<Snowflake, ViewerJson.JsonUser>> GenerateUserList(IDatabaseFile db, CancellationToken cancellationToken) {
|
||||||
var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
|
var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
|
||||||
var userIndex = new List<Snowflake>();
|
|
||||||
var userIndices = new Dictionary<ulong, int>();
|
|
||||||
|
|
||||||
await foreach (var user in db.Users.Get()) {
|
await foreach (var user in db.Users.Get(cancellationToken)) {
|
||||||
var id = user.Id;
|
users[user.Id] = new ViewerJson.JsonUser {
|
||||||
if (!userIds.Contains(id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var idSnowflake = new Snowflake(id);
|
|
||||||
userIndices[id] = users.Count;
|
|
||||||
userIndex.Add(idSnowflake);
|
|
||||||
|
|
||||||
users[idSnowflake] = new ViewerJson.JsonUser {
|
|
||||||
Name = user.Name,
|
Name = user.Name,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
Avatar = user.AvatarUrl,
|
Avatar = user.AvatarUrl,
|
||||||
Tag = user.Discriminator
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (users, userIndex, userIndices);
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(List<ViewerJson.JsonServer> Servers, Dictionary<ulong, int> ServerIndices)> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds) {
|
private static async Task<Dictionary<Snowflake, ViewerJson.JsonServer>> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, CancellationToken cancellationToken) {
|
||||||
var servers = new List<ViewerJson.JsonServer>();
|
var servers = new Dictionary<Snowflake, ViewerJson.JsonServer>();
|
||||||
var serverIndices = new Dictionary<ulong, int>();
|
|
||||||
|
|
||||||
await foreach (var server in db.Servers.Get()) {
|
await foreach (var server in db.Servers.Get(cancellationToken)) {
|
||||||
var id = server.Id;
|
if (!serverIds.Contains(server.Id)) {
|
||||||
if (!serverIds.Contains(id)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
serverIndices[id] = servers.Count;
|
servers[server.Id] = new ViewerJson.JsonServer {
|
||||||
|
|
||||||
servers.Add(new ViewerJson.JsonServer {
|
|
||||||
Name = server.Name,
|
Name = server.Name,
|
||||||
Type = ServerTypes.ToJsonViewerString(server.Type)
|
Type = ServerTypes.ToJsonViewerString(server.Type)
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (servers, serverIndices);
|
return servers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
|
private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels) {
|
||||||
var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
|
var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
|
||||||
|
|
||||||
foreach (var channel in includedChannels) {
|
foreach (var channel in includedChannels) {
|
||||||
var channelIdSnowflake = new Snowflake(channel.Id);
|
channels[channel.Id] = new ViewerJson.JsonChannel {
|
||||||
|
Server = channel.Server,
|
||||||
channels[channelIdSnowflake] = new ViewerJson.JsonChannel {
|
|
||||||
Server = serverIndices[channel.Server],
|
|
||||||
Name = channel.Name,
|
Name = channel.Name,
|
||||||
Parent = channel.ParentId?.ToString(),
|
Parent = channel.ParentId?.ToString(),
|
||||||
Position = channel.Position,
|
Position = channel.Position,
|
||||||
@@ -125,51 +110,40 @@ public static class ViewerJsonExport {
|
|||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>> GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices, IViewerExportStrategy strategy) {
|
private static async IAsyncEnumerable<ViewerJson.JsonMessage> GenerateMessageList(IDatabaseFile db, MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
var data = new Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>>();
|
await foreach (var message in db.Messages.Get(filter, cancellationToken)) {
|
||||||
|
yield return new ViewerJson.JsonMessage {
|
||||||
|
Id = message.Id,
|
||||||
|
C = message.Channel,
|
||||||
|
U = message.Sender,
|
||||||
|
T = message.Timestamp,
|
||||||
|
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
|
||||||
|
Te = message.EditTimestamp,
|
||||||
|
R = message.RepliedToId?.ToString(),
|
||||||
|
|
||||||
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
|
A = message.Attachments.IsEmpty ? null : message.Attachments.Select(static attachment => {
|
||||||
var channelIdSnowflake = new Snowflake(grouping.Key);
|
var a = new ViewerJson.JsonMessageAttachment {
|
||||||
var channelData = new Dictionary<Snowflake, ViewerJson.JsonMessage>();
|
Url = attachment.DownloadUrl,
|
||||||
|
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl
|
||||||
|
};
|
||||||
|
|
||||||
foreach (var message in grouping) {
|
if (attachment is { Width: not null, Height: not null }) {
|
||||||
var messageIdSnowflake = new Snowflake(message.Id);
|
a.Width = attachment.Width;
|
||||||
|
a.Height = attachment.Height;
|
||||||
channelData[messageIdSnowflake] = new ViewerJson.JsonMessage {
|
}
|
||||||
U = userIndices[message.Sender],
|
|
||||||
T = message.Timestamp,
|
|
||||||
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
|
|
||||||
Te = message.EditTimestamp,
|
|
||||||
R = message.RepliedToId?.ToString(),
|
|
||||||
|
|
||||||
A = message.Attachments.IsEmpty ? null : message.Attachments.Select(attachment => {
|
|
||||||
var a = new ViewerJson.JsonMessageAttachment {
|
|
||||||
Url = strategy.GetAttachmentUrl(attachment),
|
|
||||||
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl
|
|
||||||
};
|
|
||||||
|
|
||||||
if (attachment is { Width: not null, Height: not null }) {
|
return a;
|
||||||
a.Width = attachment.Width;
|
}).ToArray(),
|
||||||
a.Height = attachment.Height;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a;
|
E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
|
||||||
}).ToArray(),
|
|
||||||
|
|
||||||
E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
|
|
||||||
|
|
||||||
Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
|
|
||||||
Id = reaction.EmojiId?.ToString(),
|
|
||||||
N = reaction.EmojiName,
|
|
||||||
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
|
|
||||||
C = reaction.Count
|
|
||||||
}).ToArray()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
data[channelIdSnowflake] = channelData;
|
Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
|
||||||
|
Id = reaction.EmojiId?.ToString(),
|
||||||
|
N = reaction.EmojiName,
|
||||||
|
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
|
||||||
|
C = reaction.Count
|
||||||
|
}).ToArray()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
app/Server/Database/Export/ViewerJsonMessageContext.cs
Normal file
11
app/Server/Database/Export/ViewerJsonMessageContext.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
Converters = [typeof(SnowflakeJsonSerializer)],
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
GenerationMode = JsonSourceGenerationMode.Default
|
||||||
|
)]
|
||||||
|
[JsonSerializable(typeof(ViewerJson.JsonMessage))]
|
||||||
|
sealed partial class ViewerJsonMessageContext : JsonSerializerContext;
|
@@ -3,9 +3,9 @@ using System.Text.Json.Serialization;
|
|||||||
namespace DHT.Server.Database.Export;
|
namespace DHT.Server.Database.Export;
|
||||||
|
|
||||||
[JsonSourceGenerationOptions(
|
[JsonSourceGenerationOptions(
|
||||||
Converters = new [] { typeof(SnowflakeJsonSerializer) },
|
Converters = [typeof(SnowflakeJsonSerializer)],
|
||||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
GenerationMode = JsonSourceGenerationMode.Default
|
GenerationMode = JsonSourceGenerationMode.Default
|
||||||
)]
|
)]
|
||||||
[JsonSerializable(typeof(ViewerJson))]
|
[JsonSerializable(typeof(ViewerJson.JsonMeta))]
|
||||||
sealed partial class ViewerJsonContext : JsonSerializerContext {}
|
sealed partial class ViewerJsonMetadataContext : JsonSerializerContext;
|
@@ -4,10 +4,8 @@ using DHT.Server.Database.Repositories;
|
|||||||
|
|
||||||
namespace DHT.Server.Database;
|
namespace DHT.Server.Database;
|
||||||
|
|
||||||
public interface IDatabaseFile : IDisposable {
|
public interface IDatabaseFile : IAsyncDisposable {
|
||||||
string Path { get; }
|
string Path { get; }
|
||||||
DatabaseStatistics Statistics { get; }
|
|
||||||
Task<DatabaseStatisticsSnapshot> SnapshotStatistics();
|
|
||||||
|
|
||||||
IUserRepository Users { get; }
|
IUserRepository Users { get; }
|
||||||
IServerRepository Servers { get; }
|
IServerRepository Servers { get; }
|
||||||
|
@@ -4,4 +4,4 @@ namespace DHT.Server.Database.Import;
|
|||||||
|
|
||||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
|
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
|
||||||
[JsonSerializable(typeof(DiscordEmbedLegacyJson))]
|
[JsonSerializable(typeof(DiscordEmbedLegacyJson))]
|
||||||
sealed partial class DiscordEmbedLegacyJsonContext : JsonSerializerContext {}
|
sealed partial class DiscordEmbedLegacyJsonContext : JsonSerializerContext;
|
||||||
|
@@ -161,7 +161,7 @@ public static class LegacyArchiveImport {
|
|||||||
|
|
||||||
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
|
var messagesObj = data.HasKey(channelIdStr) ? data.RequireObject(channelIdStr, DataPath) : (JsonElement?) null;
|
||||||
if (messagesObj == null) {
|
if (messagesObj == null) {
|
||||||
return Array.Empty<Message>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return messagesObj.Value.EnumerateObject().Select(item => {
|
return messagesObj.Value.EnumerateObject().Select(item => {
|
||||||
|
@@ -1,21 +1,34 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Repositories;
|
namespace DHT.Server.Database.Repositories;
|
||||||
|
|
||||||
public interface IChannelRepository {
|
public interface IChannelRepository {
|
||||||
|
IObservable<long> TotalCount { get; }
|
||||||
|
|
||||||
Task Add(IReadOnlyList<Channel> channels);
|
Task Add(IReadOnlyList<Channel> channels);
|
||||||
|
|
||||||
IAsyncEnumerable<Channel> Get();
|
Task<long> Count(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
internal sealed class Dummy : IChannelRepository {
|
internal sealed class Dummy : IChannelRepository {
|
||||||
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task Add(IReadOnlyList<Channel> channels) {
|
public Task Add(IReadOnlyList<Channel> channels) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<Channel> Get() {
|
public Task<long> Count(CancellationToken cancellationToken) {
|
||||||
|
return Task.FromResult(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Channel> Get(CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<Channel>();
|
return AsyncEnumerable.Empty<Channel>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
|
||||||
using DHT.Server.Data.Aggregations;
|
using DHT.Server.Data.Aggregations;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Download;
|
using DHT.Server.Download;
|
||||||
@@ -10,59 +12,63 @@ using DHT.Server.Download;
|
|||||||
namespace DHT.Server.Database.Repositories;
|
namespace DHT.Server.Database.Repositories;
|
||||||
|
|
||||||
public interface IDownloadRepository {
|
public interface IDownloadRepository {
|
||||||
Task<long> CountAttachments(AttachmentFilter? filter = null, CancellationToken cancellationToken = default);
|
IObservable<long> TotalCount { get; }
|
||||||
|
|
||||||
Task AddDownload(Data.Download download);
|
Task AddDownload(Data.Download item, Stream? stream);
|
||||||
|
|
||||||
Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken = default);
|
Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<Data.Download> GetWithoutData();
|
Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAsyncEnumerable<Data.Download> Get();
|
||||||
|
|
||||||
Task<Data.Download> HydrateWithData(Data.Download download);
|
Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor);
|
||||||
|
|
||||||
|
Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<DownloadedAttachment?> GetDownloadedAttachment(string normalizedUrl);
|
IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<int> EnqueueDownloadItems(AttachmentFilter? filter = null, CancellationToken cancellationToken = default);
|
Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<DownloadItem> PullEnqueuedDownloadItems(int count, CancellationToken cancellationToken = default);
|
Task<int> RetryFailed(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
|
|
||||||
|
|
||||||
internal sealed class Dummy : IDownloadRepository {
|
internal sealed class Dummy : IDownloadRepository {
|
||||||
public Task<long> CountAttachments(AttachmentFilter? filter, CancellationToken cancellationToken) {
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
|
public Task AddDownload(Data.Download item, Stream? stream) {
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<long> Count(DownloadItemFilter filter, CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(0L);
|
return Task.FromResult(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task AddDownload(Data.Download download) {
|
public Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken) {
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken) {
|
|
||||||
return Task.FromResult(new DownloadStatusStatistics());
|
return Task.FromResult(new DownloadStatusStatistics());
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<Data.Download> GetWithoutData() {
|
public IAsyncEnumerable<Data.Download> Get() {
|
||||||
return AsyncEnumerable.Empty<Data.Download>();
|
return AsyncEnumerable.Empty<Data.Download>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Data.Download> HydrateWithData(Data.Download download) {
|
public Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor) {
|
||||||
return Task.FromResult(download);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<DownloadedAttachment?> GetDownloadedAttachment(string normalizedUrl) {
|
public Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) {
|
||||||
return Task.FromResult<DownloadedAttachment?>(null);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<int> EnqueueDownloadItems(AttachmentFilter? filter, CancellationToken cancellationToken) {
|
public IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, CancellationToken cancellationToken) {
|
||||||
return Task.FromResult(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<DownloadItem> PullEnqueuedDownloadItems(int count, CancellationToken cancellationToken) {
|
|
||||||
return AsyncEnumerable.Empty<DownloadItem>();
|
return AsyncEnumerable.Empty<DownloadItem>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
|
public Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> RetryFailed(CancellationToken cancellationToken) {
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
@@ -8,17 +10,21 @@ using DHT.Server.Data.Filters;
|
|||||||
namespace DHT.Server.Database.Repositories;
|
namespace DHT.Server.Database.Repositories;
|
||||||
|
|
||||||
public interface IMessageRepository {
|
public interface IMessageRepository {
|
||||||
|
IObservable<long> TotalCount { get; }
|
||||||
|
|
||||||
Task Add(IReadOnlyList<Message> messages);
|
Task Add(IReadOnlyList<Message> messages);
|
||||||
|
|
||||||
Task<long> Count(MessageFilter? filter = null, CancellationToken cancellationToken = default);
|
Task<long> Count(MessageFilter? filter = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<Message> Get(MessageFilter? filter = null);
|
IAsyncEnumerable<Message> Get(MessageFilter? filter = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
IAsyncEnumerable<ulong> GetIds(MessageFilter? filter = null);
|
IAsyncEnumerable<ulong> GetIds(MessageFilter? filter = null);
|
||||||
|
|
||||||
Task Remove(MessageFilter filter, FilterRemovalMode mode);
|
Task Remove(MessageFilter filter, FilterRemovalMode mode);
|
||||||
|
|
||||||
internal sealed class Dummy : IMessageRepository {
|
internal sealed class Dummy : IMessageRepository {
|
||||||
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task Add(IReadOnlyList<Message> messages) {
|
public Task Add(IReadOnlyList<Message> messages) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -27,7 +33,7 @@ public interface IMessageRepository {
|
|||||||
return Task.FromResult(0L);
|
return Task.FromResult(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<Message> Get(MessageFilter? filter) {
|
public IAsyncEnumerable<Message> Get(MessageFilter? filter, CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<Message>();
|
return AsyncEnumerable.Empty<Message>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,20 +1,33 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Repositories;
|
namespace DHT.Server.Database.Repositories;
|
||||||
|
|
||||||
public interface IServerRepository {
|
public interface IServerRepository {
|
||||||
|
IObservable<long> TotalCount { get; }
|
||||||
|
|
||||||
Task Add(IReadOnlyList<Data.Server> servers);
|
Task Add(IReadOnlyList<Data.Server> servers);
|
||||||
|
|
||||||
IAsyncEnumerable<Data.Server> Get();
|
Task<long> Count(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
internal sealed class Dummy : IServerRepository {
|
internal sealed class Dummy : IServerRepository {
|
||||||
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task Add(IReadOnlyList<Data.Server> servers) {
|
public Task Add(IReadOnlyList<Data.Server> servers) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<Data.Server> Get() {
|
public Task<long> Count(CancellationToken cancellationToken) {
|
||||||
|
return Task.FromResult(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Data.Server> Get(CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<Data.Server>();
|
return AsyncEnumerable.Empty<Data.Server>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,21 +1,34 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Repositories;
|
namespace DHT.Server.Database.Repositories;
|
||||||
|
|
||||||
public interface IUserRepository {
|
public interface IUserRepository {
|
||||||
|
IObservable<long> TotalCount { get; }
|
||||||
|
|
||||||
Task Add(IReadOnlyList<User> users);
|
Task Add(IReadOnlyList<User> users);
|
||||||
|
|
||||||
IAsyncEnumerable<User> Get();
|
Task<long> Count(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAsyncEnumerable<User> Get(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
internal sealed class Dummy : IUserRepository {
|
internal sealed class Dummy : IUserRepository {
|
||||||
|
public IObservable<long> TotalCount { get; } = Observable.Return(0L);
|
||||||
|
|
||||||
public Task Add(IReadOnlyList<User> users) {
|
public Task Add(IReadOnlyList<User> users) {
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<User> Get() {
|
public Task<long> Count(CancellationToken cancellationToken) {
|
||||||
|
return Task.FromResult(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<User> Get(CancellationToken cancellationToken) {
|
||||||
return AsyncEnumerable.Empty<User>();
|
return AsyncEnumerable.Empty<User>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,30 @@
|
|||||||
|
using System;
|
||||||
|
using System.Reactive.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
|
using DHT.Utils.Tasks;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Sqlite.Repositories;
|
||||||
|
|
||||||
|
abstract class BaseSqliteRepository : IDisposable {
|
||||||
|
private readonly ObservableThrottledTask<long> totalCountTask;
|
||||||
|
|
||||||
|
public IObservable<long> TotalCount { get; }
|
||||||
|
|
||||||
|
protected BaseSqliteRepository(Log log) {
|
||||||
|
totalCountTask = new ObservableThrottledTask<long>(log, TaskScheduler.Default);
|
||||||
|
TotalCount = totalCountTask.DistinctUntilChanged();
|
||||||
|
UpdateTotalCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() {
|
||||||
|
totalCountTask.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void UpdateTotalCount() {
|
||||||
|
totalCountTask.Post(Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract Task<long> Count(CancellationToken cancellationToken);
|
||||||
|
}
|
@@ -1,34 +1,28 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database.Repositories;
|
using DHT.Server.Database.Repositories;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
namespace DHT.Server.Database.Sqlite.Repositories;
|
||||||
|
|
||||||
sealed class SqliteChannelRepository : IChannelRepository {
|
sealed class SqliteChannelRepository : BaseSqliteRepository, IChannelRepository {
|
||||||
|
private static readonly Log Log = Log.ForType<SqliteChannelRepository>();
|
||||||
|
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
private readonly DatabaseStatistics statistics;
|
|
||||||
|
|
||||||
public SqliteChannelRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) {
|
public SqliteChannelRepository(SqliteConnectionPool pool) : base(Log) {
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
this.statistics = statistics;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task Initialize() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
await UpdateChannelStatistics(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateChannelStatistics(ISqliteConnection conn) {
|
|
||||||
statistics.TotalChannels = await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Channel> channels) {
|
public async Task Add(IReadOnlyList<Channel> channels) {
|
||||||
using var conn = pool.Take();
|
await using (var conn = await pool.Take()) {
|
||||||
|
await conn.BeginTransactionAsync();
|
||||||
await using (var tx = await conn.BeginTransactionAsync()) {
|
|
||||||
await using var cmd = conn.Upsert("channels", [
|
await using var cmd = conn.Upsert("channels", [
|
||||||
("id", SqliteType.Integer),
|
("id", SqliteType.Integer),
|
||||||
("server", SqliteType.Integer),
|
("server", SqliteType.Integer),
|
||||||
@@ -50,19 +44,24 @@ sealed class SqliteChannelRepository : IChannelRepository {
|
|||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.CommitAsync();
|
await conn.CommitTransactionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateChannelStatistics(conn);
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Channel> Get() {
|
public override async Task<long> Count(CancellationToken cancellationToken) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM channels", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Channel> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
|
await using var cmd = conn.Command("SELECT id, server, name, parent_id, position, topic, nsfw FROM channels");
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (reader.Read()) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
yield return new Channel {
|
yield return new Channel {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
Server = reader.GetUint64(1),
|
Server = reader.GetUint64(1),
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -9,201 +10,274 @@ using DHT.Server.Data.Filters;
|
|||||||
using DHT.Server.Database.Repositories;
|
using DHT.Server.Database.Repositories;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
using DHT.Server.Download;
|
using DHT.Server.Download;
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Utils.Logging;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
namespace DHT.Server.Database.Sqlite.Repositories;
|
||||||
|
|
||||||
sealed class SqliteDownloadRepository : IDownloadRepository {
|
sealed class SqliteDownloadRepository(SqliteConnectionPool pool) : BaseSqliteRepository(Log), IDownloadRepository {
|
||||||
private readonly SqliteConnectionPool pool;
|
private static readonly Log Log = Log.ForType<SqliteDownloadRepository>();
|
||||||
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
|
|
||||||
|
|
||||||
public SqliteDownloadRepository(SqliteConnectionPool pool, AsyncValueComputer<long>.Single totalDownloadsComputer) {
|
internal sealed class NewDownloadCollector : IAsyncDisposable {
|
||||||
this.pool = pool;
|
private readonly SqliteDownloadRepository repository;
|
||||||
this.totalDownloadsComputer = totalDownloadsComputer;
|
private bool hasAdded = false;
|
||||||
|
|
||||||
|
private readonly SqliteCommand metadataCmd;
|
||||||
|
|
||||||
|
public NewDownloadCollector(SqliteDownloadRepository repository, ISqliteConnection conn) {
|
||||||
|
this.repository = repository;
|
||||||
|
|
||||||
|
metadataCmd = conn.Command(
|
||||||
|
"""
|
||||||
|
INSERT INTO download_metadata (normalized_url, download_url, status, type, size)
|
||||||
|
VALUES (:normalized_url, :download_url, :status, :type, :size)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
metadataCmd.Add(":normalized_url", SqliteType.Text);
|
||||||
|
metadataCmd.Add(":download_url", SqliteType.Text);
|
||||||
|
metadataCmd.Add(":status", SqliteType.Integer);
|
||||||
|
metadataCmd.Add(":type", SqliteType.Text);
|
||||||
|
metadataCmd.Add(":size", SqliteType.Integer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Add(Data.Download download) {
|
||||||
|
metadataCmd.Set(":normalized_url", download.NormalizedUrl);
|
||||||
|
metadataCmd.Set(":download_url", download.DownloadUrl);
|
||||||
|
metadataCmd.Set(":status", (int) download.Status);
|
||||||
|
metadataCmd.Set(":type", download.Type);
|
||||||
|
metadataCmd.Set(":size", download.Size);
|
||||||
|
hasAdded |= await metadataCmd.ExecuteNonQueryAsync() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnCommitted() {
|
||||||
|
if (hasAdded) {
|
||||||
|
repository.UpdateTotalCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() {
|
||||||
|
await metadataCmd.DisposeAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<long> CountAttachments(AttachmentFilter? filter, CancellationToken cancellationToken) {
|
public async Task AddDownload(Data.Download item, Stream? stream) {
|
||||||
using var conn = pool.Take();
|
await using (var conn = await pool.Take()) {
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(DISTINCT normalized_url) FROM attachments a" + filter.GenerateWhereClause("a"), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
await conn.BeginTransactionAsync();
|
||||||
}
|
|
||||||
|
await using var metadataCmd = conn.Upsert("download_metadata", [
|
||||||
public async Task AddDownload(Data.Download download) {
|
|
||||||
using (var conn = pool.Take()) {
|
|
||||||
await using var cmd = conn.Upsert("downloads", [
|
|
||||||
("normalized_url", SqliteType.Text),
|
("normalized_url", SqliteType.Text),
|
||||||
("download_url", SqliteType.Text),
|
("download_url", SqliteType.Text),
|
||||||
("status", SqliteType.Integer),
|
("status", SqliteType.Integer),
|
||||||
|
("type", SqliteType.Text),
|
||||||
("size", SqliteType.Integer),
|
("size", SqliteType.Integer),
|
||||||
("blob", SqliteType.Blob)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
cmd.Set(":normalized_url", download.NormalizedUrl);
|
metadataCmd.Set(":normalized_url", item.NormalizedUrl);
|
||||||
cmd.Set(":download_url", download.DownloadUrl);
|
metadataCmd.Set(":download_url", item.DownloadUrl);
|
||||||
cmd.Set(":status", (int) download.Status);
|
metadataCmd.Set(":status", (int) item.Status);
|
||||||
cmd.Set(":size", download.Size);
|
metadataCmd.Set(":type", item.Type);
|
||||||
cmd.Set(":blob", download.Data);
|
metadataCmd.Set(":size", item.Size);
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await metadataCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
if (stream == null) {
|
||||||
|
await using var deleteBlobCmd = conn.Command("DELETE FROM download_blobs WHERE normalized_url = :normalized_url");
|
||||||
|
deleteBlobCmd.AddAndSet(":normalized_url", SqliteType.Text, item.NormalizedUrl);
|
||||||
|
await deleteBlobCmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await using var upsertBlobCmd = conn.Command(
|
||||||
|
"""
|
||||||
|
INSERT INTO download_blobs (normalized_url, blob)
|
||||||
|
VALUES (:normalized_url, ZEROBLOB(:blob_length))
|
||||||
|
ON CONFLICT (normalized_url) DO UPDATE SET blob = excluded.blob
|
||||||
|
RETURNING rowid
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
upsertBlobCmd.AddAndSet(":normalized_url", SqliteType.Text, item.NormalizedUrl);
|
||||||
|
upsertBlobCmd.AddAndSet(":blob_length", SqliteType.Integer, item.Size);
|
||||||
|
long rowid = await upsertBlobCmd.ExecuteLongScalarAsync();
|
||||||
|
|
||||||
|
await using var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid);
|
||||||
|
await stream.CopyToAsync(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.CommitTransactionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
totalDownloadsComputer.Recompute();
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DownloadStatusStatistics> GetStatistics(CancellationToken cancellationToken) {
|
public override Task<long> Count(CancellationToken cancellationToken) {
|
||||||
static async Task LoadUndownloadedStatistics(ISqliteConnection conn, DownloadStatusStatistics result, CancellationToken cancellationToken) {
|
return Count(filter: null, cancellationToken);
|
||||||
await using var cmd = conn.Command(
|
|
||||||
"""
|
|
||||||
SELECT IFNULL(COUNT(size), 0), IFNULL(SUM(size), 0)
|
|
||||||
FROM (SELECT MAX(a.size) size
|
|
||||||
FROM attachments a
|
|
||||||
WHERE a.normalized_url NOT IN (SELECT d.normalized_url FROM downloads d)
|
|
||||||
GROUP BY a.normalized_url)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (reader.Read()) {
|
|
||||||
result.SkippedCount = reader.GetInt32(0);
|
|
||||||
result.SkippedSize = reader.GetUint64(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task LoadSuccessStatistics(ISqliteConnection conn, DownloadStatusStatistics result, CancellationToken cancellationToken) {
|
|
||||||
await using var cmd = conn.Command(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
IFNULL(SUM(CASE WHEN status IN (:enqueued, :downloading) THEN 1 ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status IN (:enqueued, :downloading) THEN size ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status = :success THEN 1 ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status = :success THEN size ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status NOT IN (:enqueued, :downloading) AND status != :success THEN 1 ELSE 0 END), 0),
|
|
||||||
IFNULL(SUM(CASE WHEN status NOT IN (:enqueued, :downloading) AND status != :success THEN size ELSE 0 END), 0)
|
|
||||||
FROM downloads
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
|
|
||||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
|
||||||
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
|
||||||
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (reader.Read()) {
|
|
||||||
result.EnqueuedCount = reader.GetInt32(0);
|
|
||||||
result.EnqueuedSize = reader.GetUint64(1);
|
|
||||||
result.SuccessfulCount = reader.GetInt32(2);
|
|
||||||
result.SuccessfulSize = reader.GetUint64(3);
|
|
||||||
result.FailedCount = reader.GetInt32(4);
|
|
||||||
result.FailedSize = reader.GetUint64(5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new DownloadStatusStatistics();
|
|
||||||
|
|
||||||
using var conn = pool.Take();
|
|
||||||
await LoadUndownloadedStatistics(conn, result, cancellationToken);
|
|
||||||
await LoadSuccessStatistics(conn, result, cancellationToken);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Data.Download> GetWithoutData() {
|
public async Task<long> Count(DownloadItemFilter? filter, CancellationToken cancellationToken) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM download_metadata" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT normalized_url, download_url, status, size FROM downloads");
|
public async Task<DownloadStatusStatistics> GetStatistics(DownloadItemFilter nonSkippedFilter, CancellationToken cancellationToken) {
|
||||||
|
nonSkippedFilter.IncludeStatuses = null;
|
||||||
|
nonSkippedFilter.ExcludeStatuses = null;
|
||||||
|
string nonSkippedFilterConditions = nonSkippedFilter.GenerateConditions().Build();
|
||||||
|
|
||||||
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
|
await using var cmd = conn.Command(
|
||||||
|
$"""
|
||||||
|
SELECT
|
||||||
|
IFNULL(SUM(CASE WHEN (status = :downloading) OR (status = :pending AND {nonSkippedFilterConditions}) THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN (status = :downloading) OR (status = :pending AND {nonSkippedFilterConditions}) THEN IFNULL(size, 0) ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN ((status = :downloading) OR (status = :pending AND {nonSkippedFilterConditions})) AND size IS NULL THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :success THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :success THEN IFNULL(size, 0) ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :success AND size IS NULL THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status NOT IN (:pending, :downloading, :success) THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status NOT IN (:pending, :downloading, :success) THEN IFNULL(size, 0) ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status NOT IN (:pending, :downloading, :success) AND size IS NULL THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :pending AND NOT ({nonSkippedFilterConditions}) THEN 1 ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :pending AND NOT ({nonSkippedFilterConditions}) THEN IFNULL(size, 0) ELSE 0 END), 0),
|
||||||
|
IFNULL(SUM(CASE WHEN status = :pending AND NOT ({nonSkippedFilterConditions}) AND size IS NULL THEN 1 ELSE 0 END), 0)
|
||||||
|
FROM download_metadata
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
||||||
|
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
||||||
|
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await reader.ReadAsync(cancellationToken)) {
|
||||||
|
return new DownloadStatusStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DownloadStatusStatistics {
|
||||||
|
PendingCount = reader.GetInt32(0),
|
||||||
|
PendingTotalSize = reader.GetUint64(1),
|
||||||
|
PendingWithUnknownSizeCount = reader.GetInt32(2),
|
||||||
|
SuccessfulCount = reader.GetInt32(3),
|
||||||
|
SuccessfulTotalSize = reader.GetUint64(4),
|
||||||
|
SuccessfulWithUnknownSizeCount = reader.GetInt32(5),
|
||||||
|
FailedCount = reader.GetInt32(6),
|
||||||
|
FailedTotalSize = reader.GetUint64(7),
|
||||||
|
FailedWithUnknownSizeCount = reader.GetInt32(8),
|
||||||
|
SkippedCount = reader.GetInt32(9),
|
||||||
|
SkippedTotalSize = reader.GetUint64(10),
|
||||||
|
SkippedWithUnknownSizeCount = reader.GetInt32(11)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Data.Download> Get() {
|
||||||
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
|
await using var cmd = conn.Command("SELECT normalized_url, download_url, status, type, size FROM download_metadata");
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
while (reader.Read()) {
|
while (await reader.ReadAsync()) {
|
||||||
string normalizedUrl = reader.GetString(0);
|
string normalizedUrl = reader.GetString(0);
|
||||||
string downloadUrl = reader.GetString(1);
|
string downloadUrl = reader.GetString(1);
|
||||||
var status = (DownloadStatus) reader.GetInt32(2);
|
var status = (DownloadStatus) reader.GetInt32(2);
|
||||||
ulong size = reader.GetUint64(3);
|
string? type = reader.IsDBNull(3) ? null : reader.GetString(3);
|
||||||
|
ulong? size = reader.IsDBNull(4) ? null : reader.GetUint64(4);
|
||||||
|
|
||||||
yield return new Data.Download(normalizedUrl, downloadUrl, status, size);
|
yield return new Data.Download(normalizedUrl, downloadUrl, status, type, size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Data.Download> HydrateWithData(Data.Download download) {
|
public async Task<bool> GetDownloadData(string normalizedUrl, Func<Stream, Task> dataProcessor) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT blob FROM downloads WHERE normalized_url = :url");
|
await using var cmd = conn.Command("SELECT rowid FROM download_blobs WHERE normalized_url = :normalized_url");
|
||||||
cmd.AddAndSet(":url", SqliteType.Text, download.NormalizedUrl);
|
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
||||||
|
|
||||||
|
long rowid;
|
||||||
|
|
||||||
|
await using (var reader = await cmd.ExecuteReaderAsync()) {
|
||||||
|
if (!await reader.ReadAsync()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
rowid = reader.GetInt64(0);
|
||||||
|
|
||||||
if (reader.Read() && !reader.IsDBNull(0)) {
|
|
||||||
return download.WithData((byte[]) reader["blob"]);
|
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
return download;
|
await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) {
|
||||||
|
await dataProcessor(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DownloadedAttachment?> GetDownloadedAttachment(string normalizedUrl) {
|
public async Task<bool> GetSuccessfulDownloadWithData(string normalizedUrl, Func<Data.Download, Stream, CancellationToken, Task> dataProcessor, CancellationToken cancellationToken) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command(
|
await using var cmd = conn.Command(
|
||||||
"""
|
"""
|
||||||
SELECT a.type, d.blob FROM downloads d
|
SELECT dm.download_url, dm.type, db.rowid FROM download_metadata dm
|
||||||
LEFT JOIN attachments a ON d.normalized_url = a.normalized_url
|
JOIN download_blobs db ON dm.normalized_url = db.normalized_url
|
||||||
WHERE d.normalized_url = :normalized_url AND d.status = :success AND d.blob IS NOT NULL
|
WHERE dm.normalized_url = :normalized_url AND dm.status = :success IS NOT NULL
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
cmd.AddAndSet(":normalized_url", SqliteType.Text, normalizedUrl);
|
||||||
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
string downloadUrl;
|
||||||
|
string? type;
|
||||||
|
long rowid;
|
||||||
|
|
||||||
|
await using (var reader = await cmd.ExecuteReaderAsync(cancellationToken)) {
|
||||||
|
if (!await reader.ReadAsync(cancellationToken)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!reader.Read()) {
|
downloadUrl = reader.GetString(0);
|
||||||
return null;
|
type = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||||
|
rowid = reader.GetInt64(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var blob = new SqliteBlob(conn.InnerConnection, "download_blobs", "blob", rowid, readOnly: true)) {
|
||||||
|
await dataProcessor(new Data.Download(normalizedUrl, downloadUrl, DownloadStatus.Success, type, (ulong) blob.Length), blob, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DownloadedAttachment {
|
return true;
|
||||||
Type = reader.IsDBNull(0) ? null : reader.GetString(0),
|
|
||||||
Data = (byte[]) reader["blob"],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> EnqueueDownloadItems(AttachmentFilter? filter, CancellationToken cancellationToken) {
|
public async IAsyncEnumerable<DownloadItem> PullPendingDownloadItems(int count, DownloadItemFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
using var conn = pool.Take();
|
filter.IncludeStatuses = [DownloadStatus.Pending];
|
||||||
|
filter.ExcludeStatuses = null;
|
||||||
|
|
||||||
await using var cmd = conn.Command(
|
|
||||||
$"""
|
|
||||||
INSERT INTO downloads (normalized_url, download_url, status, size)
|
|
||||||
SELECT a.normalized_url, a.download_url, :enqueued, MAX(a.size)
|
|
||||||
FROM attachments a
|
|
||||||
{filter.GenerateWhereClause("a")}
|
|
||||||
GROUP BY a.normalized_url
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
|
|
||||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
|
||||||
return await cmd.ExecuteNonQueryAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<DownloadItem> PullEnqueuedDownloadItems(int count, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
|
||||||
var found = new List<DownloadItem>();
|
var found = new List<DownloadItem>();
|
||||||
|
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using (var cmd = conn.Command("SELECT normalized_url, download_url, size FROM downloads WHERE status = :enqueued LIMIT :limit")) {
|
var sql = $"""
|
||||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
SELECT normalized_url, download_url, type, size
|
||||||
|
FROM download_metadata
|
||||||
|
{filter.GenerateConditions().BuildWhereClause()}
|
||||||
|
LIMIT :limit
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using (var cmd = conn.Command(sql)) {
|
||||||
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count));
|
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count));
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (reader.Read()) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
found.Add(new DownloadItem {
|
found.Add(new DownloadItem {
|
||||||
NormalizedUrl = reader.GetString(0),
|
NormalizedUrl = reader.GetString(0),
|
||||||
DownloadUrl = reader.GetString(1),
|
DownloadUrl = reader.GetString(1),
|
||||||
Size = reader.GetUint64(2),
|
Type = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
|
Size = reader.IsDBNull(3) ? null : reader.GetUint64(3)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (found.Count != 0) {
|
if (found.Count != 0) {
|
||||||
await using var cmd = conn.Command("UPDATE downloads SET status = :downloading WHERE normalized_url = :normalized_url AND status = :enqueued");
|
await using var cmd = conn.Command("UPDATE download_metadata SET status = :downloading WHERE normalized_url = :normalized_url AND status = :pending");
|
||||||
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
|
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
||||||
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
||||||
cmd.Add(":normalized_url", SqliteType.Text);
|
cmd.Add(":normalized_url", SqliteType.Text);
|
||||||
|
|
||||||
@@ -217,17 +291,23 @@ sealed class SqliteDownloadRepository : IDownloadRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
|
public async Task MoveDownloadingItemsBackToQueue(CancellationToken cancellationToken) {
|
||||||
using (var conn = pool.Take()) {
|
await using var conn = await pool.Take();
|
||||||
await conn.ExecuteAsync(
|
|
||||||
$"""
|
|
||||||
-- noinspection SqlWithoutWhere
|
|
||||||
DELETE FROM downloads
|
|
||||||
{filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching)}
|
|
||||||
"""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
totalDownloadsComputer.Recompute();
|
await using var cmd = conn.Command("UPDATE download_metadata SET status = :pending WHERE status = :downloading");
|
||||||
|
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
||||||
|
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
|
||||||
|
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> RetryFailed(CancellationToken cancellationToken) {
|
||||||
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
|
await using var cmd = conn.Command("UPDATE download_metadata SET status = :pending WHERE status = :generic_error OR (status > :last_custom_code AND status != :success)");
|
||||||
|
cmd.AddAndSet(":pending", SqliteType.Integer, (int) DownloadStatus.Pending);
|
||||||
|
cmd.AddAndSet(":generic_error", SqliteType.Integer, (int) DownloadStatus.GenericError);
|
||||||
|
cmd.AddAndSet(":last_custom_code", SqliteType.Integer, (int) DownloadStatus.LastCustomCode);
|
||||||
|
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
|
||||||
|
return await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,26 +1,28 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Data.Filters;
|
using DHT.Server.Data.Filters;
|
||||||
using DHT.Server.Database.Repositories;
|
using DHT.Server.Database.Repositories;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
using DHT.Utils.Tasks;
|
using DHT.Server.Download;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
namespace DHT.Server.Database.Sqlite.Repositories;
|
||||||
|
|
||||||
sealed class SqliteMessageRepository : IMessageRepository {
|
sealed class SqliteMessageRepository : BaseSqliteRepository, IMessageRepository {
|
||||||
|
private static readonly Log Log = Log.ForType<SqliteMessageRepository>();
|
||||||
|
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
private readonly AsyncValueComputer<long>.Single totalMessagesComputer;
|
private readonly SqliteDownloadRepository downloads;
|
||||||
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
|
|
||||||
|
|
||||||
public SqliteMessageRepository(SqliteConnectionPool pool, AsyncValueComputer<long>.Single totalMessagesComputer, AsyncValueComputer<long>.Single totalAttachmentsComputer) {
|
public SqliteMessageRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : base(Log) {
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
this.totalMessagesComputer = totalMessagesComputer;
|
this.downloads = downloads;
|
||||||
this.totalAttachmentsComputer = totalAttachmentsComputer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Message> messages) {
|
public async Task Add(IReadOnlyList<Message> messages) {
|
||||||
@@ -37,10 +39,8 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool addedAttachments = false;
|
await using (var conn = await pool.Take()) {
|
||||||
|
await conn.BeginTransactionAsync();
|
||||||
using (var conn = pool.Take()) {
|
|
||||||
await using var tx = await conn.BeginTransactionAsync();
|
|
||||||
|
|
||||||
await using var messageCmd = conn.Upsert("messages", [
|
await using var messageCmd = conn.Upsert("messages", [
|
||||||
("message_id", SqliteType.Integer),
|
("message_id", SqliteType.Integer),
|
||||||
@@ -91,6 +91,8 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
("emoji_flags", SqliteType.Integer),
|
("emoji_flags", SqliteType.Integer),
|
||||||
("count", SqliteType.Integer)
|
("count", SqliteType.Integer)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn);
|
||||||
|
|
||||||
foreach (var message in messages) {
|
foreach (var message in messages) {
|
||||||
object messageId = message.Id;
|
object messageId = message.Id;
|
||||||
@@ -122,8 +124,6 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!message.Attachments.IsEmpty) {
|
if (!message.Attachments.IsEmpty) {
|
||||||
addedAttachments = true;
|
|
||||||
|
|
||||||
foreach (var attachment in message.Attachments) {
|
foreach (var attachment in message.Attachments) {
|
||||||
attachmentCmd.Set(":message_id", messageId);
|
attachmentCmd.Set(":message_id", messageId);
|
||||||
attachmentCmd.Set(":attachment_id", attachment.Id);
|
attachmentCmd.Set(":attachment_id", attachment.Id);
|
||||||
@@ -135,6 +135,8 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
attachmentCmd.Set(":width", attachment.Width);
|
attachmentCmd.Set(":width", attachment.Width);
|
||||||
attachmentCmd.Set(":height", attachment.Height);
|
attachmentCmd.Set(":height", attachment.Height);
|
||||||
await attachmentCmd.ExecuteNonQueryAsync();
|
await attachmentCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
await downloadCollector.Add(DownloadLinkExtractor.FromAttachment(attachment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +145,10 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
embedCmd.Set(":message_id", messageId);
|
embedCmd.Set(":message_id", messageId);
|
||||||
embedCmd.Set(":json", embed.Json);
|
embedCmd.Set(":json", embed.Json);
|
||||||
await embedCmd.ExecuteNonQueryAsync();
|
await embedCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
if (DownloadLinkExtractor.TryFromEmbedJson(embed.Json) is {} download) {
|
||||||
|
await downloadCollector.Add(download);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,30 +160,35 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
reactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
|
reactionCmd.Set(":emoji_flags", (int) reaction.EmojiFlags);
|
||||||
reactionCmd.Set(":count", reaction.Count);
|
reactionCmd.Set(":count", reaction.Count);
|
||||||
await reactionCmd.ExecuteNonQueryAsync();
|
await reactionCmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
if (reaction.EmojiId is {} emojiId) {
|
||||||
|
await downloadCollector.Add(DownloadLinkExtractor.FromEmoji(emojiId, reaction.EmojiFlags));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.CommitAsync();
|
await conn.CommitTransactionAsync();
|
||||||
|
downloadCollector.OnCommitted();
|
||||||
}
|
}
|
||||||
|
|
||||||
totalMessagesComputer.Recompute();
|
UpdateTotalCount();
|
||||||
|
|
||||||
if (addedAttachments) {
|
|
||||||
totalAttachmentsComputer.Recompute();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Task<long> Count(CancellationToken cancellationToken) {
|
||||||
|
return Count(filter: null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
|
public async Task<long> Count(MessageFilter? filter, CancellationToken cancellationToken) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM messages" + filter.GenerateConditions().BuildWhereClause(), static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class MesageToManyCommand<T> : IAsyncDisposable {
|
private sealed class MessageToManyCommand<T> : IAsyncDisposable {
|
||||||
private readonly SqliteCommand cmd;
|
private readonly SqliteCommand cmd;
|
||||||
private readonly Func<SqliteDataReader, T> readItem;
|
private readonly Func<SqliteDataReader, T> readItem;
|
||||||
|
|
||||||
public MesageToManyCommand(ISqliteConnection conn, string sql, Func<SqliteDataReader, T> readItem) {
|
public MessageToManyCommand(ISqliteConnection conn, string sql, Func<SqliteDataReader, T> readItem) {
|
||||||
this.cmd = conn.Command(sql);
|
this.cmd = conn.Command(sql);
|
||||||
this.cmd.Add(":message_id", SqliteType.Integer);
|
this.cmd.Add(":message_id", SqliteType.Integer);
|
||||||
|
|
||||||
@@ -203,8 +214,8 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Message> Get(MessageFilter? filter) {
|
public async IAsyncEnumerable<Message> Get(MessageFilter? filter, [EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
const string AttachmentSql =
|
const string AttachmentSql =
|
||||||
"""
|
"""
|
||||||
@@ -213,7 +224,7 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
WHERE message_id = :message_id
|
WHERE message_id = :message_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var attachmentCmd = new MesageToManyCommand<Attachment>(conn, AttachmentSql, static reader => new Attachment {
|
await using var attachmentCmd = new MessageToManyCommand<Attachment>(conn, AttachmentSql, static reader => new Attachment {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
Name = reader.GetString(1),
|
Name = reader.GetString(1),
|
||||||
Type = reader.IsDBNull(2) ? null : reader.GetString(2),
|
Type = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
@@ -231,7 +242,7 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
WHERE message_id = :message_id
|
WHERE message_id = :message_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var embedCmd = new MesageToManyCommand<Embed>(conn, EmbedSql, static reader => new Embed {
|
await using var embedCmd = new MessageToManyCommand<Embed>(conn, EmbedSql, static reader => new Embed {
|
||||||
Json = reader.GetString(0)
|
Json = reader.GetString(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,7 +253,7 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
WHERE message_id = :message_id
|
WHERE message_id = :message_id
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var reactionsCmd = new MesageToManyCommand<Reaction>(conn, ReactionSql, static reader => new Reaction {
|
await using var reactionsCmd = new MessageToManyCommand<Reaction>(conn, ReactionSql, static reader => new Reaction {
|
||||||
EmojiId = reader.IsDBNull(0) ? null : reader.GetUint64(0),
|
EmojiId = reader.IsDBNull(0) ? null : reader.GetUint64(0),
|
||||||
EmojiName = reader.IsDBNull(1) ? null : reader.GetString(1),
|
EmojiName = reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||||
EmojiFlags = (EmojiFlags) reader.GetInt16(2),
|
EmojiFlags = (EmojiFlags) reader.GetInt16(2),
|
||||||
@@ -255,13 +266,13 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
FROM messages m
|
FROM messages m
|
||||||
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
|
LEFT JOIN edit_timestamps et ON m.message_id = et.message_id
|
||||||
LEFT JOIN replied_to rt ON m.message_id = rt.message_id
|
LEFT JOIN replied_to rt ON m.message_id = rt.message_id
|
||||||
{filter.GenerateWhereClause("m")}
|
{filter.GenerateConditions("m").BuildWhereClause()}
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
await using var reader = await messageCmd.ExecuteReaderAsync();
|
await using var reader = await messageCmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (await reader.ReadAsync()) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
ulong messageId = reader.GetUint64(0);
|
ulong messageId = reader.GetUint64(0);
|
||||||
|
|
||||||
yield return new Message {
|
yield return new Message {
|
||||||
@@ -280,9 +291,9 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
|
public async IAsyncEnumerable<ulong> GetIds(MessageFilter? filter) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateWhereClause());
|
await using var cmd = conn.Command("SELECT message_id FROM messages" + filter.GenerateConditions().BuildWhereClause());
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
while (await reader.ReadAsync()) {
|
while (await reader.ReadAsync()) {
|
||||||
@@ -291,16 +302,16 @@ sealed class SqliteMessageRepository : IMessageRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task Remove(MessageFilter filter, FilterRemovalMode mode) {
|
public async Task Remove(MessageFilter filter, FilterRemovalMode mode) {
|
||||||
using (var conn = pool.Take()) {
|
await using (var conn = await pool.Take()) {
|
||||||
await conn.ExecuteAsync(
|
await conn.ExecuteAsync(
|
||||||
$"""
|
$"""
|
||||||
-- noinspection SqlWithoutWhere
|
-- noinspection SqlWithoutWhere
|
||||||
DELETE FROM messages
|
DELETE FROM messages
|
||||||
{filter.GenerateWhereClause(invert: mode == FilterRemovalMode.KeepMatching)}
|
{filter.GenerateConditions(invert: mode == FilterRemovalMode.KeepMatching).BuildWhereClause()}
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
totalMessagesComputer.Recompute();
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,34 +1,28 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database.Repositories;
|
using DHT.Server.Database.Repositories;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
namespace DHT.Server.Database.Sqlite.Repositories;
|
||||||
|
|
||||||
sealed class SqliteServerRepository : IServerRepository {
|
sealed class SqliteServerRepository : BaseSqliteRepository, IServerRepository {
|
||||||
|
private static readonly Log Log = Log.ForType<SqliteServerRepository>();
|
||||||
|
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
private readonly DatabaseStatistics statistics;
|
|
||||||
|
|
||||||
public SqliteServerRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) {
|
public SqliteServerRepository(SqliteConnectionPool pool) : base(Log) {
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
this.statistics = statistics;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task Initialize() {
|
|
||||||
using var conn = pool.Take();
|
|
||||||
await UpdateServerStatistics(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateServerStatistics(ISqliteConnection conn) {
|
|
||||||
statistics.TotalServers = await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<Data.Server> servers) {
|
public async Task Add(IReadOnlyList<Data.Server> servers) {
|
||||||
using var conn = pool.Take();
|
await using (var conn = await pool.Take()) {
|
||||||
|
await conn.BeginTransactionAsync();
|
||||||
await using (var tx = await conn.BeginTransactionAsync()) {
|
|
||||||
await using var cmd = conn.Upsert("servers", [
|
await using var cmd = conn.Upsert("servers", [
|
||||||
("id", SqliteType.Integer),
|
("id", SqliteType.Integer),
|
||||||
("name", SqliteType.Text),
|
("name", SqliteType.Text),
|
||||||
@@ -42,19 +36,24 @@ sealed class SqliteServerRepository : IServerRepository {
|
|||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.CommitAsync();
|
await conn.CommitTransactionAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateServerStatistics(conn);
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Data.Server> Get() {
|
public override async Task<long> Count(CancellationToken cancellationToken) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM servers", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Data.Server> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
await using var cmd = conn.Command("SELECT id, name, type FROM servers");
|
await using var cmd = conn.Command("SELECT id, name, type FROM servers");
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
while (reader.Read()) {
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
yield return new Data.Server {
|
yield return new Data.Server {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
Name = reader.GetString(1),
|
Name = reader.GetString(1),
|
||||||
|
@@ -1,67 +1,79 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DHT.Server.Data;
|
using DHT.Server.Data;
|
||||||
using DHT.Server.Database.Repositories;
|
using DHT.Server.Database.Repositories;
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
using DHT.Server.Download;
|
||||||
|
using DHT.Utils.Logging;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Repositories;
|
namespace DHT.Server.Database.Sqlite.Repositories;
|
||||||
|
|
||||||
sealed class SqliteUserRepository : IUserRepository {
|
sealed class SqliteUserRepository : BaseSqliteRepository, IUserRepository {
|
||||||
|
private static readonly Log Log = Log.ForType<SqliteUserRepository>();
|
||||||
|
|
||||||
private readonly SqliteConnectionPool pool;
|
private readonly SqliteConnectionPool pool;
|
||||||
private readonly DatabaseStatistics statistics;
|
private readonly SqliteDownloadRepository downloads;
|
||||||
|
|
||||||
public SqliteUserRepository(SqliteConnectionPool pool, DatabaseStatistics statistics) {
|
|
||||||
this.pool = pool;
|
|
||||||
this.statistics = statistics;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task Initialize() {
|
public SqliteUserRepository(SqliteConnectionPool pool, SqliteDownloadRepository downloads) : base(Log) {
|
||||||
using var conn = pool.Take();
|
this.pool = pool;
|
||||||
await UpdateUserStatistics(conn);
|
this.downloads = downloads;
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateUserStatistics(ISqliteConnection conn) {
|
|
||||||
statistics.TotalUsers = await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM users", static reader => reader?.GetInt64(0) ?? 0L);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Add(IReadOnlyList<User> users) {
|
public async Task Add(IReadOnlyList<User> users) {
|
||||||
using var conn = pool.Take();
|
await using (var conn = await pool.Take()) {
|
||||||
|
await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
await using (var tx = await conn.BeginTransactionAsync()) {
|
|
||||||
await using var cmd = conn.Upsert("users", [
|
await using var cmd = conn.Upsert("users", [
|
||||||
("id", SqliteType.Integer),
|
("id", SqliteType.Integer),
|
||||||
("name", SqliteType.Text),
|
("name", SqliteType.Text),
|
||||||
|
("display_name", SqliteType.Text),
|
||||||
("avatar_url", SqliteType.Text),
|
("avatar_url", SqliteType.Text),
|
||||||
("discriminator", SqliteType.Text)
|
("discriminator", SqliteType.Text)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await using var downloadCollector = new SqliteDownloadRepository.NewDownloadCollector(downloads, conn);
|
||||||
|
|
||||||
foreach (var user in users) {
|
foreach (var user in users) {
|
||||||
cmd.Set(":id", user.Id);
|
cmd.Set(":id", user.Id);
|
||||||
cmd.Set(":name", user.Name);
|
cmd.Set(":name", user.Name);
|
||||||
|
cmd.Set(":display_name", user.DisplayName);
|
||||||
cmd.Set(":avatar_url", user.AvatarUrl);
|
cmd.Set(":avatar_url", user.AvatarUrl);
|
||||||
cmd.Set(":discriminator", user.Discriminator);
|
cmd.Set(":discriminator", user.Discriminator);
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
if (user.AvatarUrl is {} avatarUrl) {
|
||||||
|
await downloadCollector.Add(DownloadLinkExtractor.FromUserAvatar(user.Id, avatarUrl));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.CommitAsync();
|
await conn.CommitTransactionAsync();
|
||||||
|
downloadCollector.OnCommitted();
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateUserStatistics(conn);
|
UpdateTotalCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<User> Get() {
|
public override async Task<long> Count(CancellationToken cancellationToken) {
|
||||||
using var conn = pool.Take();
|
await using var conn = await pool.Take();
|
||||||
|
return await conn.ExecuteReaderAsync("SELECT COUNT(*) FROM users", static reader => reader?.GetInt64(0) ?? 0L, cancellationToken);
|
||||||
await using var cmd = conn.Command("SELECT id, name, avatar_url, discriminator FROM users");
|
}
|
||||||
await using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (reader.Read()) {
|
public async IAsyncEnumerable<User> Get([EnumeratorCancellation] CancellationToken cancellationToken) {
|
||||||
|
await using var conn = await pool.Take();
|
||||||
|
|
||||||
|
await using var cmd = conn.Command("SELECT id, name, display_name, avatar_url, discriminator FROM users");
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(cancellationToken)) {
|
||||||
yield return new User {
|
yield return new User {
|
||||||
Id = reader.GetUint64(0),
|
Id = reader.GetUint64(0),
|
||||||
Name = reader.GetString(1),
|
Name = reader.GetString(1),
|
||||||
AvatarUrl = reader.IsDBNull(2) ? null : reader.GetString(2),
|
DisplayName = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
Discriminator = reader.IsDBNull(3) ? null : reader.GetString(3),
|
AvatarUrl = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
Discriminator = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,358 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Data.Common;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DHT.Server.Database.Exceptions;
|
|
||||||
using DHT.Server.Database.Sqlite.Utils;
|
|
||||||
using DHT.Server.Download;
|
|
||||||
using DHT.Utils.Logging;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite;
|
|
||||||
|
|
||||||
sealed class Schema {
|
|
||||||
internal const int Version = 6;
|
|
||||||
|
|
||||||
private static readonly Log Log = Log.ForType<Schema>();
|
|
||||||
|
|
||||||
private readonly ISqliteConnection conn;
|
|
||||||
|
|
||||||
public Schema(ISqliteConnection conn) {
|
|
||||||
this.conn = conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Setup(ISchemaUpgradeCallbacks callbacks) {
|
|
||||||
await conn.ExecuteAsync("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
|
|
||||||
|
|
||||||
var dbVersionStr = await conn.ExecuteReaderAsync("SELECT value FROM metadata WHERE key = 'version'", static reader => reader?.GetString(0));
|
|
||||||
if (dbVersionStr == null) {
|
|
||||||
await InitializeSchemas();
|
|
||||||
}
|
|
||||||
else if (!int.TryParse(dbVersionStr, out int dbVersion) || dbVersion < 1) {
|
|
||||||
throw new InvalidDatabaseVersionException(dbVersionStr);
|
|
||||||
}
|
|
||||||
else if (dbVersion > Version) {
|
|
||||||
throw new DatabaseTooNewException(dbVersion);
|
|
||||||
}
|
|
||||||
else if (dbVersion < Version) {
|
|
||||||
var proceed = await callbacks.CanUpgrade();
|
|
||||||
if (!proceed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await callbacks.Start(Version - dbVersion, async reporter => await UpgradeSchemas(dbVersion, reporter));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task InitializeSchemas() {
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
avatar_url TEXT,
|
|
||||||
discriminator TEXT
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE servers (
|
|
||||||
id INTEGER PRIMARY KEY NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE channels (
|
|
||||||
id INTEGER PRIMARY KEY NOT NULL,
|
|
||||||
server INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
parent_id INTEGER,
|
|
||||||
position INTEGER,
|
|
||||||
topic TEXT,
|
|
||||||
nsfw INTEGER
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE messages (
|
|
||||||
message_id INTEGER PRIMARY KEY NOT NULL,
|
|
||||||
sender_id INTEGER NOT NULL,
|
|
||||||
channel_id INTEGER NOT NULL,
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
timestamp INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE attachments (
|
|
||||||
message_id INTEGER NOT NULL,
|
|
||||||
attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type TEXT,
|
|
||||||
normalized_url TEXT NOT NULL,
|
|
||||||
download_url TEXT,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE embeds (
|
|
||||||
message_id INTEGER NOT NULL,
|
|
||||||
json TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE downloads (
|
|
||||||
normalized_url TEXT NOT NULL PRIMARY KEY,
|
|
||||||
download_url TEXT,
|
|
||||||
status INTEGER NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
blob BLOB
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE reactions (
|
|
||||||
message_id INTEGER NOT NULL,
|
|
||||||
emoji_id INTEGER,
|
|
||||||
emoji_name TEXT,
|
|
||||||
emoji_flags INTEGER NOT NULL,
|
|
||||||
count INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await CreateMessageEditTimestampTable();
|
|
||||||
await CreateMessageRepliedToTable();
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("CREATE INDEX attachments_message_ix ON attachments(message_id)");
|
|
||||||
await conn.ExecuteAsync("CREATE INDEX embeds_message_ix ON embeds(message_id)");
|
|
||||||
await conn.ExecuteAsync("CREATE INDEX reactions_message_ix ON reactions(message_id)");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CreateMessageEditTimestampTable() {
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE edit_timestamps (
|
|
||||||
message_id INTEGER PRIMARY KEY NOT NULL,
|
|
||||||
edit_timestamp INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CreateMessageRepliedToTable() {
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE replied_to (
|
|
||||||
message_id INTEGER PRIMARY KEY NOT NULL,
|
|
||||||
replied_to_id INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task NormalizeAttachmentUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
|
||||||
await reporter.SubWork("Preparing attachments...", 0, 0);
|
|
||||||
|
|
||||||
var normalizedUrls = new Dictionary<long, string>();
|
|
||||||
|
|
||||||
await using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) {
|
|
||||||
await using var reader = await selectCmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (reader.Read()) {
|
|
||||||
var attachmentId = reader.GetInt64(0);
|
|
||||||
var originalUrl = reader.GetString(1);
|
|
||||||
normalizedUrls[attachmentId] = DiscordCdn.NormalizeUrl(originalUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var tx = await conn.BeginTransactionAsync();
|
|
||||||
|
|
||||||
int totalUrls = normalizedUrls.Count;
|
|
||||||
int processedUrls = -1;
|
|
||||||
|
|
||||||
await using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
|
|
||||||
updateCmd.Add(":attachment_id", SqliteType.Integer);
|
|
||||||
updateCmd.Add(":normalized_url", SqliteType.Text);
|
|
||||||
|
|
||||||
foreach (var (attachmentId, normalizedUrl) in normalizedUrls) {
|
|
||||||
if (++processedUrls % 1000 == 0) {
|
|
||||||
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCmd.Set(":attachment_id", attachmentId);
|
|
||||||
updateCmd.Set(":normalized_url", normalizedUrl);
|
|
||||||
updateCmd.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task NormalizeDownloadUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
|
||||||
await reporter.SubWork("Preparing downloads...", 0, 0);
|
|
||||||
|
|
||||||
var normalizedUrlsToOriginalUrls = new Dictionary<string, string>();
|
|
||||||
var duplicateUrlsToDelete = new HashSet<string>();
|
|
||||||
|
|
||||||
await using (var selectCmd = conn.Command("SELECT url FROM downloads ORDER BY CASE WHEN status = 200 THEN 0 ELSE 1 END")) {
|
|
||||||
await using var reader = await selectCmd.ExecuteReaderAsync();
|
|
||||||
|
|
||||||
while (reader.Read()) {
|
|
||||||
var originalUrl = reader.GetString(0);
|
|
||||||
var normalizedUrl = DiscordCdn.NormalizeUrl(originalUrl);
|
|
||||||
|
|
||||||
if (!normalizedUrlsToOriginalUrls.TryAdd(normalizedUrl, originalUrl)) {
|
|
||||||
duplicateUrlsToDelete.Add(originalUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("PRAGMA cache_size = -20000");
|
|
||||||
|
|
||||||
DbTransaction tx;
|
|
||||||
|
|
||||||
await using (tx = await conn.BeginTransactionAsync()) {
|
|
||||||
await reporter.SubWork("Deleting duplicates...", 0, 0);
|
|
||||||
|
|
||||||
await using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) {
|
|
||||||
foreach (var duplicateUrl in duplicateUrlsToDelete) {
|
|
||||||
deleteCmd.Set(":url", duplicateUrl);
|
|
||||||
deleteCmd.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
int totalUrls = normalizedUrlsToOriginalUrls.Count;
|
|
||||||
int processedUrls = -1;
|
|
||||||
|
|
||||||
tx = await conn.BeginTransactionAsync();
|
|
||||||
|
|
||||||
await using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
|
|
||||||
updateCmd.Add(":normalized_url", SqliteType.Text);
|
|
||||||
updateCmd.Add(":download_url", SqliteType.Text);
|
|
||||||
|
|
||||||
foreach (var (normalizedUrl, downloadUrl) in normalizedUrlsToOriginalUrls) {
|
|
||||||
if (++processedUrls % 100 == 0) {
|
|
||||||
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
|
|
||||||
|
|
||||||
// Not proper way of dealing with transactions, but it avoids a long commit at the end.
|
|
||||||
// Schema upgrades are already non-atomic anyways, so this doesn't make it worse.
|
|
||||||
await tx.CommitAsync();
|
|
||||||
await tx.DisposeAsync();
|
|
||||||
|
|
||||||
tx = await conn.BeginTransactionAsync();
|
|
||||||
updateCmd.Transaction = (SqliteTransaction) tx;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCmd.Set(":normalized_url", normalizedUrl);
|
|
||||||
updateCmd.Set(":download_url", downloadUrl);
|
|
||||||
updateCmd.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
await tx.DisposeAsync();
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("PRAGMA cache_size = -2000");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpgradeSchemas(int dbVersion, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
|
||||||
var perf = Log.Start("from version " + dbVersion);
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
|
|
||||||
|
|
||||||
if (dbVersion <= 1) {
|
|
||||||
await reporter.MainWork("Applying schema changes...", 0, 1);
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE channels ADD parent_id INTEGER");
|
|
||||||
|
|
||||||
perf.Step("Upgrade to version 2");
|
|
||||||
await reporter.NextVersion();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dbVersion <= 2) {
|
|
||||||
await reporter.MainWork("Applying schema changes...", 0, 1);
|
|
||||||
|
|
||||||
await CreateMessageEditTimestampTable();
|
|
||||||
await CreateMessageRepliedToTable();
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
INSERT INTO edit_timestamps (message_id, edit_timestamp)
|
|
||||||
SELECT message_id, edit_timestamp
|
|
||||||
FROM messages
|
|
||||||
WHERE edit_timestamp IS NOT NULL
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
INSERT INTO replied_to (message_id, replied_to_id)
|
|
||||||
SELECT message_id, replied_to_id
|
|
||||||
FROM messages
|
|
||||||
WHERE replied_to_id IS NOT NULL
|
|
||||||
""");
|
|
||||||
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE messages DROP COLUMN replied_to_id");
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE messages DROP COLUMN edit_timestamp");
|
|
||||||
|
|
||||||
perf.Step("Upgrade to version 3");
|
|
||||||
|
|
||||||
await reporter.MainWork("Vacuuming the database...", 1, 1);
|
|
||||||
await conn.ExecuteAsync("VACUUM");
|
|
||||||
perf.Step("Vacuum");
|
|
||||||
|
|
||||||
await reporter.NextVersion();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dbVersion <= 3) {
|
|
||||||
await conn.ExecuteAsync("""
|
|
||||||
CREATE TABLE downloads (
|
|
||||||
url TEXT NOT NULL PRIMARY KEY,
|
|
||||||
status INTEGER NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
blob BLOB
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
perf.Step("Upgrade to version 4");
|
|
||||||
await reporter.NextVersion();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dbVersion <= 4) {
|
|
||||||
await reporter.MainWork("Applying schema changes...", 0, 1);
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE attachments ADD width INTEGER");
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE attachments ADD height INTEGER");
|
|
||||||
|
|
||||||
perf.Step("Upgrade to version 5");
|
|
||||||
await reporter.NextVersion();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dbVersion <= 5) {
|
|
||||||
await reporter.MainWork("Applying schema changes...", 0, 3);
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE attachments ADD download_url TEXT");
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE downloads ADD download_url TEXT");
|
|
||||||
|
|
||||||
await reporter.MainWork("Updating attachments...", 1, 3);
|
|
||||||
await NormalizeAttachmentUrls(reporter);
|
|
||||||
|
|
||||||
await reporter.MainWork("Updating downloads...", 2, 3);
|
|
||||||
await NormalizeDownloadUrls(reporter);
|
|
||||||
|
|
||||||
await reporter.MainWork("Applying schema changes...", 3, 3);
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE attachments RENAME COLUMN url TO normalized_url");
|
|
||||||
await conn.ExecuteAsync("ALTER TABLE downloads RENAME COLUMN url TO normalized_url");
|
|
||||||
|
|
||||||
perf.Step("Upgrade to version 6");
|
|
||||||
await reporter.NextVersion();
|
|
||||||
}
|
|
||||||
|
|
||||||
perf.End();
|
|
||||||
}
|
|
||||||
}
|
|
8
app/Server/Database/Sqlite/Schema/ISchemaUpgrade.cs
Normal file
8
app/Server/Database/Sqlite/Schema/ISchemaUpgrade.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Sqlite.Schema;
|
||||||
|
|
||||||
|
interface ISchemaUpgrade {
|
||||||
|
Task Run(ISqliteConnection conn, ISchemaUpgradeCallbacks.IProgressReporter reporter);
|
||||||
|
}
|
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace DHT.Server.Database.Sqlite.Utils;
|
namespace DHT.Server.Database.Sqlite.Schema;
|
||||||
|
|
||||||
public interface ISchemaUpgradeCallbacks {
|
public interface ISchemaUpgradeCallbacks {
|
||||||
Task<bool> CanUpgrade();
|
Task<bool> CanUpgrade();
|
11
app/Server/Database/Sqlite/Schema/SqliteSchemaUpgradeTo2.cs
Normal file
11
app/Server/Database/Sqlite/Schema/SqliteSchemaUpgradeTo2.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Sqlite.Schema;
|
||||||
|
|
||||||
|
sealed class SqliteSchemaUpgradeTo2 : ISchemaUpgrade {
|
||||||
|
async Task ISchemaUpgrade.Run(ISqliteConnection conn, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
|
await conn.ExecuteAsync("ALTER TABLE channels ADD parent_id INTEGER");
|
||||||
|
}
|
||||||
|
}
|
33
app/Server/Database/Sqlite/Schema/SqliteSchemaUpgradeTo3.cs
Normal file
33
app/Server/Database/Sqlite/Schema/SqliteSchemaUpgradeTo3.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DHT.Server.Database.Sqlite.Utils;
|
||||||
|
|
||||||
|
namespace DHT.Server.Database.Sqlite.Schema;
|
||||||
|
|
||||||
|
sealed class SqliteSchemaUpgradeTo3 : ISchemaUpgrade {
|
||||||
|
async Task ISchemaUpgrade.Run(ISqliteConnection conn, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
|
||||||
|
await reporter.MainWork("Applying schema changes...", 0, 1);
|
||||||
|
|
||||||
|
await SqliteSchema.CreateMessageEditTimestampTable(conn);
|
||||||
|
await SqliteSchema.CreateMessageRepliedToTable(conn);
|
||||||
|
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
INSERT INTO edit_timestamps (message_id, edit_timestamp)
|
||||||
|
SELECT message_id, edit_timestamp
|
||||||
|
FROM messages
|
||||||
|
WHERE edit_timestamp IS NOT NULL
|
||||||
|
""");
|
||||||
|
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
INSERT INTO replied_to (message_id, replied_to_id)
|
||||||
|
SELECT message_id, replied_to_id
|
||||||
|
FROM messages
|
||||||
|
WHERE replied_to_id IS NOT NULL
|
||||||
|
""");
|
||||||
|
|
||||||
|
await conn.ExecuteAsync("ALTER TABLE messages DROP COLUMN replied_to_id");
|
||||||
|
await conn.ExecuteAsync("ALTER TABLE messages DROP COLUMN edit_timestamp");
|
||||||
|
|
||||||
|
await reporter.MainWork("Vacuuming the database...", 1, 1);
|
||||||
|
await conn.ExecuteAsync("VACUUM");
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user